diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f7685de..87ec433 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,4 +8,7 @@ - [ ] Built wrapper: `bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh` - [ ] Collected SDK xcframeworks: `bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/collect-facebook-xcframeworks.sh` - [ ] Packed NuGet: `dotnet pack src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj -c Release -o artifacts/nuget` +- [ ] Validated package: `python3 scripts/validate-package.py` +- [ ] Public tree scan: `python3 scripts/verify-public.py` +- [ ] Feature selection: `bash tests/validate-feature-selection.sh` - [ ] Updated docs if behavior/integration changed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a07793..8b5dfa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ jobs: with: dotnet-version: "10.0.203" + - name: Scan public tree + run: python3 scripts/verify-public.py + - name: Validate feature selection run: bash tests/validate-feature-selection.sh @@ -34,12 +37,12 @@ jobs: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('global.json', '**/*.csproj') }} - - name: Cache SwiftPM scratch + - name: Cache pinned Facebook SDK archives uses: actions/cache@v4 with: path: | - src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build/spm - key: ${{ runner.os }}-swiftpm-${{ hashFiles('src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Package.resolved') }} + src/Kapusch.FacebookApisForiOSComponents/Native/iOS/.cache/downloads + key: ${{ runner.os }}-facebook-ios-sdk-${{ hashFiles('DependencyLocks/iOS/lockstate.txt') }} - name: Build iOS wrapper run: | @@ -55,51 +58,8 @@ jobs: -c Release \ -o artifacts/nuget - - name: Validate nupkg layout - run: | - python3 - <<'PY' - import glob, sys, zipfile - - nupkgs = glob.glob("artifacts/nuget/*.nupkg") - if not nupkgs: - print("ERROR: no .nupkg found under artifacts/nuget/") - sys.exit(1) - - nupkg = sorted(nupkgs)[-1] - print(f"Validating {nupkg}") - - z = zipfile.ZipFile(nupkg) - names = set(z.namelist()) - - required = [ - "buildTransitive/Kapusch.Facebook.iOS.targets", - "kfb.xcframework/Info.plist", - "kfbshare.xcframework/Info.plist", - "fb/FBAEMKit.xcframework/Info.plist", - "fb/FBSDKCoreKit.xcframework/Info.plist", - "fb/FBSDKCoreKit_Basics.xcframework/Info.plist", - "fb/FBSDKLoginKit.xcframework/Info.plist", - "fb/FBSDKShareKit.xcframework/Info.plist", - ] - - missing = [p for p in required if p not in names] - if missing: - print("ERROR: missing required paths in nupkg:") - for p in missing: - print(f" - {p}") - sys.exit(1) - - # Guardrail: ensure the wrapper is not flattened at the package root. - if "Info.plist" in names: - print("ERROR: wrapper appears flattened (found 'Info.plist' at package root).") - sys.exit(1) - - if any(name.startswith("fb/FBSDKGamingServicesKit.xcframework/") for name in names): - print("ERROR: GamingServicesKit must not be packaged.") - sys.exit(1) - - print("OK: nupkg layout looks correct.") - PY + - name: Validate package + run: python3 scripts/validate-package.py - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 15bdae9..1ca0bdd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -75,6 +75,9 @@ jobs: with: dotnet-version: "10.0.203" + - name: Scan public tree + run: python3 scripts/verify-public.py + - name: Validate feature selection run: bash tests/validate-feature-selection.sh @@ -88,12 +91,12 @@ jobs: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('global.json', '**/*.csproj') }} - - name: Cache SwiftPM scratch + - name: Cache pinned Facebook SDK archives uses: actions/cache@v4 with: path: | - src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build/spm - key: ${{ runner.os }}-swiftpm-${{ hashFiles('src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Package.resolved') }} + src/Kapusch.FacebookApisForiOSComponents/Native/iOS/.cache/downloads + key: ${{ runner.os }}-facebook-ios-sdk-${{ hashFiles('DependencyLocks/iOS/lockstate.txt') }} - name: Build iOS wrapper run: | @@ -110,50 +113,8 @@ jobs: -o artifacts/nuget \ /p:PackageVersion="${{ steps.version.outputs.version }}" - - name: Validate nupkg layout - run: | - python3 - <<'PY' - import glob, sys, zipfile - - nupkgs = glob.glob("artifacts/nuget/*.nupkg") - if not nupkgs: - print("ERROR: no .nupkg found under artifacts/nuget/") - sys.exit(1) - - nupkg = sorted(nupkgs)[-1] - print(f"Validating {nupkg}") - - z = zipfile.ZipFile(nupkg) - names = set(z.namelist()) - - required = [ - "buildTransitive/Kapusch.Facebook.iOS.targets", - "kfb.xcframework/Info.plist", - "kfbshare.xcframework/Info.plist", - "fb/FBAEMKit.xcframework/Info.plist", - "fb/FBSDKCoreKit.xcframework/Info.plist", - "fb/FBSDKCoreKit_Basics.xcframework/Info.plist", - "fb/FBSDKLoginKit.xcframework/Info.plist", - "fb/FBSDKShareKit.xcframework/Info.plist", - ] - - missing = [p for p in required if p not in names] - if missing: - print("ERROR: missing required paths in nupkg:") - for p in missing: - print(f" - {p}") - sys.exit(1) - - if "Info.plist" in names: - print("ERROR: wrapper appears flattened (found 'Info.plist' at package root).") - sys.exit(1) - - if any(name.startswith("fb/FBSDKGamingServicesKit.xcframework/") for name in names): - print("ERROR: GamingServicesKit must not be packaged.") - sys.exit(1) - - print("OK: nupkg layout looks correct.") - PY + - name: Validate package + run: python3 scripts/validate-package.py - name: Push to NuGet.org if: steps.version.outputs.publish_target == 'nuget' diff --git a/.gitignore b/.gitignore index 63b54b3..d4cf277 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ artifacts/ # macOS .DS_Store -# SwiftPM / Xcode +# Xcode .build/ *.xcworkspace/ DerivedData/ @@ -24,6 +24,7 @@ DerivedData/ # Native build output (repo-only) src/**/Native/iOS/build/ +src/**/Native/iOS/.cache/ # Sample local config samples/**/Info.plist diff --git a/AGENTS.md b/AGENTS.md index 4dd0fdc..daae7bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,22 @@ - Public OSS repo: keep docs/sample generic and not app-specific. - The NuGet ships the required `xcframework`s and references them via `NativeReference`. - Consuming apps must not download native deps at build time. -- The repo may use SwiftPM during CI/build to fetch the upstream Facebook iOS SDK, but consuming apps must not download native deps at build time. +- The repo build downloads the upstream Facebook iOS SDK release xcframeworks pinned (URL + SHA-256) in `DependencyLocks/iOS/lockstate.txt`; consuming apps must not download native deps at build time. +- The native facades are Objective-C and use only the SDK's Objective-C headers, so they do not depend on the Swift compiler release Meta used for the binaries. ## Repo layout - `src/Kapusch.FacebookApisForiOSComponents/` — NuGet project (managed API + buildTransitive MSBuild) -- `src/Kapusch.FacebookApisForiOSComponents/Native/iOS/` — Swift wrapper source + scripts (repo-only) +- `src/Kapusch.FacebookApisForiOSComponents/Native/iOS/` — Objective-C wrapper source + scripts (repo-only) +- `DependencyLocks/iOS/lockstate.txt` — pinned Facebook iOS SDK artifacts - `Docs/` — integration docs - `samples/` — optional sample template (no secrets committed) +## Validation +- `bash tests/validate-feature-selection.sh` — feature selection and wrapper source invariants. +- `python3 scripts/validate-package.py` — packed layout, native exports, App Events bindings and constants. +- `python3 scripts/verify-public.py` — no consuming-app names or real Facebook credentials in tracked files. +- When package contents or native entry points change, update `scripts/validate-package.py` in the same commit. + ## Safety - Do not add new dependency ingestion paths without documenting them in `README.md`. - Do not commit real app ids/secrets. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2eaddf3..ee89da1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,11 +12,11 @@ Thanks for contributing! If you are working without the NuGet (ProjectReference), see `Docs/SourceMode.md`. -Build the Swift wrapper: +Build the Objective-C wrappers (this also restores and collects the pinned Facebook SDK xcframeworks): - `bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh` -Collect the Facebook SDK xcframeworks (from the SwiftPM scratch produced by the build): +Re-collect the Facebook SDK xcframeworks only (restores them from `DependencyLocks/iOS/lockstate.txt` when needed): - `bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/collect-facebook-xcframeworks.sh` @@ -27,14 +27,14 @@ Pack the NuGet: ## Formatting - C#: follow `.editorconfig` (tabs, LF). -- Swift: keep changes minimal and consistent with existing style. +- Objective-C: keep changes minimal and consistent with existing style (tabs, ARC, Objective-C SDK surface only). ## Pull requests - Keep PRs focused and well-scoped. - Branch from `master` and target `master`; this repository does not use long-lived `release/*` branches. - Do not commit secrets. -- If you update the Facebook SDK version, update `Package.swift` and `Package.resolved` together. +- If you update the Facebook SDK version, update every line of `DependencyLocks/iOS/lockstate.txt` together. The SHA-256 values must equal the `remoteChecksum` values of the upstream `Package.swift` at the same tag. See `Docs/Release.md` before changing versions, tags, or publishing workflows. diff --git a/DependencyLocks/iOS/lockstate.txt b/DependencyLocks/iOS/lockstate.txt new file mode 100644 index 0000000..a8254f5 --- /dev/null +++ b/DependencyLocks/iOS/lockstate.txt @@ -0,0 +1,10 @@ +# Pinned Facebook iOS SDK release artifacts (dynamic xcframeworks). +# Format: +# The SHA-256 values equal the `remoteChecksum` values of the upstream +# Package.swift at the same tag. + +https://github.com/facebook/facebook-ios-sdk/releases/download/v19.0.0/FBSDKCoreKit_Basics-Dynamic_XCFramework.zip 7cdfe6a706a4852854aac2dcb24b4aa48ad231f1a3655605ea2333b6d0e2b305 FBSDKCoreKit_Basics-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v19.0.0/FBAEMKit-Dynamic_XCFramework.zip df494f1e8130d975a0dfbfd54d1471c63d86767589bf03b98f6631ad87feb2ad FBAEMKit-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v19.0.0/FBSDKCoreKit-Dynamic_XCFramework.zip 791756b0786b79909de375d7060304d7822edac738218863f07470b1acfcde76 FBSDKCoreKit-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v19.0.0/FBSDKLoginKit-Dynamic_XCFramework.zip d8e23d410d03776f5a2d267f3d935b12a391b878d921c286de9460ac0abad2a9 FBSDKLoginKit-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v19.0.0/FBSDKShareKit-Dynamic_XCFramework.zip e0c095a0fd6bbf51a1e349de09836f025e73ad907809bae7fcc5ee33934c2ceb FBSDKShareKit-Dynamic_XCFramework.zip diff --git a/Docs/Integration.md b/Docs/Integration.md index 89d9009..79dd462 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -4,6 +4,9 @@ This document applies whether you consume the package via NuGet or via ProjectRe ## 1) Info.plist requirements +The bundled Facebook iOS SDK 19.0.0 requires iOS 15.0 or later +(`SupportedOSPlatformVersion` >= 15.0). + Your iOS app must include: - `FacebookDisplayName` @@ -31,7 +34,10 @@ Call the interop hooks in your `AppDelegate`: These hooks apply to `KapuschFacebookFeatures=Login`. For Share-only builds, disable Meta automatic initialization and call `NativeFacebookShare.ConfigureAndInitialize(app.Handle, trackingAllowed)` only -after the ATT decision, immediately before the first share. Then call +after the ATT decision, immediately before the first share. +`ConfigureAndInitialize` sets automatic app events logging to off and advertiser +ID collection / advertiser tracking to `trackingAllowed`, except for any of these +settings the app already set through `FacebookAppEvents`, which it leaves as is. Then call `NativeFacebookShare.SharePhotoAsync(...)`; no caption is accepted. Forward Facebook callback URLs to `NativeFacebookShare.HandleOpenUrl(app.Handle, url.Handle, options.Handle)`. @@ -49,14 +55,174 @@ leaving unrelated pasteboard content untouched. ## 3) Feature selection +Set `KapuschFacebookFeatures` in the consuming project (semicolon-separated, +case-sensitive; unknown values produce a build warning): + - `Login` (default): Login wrapper and LoginKit. - `Share`: photo-share wrapper and ShareKit. - `Login;Share`: both managed/native entry points. - -At SDK 18.0.2, ShareKit's CoreKit dependency requires FBAEMKit. Treat this as a +- `AppEvents`: App Events only. Ships CoreKit, AEMKit and CoreKit_Basics, without + LoginKit or ShareKit. +- Any combination, for example `Login;AppEvents`. + +`FacebookAppEvents` works with every selection: App Events lives in CoreKit, which +every feature links, so its small facade (`kfbappevents.xcframework`) is always +linked. Selecting `AppEvents` matters when you want neither Login nor Share. + +When `Share` (or `Login`) is not selected, the package links a small +Foundation-only placeholder (`kfbsharestub.xcframework` / `kfbloginstub.xcframework`) +for its entry points, because the .NET iOS build always asks the native linker for +them. Calling `NativeFacebookShare.SharePhotoAsync(...)` then completes with +`Failed` / `share_feature_not_selected` and `NativeFacebookLogin.SignInAsync(...)` +with `Failed` / `login_feature_not_selected`; no ShareKit or LoginKit code is shipped. + +At SDK 19.0.0, CoreKit (required by every feature) links FBAEMKit. Treat this as a privacy-review blocker if the consumer's artifact policy excludes FBAEMKit. -## 4) Limited Login +## 4) App Events (attribution and conversion events) + +`Kapusch.Facebook.iOS.FacebookAppEvents` wraps `FBSDKAppEvents`, `FBSDKSettings` and +`FBSDKApplicationDelegate`. Event and parameter names are available as constants in +`FacebookAppEventNames`, `FacebookAppEventParameterNames`, +`FacebookAppEventParameterValues` and `FacebookAppEventUserDataTypes`. + +### 4.1 Info.plist keys + +Besides the keys of section 1 (`FacebookAppID`, `FacebookClientToken`, +`FacebookDisplayName`, `CFBundleURLTypes` with `fb`), the SDK reads +these booleans. Values set at run time through `FacebookAppEvents` are persisted by the +SDK and override the `Info.plist` values on later launches. + +| Key | SDK default | Runtime API | Controls | +|---|---|---|---| +| `FacebookAutoLogAppEventsEnabled` | `true` | `SetAutoLogAppEventsEnabled` | Automatic activation / install events and, if enabled in the Meta dashboard, automatic in-app purchase events | +| `FacebookAdvertiserIDCollectionEnabled` | `true` | `SetAdvertiserIdCollectionEnabled` | IDFA collection (all zeros until App Tracking Transparency is authorized) | +| `FBSDKAutoLogMetaDataEnabled` | `true` | `SetMetadataCollectionEnabled` | SDK 19 metadata collection: screen titles and App Link URLs attached to events | +| `FBSDKAutoLogProductImprovementEnabled` | `true` | `SetAutoLogProductImprovementEnabled` | SDK 19 product-improvement switch (SDK 19.0.0 adds the control only) | +| `FacebookSKAdNetworkReportEnabled` | `true` | `SetSKAdNetworkReportEnabled` | SKAdNetwork conversion-value updates by the SDK | + +Android's `com.facebook.sdk.AutoInitEnabled` has no iOS counterpart: on iOS the SDK only +starts when the app initializes it (section 4.2). + +If the app requests App Tracking Transparency, add `NSUserTrackingUsageDescription`. +`FBSDKCoreKit`'s privacy manifest declares `NSPrivacyTracking=true` with the tracking +domain `ep1.facebook.com`; reflect it in your App Store privacy answers. iOS blocks that +domain until tracking is authorized, and the SDK then routes app events to Meta's +non-tracking endpoint. + +### 4.2 Consent gating + +The SDK sends nothing before it is initialized, but initialization itself contacts Meta +(SDK configuration requests) even when automatic logging is off. A consent-first +integration therefore: + +1. Ships with every switch off in `Info.plist`: + + ```xml + FacebookAutoLogAppEventsEnabled + FacebookAdvertiserIDCollectionEnabled + FBSDKAutoLogMetaDataEnabled + FBSDKAutoLogProductImprovementEnabled + ``` + +2. Does not call `FacebookAppEvents.InitializeSdk()` (nor + `NativeFacebookLogin.Initialize` / `NativeFacebookShare.ConfigureAndInitialize`) + before the user consents. Login and Share also initialize the SDK, so with those + features the settings above are what keeps app events off. +3. After consent (and after the ATT prompt, if the app shows one): + + ```csharp + FacebookAppEvents.InitializeSdk(); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(true); + // Keep automatic logging off when a server reports purchases (section 4.4): + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.ActivateApp(); // activation + one-time install ping + ``` + + Then call `FacebookAppEvents.ActivateApp()` each time the app becomes active + (`OnActivated`), because automatic activation logging is off. The SDK ignores + activations less than 60 seconds apart. +4. On withdrawal: + + ```csharp + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(false); + FacebookAppEvents.ClearUserId(); + FacebookAppEvents.ClearUserData(); + // and stop calling ActivateApp / Log* methods. + ``` + + The SDK cannot delete events already sent; that goes through Meta's data deletion + process. +5. On later launches, initialize only if consent is still granted. + +Settings set before `InitializeSdk()` apply immediately and are applied again once the +SDK is initialized, so the SDK persists them in both cases. + +For Limited Data Use, call +`FacebookAppEvents.SetDataProcessingOptions([FacebookDataProcessingOptions.LimitedDataUse])` +(country/state 0 lets Meta geolocate the user); an empty array disables it. + +### 4.3 Deep links and Aggregated Event Measurement + +Forward URLs and Universal Links so AEM can attribute iOS users who did not authorize +tracking: + +```csharp +public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options) => + FacebookAppEvents.HandleOpenUrl(app.Handle, url.Handle, options?.Handle ?? IntPtr.Zero); + +public override bool ContinueUserActivity( + UIApplication app, NSUserActivity userActivity, UIApplicationRestorationHandler completionHandler) => + FacebookAppEvents.ContinueUserActivity(app.Handle, userActivity.Handle); +``` + +With a scene delegate, forward `OpenUrlContexts` and `ContinueUserActivity` the same +way. When consent is already known at launch, call +`FacebookAppEvents.FinishedLaunching(app.Handle, launchOptions?.Handle ?? IntPtr.Zero)` +from `FinishedLaunching` instead of `InitializeSdk()`, so a cold-start URL reaches the +SDK. With `Login` selected, `NativeFacebookLogin.HandleOpenUrl` forwards to the same +SDK entry point; call only one of them per URL. + +### 4.4 Avoid double counting with server-side events + +Meta does not deduplicate SDK events against server-side (Conversions API) events +unless both share an `event_id`, which the SDK does not expose. Use exactly one source +per event: + +- When a server (for example a subscription platform integration) sends trial, + subscription or purchase events to Meta, do **not** call `LogStartTrial`, + `LogSubscribe` or `LogPurchase` for them, and disable automatic in-app purchase + logging: + - keep `FacebookAutoLogAppEventsEnabled=false` and call `ActivateApp()` yourself + (iOS only observes StoreKit transactions when automatic logging is on); and/or + - set "Log In-App Events Automatically" to "No" in the Meta App Dashboard. +- Since SDK 18, automatic logging on iOS scans StoreKit 2 transactions at each + activation, renewals included, so leaving it on next to a server integration + double-counts subscriptions. + +### 4.5 SKAdNetwork + +Only one component may update SKAdNetwork conversion values. If another SDK or your +own code owns them, call `FacebookAppEvents.SetSKAdNetworkReportEnabled(false)` (or set +`FacebookSKAdNetworkReportEnabled=false`). An advertised app does not need +`SKAdNetworkItems`; those are for apps that display ads. + +### 4.6 Diagnostics + +`FacebookAppEvents.SetDebugLoggingEnabled(true)` enables the SDK's app-events, +developer-error and informational logs in the device log; `false` restores the +previous logging behaviours. Combine it with Events Manager > Test Events. +`FacebookAppEvents.SdkVersion` returns the bundled SDK version. + +### 4.7 Platform gaps + +- No `SetAutoInitEnabled` on iOS (see 4.1). +- `SetAdvertiserTrackingEnabled` only matters on iOS 15 and 16; iOS 17+ uses the ATT + status. +- `SetUserDataValue(FacebookAppEventUserDataTypes.ExternalId, ...)` is an iOS SDK API. + +## 5) Limited Login For Limited Login, pass: - `FacebookTrackingMode.Limited` @@ -66,7 +232,7 @@ The result can contain: - `AuthenticationToken` - `Nonce` -## 5) Secrets policy +## 6) Secrets policy Do not commit real values in this repo. Use templates and `.gitignore`d local files for any sample app configuration. diff --git a/Docs/Release.md b/Docs/Release.md index 665e3d4..94a5c2b 100644 --- a/Docs/Release.md +++ b/Docs/Release.md @@ -14,7 +14,7 @@ NuGet versions and Git tags are immutable. Query the official NuGet index before ## Release sequence 1. Fetch `origin`, start a short-lived branch from `origin/master`, and verify the worktree is clean. -2. Build the Swift wrappers, collect the locked Facebook xcframeworks, pack the NuGet and validate its module selection. +2. Build the Objective-C wrappers, collect the locked Facebook xcframeworks, pack the NuGet and validate its module selection. 3. Open a PR to `master` and require CI to pass. 4. Merge and fetch the resulting `origin/master` commit. 5. Create the RC or stable tag on that exact commit and push only the new tag. diff --git a/Docs/SourceMode.md b/Docs/SourceMode.md index 395c733..eadda38 100644 --- a/Docs/SourceMode.md +++ b/Docs/SourceMode.md @@ -18,7 +18,11 @@ Use GitHub Packages pre-release and consume the package. - `bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/collect-facebook-xcframeworks.sh` This produces: -- `src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build/kfb.xcframework` +- `src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build/kfb.xcframework` (Login) +- `src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build/kfbloginstub.xcframework` (Login placeholder) +- `src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build/kfbshare.xcframework` (Share) +- `src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build/kfbsharestub.xcframework` (Share placeholder) +- `src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build/kfbappevents.xcframework` (App Events) - `src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build/fb/*.xcframework` 3) Add a project reference from your app to: @@ -28,6 +32,9 @@ This produces: 4) If you build the repo in a different layout, you can set MSBuild properties on the consuming project: - `InteropIosWrapperDir` (path to `kfb.xcframework`) +- `InteropIosLoginStubWrapperDir`, `InteropIosShareWrapperDir`, `InteropIosShareStubWrapperDir`, + `InteropIosAppEventsWrapperDir` (paths to the `kfbloginstub`, `kfbshare`, `kfbsharestub` and + `kfbappevents` xcframeworks) - `FacebookIosFrameworksDir` (path to the folder containing `fb/*.xcframework`) Notes: diff --git a/README.md b/README.md index 03436e7..188f0be 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,35 @@ Public OSS repository that packages selected Facebook iOS features into a consum ## What this repo ships A NuGet package that: -- provides managed APIs for **Facebook Login** and **Facebook photo sharing**, and +- provides managed APIs for **Facebook Login**, **Facebook photo sharing** and **Meta App Events** + (install / activation attribution, standard and custom events, consent-related settings, + AEM deep-link forwarding), and - redistributes the required **Facebook iOS SDK xcframeworks** inside the `.nupkg` (classic/native binding packaging), - injects the xcframeworks into consuming apps via `buildTransitive` `NativeReference` items. -Select features with `KapuschFacebookFeatures=Login`, `Share`, or `Login;Share`. -The default remains `Login` for compatibility. +Select features with `KapuschFacebookFeatures=Login`, `Share`, `AppEvents` or a combination +such as `Login;Share`. The default remains `Login` for compatibility. `FacebookAppEvents` is +available with every selection because CoreKit is always linked; `AppEvents` alone ships +CoreKit, AEMKit and CoreKit_Basics only. -Facebook iOS SDK 18.0.2 links `FBSDKCoreKit` to `FBAEMKit` non-weakly. Therefore +The package bundles Facebook iOS SDK **19.0.0** (see `DependencyLocks/iOS/lockstate.txt`). +It requires iOS 15.0 or later, the minimum of that SDK release. + +Facebook iOS SDK 19.0.0 links `FBSDKCoreKit` to `FBAEMKit` non-weakly. Therefore `Share` currently includes `ShareKit`, `CoreKit`, `CoreKit_Basics` and `FBAEMKit`. A consumer that forbids `FBAEMKit` must block Store delivery; this repository does not strip or conceal the dependency. +## Upstream dependency ingestion + +- The repo build downloads Meta's prebuilt **dynamic** xcframework archives from the + `facebook/facebook-ios-sdk` GitHub release named in `DependencyLocks/iOS/lockstate.txt`. +- Each archive is verified against the SHA-256 in that lock file (the same value as the + upstream `Package.swift` `remoteChecksum`) before it is extracted. +- The wrappers are Objective-C and compile against the SDK's Objective-C headers only. + They do not need the Swift compiler release Meta used to build the binaries. +- Consuming apps never download anything: the NuGet already contains every xcframework. + ## Third-party licenses See `THIRD_PARTY_NOTICES.md`. @@ -38,7 +55,7 @@ Prereqs: - Xcode installed (for `xcrun`, iOS SDKs) - .NET SDK 10 (`global.json` pins 10.0.100) -Build the native wrapper: +Build the native wrappers (restores the pinned SDK archives first): - `bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh` Collect Facebook xcframeworks for packing: @@ -51,7 +68,8 @@ Pack the NuGet: - Install the package from NuGet.org for public release tags. - Install the package from GitHub Packages for internal preview builds. -- Follow `Docs/Integration.md` for Info.plist keys and AppDelegate hooks. +- Follow `Docs/Integration.md` for Info.plist keys, AppDelegate hooks, App Events consent + gating and how to avoid double counting with server-side purchase events. ## CI diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4c85a68..3dc62c3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -7,7 +7,13 @@ This repository and its NuGet package (`Kapusch.Facebook.iOS`) redistribute thir - Upstream: facebook-ios-sdk - Source: https://github.com/facebook/facebook-ios-sdk -The NuGet package includes Facebook SDK `xcframework`s under `fb/`. +The NuGet package includes Facebook SDK `xcframework`s under `fb/`, taken unmodified (apart +from removing the Mac Catalyst slice, signatures, dSYMs and Swift module files) from the +release archives pinned in `DependencyLocks/iOS/lockstate.txt`. + +`FBSDKCoreKit`'s privacy manifest declares `NSPrivacyTracking=true` and the tracking domain +`ep1.facebook.com`. Apps that ship this package must reflect it in their own privacy +declarations. Each included `xcframework` contains an upstream `LICENSE` file. If you are redistributing this package further, ensure you comply with the upstream license terms. diff --git a/samples/Kapusch.Facebook.iOS.Sample/AppDelegate.cs b/samples/Kapusch.Facebook.iOS.Sample/AppDelegate.cs index 99eb820..e1e5f2e 100644 --- a/samples/Kapusch.Facebook.iOS.Sample/AppDelegate.cs +++ b/samples/Kapusch.Facebook.iOS.Sample/AppDelegate.cs @@ -8,6 +8,8 @@ namespace Kapusch.Facebook.iOS.Sample; [Register("AppDelegate")] public sealed class AppDelegate : UIApplicationDelegate { + private bool _appEventsConsented; + public override UIWindow? Window { get; set; } public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions) @@ -49,6 +51,7 @@ public override bool FinishedLaunching(UIApplication application, NSDictionary? }; viewController.View.AddSubview(signInButton); + AddAppEventsButtons(viewController.View); Window.RootViewController = viewController; Window.MakeKeyAndVisible(); @@ -56,16 +59,136 @@ public override bool FinishedLaunching(UIApplication application, NSDictionary? return true; } + public override void OnActivated(UIApplication application) + { + // Automatic logging stays off (Info.plist), so log activations manually after consent. + if (_appEventsConsented) + FacebookAppEvents.ActivateApp(); + } + + private void AddAppEventsButtons(UIView view) + { + var y = 200; + void AddButton(string title, Action action) + { + var button = new UIButton(UIButtonType.System); + button.SetTitle(title, UIControlState.Normal); + button.Frame = new CoreGraphics.CGRect(40, y, 280, 44); + button.TouchUpInside += (_, _) => + { + try + { + action(); + } + catch (Exception ex) + { + Console.WriteLine($"App Events exception: {ex}"); + } + }; + view.AddSubview(button); + y += 52; + } + + AddButton("App Events: grant consent", GrantAppEventsConsent); + AddButton( + "App Events: log StartTrial", + () => + { + if (!EnsureConsented()) + return; + + FacebookAppEvents.LogStartTrial( + value: 9.99m, + currencyIsoCode: "EUR", + orderId: "sample-subscription", + parameters: new Dictionary + { + [FacebookAppEventParameterNames.ContentId] = "sample.monthly", + } + ); + FacebookAppEvents.LogEvent("sample_custom_event", new Dictionary { ["step"] = 1 }); + Console.WriteLine("App Events: logged StartTrial and sample_custom_event."); + } + ); + AddButton( + "App Events: flush", + () => + { + if (EnsureConsented()) + FacebookAppEvents.Flush(); + } + ); + AddButton("App Events: withdraw consent", WithdrawAppEventsConsent); + } + + private void GrantAppEventsConsent() + { + if (!HasFacebookConfiguration()) + return; + + FacebookAppEvents.SetDebugLoggingEnabled(true); + FacebookAppEvents.InitializeSdk(); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(true); + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.ActivateApp(); + _appEventsConsented = true; + + Console.WriteLine( + $"App Events: SDK {FacebookAppEvents.SdkVersion}, anonymous ID {FacebookAppEvents.GetAnonymousId()}, " + + $"autoLog={FacebookAppEvents.IsAutoLogAppEventsEnabled}, " + + $"advertiserId={FacebookAppEvents.IsAdvertiserIdCollectionEnabled}" + ); + } + + private void WithdrawAppEventsConsent() + { + _appEventsConsented = false; + if (!HasFacebookConfiguration()) + return; + + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(false); + FacebookAppEvents.ClearUserId(); + FacebookAppEvents.ClearUserData(); + Console.WriteLine("App Events: consent withdrawn."); + } + + private bool EnsureConsented() + { + if (_appEventsConsented) + return true; + + Console.WriteLine("App Events: grant consent first."); + return false; + } + + private static bool HasFacebookConfiguration() + { + // The SDK requires a Facebook app ID; the committed template has none. + if (NSBundle.MainBundle.ObjectForInfoDictionary("FacebookAppID") is NSString) + return true; + + Console.WriteLine("App Events: add FacebookAppID and FacebookClientToken to the local Info.plist first."); + return false; + } + private static string GenerateRawNonce(int size = 32) { var bytes = RandomNumberGenerator.GetBytes(size); return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } + // Login forwards URLs to the SDK's ApplicationDelegate, which also serves App Events (AEM). public override bool OpenUrl(UIApplication application, NSUrl url, NSDictionary options) => NativeFacebookLogin.HandleOpenUrl( application.Handle, url.Handle, options?.Handle ?? IntPtr.Zero ); + + public override bool ContinueUserActivity( + UIApplication application, + NSUserActivity userActivity, + UIApplicationRestorationHandler completionHandler + ) => FacebookAppEvents.ContinueUserActivity(application.Handle, userActivity.Handle); } diff --git a/samples/Kapusch.Facebook.iOS.Sample/Info.template.plist b/samples/Kapusch.Facebook.iOS.Sample/Info.template.plist index d7b93e7..436785c 100644 --- a/samples/Kapusch.Facebook.iOS.Sample/Info.template.plist +++ b/samples/Kapusch.Facebook.iOS.Sample/Info.template.plist @@ -24,5 +24,16 @@ UIInterfaceOrientationPortrait + + FacebookAutoLogAppEventsEnabled + + FacebookAdvertiserIDCollectionEnabled + + FBSDKAutoLogMetaDataEnabled + + FBSDKAutoLogProductImprovementEnabled + diff --git a/samples/Kapusch.Facebook.iOS.Sample/Kapusch.Facebook.iOS.Sample.csproj b/samples/Kapusch.Facebook.iOS.Sample/Kapusch.Facebook.iOS.Sample.csproj index 6e5445d..a392b6b 100644 --- a/samples/Kapusch.Facebook.iOS.Sample/Kapusch.Facebook.iOS.Sample.csproj +++ b/samples/Kapusch.Facebook.iOS.Sample/Kapusch.Facebook.iOS.Sample.csproj @@ -8,6 +8,7 @@ com.kapusch.facebookios.sample Kapusch Facebook iOS Sample true + Login;AppEvents $(NoWarn);CA1422 diff --git a/samples/Kapusch.Facebook.iOS.Sample/README.md b/samples/Kapusch.Facebook.iOS.Sample/README.md index 7e12e9d..b53933f 100644 --- a/samples/Kapusch.Facebook.iOS.Sample/README.md +++ b/samples/Kapusch.Facebook.iOS.Sample/README.md @@ -1,8 +1,9 @@ # Kapusch.Facebook.iOS.Sample Small, buildable iOS sample project for validating: -- Managed API compilation +- Managed API compilation (Login and App Events; the sample selects `Login;AppEvents`) - Native asset injection via `NativeReference` (wrapper + Facebook xcframeworks) +- The App Events consent recipe of `Docs/Integration.md` (grant / log / flush / withdraw buttons) No secrets are committed. @@ -12,10 +13,9 @@ Prereqs: - macOS + Xcode - .NET SDK 10 (`global.json` pins 10.0.100) -Build native assets (repo-only): +Build native assets (repo-only; restores the pinned Facebook SDK archives): ```bash bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh -bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/collect-facebook-xcframeworks.sh ``` Build the sample (simulator, no signing): @@ -29,4 +29,11 @@ dotnet build samples/Kapusch.Facebook.iOS.Sample/Kapusch.Facebook.iOS.Sample.csp ## Runtime notes This sample calls into the interop API but does not include a configured Facebook App ID / URL schemes. -To make the login flow work end-to-end, update the generated `Info.plist` (gitignored) with the required Facebook keys and URL schemes. +To make the login flow or App Events work end-to-end, update the generated `Info.plist` (gitignored) +with `FacebookAppID`, `FacebookClientToken`, `FacebookDisplayName` and the `fb` URL scheme. +Never commit those values. + +The template ships the App Events switches off. "App Events: grant consent" initializes the SDK, +enables advertiser ID collection and SDK debug logging, and logs an activation; check the device log +and Events Manager > Test Events. Without a `FacebookAppID` the App Events buttons only print a +message. diff --git a/scripts/validate-package.py b/scripts/validate-package.py new file mode 100755 index 0000000..de3f227 --- /dev/null +++ b/scripts/validate-package.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Validates the packed Kapusch.Facebook.iOS NuGet and the App Events bindings. + +Usage: python3 scripts/validate-package.py [path/to/package.nupkg] +(defaults to the newest artifacts/nuget/*.nupkg) + +Checks: +1. Package layout: every wrapper and Facebook SDK xcframework is present, + nothing is flattened at the root and no unwanted SDK kit is shipped. +2. Every C entry point declared by the wrapper headers is exported by both + slices of its static library. +3. Every kfb_appevents_* entry point is bound by FacebookAppEvents with a + static __Internal import, and no Login / Share / App Events binding + resolves symbols at run time. +4. The managed constants cover every App Events name the bundled SDK headers + declare, and every constant value exists in the bundled FBSDKCoreKit binary. +""" +from __future__ import annotations + +import glob +import re +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" / "Kapusch.FacebookApisForiOSComponents" +HEADERS = SRC / "Native" / "iOS" / "KapuschFacebookAuthInterop" / "include" +FACEBOOK_CS = SRC / "Facebook" + +WRAPPERS = { + "kfb.xcframework": ("libKapuschFacebookAuthInterop.a", "KapuschFacebookAuthInterop.h"), + "kfbloginstub.xcframework": ("libKapuschFacebookLoginStub.a", "KapuschFacebookAuthInterop.h"), + "kfbshare.xcframework": ("libKapuschFacebookShareInterop.a", "KapuschFacebookShareInterop.h"), + "kfbsharestub.xcframework": ("libKapuschFacebookShareStub.a", "KapuschFacebookShareInterop.h"), + "kfbappevents.xcframework": ("libKapuschFacebookAppEventsInterop.a", "KapuschFacebookAppEventsInterop.h"), +} + +FACEBOOK_FRAMEWORKS = [ + "FBAEMKit", + "FBSDKCoreKit", + "FBSDKCoreKit_Basics", + "FBSDKLoginKit", + "FBSDKShareKit", +] + +FORBIDDEN_PREFIXES = [ + "fb/FBSDKGamingServicesKit.xcframework/", +] + +# Header -> Objective-C symbol prefix of the constants the managed API must cover. +CONSTANT_HEADERS = { + "FBSDKAppEventName.h": "FBSDKAppEventName", + "FBSDKAppEventParameterName.h": "FBSDKAppEventParameterName", + "FBSDKAppEventParameterValue.h": "FBSDKAppEventParameterValue", + "FBSDKAppEventUserDataType.h": "FBSDKAppEvent", + "FBSDKAppEventParameterProduct.h": "FBSDKAppEventParameterProduct", +} + +CONSTANT_SOURCES = [ + FACEBOOK_CS / "FacebookAppEventNames.cs", + FACEBOOK_CS / "FacebookAppEventParameterNames.cs", +] + +errors: list[str] = [] + + +def fail(message: str) -> None: + errors.append(message) + + +def exported_symbols(header: Path) -> list[str]: + text = header.read_text() + return re.findall(r"^KFB_EXPORT\s+[^;{]*?\b(kfb_[a-z0-9_]+)\s*\(", text, re.MULTILINE) + + +def nm_symbols(library: Path) -> set[str]: + output = subprocess.check_output(["nm", "-gUj", str(library)], text=True, stderr=subprocess.DEVNULL) + return {line.strip().lstrip("_") for line in output.splitlines() if line.strip()} + + +def check_layout(names: set[str]) -> None: + required = ["buildTransitive/Kapusch.Facebook.iOS.props", "buildTransitive/Kapusch.Facebook.iOS.targets"] + required += [f"{wrapper}/Info.plist" for wrapper in WRAPPERS] + required += [f"fb/{framework}.xcframework/Info.plist" for framework in FACEBOOK_FRAMEWORKS] + for path in required: + if path not in names: + fail(f"missing package entry: {path}") + + if "Info.plist" in names: + fail("a wrapper appears flattened (found 'Info.plist' at the package root)") + + for prefix in FORBIDDEN_PREFIXES: + if any(name.startswith(prefix) for name in names): + fail(f"forbidden package content: {prefix}") + + +def check_exports(extracted: Path) -> None: + for wrapper, (library, header) in WRAPPERS.items(): + expected = exported_symbols(HEADERS / header) + if not expected: + fail(f"no KFB_EXPORT symbols parsed from {header}") + continue + slices = sorted((extracted / wrapper).glob(f"ios-*/{library}")) + if len(slices) != 2: + fail(f"{wrapper}: expected a device and a simulator slice of {library}, found {len(slices)}") + for slice_library in slices: + missing = sorted(set(expected) - nm_symbols(slice_library)) + if missing: + fail(f"{slice_library.relative_to(extracted)} does not export: {', '.join(missing)}") + + +def check_app_events_bindings() -> None: + source = (FACEBOOK_CS / "FacebookAppEvents.iOS.cs").read_text() + for symbol in exported_symbols(HEADERS / "KapuschFacebookAppEventsInterop.h"): + if f'EntryPoint = "{symbol}"' not in source: + fail(f"FacebookAppEvents does not bind {symbol}") + if 'private const string NativeLibraryName = "__Internal";' not in source: + fail("FacebookAppEvents must bind its entry points with static __Internal imports") + for name in ["FacebookAppEvents.iOS.cs", "NativeFacebookLogin.iOS.cs", "NativeFacebookShare.iOS.cs"]: + if "NativeLibrary.GetExport" in (FACEBOOK_CS / name).read_text(): + fail(f"{name} must use static __Internal imports so iOS keeps its native symbols") + + +def check_constants(extracted: Path) -> None: + core = sorted((extracted / "fb" / "FBSDKCoreKit.xcframework").glob("ios-*/FBSDKCoreKit.framework")) + device = [path for path in core if "simulator" not in path.parent.name] + if not device: + fail("FBSDKCoreKit device slice not found") + return + framework = device[0] + + managed = "\n".join(path.read_text() for path in CONSTANT_SOURCES) + for header, prefix in CONSTANT_HEADERS.items(): + text = (framework / "Headers" / header).read_text() + symbols = re.findall(rf"FOUNDATION_EXPORT\s+\w+\s+(?:const\s+)?({prefix}\w+)\s*", text) + if not symbols: + fail(f"no exported constants parsed from {header}") + for symbol in symbols: + if not re.search(rf"\b{symbol}\b", managed): + fail(f"managed constants do not cover {symbol} ({header})") + + binary = (framework / "FBSDKCoreKit").read_bytes() + for value in re.findall(r'public const string \w+ = "([^"]+)";', managed): + if len(value) > 1 and value.encode() not in binary: + fail(f'constant value "{value}" not found in the bundled FBSDKCoreKit binary') + + +def main() -> int: + if len(sys.argv) > 1: + nupkg = Path(sys.argv[1]) + else: + candidates = sorted(glob.glob(str(ROOT / "artifacts" / "nuget" / "*.nupkg")), key=lambda p: Path(p).stat().st_mtime) + if not candidates: + print("ERROR: no .nupkg found under artifacts/nuget/", file=sys.stderr) + return 1 + nupkg = Path(candidates[-1]) + + print(f"Validating {nupkg}") + with zipfile.ZipFile(nupkg) as archive, tempfile.TemporaryDirectory() as temp: + names = set(archive.namelist()) + check_layout(names) + archive.extractall(temp) + extracted = Path(temp) + check_exports(extracted) + check_app_events_bindings() + check_constants(extracted) + + if errors: + print("ERROR: package validation failed:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + print("OK: package layout, native exports, App Events bindings and constants are valid.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify-public.py b/scripts/verify-public.py new file mode 100755 index 0000000..7b8488f --- /dev/null +++ b/scripts/verify-public.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Fails when a tracked file contains a private identifier. + +This is a public, generic package: docs, samples and tests must not carry a +consuming app's name or real Facebook credentials. The app-name pattern is +hex-escaped so this script does not contain the name itself. +""" +from pathlib import Path +import re +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] + +PATTERNS = { + "consuming app name": re.compile(rb"(?i)\x62\x6c\x6f\x6f\x6d[ &_-]*(?:and[ _-]*)?(?:fly|y)"), + "real Facebook URL scheme": re.compile(rb"\bfb[0-9]{8,}\b"), + "real Facebook app ID": re.compile(rb"FacebookAppID\s*[0-9]{8,}"), + "real Facebook client token": re.compile(rb"FacebookClientToken\s*[0-9a-f]{32}"), +} + + +def main() -> int: + files = subprocess.check_output(["git", "ls-files"], cwd=ROOT, text=True).splitlines() + failures = [] + for name in files: + path = ROOT / name + if not path.is_file(): + continue + data = path.read_bytes() + for label, pattern in PATTERNS.items(): + if pattern.search(data): + failures.append(f"{name}: {label}") + + if failures: + print("Private identifiers found in tracked files:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + + print("Public tree scan passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventNames.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventNames.cs new file mode 100644 index 0000000..0490c40 --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventNames.cs @@ -0,0 +1,136 @@ +namespace Kapusch.Facebook.iOS; + +/// +/// Standard Meta app event names, as declared by FBSDKAppEventName.h in the +/// bundled Facebook iOS SDK (values from FBSDKAppEventName.m). The names match +/// AppEventsConstants.EVENT_NAME_* on Android. +/// +/// +/// Each constant names its Objective-C symbol in a FBSDK… remark. The package +/// validation checks that every symbol of the bundled header is covered and that each +/// value exists in the bundled FBSDKCoreKit binary. +/// +public static class FacebookAppEventNames +{ + // General purpose. + + /// Ad click (ad-supported apps). FBSDKAppEventNameAdClick + public const string AdClick = "AdClick"; + + /// Ad impression (ad-supported apps). FBSDKAppEventNameAdImpression + public const string AdImpression = "AdImpression"; + + /// The user completed a registration. FBSDKAppEventNameCompletedRegistration + public const string CompletedRegistration = "fb_mobile_complete_registration"; + + /// The user completed a tutorial. FBSDKAppEventNameCompletedTutorial + public const string CompletedTutorial = "fb_mobile_tutorial_completion"; + + /// The user contacted the business. FBSDKAppEventNameContact + public const string Contact = "Contact"; + + /// The user customized a product. FBSDKAppEventNameCustomizeProduct + public const string CustomizeProduct = "CustomizeProduct"; + + /// The user donated. FBSDKAppEventNameDonate + public const string Donate = "Donate"; + + /// The user searched for a location. FBSDKAppEventNameFindLocation + public const string FindLocation = "FindLocation"; + + /// The user rated an item. FBSDKAppEventNameRated + public const string Rated = "fb_mobile_rate"; + + /// The user booked an appointment. FBSDKAppEventNameSchedule + public const string Schedule = "Schedule"; + + /// The user performed a search. FBSDKAppEventNameSearched + public const string Searched = "fb_mobile_search"; + + /// The user started a free trial. FBSDKAppEventNameStartTrial + public const string StartTrial = "StartTrial"; + + /// The user submitted an application. FBSDKAppEventNameSubmitApplication + public const string SubmitApplication = "SubmitApplication"; + + /// The user started a paid subscription. FBSDKAppEventNameSubscribe + public const string Subscribe = "Subscribe"; + + /// The user viewed content. FBSDKAppEventNameViewedContent + public const string ViewedContent = "fb_mobile_content_view"; + + // E-commerce. + + /// The user added payment information. FBSDKAppEventNameAddedPaymentInfo + public const string AddedPaymentInfo = "fb_mobile_add_payment_info"; + + /// The user added an item to the cart. FBSDKAppEventNameAddedToCart + public const string AddedToCart = "fb_mobile_add_to_cart"; + + /// The user added an item to a wishlist. FBSDKAppEventNameAddedToWishlist + public const string AddedToWishlist = "fb_mobile_add_to_wishlist"; + + /// The user started a checkout. FBSDKAppEventNameInitiatedCheckout + public const string InitiatedCheckout = "fb_mobile_initiated_checkout"; + + /// + /// Purchase. Prefer , which logs this name. + /// FBSDKAppEventNamePurchased + /// + public const string Purchased = "fb_mobile_purchase"; + + /// A purchase was restored (logged by automatic purchase logging). FBSDKAppEventNamePurchaseRestored + public const string PurchaseRestored = "fb_mobile_purchase_restored"; + + /// A subscription was restored (logged by automatic purchase logging). FBSDKAppEventNameSubscribeRestore + public const string SubscribeRestore = "SubscriptionRestore"; + + /// A purchase failed (logged by automatic purchase logging). FBSDKAppEventNamePurchaseFailed + public const string PurchaseFailed = "fb_mobile_purchase_failed"; + + /// A subscription checkout started (logged by automatic purchase logging). FBSDKAppEventNameSubscribeInitiatedCheckout + public const string SubscribeInitiatedCheckout = "SubscriptionInitiatedCheckout"; + + /// A subscription purchase failed (logged by automatic purchase logging). FBSDKAppEventNameSubscribeFailed + public const string SubscribeFailed = "SubscriptionFailed"; + + // Gaming. + + /// The user achieved a level. FBSDKAppEventNameAchievedLevel + public const string AchievedLevel = "fb_mobile_level_achieved"; + + /// The user unlocked an achievement. FBSDKAppEventNameUnlockedAchievement + public const string UnlockedAchievement = "fb_mobile_achievement_unlocked"; + + /// The user spent in-app credits. FBSDKAppEventNameSpentCredits + public const string SpentCredits = "fb_mobile_spent_credits"; + + // Logged by the SDK itself. Listed so Events Manager names can be matched; do not log them manually. + + /// + /// App activation, logged by or by automatic logging. + /// Internal on iOS, public on Android (EVENT_NAME_ACTIVATED_APP). FBSDKAppEventNameActivatedApp + /// + public const string ActivatedApp = "fb_mobile_activate_app"; + + /// + /// App deactivation, logged by the SDK. Internal on iOS, public on Android + /// (EVENT_NAME_DEACTIVATED_APP). FBSDKAppEventNameDeactivatedApp + /// + public const string DeactivatedApp = "fb_mobile_deactivate_app"; + + /// SDK initialization, logged by the SDK when automatic logging is on. FBSDKAppEventNameInitializeSDK + public const string InitializeSdk = "fb_sdk_initialize"; + + /// Background refresh available, logged by the SDK. FBSDKAppEventNameBackgroundStatusAvailable + public const string BackgroundStatusAvailable = "fb_sdk_background_status_available"; + + /// Background refresh denied, logged by the SDK. FBSDKAppEventNameBackgroundStatusDenied + public const string BackgroundStatusDenied = "fb_sdk_background_status_denied"; + + /// Background refresh restricted, logged by the SDK. FBSDKAppEventNameBackgroundStatusRestricted + public const string BackgroundStatusRestricted = "fb_sdk_background_status_restricted"; + + /// SDK settings changed, logged by the SDK. FBSDKAppEventNameSDKSettingsChanged + public const string SdkSettingsChanged = "fb_sdk_settings_changed"; +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventParameterNames.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventParameterNames.cs new file mode 100644 index 0000000..067481f --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventParameterNames.cs @@ -0,0 +1,249 @@ +namespace Kapusch.Facebook.iOS; + +/// +/// Standard Meta app event parameter names, as declared by +/// FBSDKAppEventParameterName.h and FBSDKAppEventParameterProduct.h in the +/// bundled Facebook iOS SDK. The names match AppEventsConstants.EVENT_PARAM_* on Android. +/// +/// +/// Each constant names its Objective-C symbol in a FBSDK… remark. The package +/// validation checks that every symbol of the bundled headers is covered and that each +/// value exists in the bundled FBSDKCoreKit binary. +/// +public static class FacebookAppEventParameterNames +{ + // General parameters. + + /// Generic content data. FBSDKAppEventParameterNameContent + public const string Content = "fb_content"; + + /// Content or product identifier (for example a SKU). FBSDKAppEventParameterNameContentID + public const string ContentId = "fb_content_id"; + + /// Content type, for example product. FBSDKAppEventParameterNameContentType + public const string ContentType = "fb_content_type"; + + /// ISO 4217 currency code. FBSDKAppEventParameterNameCurrency + public const string Currency = "fb_currency"; + + /// Free-form description. FBSDKAppEventParameterNameDescription + public const string Description = "fb_description"; + + /// Level reached. FBSDKAppEventParameterNameLevel + public const string Level = "fb_level"; + + /// Upper bound of a rating scale. FBSDKAppEventParameterNameMaxRatingValue + public const string MaxRatingValue = "fb_max_rating_value"; + + /// Number of items. FBSDKAppEventParameterNameNumItems + public const string NumItems = "fb_num_items"; + + /// Whether payment information is available (1/0). FBSDKAppEventParameterNamePaymentInfoAvailable + public const string PaymentInfoAvailable = "fb_payment_info_available"; + + /// Registration method, for example email. FBSDKAppEventParameterNameRegistrationMethod + public const string RegistrationMethod = "fb_registration_method"; + + /// Search query. FBSDKAppEventParameterNameSearchString + public const string SearchString = "fb_search_string"; + + /// Whether the activity succeeded (1/0). FBSDKAppEventParameterNameSuccess + public const string Success = "fb_success"; + + /// Ad type for and . FBSDKAppEventParameterNameAdType + public const string AdType = "ad_type"; + + /// + /// Order identifier. For and + /// , Meta documents it as the unique ID of + /// the subscription. FBSDKAppEventParameterNameOrderID + /// + public const string OrderId = "fb_order_id"; + + /// + /// Numeric value summed across events. Pass the valueToSum argument of + /// instead of this key. Internal on iOS, + /// public on Android (EVENT_PARAM_VALUE_TO_SUM). + /// + public const string ValueToSum = "_valueToSum"; + + /// Event name key used inside the SDK payloads. FBSDKAppEventParameterNameEventName + public const string EventName = "_eventName"; + + /// Event time key used inside the SDK payloads. FBSDKAppEventParameterNameLogTime + public const string LogTime = "_logTime"; + + // In-app purchase parameters set by the SDK's automatic purchase logging. + + /// Marks an automatically logged purchase. FBSDKAppEventParameterNameImplicitlyLoggedPurchase + public const string ImplicitlyLoggedPurchase = "_implicitlyLogged"; + + /// Transaction date. FBSDKAppEventParameterNameTransactionDate + public const string TransactionDate = "fb_transaction_date"; + + /// Product title. FBSDKAppEventParameterNameProductTitle + public const string ProductTitle = "fb_content_title"; + + /// Transaction identifier. FBSDKAppEventParameterNameTransactionID + public const string TransactionId = "fb_transaction_id"; + + /// In-app purchase product type. FBSDKAppEventParameterNameInAppPurchaseType + public const string InAppPurchaseType = "fb_iap_product_type"; + + /// Subscription period. FBSDKAppEventParameterNameSubscriptionPeriod + public const string SubscriptionPeriod = "fb_iap_subs_period"; + + /// Whether the purchase starts a trial. FBSDKAppEventParameterNameIsStartTrial + public const string IsStartTrial = "fb_iap_is_start_trial"; + + /// Whether the product has a free trial. FBSDKAppEventParameterNameHasFreeTrial + public const string HasFreeTrial = "fb_iap_has_free_trial"; + + /// Trial period. FBSDKAppEventParameterNameTrialPeriod + public const string TrialPeriod = "fb_iap_trial_period"; + + /// Trial price. FBSDKAppEventParameterNameTrialPrice + public const string TrialPrice = "fb_iap_trial_price"; + + /// Receipt validation result. FBSDKAppEventParameterNameValidationResult + public const string ValidationResult = "fb_iap_validation_result"; + + /// StoreKit client library version. FBSDKAppEventParameterNameIAPClientLibraryVersion + public const string IapClientLibraryVersion = "fb_iap_client_library_version"; + + /// StoreKit library versions supported by the SDK. FBSDKAppEventParameterNameIAPSDKLibraryVersions + public const string IapSdkLibraryVersions = "fb_iap_sdk_supported_library_versions"; + + /// Receipt data. FBSDKAppEventParameterNameIAPReceiptData + public const string IapReceiptData = "receipt_data"; + + /// Product classification. FBSDKAppEventParameterNameProductClassification + public const string ProductClassification = "fb_iap_product_classification"; + + /// Whether consumables are in the purchase history. FBSDKAppEventParameterNameConsumablesInPurchaseHistory + public const string ConsumablesInPurchaseHistory = "fb_iap_consumables_in_purchase_history"; + + /// Original transaction identifier. FBSDKAppEventParameterNameOriginalTransactionID + public const string OriginalTransactionId = "fb_original_transaction_id"; + + /// Original transaction date. FBSDKAppEventParameterNameOriginalTransactionDate + public const string OriginalTransactionDate = "fb_original_transaction_date"; + + // Product catalog parameters. + + /// Product category. FBSDKAppEventParameterProductCategory + public const string ProductCategory = "fb_product_category"; + + /// Product custom label 0. FBSDKAppEventParameterProductCustomLabel0 + public const string ProductCustomLabel0 = "fb_product_custom_label_0"; + + /// Product custom label 1. FBSDKAppEventParameterProductCustomLabel1 + public const string ProductCustomLabel1 = "fb_product_custom_label_1"; + + /// Product custom label 2. FBSDKAppEventParameterProductCustomLabel2 + public const string ProductCustomLabel2 = "fb_product_custom_label_2"; + + /// Product custom label 3. FBSDKAppEventParameterProductCustomLabel3 + public const string ProductCustomLabel3 = "fb_product_custom_label_3"; + + /// Product custom label 4. FBSDKAppEventParameterProductCustomLabel4 + public const string ProductCustomLabel4 = "fb_product_custom_label_4"; + + /// Product App Link iOS URL. FBSDKAppEventParameterProductAppLinkIOSUrl + public const string ProductAppLinkIosUrl = "fb_product_applink_ios_url"; + + /// Product App Link iOS App Store ID. FBSDKAppEventParameterProductAppLinkIOSAppStoreID + public const string ProductAppLinkIosAppStoreId = "fb_product_applink_ios_app_store_id"; + + /// Product App Link iOS app name. FBSDKAppEventParameterProductAppLinkIOSAppName + public const string ProductAppLinkIosAppName = "fb_product_applink_ios_app_name"; + + /// Product App Link iPhone URL. FBSDKAppEventParameterProductAppLinkIPhoneUrl + public const string ProductAppLinkIPhoneUrl = "fb_product_applink_iphone_url"; + + /// Product App Link iPhone App Store ID. FBSDKAppEventParameterProductAppLinkIPhoneAppStoreID + public const string ProductAppLinkIPhoneAppStoreId = "fb_product_applink_iphone_app_store_id"; + + /// Product App Link iPhone app name. FBSDKAppEventParameterProductAppLinkIPhoneAppName + public const string ProductAppLinkIPhoneAppName = "fb_product_applink_iphone_app_name"; + + /// Product App Link iPad URL. FBSDKAppEventParameterProductAppLinkIPadUrl + public const string ProductAppLinkIPadUrl = "fb_product_applink_ipad_url"; + + /// Product App Link iPad App Store ID. FBSDKAppEventParameterProductAppLinkIPadAppStoreID + public const string ProductAppLinkIPadAppStoreId = "fb_product_applink_ipad_app_store_id"; + + /// Product App Link iPad app name. FBSDKAppEventParameterProductAppLinkIPadAppName + public const string ProductAppLinkIPadAppName = "fb_product_applink_ipad_app_name"; + + /// Product App Link Android URL. FBSDKAppEventParameterProductAppLinkAndroidUrl + public const string ProductAppLinkAndroidUrl = "fb_product_applink_android_url"; + + /// Product App Link Android package. FBSDKAppEventParameterProductAppLinkAndroidPackage + public const string ProductAppLinkAndroidPackage = "fb_product_applink_android_package"; + + /// Product App Link Android app name. FBSDKAppEventParameterProductAppLinkAndroidAppName + public const string ProductAppLinkAndroidAppName = "fb_product_applink_android_app_name"; + + /// Product App Link Windows Phone URL. FBSDKAppEventParameterProductAppLinkWindowsPhoneUrl + public const string ProductAppLinkWindowsPhoneUrl = "fb_product_applink_windows_phone_url"; + + /// Product App Link Windows Phone app ID. FBSDKAppEventParameterProductAppLinkWindowsPhoneAppID + public const string ProductAppLinkWindowsPhoneAppId = "fb_product_applink_windows_phone_app_id"; + + /// Product App Link Windows Phone app name. FBSDKAppEventParameterProductAppLinkWindowsPhoneAppName + public const string ProductAppLinkWindowsPhoneAppName = "fb_product_applink_windows_phone_app_name"; +} + +/// +/// Standard parameter values (FBSDKAppEventParameterValue.h). Boolean parameter +/// values passed to are converted to these strings. +/// +public static class FacebookAppEventParameterValues +{ + /// Yes / true. FBSDKAppEventParameterValueYes + public const string Yes = "1"; + + /// No / false. FBSDKAppEventParameterValueNo + public const string No = "0"; +} + +/// +/// Advanced-matching user data types (FBSDKAppEventUserDataType.h), for +/// . The SDK hashes values before sending them. +/// +public static class FacebookAppEventUserDataTypes +{ + /// Email. FBSDKAppEventEmail + public const string Email = "em"; + + /// First name. FBSDKAppEventFirstName + public const string FirstName = "fn"; + + /// Last name. FBSDKAppEventLastName + public const string LastName = "ln"; + + /// Phone number. FBSDKAppEventPhone + public const string Phone = "ph"; + + /// Date of birth (YYYYMMDD). FBSDKAppEventDateOfBirth + public const string DateOfBirth = "dob"; + + /// Gender (m or f). FBSDKAppEventGender + public const string Gender = "ge"; + + /// City. FBSDKAppEventCity + public const string City = "ct"; + + /// State or province. FBSDKAppEventState + public const string State = "st"; + + /// Zip or postal code. FBSDKAppEventZip + public const string Zip = "zp"; + + /// Country (ISO 3166-1 alpha-2). FBSDKAppEventCountry + public const string Country = "country"; + + /// Advertiser-side user identifier. iOS only in the native SDK API. FBSDKAppEventExternalId + public const string ExternalId = "external_id"; +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs new file mode 100644 index 0000000..daf2e2b --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs @@ -0,0 +1,629 @@ +using System.Buffers; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text.Json; + +namespace Kapusch.Facebook.iOS; + +/// +/// Meta App Events on iOS: SDK initialization, consent-related settings, event logging, +/// identity and AEM / deep-link forwarding. Thin facade over FBSDKAppEvents, +/// FBSDKSettings and FBSDKApplicationDelegate of the bundled Facebook iOS SDK. +/// +/// +/// +/// Available with every KapuschFacebookFeatures selection, because FBSDKCoreKit +/// is always linked. Select AppEvents alone to ship only CoreKit, AEMKit and +/// CoreKit_Basics. +/// +/// +/// The facade never enables anything on its own. Values set through the Set…Enabled +/// methods are persisted by the SDK and override the matching Info.plist keys on later +/// launches. Values set before are applied again once the SDK is +/// initialized, so the SDK persists them as well. +/// +/// +/// iOS has no counterpart of Android's setAutoInitEnabled: the SDK only starts when the +/// app calls , , +/// NativeFacebookLogin.Initialize or NativeFacebookShare.ConfigureAndInitialize. +/// +/// +public static unsafe partial class FacebookAppEvents +{ + private const string NativeLibraryName = "__Internal"; + + // SDK lifecycle. + + /// + /// Initializes the SDK (application:didFinishLaunchingWithOptions: with no launch + /// options). Idempotent. Runs on the main thread; when called from another thread it is + /// queued on the main thread. + /// + /// + /// Initialization contacts Meta to load the app's SDK configuration, even when automatic + /// event logging is disabled. Call it only once the user has consented. + /// + public static void InitializeSdk() => InitializeSdkNative(); + + /// + /// Forwards UIApplicationDelegate.FinishedLaunching to the SDK, including the launch + /// options (a cold-start URL is used for Aggregated Event Measurement). This initializes the + /// SDK like ; only call it at launch when the user has already + /// consented. Must be called on the main thread. + /// + /// UIApplication.Handle. + /// NSDictionary.Handle of the launch options, or . + /// The SDK result: when the SDK was already initialized. + public static bool FinishedLaunching(IntPtr uiApplicationHandle, IntPtr launchOptionsHandle) + { + if (uiApplicationHandle == IntPtr.Zero) + throw new ArgumentException("UIApplication is required.", nameof(uiApplicationHandle)); + + return FinishedLaunchingNative(uiApplicationHandle, launchOptionsHandle); + } + + /// + /// Forwards UIApplicationDelegate.OpenUrl (and scene URL contexts) to the SDK, for + /// Aggregated Event Measurement and App Links. Must be called on the main thread. + /// + /// when the SDK handled the URL. + public static bool HandleOpenUrl(IntPtr uiApplicationHandle, IntPtr nsUrlHandle, IntPtr optionsHandle) + { + if (uiApplicationHandle == IntPtr.Zero || nsUrlHandle == IntPtr.Zero) + return false; + + return HandleOpenUrlNative(uiApplicationHandle, nsUrlHandle, optionsHandle); + } + + /// + /// Forwards UIApplicationDelegate.ContinueUserActivity (Universal Links) to the SDK, + /// for Aggregated Event Measurement. Only browsing-web activities are used by the SDK. Must + /// be called on the main thread. + /// + /// when the SDK handled the activity. + public static bool ContinueUserActivity(IntPtr uiApplicationHandle, IntPtr nsUserActivityHandle) + { + if (uiApplicationHandle == IntPtr.Zero || nsUserActivityHandle == IntPtr.Zero) + return false; + + return ContinueUserActivityNative(uiApplicationHandle, nsUserActivityHandle); + } + + /// Version of the bundled Facebook iOS SDK, for diagnostics. + public static string SdkVersion => TakeNativeString(CopySdkVersionNative()) ?? string.Empty; + + // Settings. + + /// + /// Enables or disables automatic app event logging (FacebookAutoLogAppEventsEnabled): + /// app activation, install attribution ping and, when enabled in the Meta dashboard, + /// in-app purchases. When disabled, call to keep install and + /// activation events. + /// + public static void SetAutoLogAppEventsEnabled(bool enabled) => SetAutoLogAppEventsEnabledNative(enabled); + + /// Current automatic app event logging setting. + public static bool IsAutoLogAppEventsEnabled => IsAutoLogAppEventsEnabledNative(); + + /// + /// Enables or disables advertiser ID (IDFA) collection + /// (FacebookAdvertiserIDCollectionEnabled). iOS only returns a non-zero IDFA once App + /// Tracking Transparency is authorized. + /// + public static void SetAdvertiserIdCollectionEnabled(bool enabled) => SetAdvertiserIdCollectionEnabledNative(enabled); + + /// Current advertiser ID collection setting. + public static bool IsAdvertiserIdCollectionEnabled => IsAdvertiserIdCollectionEnabledNative(); + + /// + /// Sets the advertiser tracking flag. Only used on iOS 15 and 16: on iOS 17 and later the SDK + /// ignores it and reads the App Tracking Transparency status instead. + /// + public static void SetAdvertiserTrackingEnabled(bool enabled) => SetAdvertiserTrackingEnabledNative(enabled); + + /// Current advertiser tracking flag (derived from App Tracking Transparency on iOS 17+). + public static bool IsAdvertiserTrackingEnabled => IsAdvertiserTrackingEnabledNative(); + + /// + /// Enables or disables SKAdNetwork reporting by the SDK (FacebookSKAdNetworkReportEnabled). + /// Disable it when another SDK owns SKAdNetwork conversion values. + /// + public static void SetSKAdNetworkReportEnabled(bool enabled) => SetSKAdNetworkReportEnabledNative(enabled); + + /// Current SKAdNetwork reporting setting. + public static bool IsSKAdNetworkReportEnabled => IsSKAdNetworkReportEnabledNative(); + + /// + /// Enables or disables the SDK 19 metadata collection (screen titles and App Link URLs + /// attached to events; FBSDKAutoLogMetaDataEnabled). + /// + public static void SetMetadataCollectionEnabled(bool enabled) => SetMetadataCollectionEnabledNative(enabled); + + /// Current metadata collection setting. + public static bool IsMetadataCollectionEnabled => IsMetadataCollectionEnabledNative(); + + /// + /// Enables or disables the SDK 19 product-improvement switch + /// (FBSDKAutoLogProductImprovementEnabled). SDK 19.0.0 adds the control only and does + /// not collect such data yet. + /// + public static void SetAutoLogProductImprovementEnabled(bool enabled) => SetAutoLogProductImprovementEnabledNative(enabled); + + /// Current product-improvement setting. + public static bool IsAutoLogProductImprovementEnabled => IsAutoLogProductImprovementEnabledNative(); + + /// + /// Sets data processing options, for example + /// [FacebookDataProcessingOptions.LimitedDataUse] with country and state 0 to let Meta + /// geolocate the user. An empty array explicitly disables Limited Data Use. + /// + public static void SetDataProcessingOptions(string[] options, int country = 0, int state = 0) + { + ArgumentNullException.ThrowIfNull(options); + foreach (var option in options) + { + if (string.IsNullOrWhiteSpace(option)) + throw new ArgumentException("Data processing options cannot be empty.", nameof(options)); + } + + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartArray(); + foreach (var option in options) + writer.WriteStringValue(option); + writer.WriteEndArray(); + } + + fixed (byte* json = NullTerminated(buffer)) + SetDataProcessingOptionsNative(json, country, state); + } + + /// + /// Enables or disables SDK console logging for app events, developer errors and + /// informational messages, for diagnostics (shown in the device log / Xcode console). + /// Disabling restores the logging behaviours that were active before. + /// + public static void SetDebugLoggingEnabled(bool enabled) => SetDebugLoggingEnabledNative(enabled); + + // Event logging. + + /// + /// Logs fb_mobile_activate_app and, once per install, the install attribution ping. + /// Call it when the app becomes active if automatic logging is disabled. The SDK only logs + /// an activation after 60 seconds or more of inactivity. + /// + public static void ActivateApp() => ActivateAppNative(); + + /// Logs an app event. + /// A constant or a custom name (2-40 characters: letters, digits, _, - or spaces). + /// Optional parameters. Values must be strings, numbers or booleans (sent as 1/0). + /// Optional value summed across events of this name, for example a price. + public static void LogEvent( + string eventName, + IReadOnlyDictionary? parameters = null, + double? valueToSum = null + ) + { + if (string.IsNullOrWhiteSpace(eventName)) + throw new ArgumentException("Event name is required.", nameof(eventName)); + if (valueToSum is { } value && !double.IsFinite(value)) + throw new ArgumentOutOfRangeException(nameof(valueToSum), "Value to sum must be finite."); + + var json = SerializeParameters(parameters); + bool logged; + fixed (byte* parametersJson = json) + { + logged = LogEventNative( + eventName, + parametersJson, + valueToSum.HasValue, + valueToSum.GetValueOrDefault() + ); + } + + if (!logged) + throw new InvalidOperationException("The native App Events facade rejected the event."); + } + + /// + /// Logs fb_mobile_purchase. The SDK rounds the amount to three decimals and flushes + /// immediately unless the flush behaviour is . + /// Do not use it for purchases that a server already reports to Meta. + /// + /// Purchase amount in . + /// ISO 4217 currency code, for example EUR. + /// Optional parameters (strings, numbers or booleans). + public static void LogPurchase( + decimal amount, + string currencyIsoCode, + IReadOnlyDictionary? parameters = null + ) + { + var currency = NormalizeCurrency(currencyIsoCode, nameof(currencyIsoCode)); + var json = SerializeParameters(parameters); + bool logged; + fixed (byte* parametersJson = json) + logged = LogPurchaseNative(decimal.ToDouble(amount), currency, parametersJson); + + if (!logged) + throw new InvalidOperationException("The native App Events facade rejected the purchase."); + } + + /// + /// Logs . Do not use it when a server (for + /// example a subscription platform) already sends trial events to Meta. + /// + /// Optional value (for example the price after the trial), sent as valueToSum. + /// ISO 4217 currency code; required with . + /// Optional subscription identifier, sent as fb_order_id. + /// Optional extra parameters. The typed arguments win on conflicts. + public static void LogStartTrial( + decimal? value = null, + string? currencyIsoCode = null, + string? orderId = null, + IReadOnlyDictionary? parameters = null + ) => LogValueEvent(FacebookAppEventNames.StartTrial, value, currencyIsoCode, orderId, parameters); + + /// + /// Logs . Do not use it when a server (for + /// example a subscription platform) already sends subscription events to Meta. + /// + /// Optional subscription price, sent as valueToSum. + /// ISO 4217 currency code; required with . + /// Optional subscription identifier, sent as fb_order_id. + /// Optional extra parameters. The typed arguments win on conflicts. + public static void LogSubscribe( + decimal? value = null, + string? currencyIsoCode = null, + string? orderId = null, + IReadOnlyDictionary? parameters = null + ) => LogValueEvent(FacebookAppEventNames.Subscribe, value, currencyIsoCode, orderId, parameters); + + /// Logs . + /// Optional method, for example email, sent as fb_registration_method. + /// Optional value, sent as valueToSum. + /// ISO 4217 currency code; required with . + /// Optional extra parameters. The typed arguments win on conflicts. + public static void LogCompleteRegistration( + string? registrationMethod = null, + decimal? value = null, + string? currencyIsoCode = null, + IReadOnlyDictionary? parameters = null + ) + { + var merged = MergeParameters(parameters); + if (!string.IsNullOrWhiteSpace(registrationMethod)) + merged[FacebookAppEventParameterNames.RegistrationMethod] = registrationMethod; + + LogValueEvent(FacebookAppEventNames.CompletedRegistration, value, currencyIsoCode, orderId: null, merged); + } + + /// Sends queued events now. + public static void Flush() => FlushNative(); + + /// Sets when queued events are sent. + public static void SetFlushBehavior(FacebookAppEventsFlushBehavior behavior) + { + if (!Enum.IsDefined(behavior)) + throw new ArgumentOutOfRangeException(nameof(behavior)); + + SetFlushBehaviorNative((int)behavior); + } + + /// Current flush behaviour. + public static FacebookAppEventsFlushBehavior FlushBehavior => + (FacebookAppEventsFlushBehavior)GetFlushBehaviorNative(); + + // Identity and advanced matching. + + /// + /// Anonymous, SDK-generated install identifier (AppEvents.shared.anonymousID), for + /// example for server-side attribution partners. Generated and persisted on first access. + /// + public static string? GetAnonymousId() => TakeNativeString(CopyAnonymousIdNative()); + + /// + /// Associates a custom user ID with subsequent events. Persisted by the SDK until cleared. + /// or empty clears it. + /// + public static void SetUserId(string? userId) => SetUserIdNative(string.IsNullOrEmpty(userId) ? null : userId); + + /// Clears the custom user ID. + public static void ClearUserId() => SetUserIdNative(null); + + /// Current custom user ID, if any. + public static string? GetUserId() => TakeNativeString(CopyUserIdNative()); + + /// + /// Sets advanced-matching user data. Replaces the ten standard fields at once; a + /// property clears that field. The SDK hashes the values. + /// + public static void SetUserData(FacebookAppEventsUserData userData) + { + ArgumentNullException.ThrowIfNull(userData); + SetUserDataNative( + userData.Email, + userData.FirstName, + userData.LastName, + userData.Phone, + userData.DateOfBirth, + userData.Gender, + userData.City, + userData.State, + userData.Zip, + userData.Country + ); + } + + /// + /// Sets one advanced-matching value, for example + /// . clears it. + /// + public static void SetUserDataValue(string userDataType, string? value) + { + if (string.IsNullOrWhiteSpace(userDataType)) + throw new ArgumentException("User data type is required.", nameof(userDataType)); + + SetUserDataForTypeNative(userDataType, value); + } + + /// Clears all advanced-matching user data. + public static void ClearUserData() => ClearUserDataNative(); + + /// Clears one advanced-matching value. + public static void ClearUserDataValue(string userDataType) + { + if (string.IsNullOrWhiteSpace(userDataType)) + throw new ArgumentException("User data type is required.", nameof(userDataType)); + + ClearUserDataForTypeNative(userDataType); + } + + // Helpers. + + private static void LogValueEvent( + string eventName, + decimal? value, + string? currencyIsoCode, + string? orderId, + IReadOnlyDictionary? parameters + ) + { + var merged = MergeParameters(parameters); + if (value.HasValue && string.IsNullOrWhiteSpace(currencyIsoCode)) + throw new ArgumentException("A currency is required with a value.", nameof(currencyIsoCode)); + if (!string.IsNullOrWhiteSpace(currencyIsoCode)) + merged[FacebookAppEventParameterNames.Currency] = NormalizeCurrency(currencyIsoCode, nameof(currencyIsoCode)); + if (!string.IsNullOrWhiteSpace(orderId)) + merged[FacebookAppEventParameterNames.OrderId] = orderId; + + LogEvent(eventName, merged, value.HasValue ? decimal.ToDouble(value.Value) : null); + } + + private static Dictionary MergeParameters(IReadOnlyDictionary? parameters) + { + var merged = new Dictionary(StringComparer.Ordinal); + if (parameters is null) + return merged; + + foreach (var (key, value) in parameters) + merged[key] = value; + return merged; + } + + private static string NormalizeCurrency(string? currencyIsoCode, string parameterName) + { + var currency = currencyIsoCode?.Trim(); + if (currency is not { Length: 3 } || !currency.All(char.IsAsciiLetter)) + throw new ArgumentException("Currency must be an ISO 4217 code such as EUR.", parameterName); + + return currency.ToUpperInvariant(); + } + + private static byte[]? SerializeParameters(IReadOnlyDictionary? parameters) + { + if (parameters is null || parameters.Count == 0) + return null; + + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + foreach (var (key, value) in parameters) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Parameter names cannot be empty.", nameof(parameters)); + + writer.WritePropertyName(key); + WriteParameterValue(writer, key, value); + } + writer.WriteEndObject(); + } + + return NullTerminated(buffer); + } + + private static void WriteParameterValue(Utf8JsonWriter writer, string key, object? value) + { + switch (value) + { + case string text: + writer.WriteStringValue(text); + break; + case bool flag: + writer.WriteStringValue(flag ? FacebookAppEventParameterValues.Yes : FacebookAppEventParameterValues.No); + break; + case byte or sbyte or short or ushort or int or uint or long: + writer.WriteNumberValue(Convert.ToInt64(value, CultureInfo.InvariantCulture)); + break; + case ulong unsignedLong: + writer.WriteNumberValue(unsignedLong); + break; + case float or double: + var number = Convert.ToDouble(value, CultureInfo.InvariantCulture); + if (!double.IsFinite(number)) + throw new ArgumentException($"Parameter '{key}' must be a finite number.", nameof(value)); + writer.WriteNumberValue(number); + break; + case decimal amount: + writer.WriteNumberValue(amount); + break; + default: + throw new ArgumentException( + $"Parameter '{key}' must be a string, a number or a boolean.", + nameof(value) + ); + } + } + + private static byte[] NullTerminated(ArrayBufferWriter buffer) + { + var bytes = new byte[buffer.WrittenCount + 1]; + buffer.WrittenSpan.CopyTo(bytes); + return bytes; + } + + private static string? TakeNativeString(IntPtr pointer) + { + if (pointer == IntPtr.Zero) + return null; + + try + { + return Marshal.PtrToStringUTF8(pointer); + } + finally + { + FreeNative(pointer); + } + } + + // Native entry points (kfbappevents.xcframework, always linked). + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_initialize_sdk")] + private static partial void InitializeSdkNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_finished_launching")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool FinishedLaunchingNative(IntPtr uiApplicationHandle, IntPtr launchOptionsHandle); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_handle_open_url")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool HandleOpenUrlNative(IntPtr uiApplicationHandle, IntPtr nsUrlHandle, IntPtr optionsHandle); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_continue_user_activity")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool ContinueUserActivityNative(IntPtr uiApplicationHandle, IntPtr nsUserActivityHandle); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_copy_sdk_version")] + private static partial IntPtr CopySdkVersionNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_auto_log_app_events_enabled")] + private static partial void SetAutoLogAppEventsEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_auto_log_app_events_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsAutoLogAppEventsEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_advertiser_id_collection_enabled")] + private static partial void SetAdvertiserIdCollectionEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_advertiser_id_collection_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsAdvertiserIdCollectionEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_advertiser_tracking_enabled")] + private static partial void SetAdvertiserTrackingEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_advertiser_tracking_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsAdvertiserTrackingEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_skadnetwork_report_enabled")] + private static partial void SetSKAdNetworkReportEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_skadnetwork_report_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsSKAdNetworkReportEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_metadata_collection_enabled")] + private static partial void SetMetadataCollectionEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_metadata_collection_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsMetadataCollectionEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_auto_log_product_improvement_enabled")] + private static partial void SetAutoLogProductImprovementEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_auto_log_product_improvement_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsAutoLogProductImprovementEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_data_processing_options")] + private static partial void SetDataProcessingOptionsNative(byte* optionsJson, int country, int state); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_debug_logging_enabled")] + private static partial void SetDebugLoggingEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_activate_app")] + private static partial void ActivateAppNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_log_event", StringMarshalling = StringMarshalling.Utf8)] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool LogEventNative( + string eventName, + byte* parametersJson, + [MarshalAs(UnmanagedType.U1)] bool hasValueToSum, + double valueToSum + ); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_log_purchase", StringMarshalling = StringMarshalling.Utf8)] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool LogPurchaseNative(double amount, string currency, byte* parametersJson); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_flush")] + private static partial void FlushNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_flush_behavior")] + private static partial void SetFlushBehaviorNative(int behavior); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_get_flush_behavior")] + private static partial int GetFlushBehaviorNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_copy_anonymous_id")] + private static partial IntPtr CopyAnonymousIdNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_user_id", StringMarshalling = StringMarshalling.Utf8)] + private static partial void SetUserIdNative(string? userId); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_copy_user_id")] + private static partial IntPtr CopyUserIdNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_user_data", StringMarshalling = StringMarshalling.Utf8)] + private static partial void SetUserDataNative( + string? email, + string? firstName, + string? lastName, + string? phone, + string? dateOfBirth, + string? gender, + string? city, + string? state, + string? zip, + string? country + ); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_user_data_for_type", StringMarshalling = StringMarshalling.Utf8)] + private static partial void SetUserDataForTypeNative(string type, string? value); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_clear_user_data")] + private static partial void ClearUserDataNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_clear_user_data_for_type", StringMarshalling = StringMarshalling.Utf8)] + private static partial void ClearUserDataForTypeNative(string type); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_free")] + private static partial void FreeNative(IntPtr pointer); +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs new file mode 100644 index 0000000..4f19429 --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs @@ -0,0 +1,56 @@ +namespace Kapusch.Facebook.iOS; + +/// When the SDK sends queued app events (FBSDKAppEventsFlushBehavior). +public enum FacebookAppEventsFlushBehavior +{ + /// Flush periodically, at an event-count threshold and on app reactivation (SDK default). + Auto = 0, + + /// Only flush on . Events are persisted meanwhile. + ExplicitOnly = 1, +} + +/// +/// Advanced-matching user data passed to . +/// The SDK normalizes and hashes (SHA-256) each value on the device before sending it. +/// A property clears that field. +/// +public sealed record FacebookAppEventsUserData +{ + /// Email address. + public string? Email { get; init; } + + /// First name. + public string? FirstName { get; init; } + + /// Last name. + public string? LastName { get; init; } + + /// Phone number, including the country code. + public string? Phone { get; init; } + + /// Date of birth, formatted YYYYMMDD. + public string? DateOfBirth { get; init; } + + /// Gender: m or f. + public string? Gender { get; init; } + + /// City. + public string? City { get; init; } + + /// State or province code. + public string? State { get; init; } + + /// Zip or postal code. + public string? Zip { get; init; } + + /// Country code (ISO 3166-1 alpha-2). + public string? Country { get; init; } +} + +/// Data processing options for . +public static class FacebookDataProcessingOptions +{ + /// Limited Data Use. Pass an empty array to explicitly not enable it. + public const string LimitedDataUse = "LDU"; +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/NativeFacebookLogin.iOS.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/NativeFacebookLogin.iOS.cs index c9d4dcc..5a2a887 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Facebook/NativeFacebookLogin.iOS.cs +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/NativeFacebookLogin.iOS.cs @@ -3,7 +3,7 @@ namespace Kapusch.Facebook.iOS; -public static unsafe class NativeFacebookLogin +public static unsafe partial class NativeFacebookLogin { public static void Initialize(IntPtr uiApplicationHandle, IntPtr launchOptionsHandle) { @@ -12,10 +12,7 @@ public static void Initialize(IntPtr uiApplicationHandle, IntPtr launchOptionsHa try { - ResolveInitialize()( - uiApplicationHandle, - launchOptionsHandle - ); + InitializeNative(uiApplicationHandle, launchOptionsHandle); } catch { @@ -34,11 +31,7 @@ IntPtr optionsHandle try { - return ResolveHandleOpenUrl()( - uiApplicationHandle, - nsUrlHandle, - optionsHandle - ) != 0; + return HandleOpenUrlNative(uiApplicationHandle, nsUrlHandle, optionsHandle) != 0; } catch { @@ -71,7 +64,7 @@ public static Task SignInAsync( : Marshal.StringToCoTaskMemUTF8(rawNonce); try { - ResolveSignInStart()( + SignInStartNative( presentingViewControllerHandle, (int)trackingMode, noncePointer, @@ -101,7 +94,7 @@ public static void SignOut() { try { - ResolveSignOut()(); + SignOutNative(); } catch { @@ -144,32 +137,29 @@ IntPtr context } } - private static IntPtr Resolve(string symbol) => - NativeLibrary.GetExport(NativeLibrary.GetMainProgramHandle(), symbol); + // Static __Internal imports make the .NET iOS build ask the native linker for + // these symbols, so they survive dead stripping. The previous run-time lookup in + // the main program found nothing once the linker had stripped them. - private static delegate* unmanaged[Cdecl] ResolveInitialize() => - (delegate* unmanaged[Cdecl])Resolve("kfb_facebook_initialize"); + [LibraryImport("__Internal", EntryPoint = "kfb_facebook_initialize")] + private static partial void InitializeNative(IntPtr uiApplicationHandle, IntPtr launchOptionsHandle); - private static delegate* unmanaged[Cdecl] ResolveHandleOpenUrl() => - (delegate* unmanaged[Cdecl])Resolve( - "kfb_facebook_handle_open_url" - ); + [LibraryImport("__Internal", EntryPoint = "kfb_facebook_handle_open_url")] + private static partial byte HandleOpenUrlNative( + IntPtr uiApplicationHandle, + IntPtr nsUrlHandle, + IntPtr optionsHandle + ); + + [LibraryImport("__Internal", EntryPoint = "kfb_facebook_signin_start")] + private static partial void SignInStartNative( + IntPtr presentingViewControllerHandle, + int trackingMode, + IntPtr nonce, + delegate* unmanaged[Cdecl] callback, + IntPtr context + ); - private static delegate* unmanaged[Cdecl]< - IntPtr, - int, - IntPtr, - delegate* unmanaged[Cdecl], - IntPtr, - void> ResolveSignInStart() => - (delegate* unmanaged[Cdecl]< - IntPtr, - int, - IntPtr, - delegate* unmanaged[Cdecl], - IntPtr, - void>)Resolve("kfb_facebook_signin_start"); - - private static delegate* unmanaged[Cdecl] ResolveSignOut() => - (delegate* unmanaged[Cdecl])Resolve("kfb_facebook_signout"); + [LibraryImport("__Internal", EntryPoint = "kfb_facebook_signout")] + private static partial void SignOutNative(); } diff --git a/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj b/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj index e7a2f17..ef091de 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj +++ b/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj @@ -7,7 +7,7 @@ $(DefaultItemExcludes);Native/** Kapusch.Facebook.iOS Kapusch - Facebook iOS auth interop wrapper + classic xcframework packaging. + Facebook iOS SDK for .NET iOS: Login, photo Share and Meta App Events interop with bundled xcframeworks. MIT https://github.com/Kapusch/FacebookApisForiOSComponents true @@ -16,16 +16,28 @@ $(NoWarn);NU5128 Kapusch.Facebook.iOS Kapusch.Facebook.iOS - 1.1.0 + 1.2.0 nuget-readme.md Native/iOS/build/kfb.xcframework + Native/iOS/build/kfbloginstub.xcframework Native/iOS/build/kfbshare.xcframework + Native/iOS/build/kfbsharestub.xcframework + Native/iOS/build/kfbappevents.xcframework Native/iOS/build/fb + + + + + + + + + @@ -65,10 +86,22 @@ Condition="'$(InteropIosWrapperDir)' == '' or !Exists('$(InteropIosWrapperDir)')" Text="Missing iOS native wrapper. Build it first via src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh or provide InteropIosWrapperDir." /> + + + +#include + +#include "KapuschFacebookAppEventsInterop.h" +#include "KapuschFacebookSettingsRegistry.h" + +static os_log_t KFBAppEventsLog(void) +{ + static os_log_t log; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSString *subsystem = NSBundle.mainBundle.bundleIdentifier ?: @"Kapusch.Facebook.iOS"; + log = os_log_create(subsystem.UTF8String, "FacebookAppEvents"); + }); + return log; +} + +#pragma mark - Explicit settings registry + +// Settings changed through this facade, by name, with their last value. +// +// - The values are applied again right after the SDK is initialized. The SDK +// keeps a value set before initialization in memory only; re-applying it once +// the SDK is configured lets the SDK persist it, like any later change. +// - Other wrappers in this package look the registry up through the +// Objective-C runtime (no link-time dependency) so they do not override a +// setting the app configured explicitly. +// +// The class name, selectors and setting names are a contract with +// KapuschFacebookSettingsRegistry.h; keep them in sync. +@interface KFBAppEventsSettingsRegistry : NSObject ++ (void)recordValue:(BOOL)value forSetting:(NSString *)setting; ++ (nullable NSNumber *)explicitValueForSetting:(NSString *)setting; ++ (NSDictionary *)explicitValues; ++ (void)applyExplicitValues; +@end + +static void KFBReapplyExplicitSettings(void); + +@implementation KFBAppEventsSettingsRegistry + ++ (NSMutableDictionary *)storage +{ + static NSMutableDictionary *storage; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + storage = [NSMutableDictionary dictionary]; + }); + return storage; +} + ++ (void)recordValue:(BOOL)value forSetting:(NSString *)setting +{ + @synchronized(self) { + [self storage][setting] = @(value); + } +} + ++ (nullable NSNumber *)explicitValueForSetting:(NSString *)setting +{ + @synchronized(self) { + return [self storage][setting]; + } +} + ++ (NSDictionary *)explicitValues +{ + @synchronized(self) { + return [[self storage] copy]; + } +} + ++ (void)applyExplicitValues +{ + KFBReapplyExplicitSettings(); +} + +@end + +// Settings shared with the Login and Share wrappers come from KapuschFacebookSettingsRegistry.h. +#define KFBSettingAutoLogAppEvents KFBRegistrySettingAutoLogAppEvents +#define KFBSettingAdvertiserIDCollection KFBRegistrySettingAdvertiserIDCollection +#define KFBSettingAdvertiserTracking KFBRegistrySettingAdvertiserTracking +static NSString *const KFBSettingSKAdNetworkReport = @"skAdNetworkReport"; +static NSString *const KFBSettingMetadataCollection = @"metadataCollection"; +static NSString *const KFBSettingAutoLogProductImprovement = @"autoLogProductImprovement"; + +static void KFBApplySetting(NSString *setting, BOOL value) +{ + FBSDKSettings *settings = FBSDKSettings.sharedSettings; + if ([setting isEqualToString:KFBSettingAutoLogAppEvents]) { + settings.isAutoLogAppEventsEnabled = value; + } else if ([setting isEqualToString:KFBSettingAdvertiserIDCollection]) { + settings.isAdvertiserIDCollectionEnabled = value; + } else if ([setting isEqualToString:KFBSettingAdvertiserTracking]) { + // Deprecated by Meta on iOS 17+, where the SDK reads the App Tracking + // Transparency status instead and ignores this value. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + settings.isAdvertiserTrackingEnabled = value; +#pragma clang diagnostic pop + } else if ([setting isEqualToString:KFBSettingSKAdNetworkReport]) { + settings.isSKAdNetworkReportEnabled = value; + } else if ([setting isEqualToString:KFBSettingMetadataCollection]) { + settings.isMetaDataCollectionEnabled = value; + } else if ([setting isEqualToString:KFBSettingAutoLogProductImprovement]) { + settings.isAutoLogProductImprovementEnabled = value; + } +} + +static void KFBSetExplicitSetting(NSString *setting, bool value) +{ + [KFBAppEventsSettingsRegistry recordValue:value forSetting:setting]; + KFBApplySetting(setting, value); +} + +static void KFBReapplyExplicitSettings(void) +{ + [[KFBAppEventsSettingsRegistry explicitValues] + enumerateKeysAndObjectsUsingBlock:^(NSString *setting, NSNumber *value, BOOL *stop) { + KFBApplySetting(setting, value.boolValue); + }]; +} + +#pragma mark - Helpers + +static void KFBRunOnMainThread(dispatch_block_t block) +{ + if (NSThread.isMainThread) { + block(); + } else { + dispatch_async(dispatch_get_main_queue(), block); + } +} + +static NSString *KFBStringOrNil(const char *value) +{ + return value == NULL ? nil : [NSString stringWithUTF8String:value]; +} + +static const char *KFBCopyString(NSString *value) +{ + if (value == nil) { + return NULL; + } + const char *utf8 = value.UTF8String; + return utf8 == NULL ? NULL : strdup(utf8); +} + +static id KFBParseJson(const char *json) +{ + if (json == NULL) { + return nil; + } + NSData *data = [NSData dataWithBytes:json length:strlen(json)]; + NSError *error = nil; + id value = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if (value == nil) { + os_log_error( + KFBAppEventsLog(), + "Invalid JSON payload from managed code: %{public}@", + error.localizedDescription); + } + return value; +} + +// Parses event parameters. Returns NO when the payload is not a JSON object of +// string or number values; *parameters is nil when no payload was given. +static BOOL KFBParseParameters(const char *json, NSDictionary **parameters) +{ + *parameters = nil; + if (json == NULL) { + return YES; + } + + id value = KFBParseJson(json); + if (![value isKindOfClass:NSDictionary.class]) { + return NO; + } + + __block BOOL valid = YES; + [(NSDictionary *)value enumerateKeysAndObjectsUsingBlock:^(id key, id object, BOOL *stop) { + if (![key isKindOfClass:NSString.class] + || !([object isKindOfClass:NSString.class] || [object isKindOfClass:NSNumber.class])) { + valid = NO; + *stop = YES; + } + }]; + if (!valid) { + os_log_error(KFBAppEventsLog(), "App event parameters must map strings to strings or numbers."); + return NO; + } + + *parameters = (NSDictionary *)value; + return YES; +} + +static NSDictionary *KFBOpenUrlOptions(void *optionsPtr) +{ + NSDictionary *options = optionsPtr == NULL ? nil : (__bridge NSDictionary *)optionsPtr; + return [options isKindOfClass:NSDictionary.class] ? options : @{}; +} + +#pragma mark - SDK lifecycle + +static BOOL KFBFinishLaunching(UIApplication *application, NSDictionary *launchOptions) +{ + BOOL result = [FBSDKApplicationDelegate.sharedInstance application:application + didFinishLaunchingWithOptions:launchOptions]; + KFBReapplyExplicitSettings(); + return result; +} + +KFB_EXPORT void kfb_appevents_initialize_sdk(void) +{ + KFBRunOnMainThread(^{ + KFBFinishLaunching(UIApplication.sharedApplication, nil); + }); +} + +KFB_EXPORT bool kfb_appevents_finished_launching(void *applicationPtr, void *launchOptionsPtr) +{ + UIApplication *application = (__bridge UIApplication *)applicationPtr; + NSDictionary *launchOptions = launchOptionsPtr == NULL + ? nil + : (__bridge NSDictionary *)launchOptionsPtr; + return KFBFinishLaunching(application, launchOptions); +} + +KFB_EXPORT bool kfb_appevents_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr) +{ + UIApplication *application = (__bridge UIApplication *)applicationPtr; + NSURL *url = (__bridge NSURL *)urlPtr; + return [FBSDKApplicationDelegate.sharedInstance application:application + openURL:url + options:KFBOpenUrlOptions(optionsPtr)]; +} + +KFB_EXPORT bool kfb_appevents_continue_user_activity(void *applicationPtr, void *userActivityPtr) +{ + UIApplication *application = (__bridge UIApplication *)applicationPtr; + NSUserActivity *userActivity = (__bridge NSUserActivity *)userActivityPtr; + return [FBSDKApplicationDelegate.sharedInstance application:application + continueUserActivity:userActivity]; +} + +KFB_EXPORT const char *kfb_appevents_copy_sdk_version(void) +{ + return KFBCopyString(FBSDKSettings.sharedSettings.sdkVersion); +} + +#pragma mark - Settings + +KFB_EXPORT void kfb_appevents_set_auto_log_app_events_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingAutoLogAppEvents, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_auto_log_app_events_enabled(void) +{ + return FBSDKSettings.sharedSettings.isAutoLogAppEventsEnabled; +} + +KFB_EXPORT void kfb_appevents_set_advertiser_id_collection_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingAdvertiserIDCollection, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_advertiser_id_collection_enabled(void) +{ + return FBSDKSettings.sharedSettings.isAdvertiserIDCollectionEnabled; +} + +KFB_EXPORT void kfb_appevents_set_advertiser_tracking_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingAdvertiserTracking, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_advertiser_tracking_enabled(void) +{ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + return FBSDKSettings.sharedSettings.isAdvertiserTrackingEnabled; +#pragma clang diagnostic pop +} + +KFB_EXPORT void kfb_appevents_set_skadnetwork_report_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingSKAdNetworkReport, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_skadnetwork_report_enabled(void) +{ + return FBSDKSettings.sharedSettings.isSKAdNetworkReportEnabled; +} + +KFB_EXPORT void kfb_appevents_set_metadata_collection_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingMetadataCollection, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_metadata_collection_enabled(void) +{ + return FBSDKSettings.sharedSettings.isMetaDataCollectionEnabled; +} + +KFB_EXPORT void kfb_appevents_set_auto_log_product_improvement_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingAutoLogProductImprovement, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_auto_log_product_improvement_enabled(void) +{ + return FBSDKSettings.sharedSettings.isAutoLogProductImprovementEnabled; +} + +KFB_EXPORT void kfb_appevents_set_data_processing_options(const char *optionsJson, int32_t country, int32_t state) +{ + id value = KFBParseJson(optionsJson); + if (![value isKindOfClass:NSArray.class]) { + os_log_error(KFBAppEventsLog(), "Data processing options must be a JSON array of strings."); + return; + } + for (id option in (NSArray *)value) { + if (![option isKindOfClass:NSString.class]) { + os_log_error(KFBAppEventsLog(), "Data processing options must be a JSON array of strings."); + return; + } + } + [FBSDKSettings.sharedSettings setDataProcessingOptions:(NSArray *)value + country:country + state:state]; +} + +static NSSet *KFBDebugLoggingBehaviors(void) +{ + return [NSSet setWithObjects: + FBSDKLoggingBehaviorAppEvents, + FBSDKLoggingBehaviorDeveloperErrors, + FBSDKLoggingBehaviorInformational, + nil]; +} + +KFB_EXPORT void kfb_appevents_set_debug_logging_enabled(bool enabled) +{ + // Remember the behaviours that were active before enabling diagnostics, so + // disabling them restores the SDK defaults instead of silencing everything. + static NSSet *previousBehaviors = nil; + @synchronized(KFBAppEventsSettingsRegistry.class) { + FBSDKSettings *settings = FBSDKSettings.sharedSettings; + if (enabled) { + if (previousBehaviors == nil) { + previousBehaviors = [settings.loggingBehaviors copy]; + } + settings.loggingBehaviors = [settings.loggingBehaviors setByAddingObjectsFromSet:KFBDebugLoggingBehaviors()]; + } else if (previousBehaviors != nil) { + settings.loggingBehaviors = previousBehaviors; + previousBehaviors = nil; + } + } +} + +#pragma mark - Event logging + +KFB_EXPORT void kfb_appevents_activate_app(void) +{ + KFBRunOnMainThread(^{ + [FBSDKAppEvents.shared activateApp]; + }); +} + +KFB_EXPORT bool kfb_appevents_log_event(const char *eventName, const char *parametersJson, bool hasValueToSum, double valueToSum) +{ + NSString *name = KFBStringOrNil(eventName); + NSDictionary *parameters = nil; + if (name.length == 0 || !KFBParseParameters(parametersJson, ¶meters)) { + return false; + } + + if (hasValueToSum) { + [FBSDKAppEvents.shared logEvent:name valueToSum:valueToSum parameters:parameters]; + } else { + [FBSDKAppEvents.shared logEvent:name parameters:parameters]; + } + return true; +} + +KFB_EXPORT bool kfb_appevents_log_purchase(double amount, const char *currency, const char *parametersJson) +{ + NSString *currencyCode = KFBStringOrNil(currency); + NSDictionary *parameters = nil; + if (currencyCode.length == 0 || !KFBParseParameters(parametersJson, ¶meters)) { + return false; + } + + [FBSDKAppEvents.shared logPurchase:amount currency:currencyCode parameters:parameters]; + return true; +} + +KFB_EXPORT void kfb_appevents_flush(void) +{ + [FBSDKAppEvents.shared flush]; +} + +KFB_EXPORT void kfb_appevents_set_flush_behavior(int32_t behavior) +{ + FBSDKAppEvents.shared.flushBehavior = behavior == (int32_t)FBSDKAppEventsFlushBehaviorExplicitOnly + ? FBSDKAppEventsFlushBehaviorExplicitOnly + : FBSDKAppEventsFlushBehaviorAuto; +} + +KFB_EXPORT int32_t kfb_appevents_get_flush_behavior(void) +{ + return (int32_t)FBSDKAppEvents.shared.flushBehavior; +} + +#pragma mark - Identity and advanced matching + +KFB_EXPORT const char *kfb_appevents_copy_anonymous_id(void) +{ + return KFBCopyString(FBSDKAppEvents.shared.anonymousID); +} + +KFB_EXPORT void kfb_appevents_set_user_id(const char *userId) +{ + NSString *value = KFBStringOrNil(userId); + FBSDKAppEvents.shared.userID = value.length == 0 ? nil : value; +} + +KFB_EXPORT const char *kfb_appevents_copy_user_id(void) +{ + return KFBCopyString(FBSDKAppEvents.shared.userID); +} + +KFB_EXPORT void kfb_appevents_set_user_data( + const char *email, + const char *firstName, + const char *lastName, + const char *phone, + const char *dateOfBirth, + const char *gender, + const char *city, + const char *state, + const char *zip, + const char *country) +{ + [FBSDKAppEvents.shared setUserEmail:KFBStringOrNil(email) + firstName:KFBStringOrNil(firstName) + lastName:KFBStringOrNil(lastName) + phone:KFBStringOrNil(phone) + dateOfBirth:KFBStringOrNil(dateOfBirth) + gender:KFBStringOrNil(gender) + city:KFBStringOrNil(city) + state:KFBStringOrNil(state) + zip:KFBStringOrNil(zip) + country:KFBStringOrNil(country)]; +} + +KFB_EXPORT void kfb_appevents_set_user_data_for_type(const char *type, const char *value) +{ + NSString *dataType = KFBStringOrNil(type); + if (dataType.length == 0) { + return; + } + [FBSDKAppEvents.shared setUserData:KFBStringOrNil(value) forType:dataType]; +} + +KFB_EXPORT void kfb_appevents_clear_user_data(void) +{ + [FBSDKAppEvents.shared clearUserData]; +} + +KFB_EXPORT void kfb_appevents_clear_user_data_for_type(const char *type) +{ + NSString *dataType = KFBStringOrNil(type); + if (dataType.length == 0) { + return; + } + [FBSDKAppEvents.shared clearUserDataForType:dataType]; +} + +KFB_EXPORT void kfb_appevents_free(void *pointer) +{ + free(pointer); +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m new file mode 100644 index 0000000..dea4b42 --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m @@ -0,0 +1,242 @@ +// Facebook Login interop exported to .NET through C symbols. +// +// The facade is written against the Objective-C surface of the prebuilt +// Facebook iOS SDK xcframeworks. That surface is compiled by any recent clang, +// whereas the SDK's Swift interfaces only compile with the exact Swift compiler +// release Meta used to build the binaries. + +@import Foundation; +@import UIKit; +@import FBSDKCoreKit; +@import FBSDKLoginKit; + +#include "KapuschFacebookAuthInterop.h" +#include "KapuschFacebookSettingsRegistry.h" + +typedef NS_ENUM(int32_t, KFBProviderStatus) { + KFBProviderStatusSuccess = 0, + KFBProviderStatusCancelled = 1, + KFBProviderStatusFailed = 2, +}; + +typedef NS_ENUM(int32_t, KFBTrackingMode) { + KFBTrackingModeEnabled = 0, + KFBTrackingModeLimited = 1, +}; + +static BOOL KFBSignInInProgress = NO; +static KapuschFacebookSignInCallback KFBSignInCallback = NULL; +static void *KFBSignInContext = NULL; + +static void KFBResetSignInState(void) +{ + KFBSignInInProgress = NO; + KFBSignInCallback = NULL; + KFBSignInContext = NULL; +} + +static void KFBInvokeSignInCallback( + KapuschFacebookSignInCallback callback, + KFBProviderStatus status, + NSString *accessToken, + NSString *authenticationToken, + NSString *userId, + NSString *nonce, + NSString *errorCode, + NSString *errorMessage, + void *context) +{ + // The UTF-8 buffers are owned by the NSString instances, which stay alive + // for the duration of this synchronous call. + callback( + status, + accessToken.UTF8String, + authenticationToken.UTF8String, + userId.UTF8String, + nonce.UTF8String, + errorCode.UTF8String, + errorMessage.UTF8String, + context); +} + +static void KFBInvokeSignInFailure( + KapuschFacebookSignInCallback callback, + NSString *errorCode, + NSString *errorMessage, + void *context) +{ + KFBInvokeSignInCallback( + callback, + KFBProviderStatusFailed, + nil, + nil, + nil, + nil, + errorCode, + errorMessage, + context); +} + +KFB_EXPORT void kfb_facebook_initialize(void *applicationPtr, void *launchOptionsPtr) +{ + UIApplication *application = (__bridge UIApplication *)applicationPtr; + NSDictionary *launchOptions = launchOptionsPtr == NULL + ? nil + : (__bridge NSDictionary *)launchOptionsPtr; + + [FBSDKApplicationDelegate.sharedInstance application:application + didFinishLaunchingWithOptions:launchOptions]; + // No-op unless the app changed settings through FacebookAppEvents. + KFBApplyExplicitAppEventsSettings(); +} + +KFB_EXPORT bool kfb_facebook_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr) +{ + UIApplication *application = (__bridge UIApplication *)applicationPtr; + NSURL *url = (__bridge NSURL *)urlPtr; + NSDictionary *options = optionsPtr == NULL ? nil : (__bridge NSDictionary *)optionsPtr; + NSDictionary *openUrlOptions = + [options isKindOfClass:NSDictionary.class] ? options : @{}; + + return [FBSDKApplicationDelegate.sharedInstance application:application + openURL:url + options:openUrlOptions]; +} + +KFB_EXPORT void kfb_facebook_signin_start( + void *presentingViewControllerPtr, + int32_t trackingMode, + const char *noncePtr, + KapuschFacebookSignInCallback callback, + void *context) +{ + if (KFBSignInInProgress) { + KFBInvokeSignInFailure( + callback, + @"already_in_progress", + @"Facebook sign-in is already in progress.", + context); + return; + } + + KFBSignInInProgress = YES; + KFBSignInCallback = callback; + KFBSignInContext = context; + + UIViewController *presenting = (__bridge UIViewController *)presentingViewControllerPtr; + FBSDKLoginTracking tracking = trackingMode == KFBTrackingModeEnabled + ? FBSDKLoginTrackingEnabled + : FBSDKLoginTrackingLimited; + + NSString *nonce = nil; + if (noncePtr != NULL) { + NSString *trimmed = [[NSString stringWithUTF8String:noncePtr] + stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + nonce = trimmed.length == 0 ? nil : trimmed; + } + + if (tracking == FBSDKLoginTrackingLimited && nonce == nil) { + KFBInvokeSignInFailure( + callback, + @"missing_nonce", + @"Facebook Limited Login requires a nonce.", + context); + KFBResetSignInState(); + return; + } + + NSArray *permissions = @[ @"public_profile", @"email" ]; + FBSDKLoginConfiguration *loginConfig = nonce != nil + ? [[FBSDKLoginConfiguration alloc] initWithPermissions:permissions + tracking:tracking + nonce:nonce] + : [[FBSDKLoginConfiguration alloc] initWithPermissions:permissions + tracking:tracking + messengerPageId:nil]; + + if (loginConfig == nil) { + KFBInvokeSignInFailure( + callback, + @"invalid_login_configuration", + @"Cannot create a valid Facebook LoginConfiguration.", + context); + KFBResetSignInState(); + return; + } + + [[FBSDKLoginManager new] + logInFromViewController:presenting + configuration:loginConfig + completion:^(FBSDKLoginManagerLoginResult *result, NSError *error) { + KapuschFacebookSignInCallback pendingCallback = KFBSignInCallback; + void *pendingContext = KFBSignInContext; + if (pendingCallback == NULL || pendingContext == NULL) { + KFBSignInInProgress = NO; + return; + } + + KFBResetSignInState(); + + // Same mapping as the SDK's Swift LoginResult: a missing result or any + // error is a failure, then cancellation, then success. + if (result == nil || error != nil) { + NSError *failure = error ?: [NSError errorWithDomain:FBSDKLoginErrorDomain + code:FBSDKLoginErrorUnknown + userInfo:nil]; + KFBInvokeSignInFailure( + pendingCallback, + [NSString stringWithFormat:@"%@:%ld", failure.domain, (long)failure.code], + failure.localizedDescription, + pendingContext); + return; + } + + if (result.isCancelled) { + KFBInvokeSignInCallback( + pendingCallback, + KFBProviderStatusCancelled, + nil, + nil, + nil, + nil, + nil, + nil, + pendingContext); + return; + } + + FBSDKAccessToken *token = result.token; + FBSDKAuthenticationToken *currentAuthenticationToken = + FBSDKAuthenticationToken.currentAuthenticationToken; + NSString *accessToken = token.tokenString; + NSString *authenticationToken = currentAuthenticationToken.tokenString; + NSString *resolvedNonce = currentAuthenticationToken.nonce ?: nonce; + NSString *userId = token.userID; + + if (accessToken != nil && accessToken.length == 0 + && authenticationToken != nil && authenticationToken.length == 0) { + KFBInvokeSignInFailure( + pendingCallback, + @"missing_token", + @"Facebook sign-in returned no token.", + pendingContext); + return; + } + + KFBInvokeSignInCallback( + pendingCallback, + KFBProviderStatusSuccess, + accessToken, + authenticationToken, + userId, + resolvedNonce, + nil, + nil, + pendingContext); + }]; +} + +KFB_EXPORT void kfb_facebook_signout(void) +{ + [[FBSDKLoginManager new] logOut]; +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.swift b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.swift deleted file mode 100644 index 722dc49..0000000 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.swift +++ /dev/null @@ -1,273 +0,0 @@ -import Foundation -import UIKit - -import FacebookCore -import FacebookLogin - -public typealias KapuschFacebookSignInCallback = @convention(c) ( - Int32, - UnsafePointer?, - UnsafePointer?, - UnsafePointer?, - UnsafePointer?, - UnsafePointer?, - UnsafePointer?, - UnsafeMutableRawPointer -) -> Void - -private enum ProviderStatus: Int32 { - case success = 0 - case cancelled = 1 - case failed = 2 -} - -private func withCString(_ value: String?, _ body: (UnsafePointer?) -> Void) { - guard let value else { - body(nil) - return - } - - value.withCString { cstr in - body(cstr) - } -} - -private func callFacebookCallback( - _ callback: KapuschFacebookSignInCallback, - status: ProviderStatus, - accessToken: String? = nil, - authenticationToken: String? = nil, - userId: String? = nil, - nonce: String? = nil, - errorCode: String? = nil, - errorMessage: String? = nil, - context: UnsafeMutableRawPointer -) { - withCString(accessToken) { accessTokenC in - withCString(authenticationToken) { authTokenC in - withCString(userId) { userIdC in - withCString(nonce) { nonceC in - withCString(errorCode) { errorCodeC in - withCString(errorMessage) { errorMessageC in - callback( - status.rawValue, - accessTokenC, - authTokenC, - userIdC, - nonceC, - errorCodeC, - errorMessageC, - context - ) - } - } - } - } - } - } -} - -private final class FacebookState { - nonisolated(unsafe) static var inProgress = false - nonisolated(unsafe) static var callback: KapuschFacebookSignInCallback? - nonisolated(unsafe) static var context: UnsafeMutableRawPointer? -} - -private enum FacebookTrackingMode: Int32 { - case enabled = 0 - case limited = 1 -} - -@_cdecl("kfb_facebook_initialize") -public func kfb_facebook_initialize( - _ applicationPtr: UnsafeMutableRawPointer, - _ launchOptionsPtr: UnsafeMutableRawPointer? -) { - let application = Unmanaged - .fromOpaque(applicationPtr) - .takeUnretainedValue() - - let options: NSDictionary? = { - guard let launchOptionsPtr else { return nil } - return Unmanaged.fromOpaque(launchOptionsPtr).takeUnretainedValue() - }() - - let launchOptions = options as? [UIApplication.LaunchOptionsKey: Any] - _ = ApplicationDelegate.shared.application( - application, - didFinishLaunchingWithOptions: launchOptions - ) -} - -@_cdecl("kfb_facebook_handle_open_url") -public func kfb_facebook_handle_open_url( - _ applicationPtr: UnsafeMutableRawPointer, - _ urlPtr: UnsafeMutableRawPointer, - _ optionsPtr: UnsafeMutableRawPointer? -) -> Bool { - let application = Unmanaged - .fromOpaque(applicationPtr) - .takeUnretainedValue() - - let url = Unmanaged.fromOpaque(urlPtr).takeUnretainedValue() as URL - - let options: NSDictionary? = { - guard let optionsPtr else { return nil } - return Unmanaged.fromOpaque(optionsPtr).takeUnretainedValue() - }() - - let openUrlOptions = options as? [UIApplication.OpenURLOptionsKey: Any] ?? [:] - - return ApplicationDelegate.shared.application( - application, - open: url, - options: openUrlOptions - ) -} - -@_cdecl("kfb_facebook_signin_start") -public func kfb_facebook_signin_start( - _ presentingViewControllerPtr: UnsafeMutableRawPointer, - _ trackingMode: Int32, - _ noncePtr: UnsafePointer?, - _ callback: @escaping KapuschFacebookSignInCallback, - _ context: UnsafeMutableRawPointer -) { - if FacebookState.inProgress { - callFacebookCallback( - callback, - status: .failed, - errorCode: "already_in_progress", - errorMessage: "Facebook sign-in is already in progress.", - context: context - ) - return - } - - FacebookState.inProgress = true - FacebookState.callback = callback - FacebookState.context = context - - let presenting = Unmanaged - .fromOpaque(presentingViewControllerPtr) - .takeUnretainedValue() - - let tracking = trackingMode == FacebookTrackingMode.enabled.rawValue - ? LoginTracking.enabled - : LoginTracking.limited - let nonce = noncePtr.flatMap { raw -> String? in - let trimmed = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - if tracking == .limited && nonce == nil { - callFacebookCallback( - callback, - status: .failed, - errorCode: "missing_nonce", - errorMessage: "Facebook Limited Login requires a nonce.", - context: context - ) - FacebookState.inProgress = false - FacebookState.callback = nil - FacebookState.context = nil - return - } - - let loginConfig: LoginConfiguration? = { - if let nonce { - return LoginConfiguration( - permissions: ["public_profile", "email"], - tracking: tracking, - nonce: nonce - ) - } - - return LoginConfiguration( - permissions: ["public_profile", "email"], - tracking: tracking, - messengerPageId: nil - ) - }() - - guard let loginConfig else { - callFacebookCallback( - callback, - status: .failed, - errorCode: "invalid_login_configuration", - errorMessage: "Cannot create a valid Facebook LoginConfiguration.", - context: context - ) - FacebookState.inProgress = false - FacebookState.callback = nil - FacebookState.context = nil - return - } - - LoginManager().logIn( - viewController: presenting, - configuration: loginConfig, - completion: { result in - guard let callback = FacebookState.callback, - let context = FacebookState.context - else { - FacebookState.inProgress = false - return - } - - defer { - FacebookState.inProgress = false - FacebookState.callback = nil - FacebookState.context = nil - } - - switch result { - case .cancelled: - callFacebookCallback(callback, status: .cancelled, context: context) - return - case .failed(let error): - let nsError = error as NSError - callFacebookCallback( - callback, - status: .failed, - errorCode: "\(nsError.domain):\(nsError.code)", - errorMessage: nsError.localizedDescription, - context: context - ) - return - case .success(_, _, let token): - let accessToken = token?.tokenString - let authenticationToken = AuthenticationToken.current?.tokenString - let resolvedNonce = AuthenticationToken.current?.nonce ?? nonce - let userId = token?.userID - - if accessToken?.isEmpty == true - && authenticationToken?.isEmpty == true - { - callFacebookCallback( - callback, - status: .failed, - errorCode: "missing_token", - errorMessage: "Facebook sign-in returned no token.", - context: context - ) - return - } - - callFacebookCallback( - callback, - status: .success, - accessToken: accessToken, - authenticationToken: authenticationToken, - userId: userId, - nonce: resolvedNonce, - context: context - ) - } - } - ) -} - -@_cdecl("kfb_facebook_signout") -public func kfb_facebook_signout() { - LoginManager().logOut() -} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookLoginStub/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookLoginStub/Interop.m new file mode 100644 index 0000000..6fd8a21 --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookLoginStub/Interop.m @@ -0,0 +1,54 @@ +// Placeholder for the Login entry points, linked when `Login` is not selected. +// +// NativeFacebookLogin binds its entry points with `[LibraryImport("__Internal")]`, +// so the .NET iOS build asks the native linker for these symbols whatever the +// selected features are. This placeholder depends on Foundation only and never +// touches the Facebook SDK. + +@import Foundation; +@import os.log; + +#include "KapuschFacebookAuthInterop.h" + +static const int32_t KFBLoginStubStatusFailed = 2; + +static void KFBLogLoginNotSelected(void) +{ + os_log_error( + OS_LOG_DEFAULT, + "Kapusch.Facebook.iOS: Facebook Login is not selected. Add Login to KapuschFacebookFeatures."); +} + +KFB_EXPORT void kfb_facebook_initialize(void *applicationPtr, void *launchOptionsPtr) +{ + KFBLogLoginNotSelected(); +} + +KFB_EXPORT bool kfb_facebook_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr) +{ + return false; +} + +KFB_EXPORT void kfb_facebook_signin_start( + void *presentingViewControllerPtr, + int32_t trackingMode, + const char *noncePtr, + KapuschFacebookSignInCallback callback, + void *context) +{ + KFBLogLoginNotSelected(); + callback( + KFBLoginStubStatusFailed, + NULL, + NULL, + NULL, + NULL, + "login_feature_not_selected", + "Facebook Login is not selected in KapuschFacebookFeatures.", + context); +} + +KFB_EXPORT void kfb_facebook_signout(void) +{ + KFBLogLoginNotSelected(); +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m new file mode 100644 index 0000000..f50c311 --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m @@ -0,0 +1,236 @@ +// Facebook photo-share interop exported to .NET through C symbols. +// +// Written against the Objective-C surface of the prebuilt Facebook iOS SDK so +// it does not depend on the Swift compiler release Meta used for the binaries. + +@import Foundation; +@import os.log; +@import UIKit; +@import FBSDKCoreKit; +@import FBSDKShareKit; + +#include "KapuschFacebookShareInterop.h" +#include "KapuschFacebookSettingsRegistry.h" + +typedef NS_ENUM(int32_t, KFBShareStatus) { + KFBShareStatusSuccess = 0, + KFBShareStatusCancelled = 1, + KFBShareStatusFailed = 2, +}; + +static NSString *const KFBFacebookBridgePasteboardType = @"com.facebook.Facebook.FBAppBridgeType"; + +static os_log_t KFBShareLog(void) +{ + static os_log_t log; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSString *subsystem = NSBundle.mainBundle.bundleIdentifier ?: @"Kapusch.Facebook.iOS"; + log = os_log_create(subsystem.UTF8String, "FacebookShare"); + }); + return log; +} + +// Meta's app bridge leaves a private marker on the general pasteboard. Replace +// it with empty data without reading the pasteboard, so iOS never shows a paste +// authorization prompt and unrelated pasteboard content is left untouched. +static void KFBClearPendingShareDataWithoutReading(void) +{ + [UIPasteboard.generalPasteboard setData:[NSData data] + forPasteboardType:KFBFacebookBridgePasteboardType]; + os_log_debug(KFBShareLog(), "Cleared pending Facebook bridge data without reading the pasteboard."); +} + +static void KFBInvokeShareCallback( + KapuschFacebookShareCallback callback, + KFBShareStatus status, + NSString *errorCode, + void *context) +{ + callback(status, errorCode.UTF8String, context); +} + +@class KFBShareDelegate; + +static FBSDKShareDialog *KFBShareDialogInFlight = nil; +static KFBShareDelegate *KFBShareDelegateInFlight = nil; +static BOOL KFBShareSdkInitialized = NO; + +static void KFBClearShareState(void) +{ + KFBShareDialogInFlight = nil; + KFBShareDelegateInFlight = nil; +} + +@interface KFBShareDelegate : NSObject +@property (nonatomic, readonly) BOOL didFinish; +- (instancetype)initWithCallback:(KapuschFacebookShareCallback)callback context:(void *)context; +@end + +@implementation KFBShareDelegate { + KapuschFacebookShareCallback _callback; + void *_context; +} + +- (instancetype)initWithCallback:(KapuschFacebookShareCallback)callback context:(void *)context +{ + self = [super init]; + if (self) { + _callback = callback; + _context = context; + _didFinish = NO; + } + return self; +} + +// Every completion path is guarded so the managed task completes at most once, +// including when ShareKit reports a synchronous failure and a late callback. +- (void)sharer:(id)sharer didCompleteWithResults:(NSDictionary *)results +{ + if (_didFinish) { + return; + } + _didFinish = YES; + os_log_info(KFBShareLog(), "Facebook share completed."); + KFBInvokeShareCallback(_callback, KFBShareStatusSuccess, nil, _context); + KFBClearShareState(); +} + +- (void)sharer:(id)sharer didFailWithError:(NSError *)error +{ + if (_didFinish) { + return; + } + _didFinish = YES; + os_log_error( + KFBShareLog(), + "Facebook share failed. domain=%{public}@ code=%ld", + error.domain, + (long)error.code); + KFBInvokeShareCallback( + _callback, + KFBShareStatusFailed, + [NSString stringWithFormat:@"%@:%ld", error.domain, (long)error.code], + _context); + KFBClearShareState(); +} + +- (void)sharerDidCancel:(id)sharer +{ + if (_didFinish) { + return; + } + _didFinish = YES; + os_log_info(KFBShareLog(), "Facebook share cancelled."); + KFBInvokeShareCallback(_callback, KFBShareStatusCancelled, nil, _context); + KFBClearShareState(); +} + +@end + +KFB_EXPORT void kfb_facebook_share_configure_and_initialize(void *applicationPtr, bool trackingAllowed) +{ + os_log_debug( + KFBShareLog(), + "Configuring Facebook Share SDK. trackingAllowed=%{public}d", + trackingAllowed ? 1 : 0); + // Share-only apps get the historical defaults. A value the app set + // explicitly through FacebookAppEvents wins and is left untouched. + FBSDKSettings *settings = FBSDKSettings.sharedSettings; + if (KFBExplicitAppEventsSetting(KFBRegistrySettingAutoLogAppEvents) == nil) { + settings.isAutoLogAppEventsEnabled = NO; + } else { + os_log_debug(KFBShareLog(), "Keeping the automatic app events setting configured by the app."); + } + if (KFBExplicitAppEventsSetting(KFBRegistrySettingAdvertiserIDCollection) == nil) { + settings.isAdvertiserIDCollectionEnabled = trackingAllowed; + } else { + os_log_debug(KFBShareLog(), "Keeping the advertiser ID collection setting configured by the app."); + } + if (KFBExplicitAppEventsSetting(KFBRegistrySettingAdvertiserTracking) == nil) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + settings.isAdvertiserTrackingEnabled = trackingAllowed; +#pragma clang diagnostic pop + } else { + os_log_debug(KFBShareLog(), "Keeping the advertiser tracking setting configured by the app."); + } + if (KFBShareSdkInitialized) { + return; + } + + UIApplication *application = (__bridge UIApplication *)applicationPtr; + [FBSDKApplicationDelegate.sharedInstance application:application + didFinishLaunchingWithOptions:nil]; + KFBApplyExplicitAppEventsSettings(); + KFBShareSdkInitialized = YES; +} + +KFB_EXPORT void kfb_facebook_share_photo( + void *presentingViewControllerPtr, + const char *imagePathPtr, + KapuschFacebookShareCallback callback, + void *context) +{ + os_log_debug(KFBShareLog(), "Facebook photo share requested."); + if (KFBShareDialogInFlight != nil) { + os_log_error(KFBShareLog(), "Facebook share rejected because another request is in progress."); + KFBInvokeShareCallback(callback, KFBShareStatusFailed, @"already_in_progress", context); + return; + } + + NSString *imagePath = [NSString stringWithUTF8String:imagePathPtr]; + UIImage *image = imagePath == nil ? nil : [UIImage imageWithContentsOfFile:imagePath]; + if (image == nil) { + os_log_error(KFBShareLog(), "Facebook share could not decode the image."); + KFBInvokeShareCallback(callback, KFBShareStatusFailed, @"image_decode_failed", context); + return; + } + + UIViewController *presenting = (__bridge UIViewController *)presentingViewControllerPtr; + FBSDKSharePhoto *photo = [[FBSDKSharePhoto alloc] initWithImage:image isUserGenerated:NO]; + FBSDKSharePhotoContent *content = [FBSDKSharePhotoContent new]; + content.photos = @[ photo ]; + KFBShareDelegate *delegate = [[KFBShareDelegate alloc] initWithCallback:callback context:context]; + FBSDKShareDialog *dialog = [[FBSDKShareDialog alloc] initWithViewController:presenting + content:content + delegate:delegate]; + dialog.mode = FBSDKShareDialogModeNative; + // ShareDialog holds its delegate weakly: keep both alive until completion. + KFBShareDelegateInFlight = delegate; + KFBShareDialogInFlight = dialog; + os_log_debug(KFBShareLog(), "Facebook ShareDialog canShow=%{public}d", dialog.canShow ? 1 : 0); + BOOL shown = [dialog show]; + os_log_debug(KFBShareLog(), "Facebook ShareDialog show returned %{public}d", shown ? 1 : 0); + if (!shown && !delegate.didFinish) { + os_log_error(KFBShareLog(), "Facebook ShareDialog was unavailable without a delegate error."); + KFBClearShareState(); + KFBInvokeShareCallback(callback, KFBShareStatusFailed, @"share_dialog_unavailable", context); + } +} + +KFB_EXPORT bool kfb_facebook_share_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr) +{ + UIApplication *application = (__bridge UIApplication *)applicationPtr; + NSURL *url = (__bridge NSURL *)urlPtr; + id appIdValue = NSBundle.mainBundle.infoDictionary[@"FacebookAppID"]; + if (![appIdValue isKindOfClass:NSString.class]) { + return false; + } + NSString *expectedScheme = [@"fb" stringByAppendingString:(NSString *)appIdValue]; + if (url.scheme == nil || [url.scheme caseInsensitiveCompare:expectedScheme] != NSOrderedSame) { + return false; + } + + NSDictionary *options = optionsPtr == NULL ? nil : (__bridge NSDictionary *)optionsPtr; + NSDictionary *openUrlOptions = + [options isKindOfClass:NSDictionary.class] ? options : @{}; + + if (KFBShareDialogInFlight != nil) { + KFBClearPendingShareDataWithoutReading(); + } + + return [FBSDKApplicationDelegate.sharedInstance application:application + openURL:url + options:openUrlOptions]; +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.swift b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.swift deleted file mode 100644 index b938b2c..0000000 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.swift +++ /dev/null @@ -1,217 +0,0 @@ -import Foundation -import OSLog -import UIKit - -import FacebookCore -import FacebookShare - -private let shareLogger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "Kapusch.Facebook.iOS", - category: "FacebookShare" -) - -private enum FacebookBridgePasteboard { - static let dataType = "com.facebook.Facebook.FBAppBridgeType" - - static func clearPendingShareDataWithoutReading() { - UIPasteboard.general.setData(Data(), forPasteboardType: dataType) - shareLogger.debug("Cleared pending Facebook bridge data without reading the pasteboard.") - } -} - -public typealias KapuschFacebookShareCallback = @convention(c) ( - Int32, - UnsafePointer?, - UnsafeMutableRawPointer -) -> Void - -private enum ShareStatus: Int32 { - case success = 0 - case cancelled = 1 - case failed = 2 -} - -private func invokeShareCallback( - _ callback: KapuschFacebookShareCallback, - status: ShareStatus, - errorCode: String? = nil, - context: UnsafeMutableRawPointer -) { - guard let errorCode else { - callback(status.rawValue, nil, context) - return - } - errorCode.withCString { callback(status.rawValue, $0, context) } -} - -private final class ShareDelegate: NSObject, SharingDelegate { - let callback: KapuschFacebookShareCallback - let context: UnsafeMutableRawPointer - private(set) var didFinish = false - - init(callback: @escaping KapuschFacebookShareCallback, context: UnsafeMutableRawPointer) { - self.callback = callback - self.context = context - } - - func sharer(_ sharer: Sharing, didCompleteWithResults results: [String: Any]) { - guard !didFinish else { return } - didFinish = true - shareLogger.info("Facebook share completed.") - invokeShareCallback(callback, status: .success, context: context) - ShareState.clear() - } - - func sharer(_ sharer: Sharing, didFailWithError error: Error) { - guard !didFinish else { return } - didFinish = true - let nsError = error as NSError - shareLogger.error( - "Facebook share failed. domain=\(nsError.domain, privacy: .public) code=\(nsError.code)" - ) - invokeShareCallback( - callback, - status: .failed, - errorCode: "\(nsError.domain):\(nsError.code)", - context: context - ) - ShareState.clear() - } - - func sharerDidCancel(_ sharer: Sharing) { - guard !didFinish else { return } - didFinish = true - shareLogger.info("Facebook share cancelled.") - invokeShareCallback(callback, status: .cancelled, context: context) - ShareState.clear() - } -} - -private final class ShareState { - nonisolated(unsafe) static var dialog: ShareDialog? - nonisolated(unsafe) static var delegate: ShareDelegate? - nonisolated(unsafe) static var isSdkInitialized = false - - static func clear() { - dialog = nil - delegate = nil - } -} - -@_cdecl("kfb_facebook_share_configure_and_initialize") -public func kfb_facebook_share_configure_and_initialize( - _ applicationPtr: UnsafeMutableRawPointer, - _ trackingAllowed: Bool -) { - shareLogger.debug( - "Configuring Facebook Share SDK. trackingAllowed=\(trackingAllowed, privacy: .public)" - ) - Settings.shared.isAutoLogAppEventsEnabled = false - Settings.shared.isAdvertiserIDCollectionEnabled = trackingAllowed - Settings.shared.isAdvertiserTrackingEnabled = trackingAllowed - guard !ShareState.isSdkInitialized else { - return - } - - let application = Unmanaged - .fromOpaque(applicationPtr) - .takeUnretainedValue() - _ = ApplicationDelegate.shared.application( - application, - didFinishLaunchingWithOptions: nil - ) - ShareState.isSdkInitialized = true -} - -@_cdecl("kfb_facebook_share_photo") -public func kfb_facebook_share_photo( - _ presentingViewControllerPtr: UnsafeMutableRawPointer, - _ imagePathPtr: UnsafePointer, - _ callback: @escaping KapuschFacebookShareCallback, - _ context: UnsafeMutableRawPointer -) { - shareLogger.debug("Facebook photo share requested.") - guard ShareState.dialog == nil else { - shareLogger.error("Facebook share rejected because another request is in progress.") - invokeShareCallback( - callback, - status: .failed, - errorCode: "already_in_progress", - context: context - ) - return - } - - let imagePath = String(cString: imagePathPtr) - guard let image = UIImage(contentsOfFile: imagePath) else { - shareLogger.error("Facebook share could not decode the image.") - invokeShareCallback( - callback, - status: .failed, - errorCode: "image_decode_failed", - context: context - ) - return - } - - let presenting = Unmanaged - .fromOpaque(presentingViewControllerPtr) - .takeUnretainedValue() - let photo = SharePhoto(image: image, isUserGenerated: false) - let content = SharePhotoContent() - content.photos = [photo] - let delegate = ShareDelegate(callback: callback, context: context) - let dialog = ShareDialog( - viewController: presenting, - content: content, - delegate: delegate - ) - dialog.mode = .native - ShareState.delegate = delegate - ShareState.dialog = dialog - shareLogger.debug("Facebook ShareDialog canShow=\(dialog.canShow, privacy: .public)") - let shown = dialog.show() - shareLogger.debug("Facebook ShareDialog show returned \(shown, privacy: .public)") - if !shown && !delegate.didFinish { - shareLogger.error("Facebook ShareDialog was unavailable without a delegate error.") - ShareState.clear() - invokeShareCallback( - callback, - status: .failed, - errorCode: "share_dialog_unavailable", - context: context - ) - } -} - -@_cdecl("kfb_facebook_share_handle_open_url") -public func kfb_facebook_share_handle_open_url( - _ applicationPtr: UnsafeMutableRawPointer, - _ urlPtr: UnsafeMutableRawPointer, - _ optionsPtr: UnsafeMutableRawPointer? -) -> Bool { - let application = Unmanaged - .fromOpaque(applicationPtr) - .takeUnretainedValue() - let url = Unmanaged.fromOpaque(urlPtr).takeUnretainedValue() as URL - guard let appID = Bundle.main.infoDictionary?["FacebookAppID"] as? String, - url.scheme?.caseInsensitiveCompare("fb\(appID)") == .orderedSame - else { - return false - } - let options: NSDictionary? = { - guard let optionsPtr else { return nil } - return Unmanaged.fromOpaque(optionsPtr).takeUnretainedValue() - }() - let openUrlOptions = options as? [UIApplication.OpenURLOptionsKey: Any] ?? [:] - - if ShareState.dialog != nil { - FacebookBridgePasteboard.clearPendingShareDataWithoutReading() - } - - return ApplicationDelegate.shared.application( - application, - open: url, - options: openUrlOptions - ) -} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareStub/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareStub/Interop.m new file mode 100644 index 0000000..b6fc858 --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareStub/Interop.m @@ -0,0 +1,41 @@ +// Placeholder for the Share entry points, linked when `Share` is not selected. +// +// NativeFacebookShare binds its entry points with `[LibraryImport("__Internal")]`, +// so the .NET iOS build asks the native linker for these symbols whatever the +// selected features are. Without this placeholder, a Login-only or +// AppEvents-only app fails to link. It depends on Foundation only and never +// touches the Facebook SDK. + +@import Foundation; +@import os.log; + +#include "KapuschFacebookShareInterop.h" + +static const int32_t KFBShareStubStatusFailed = 2; + +static void KFBLogShareNotSelected(void) +{ + os_log_error( + OS_LOG_DEFAULT, + "Kapusch.Facebook.iOS: Facebook Share is not selected. Add Share to KapuschFacebookFeatures."); +} + +KFB_EXPORT void kfb_facebook_share_configure_and_initialize(void *applicationPtr, bool trackingAllowed) +{ + KFBLogShareNotSelected(); +} + +KFB_EXPORT void kfb_facebook_share_photo( + void *presentingViewControllerPtr, + const char *imagePathPtr, + KapuschFacebookShareCallback callback, + void *context) +{ + KFBLogShareNotSelected(); + callback(KFBShareStubStatusFailed, "share_feature_not_selected", context); +} + +KFB_EXPORT bool kfb_facebook_share_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr) +{ + return false; +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h new file mode 100644 index 0000000..e29837a --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h @@ -0,0 +1,63 @@ +// C interop surface of the Facebook App Events static library +// (kfbappevents.xcframework). It only uses FBSDKCoreKit, which every feature +// selection already links. +// +// Strings returned by kfb_appevents_copy_* are allocated with malloc and must +// be released with kfb_appevents_free. Event parameters are passed as a UTF-8 +// JSON object whose values are strings or numbers. + +#pragma once + +#include "KapuschFacebookInteropExport.h" + +// SDK lifecycle. +KFB_EXPORT void kfb_appevents_initialize_sdk(void); +KFB_EXPORT bool kfb_appevents_finished_launching(void *applicationPtr, void *launchOptionsPtr); +KFB_EXPORT bool kfb_appevents_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr); +KFB_EXPORT bool kfb_appevents_continue_user_activity(void *applicationPtr, void *userActivityPtr); +KFB_EXPORT const char *kfb_appevents_copy_sdk_version(void); + +// Settings (persisted by the SDK across launches). +KFB_EXPORT void kfb_appevents_set_auto_log_app_events_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_auto_log_app_events_enabled(void); +KFB_EXPORT void kfb_appevents_set_advertiser_id_collection_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_advertiser_id_collection_enabled(void); +KFB_EXPORT void kfb_appevents_set_advertiser_tracking_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_advertiser_tracking_enabled(void); +KFB_EXPORT void kfb_appevents_set_skadnetwork_report_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_skadnetwork_report_enabled(void); +KFB_EXPORT void kfb_appevents_set_metadata_collection_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_metadata_collection_enabled(void); +KFB_EXPORT void kfb_appevents_set_auto_log_product_improvement_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_auto_log_product_improvement_enabled(void); +KFB_EXPORT void kfb_appevents_set_data_processing_options(const char *optionsJson, int32_t country, int32_t state); +KFB_EXPORT void kfb_appevents_set_debug_logging_enabled(bool enabled); + +// Event logging. +KFB_EXPORT void kfb_appevents_activate_app(void); +KFB_EXPORT bool kfb_appevents_log_event(const char *eventName, const char *parametersJson, bool hasValueToSum, double valueToSum); +KFB_EXPORT bool kfb_appevents_log_purchase(double amount, const char *currency, const char *parametersJson); +KFB_EXPORT void kfb_appevents_flush(void); +KFB_EXPORT void kfb_appevents_set_flush_behavior(int32_t behavior); +KFB_EXPORT int32_t kfb_appevents_get_flush_behavior(void); + +// Identity and advanced matching (the SDK hashes user data before sending it). +KFB_EXPORT const char *kfb_appevents_copy_anonymous_id(void); +KFB_EXPORT void kfb_appevents_set_user_id(const char *userId); +KFB_EXPORT const char *kfb_appevents_copy_user_id(void); +KFB_EXPORT void kfb_appevents_set_user_data( + const char *email, + const char *firstName, + const char *lastName, + const char *phone, + const char *dateOfBirth, + const char *gender, + const char *city, + const char *state, + const char *zip, + const char *country); +KFB_EXPORT void kfb_appevents_set_user_data_for_type(const char *type, const char *value); +KFB_EXPORT void kfb_appevents_clear_user_data(void); +KFB_EXPORT void kfb_appevents_clear_user_data_for_type(const char *type); + +KFB_EXPORT void kfb_appevents_free(void *pointer); diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAuthInterop.h b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAuthInterop.h index cb0c9fa..7e4dab3 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAuthInterop.h +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAuthInterop.h @@ -1,5 +1,28 @@ -// Intentionally minimal. -// -// This header exists only to satisfy `xcodebuild -create-xcframework -headers ...` when packaging the -// static library product. The interop surface is exposed via `@_cdecl` symbols in Swift and invoked -// from .NET via P/Invoke. +// C interop surface of the Facebook Login static library (kfb.xcframework). + +#pragma once + +#include "KapuschFacebookInteropExport.h" + +typedef void (*KapuschFacebookSignInCallback)( + int32_t status, + const char *accessToken, + const char *authenticationToken, + const char *userId, + const char *nonce, + const char *errorCode, + const char *errorMessage, + void *context); + +KFB_EXPORT void kfb_facebook_initialize(void *applicationPtr, void *launchOptionsPtr); + +KFB_EXPORT bool kfb_facebook_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr); + +KFB_EXPORT void kfb_facebook_signin_start( + void *presentingViewControllerPtr, + int32_t trackingMode, + const char *noncePtr, + KapuschFacebookSignInCallback callback, + void *context); + +KFB_EXPORT void kfb_facebook_signout(void); diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookInteropExport.h b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookInteropExport.h new file mode 100644 index 0000000..2fb4319 --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookInteropExport.h @@ -0,0 +1,19 @@ +// Shared export macro for the Kapusch Facebook interop static libraries. +// +// Every symbol marked with KFB_EXPORT is a plain C function consumed by .NET +// through `[LibraryImport("__Internal")]` (or a main-program symbol lookup for +// the historical Login entry points). `used` keeps the symbol in the archive +// even when nothing in the static library references it. + +#pragma once + +#include +#include + +#ifdef __cplusplus +#define KFB_EXTERN_C extern "C" +#else +#define KFB_EXTERN_C +#endif + +#define KFB_EXPORT KFB_EXTERN_C __attribute__((visibility("default"), used)) diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookSettingsRegistry.h b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookSettingsRegistry.h new file mode 100644 index 0000000..d50ff53 --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookSettingsRegistry.h @@ -0,0 +1,47 @@ +// Runtime lookup of the App Events settings registry. +// +// kfbappevents.xcframework records every setting changed through +// FacebookAppEvents in the Objective-C class KFBAppEventsSettingsRegistry. +// The Login and Share wrappers look it up through the Objective-C runtime, so +// they have no link-time dependency on it: +// - Share does not override a setting the app configured explicitly; +// - both apply the recorded values again after initializing the SDK, so the +// SDK persists values that were set before initialization. + +#pragma once + +#import + +NS_ASSUME_NONNULL_BEGIN + +static NSString *const KFBRegistrySettingAutoLogAppEvents = @"autoLogAppEvents"; +static NSString *const KFBRegistrySettingAdvertiserIDCollection = @"advertiserIDCollection"; +static NSString *const KFBRegistrySettingAdvertiserTracking = @"advertiserTracking"; + +@protocol KFBAppEventsSettingsRegistryLookup ++ (nullable NSNumber *)explicitValueForSetting:(NSString *)setting; ++ (void)applyExplicitValues; +@end + +static inline Class _Nullable KFBAppEventsSettingsRegistryClass(void) +{ + Class registry = NSClassFromString(@"KFBAppEventsSettingsRegistry"); + if (registry == Nil + || ![registry respondsToSelector:@selector(explicitValueForSetting:)] + || ![registry respondsToSelector:@selector(applyExplicitValues)]) { + return Nil; + } + return (Class)registry; +} + +static inline NSNumber *_Nullable KFBExplicitAppEventsSetting(NSString *setting) +{ + return [KFBAppEventsSettingsRegistryClass() explicitValueForSetting:setting]; +} + +static inline void KFBApplyExplicitAppEventsSettings(void) +{ + [KFBAppEventsSettingsRegistryClass() applyExplicitValues]; +} + +NS_ASSUME_NONNULL_END diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookShareInterop.h b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookShareInterop.h index dfd030c..ed51f30 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookShareInterop.h +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookShareInterop.h @@ -1 +1,20 @@ -// The public interop surface is exported through @_cdecl Swift symbols and consumed by .NET P/Invoke. +// C interop surface of the Facebook Share static library (kfbshare.xcframework). + +#pragma once + +#include "KapuschFacebookInteropExport.h" + +typedef void (*KapuschFacebookShareCallback)( + int32_t status, + const char *errorCode, + void *context); + +KFB_EXPORT void kfb_facebook_share_configure_and_initialize(void *applicationPtr, bool trackingAllowed); + +KFB_EXPORT void kfb_facebook_share_photo( + void *presentingViewControllerPtr, + const char *imagePathPtr, + KapuschFacebookShareCallback callback, + void *context); + +KFB_EXPORT bool kfb_facebook_share_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr); diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh index 7a5f018..3315ad2 100755 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh @@ -1,103 +1,152 @@ #!/usr/bin/env bash set -euo pipefail +# Builds the Objective-C interop static libraries against the pinned Facebook +# iOS SDK xcframeworks and wraps each one in an xcframework: +# build/kfb.xcframework (Login) +# build/kfbloginstub.xcframework (Login entry points when Login is not selected) +# build/kfbshare.xcframework (Share) +# build/kfbsharestub.xcframework (Share entry points when Share is not selected) +# build/kfbappevents.xcframework (App Events, linked with every selection) +# It also leaves the stripped SDK xcframeworks in build/fb/. +# +# The facades only use the SDK's Objective-C headers (including the generated +# *-Swift.h headers). They therefore compile with any recent Xcode, even when +# Meta built the binaries with a newer Swift compiler than the local one. + ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PACKAGE_DIR="$ROOT_DIR/KapuschFacebookAuthInterop" +SOURCES_DIR="$PACKAGE_DIR/Sources" +HEADERS_DIR="$PACKAGE_DIR/include" BUILD_DIR="$ROOT_DIR/build" - -XCFRAMEWORK_OUT="$BUILD_DIR/kfb.xcframework" -SHARE_XCFRAMEWORK_OUT="$BUILD_DIR/kfbshare.xcframework" +FB_DIR="$BUILD_DIR/fb" +OBJ_DIR="$BUILD_DIR/obj" +MODULE_CACHE_DIR="$BUILD_DIR/clang-module-cache" +DEPLOYMENT_TARGET="15.0" rm -rf "$BUILD_DIR" mkdir -p "$BUILD_DIR" +bash "$ROOT_DIR/restore-facebook-xcframeworks.sh" +bash "$ROOT_DIR/collect-facebook-xcframeworks.sh" + SDK_IPHONEOS_PATH="$(xcrun --sdk iphoneos --show-sdk-path)" SDK_SIMULATOR_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)" -SCRATCH_DIR="$BUILD_DIR/spm" - -echo "[KapuschFacebookAuthInterop] Building (iOS device arm64)..." -swift build \ - --package-path "$PACKAGE_DIR" \ - --scratch-path "$SCRATCH_DIR/iphoneos" \ - -c release \ - --sdk "$SDK_IPHONEOS_PATH" \ - --triple "arm64-apple-ios15.0" - -echo "[KapuschFacebookAuthInterop] Building (iOS simulator arm64)..." -swift build \ - --package-path "$PACKAGE_DIR" \ - --scratch-path "$SCRATCH_DIR/iphonesimulator-arm64" \ - -c release \ - --sdk "$SDK_SIMULATOR_PATH" \ - --triple "arm64-apple-ios15.0-simulator" - -echo "[KapuschFacebookAuthInterop] Building (iOS simulator x86_64)..." -swift build \ - --package-path "$PACKAGE_DIR" \ - --scratch-path "$SCRATCH_DIR/iphonesimulator-x86_64" \ - -c release \ - --sdk "$SDK_SIMULATOR_PATH" \ - --triple "x86_64-apple-ios15.0-simulator" - -IOS_LIB="$(find "$SCRATCH_DIR/iphoneos" -maxdepth 4 -path "*/release/libKapuschFacebookAuthInterop.a" | head -n 1)" -SIM_ARM64_LIB="$(find "$SCRATCH_DIR/iphonesimulator-arm64" -maxdepth 4 -path "*/release/libKapuschFacebookAuthInterop.a" | head -n 1)" -SIM_X64_LIB="$(find "$SCRATCH_DIR/iphonesimulator-x86_64" -maxdepth 4 -path "*/release/libKapuschFacebookAuthInterop.a" | head -n 1)" - -SIM_UNIVERSAL_LIB="$BUILD_DIR/libKapuschFacebookAuthInterop_simulator_universal.a" -SHARE_IOS_LIB="$(find "$SCRATCH_DIR/iphoneos" -maxdepth 4 -path "*/release/libKapuschFacebookShareInterop.a" | head -n 1)" -SHARE_SIM_ARM64_LIB="$(find "$SCRATCH_DIR/iphonesimulator-arm64" -maxdepth 4 -path "*/release/libKapuschFacebookShareInterop.a" | head -n 1)" -SHARE_SIM_X64_LIB="$(find "$SCRATCH_DIR/iphonesimulator-x86_64" -maxdepth 4 -path "*/release/libKapuschFacebookShareInterop.a" | head -n 1)" -SHARE_SIM_UNIVERSAL_LIB="$BUILD_DIR/libKapuschFacebookShareInterop_simulator_universal.a" -echo "[KapuschFacebookAuthInterop] Creating universal simulator static library..." -if [ ! -f "$SIM_ARM64_LIB" ]; then - echo "Expected simulator (arm64) static library not found: $SIM_ARM64_LIB" >&2 - exit 1 -fi - -if [ ! -f "$SIM_X64_LIB" ]; then - echo "Expected simulator (x86_64) static library not found: $SIM_X64_LIB" >&2 - exit 1 -fi - -lipo -create "$SIM_ARM64_LIB" "$SIM_X64_LIB" -output "$SIM_UNIVERSAL_LIB" -lipo -create "$SHARE_SIM_ARM64_LIB" "$SHARE_SIM_X64_LIB" -output "$SHARE_SIM_UNIVERSAL_LIB" - -HEADERS_DIR="$PACKAGE_DIR/include" - -if [ -z "$IOS_LIB" ] || [ ! -f "$IOS_LIB" ]; then - echo "Expected iOS static library not found: $IOS_LIB" >&2 - exit 1 -fi - -if [ ! -f "$SIM_UNIVERSAL_LIB" ]; then - echo "Expected simulator static library not found: $SIM_UNIVERSAL_LIB" >&2 - exit 1 -fi - -if [ -z "$SHARE_IOS_LIB" ] || [ ! -f "$SHARE_IOS_LIB" ]; then - echo "Expected share iOS static library not found: $SHARE_IOS_LIB" >&2 - exit 1 -fi - -if [ ! -f "$SHARE_SIM_UNIVERSAL_LIB" ]; then - echo "Expected share simulator static library not found: $SHARE_SIM_UNIVERSAL_LIB" >&2 - exit 1 -fi - -echo "[KapuschFacebookAuthInterop] Creating xcframework..." -xcodebuild -create-xcframework \ - -library "$IOS_LIB" -headers "$HEADERS_DIR" \ - -library "$SIM_UNIVERSAL_LIB" -headers "$HEADERS_DIR" \ - -output "$XCFRAMEWORK_OUT" - -echo "[KapuschFacebookShareInterop] Creating xcframework..." -xcodebuild -create-xcframework \ - -library "$SHARE_IOS_LIB" -headers "$HEADERS_DIR" \ - -library "$SHARE_SIM_UNIVERSAL_LIB" -headers "$HEADERS_DIR" \ - -output "$SHARE_XCFRAMEWORK_OUT" - -bash "$ROOT_DIR/collect-facebook-xcframeworks.sh" - -echo "[KapuschFacebookAuthInterop] Done: $XCFRAMEWORK_OUT" -echo "[KapuschFacebookShareInterop] Done: $SHARE_XCFRAMEWORK_OUT" +# Prints the slice directory of an xcframework for "device" or "simulator". +slice_dir() { + local xcframework="$1" + local platform="$2" + local candidate + for candidate in "$xcframework"/ios-*; do + [ -d "$candidate" ] || continue + case "$(basename "$candidate")" in + *-maccatalyst) continue ;; + *-simulator) + if [ "$platform" = "simulator" ]; then + echo "$candidate" + return 0 + fi + ;; + *) + if [ "$platform" = "device" ]; then + echo "$candidate" + return 0 + fi + ;; + esac + done + echo "No $platform slice found in $xcframework" >&2 + return 1 +} + +framework_search_flags() { + local platform="$1" + local xcframework + for xcframework in "$FB_DIR"/*.xcframework; do + printf -- '-F\n%s\n' "$(slice_dir "$xcframework" "$platform")" + done +} + +# compile_library +compile_library() { + local library="$1" + local source_dir="$2" + local platform="$3" + local arch="$4" + local sdk_path="$5" + local triple="$6" + local output="$7" + local obj_dir="$OBJ_DIR/$library/$platform-$arch" + local search_flags=() + local source + local objects=() + + while IFS= read -r flag; do + search_flags+=("$flag") + done < <(framework_search_flags "$platform") + + mkdir -p "$obj_dir" + for source in "$source_dir"/*.m; do + local object="$obj_dir/$(basename "${source%.m}").o" + xcrun clang \ + -c "$source" \ + -o "$object" \ + -target "$triple" \ + -isysroot "$sdk_path" \ + -fobjc-arc \ + -fmodules \ + -fmodules-cache-path="$MODULE_CACHE_DIR" \ + -O2 \ + -Wall \ + -Werror \ + -I "$HEADERS_DIR" \ + "${search_flags[@]}" + objects+=("$object") + done + + mkdir -p "$(dirname "$output")" + xcrun libtool -static -no_warning_for_no_symbols -o "$output" "${objects[@]}" +} + +# build_xcframework +build_xcframework() { + local library="$1" + local source_dir="$2" + local xcframework_out="$3" + local lib_dir="$BUILD_DIR/lib/$library" + local device_lib="$lib_dir/iphoneos/lib$library.a" + local sim_arm64_lib="$lib_dir/iphonesimulator-arm64/lib$library.a" + local sim_x64_lib="$lib_dir/iphonesimulator-x86_64/lib$library.a" + local sim_universal_lib="$lib_dir/iphonesimulator/lib$library.a" + + echo "[$library] Building (iOS device arm64)..." + compile_library "$library" "$source_dir" device arm64 "$SDK_IPHONEOS_PATH" \ + "arm64-apple-ios$DEPLOYMENT_TARGET" "$device_lib" + + echo "[$library] Building (iOS simulator arm64)..." + compile_library "$library" "$source_dir" simulator arm64 "$SDK_SIMULATOR_PATH" \ + "arm64-apple-ios$DEPLOYMENT_TARGET-simulator" "$sim_arm64_lib" + + echo "[$library] Building (iOS simulator x86_64)..." + compile_library "$library" "$source_dir" simulator x86_64 "$SDK_SIMULATOR_PATH" \ + "x86_64-apple-ios$DEPLOYMENT_TARGET-simulator" "$sim_x64_lib" + + echo "[$library] Creating universal simulator static library..." + mkdir -p "$(dirname "$sim_universal_lib")" + lipo -create "$sim_arm64_lib" "$sim_x64_lib" -output "$sim_universal_lib" + + echo "[$library] Creating xcframework..." + xcodebuild -create-xcframework \ + -library "$device_lib" -headers "$HEADERS_DIR" \ + -library "$sim_universal_lib" -headers "$HEADERS_DIR" \ + -output "$xcframework_out" >/dev/null + + echo "[$library] Done: $xcframework_out" +} + +build_xcframework KapuschFacebookAuthInterop "$SOURCES_DIR/KapuschFacebookAuthInterop" "$BUILD_DIR/kfb.xcframework" +build_xcframework KapuschFacebookLoginStub "$SOURCES_DIR/KapuschFacebookLoginStub" "$BUILD_DIR/kfbloginstub.xcframework" +build_xcframework KapuschFacebookShareInterop "$SOURCES_DIR/KapuschFacebookShareInterop" "$BUILD_DIR/kfbshare.xcframework" +build_xcframework KapuschFacebookShareStub "$SOURCES_DIR/KapuschFacebookShareStub" "$BUILD_DIR/kfbsharestub.xcframework" +build_xcframework KapuschFacebookAppEventsInterop "$SOURCES_DIR/KapuschFacebookAppEventsInterop" "$BUILD_DIR/kfbappevents.xcframework" diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/collect-facebook-xcframeworks.sh b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/collect-facebook-xcframeworks.sh index 26050ef..efa01cc 100755 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/collect-facebook-xcframeworks.sh +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/collect-facebook-xcframeworks.sh @@ -1,20 +1,15 @@ #!/usr/bin/env bash set -euo pipefail +# Copies the restored Facebook iOS SDK xcframeworks into build/fb/ in the +# layout packed into the NuGet and consumed by buildTransitive NativeReference +# items. Restores the pinned archives first when they are not present yet. + ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BUILD_DIR="$ROOT_DIR/build" -SCRATCH_DIR="$BUILD_DIR/spm" +UPSTREAM_DIR="$BUILD_DIR/upstream" OUT_DIR="$BUILD_DIR/fb" -if [ ! -d "$SCRATCH_DIR" ]; then - echo "SwiftPM scratch dir not found: $SCRATCH_DIR" >&2 - echo "Run build.sh first." >&2 - exit 1 -fi - -rm -rf "$OUT_DIR" -mkdir -p "$OUT_DIR" - frameworks=( "FBAEMKit.xcframework" "FBSDKCoreKit.xcframework" @@ -23,10 +18,24 @@ frameworks=( "FBSDKShareKit.xcframework" ) +missing_upstream=false +for name in "${frameworks[@]}"; do + if [ ! -d "$UPSTREAM_DIR/$name" ]; then + missing_upstream=true + fi +done + +if [ "$missing_upstream" = true ]; then + bash "$ROOT_DIR/restore-facebook-xcframeworks.sh" +fi + +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR" + for name in "${frameworks[@]}"; do - src="$(find "$SCRATCH_DIR" -maxdepth 8 -type d -name "$name" | head -n 1 || true)" - if [ -z "$src" ] || [ ! -d "$src" ]; then - echo "Missing xcframework in scratch: $name" >&2 + src="$UPSTREAM_DIR/$name" + if [ ! -d "$src" ]; then + echo "Missing restored xcframework: $name" >&2 exit 1 fi @@ -34,7 +43,7 @@ for name in "${frameworks[@]}"; do cp -R "$src" "$dst" # Keep iOS + iOS simulator only. - rm -rf "$dst/ios-arm64_x86_64-maccatalyst" 2>/dev/null || true + find "$dst" -mindepth 1 -maxdepth 1 -type d -name "*-maccatalyst" -prune -exec rm -rf {} + # Strip debug symbols, signatures and Swift compile-time module artifacts. find "$dst" -type d -name "dSYMs" -prune -exec rm -rf {} + diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/restore-facebook-xcframeworks.sh b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/restore-facebook-xcframeworks.sh new file mode 100755 index 0000000..0f4c4bd --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/restore-facebook-xcframeworks.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Restores the Facebook iOS SDK dynamic xcframeworks pinned in +# DependencyLocks/iOS/lockstate.txt. Every archive is verified against its +# SHA-256 before it is extracted; a mismatch fails the build. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$ROOT_DIR/../../../.." && pwd)" +LOCK_FILE="$REPO_ROOT/DependencyLocks/iOS/lockstate.txt" +CACHE_DIR="${KAPUSCH_FACEBOOK_IOS_DOWNLOAD_CACHE:-$ROOT_DIR/.cache/downloads}" +UPSTREAM_DIR="$ROOT_DIR/build/upstream" + +if [ ! -f "$LOCK_FILE" ]; then + echo "Missing lockstate.txt (expected at $LOCK_FILE)." >&2 + exit 1 +fi + +sha256_file() { + local file="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + return 0 + fi + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + return 0 + fi + echo "No SHA-256 tool found (need shasum or sha256sum)." >&2 + return 1 +} + +mkdir -p "$CACHE_DIR" +rm -rf "$UPSTREAM_DIR" +mkdir -p "$UPSTREAM_DIR" + +echo "[KapuschFacebookInterop] Restoring pinned Facebook iOS SDK artifacts..." + +restored_any=false + +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|'#'*) continue ;; + esac + + url="$(echo "$line" | awk '{print $1}')" + expected_sha="$(echo "$line" | awk '{print $2}')" + filename="$(echo "$line" | awk '{print $3}')" + + if [ -z "$url" ] || [ -z "$expected_sha" ] || [ -z "$filename" ]; then + echo "Invalid lock line (expected: ): $line" >&2 + exit 1 + fi + + archive="$CACHE_DIR/$filename" + if [ -f "$archive" ] && [ "$(sha256_file "$archive")" = "$expected_sha" ]; then + echo "[KapuschFacebookInterop] Using cached $filename" + else + echo "[KapuschFacebookInterop] Downloading $filename" + curl -fsSL --retry 3 --retry-delay 1 -o "$archive.partial" "$url" + actual_sha="$(sha256_file "$archive.partial")" + if [ "$actual_sha" != "$expected_sha" ]; then + rm -f "$archive.partial" + echo "SHA256 mismatch for $filename" >&2 + echo "Expected: $expected_sha" >&2 + echo "Actual: $actual_sha" >&2 + exit 1 + fi + mv -f "$archive.partial" "$archive" + fi + + unzip -q -o "$archive" -d "$UPSTREAM_DIR" + restored_any=true +done < "$LOCK_FILE" + +if [ "$restored_any" != true ]; then + echo "No artifacts restored (lock file empty?)." >&2 + exit 1 +fi + +echo "[KapuschFacebookInterop] Restored upstream xcframeworks to: $UPSTREAM_DIR" diff --git a/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets b/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets index d76447a..5f61aba 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets +++ b/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets @@ -5,11 +5,32 @@ <_KapuschFacebookFeaturesDelimited>;$([System.String]::Copy('$(KapuschFacebookFeatures)').Replace(' ', '')); <_KapuschFacebookLoginEnabled>$([System.String]::Copy('$(_KapuschFacebookFeaturesDelimited)').Contains(';Login;')) <_KapuschFacebookShareEnabled>$([System.String]::Copy('$(_KapuschFacebookFeaturesDelimited)').Contains(';Share;')) + + <_KapuschFacebookAppEventsEnabled>$([System.String]::Copy('$(_KapuschFacebookFeaturesDelimited)').Contains(';AppEvents;')) <_KapuschFacebookInteropPackageRoot>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..')) <_KapuschFacebookInteropSourceRoot>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../Native/iOS/build')) + + + <_KapuschFacebookRequestedFeature Include="$([MSBuild]::Unescape($(KapuschFacebookFeatures.Replace(' ', ''))))" /> + + <_KapuschFacebookUnknownFeature + Include="@(_KapuschFacebookRequestedFeature)" + Condition="!$([System.String]::Copy('%(Identity)').Equals('Login')) and !$([System.String]::Copy('%(Identity)').Equals('Share')) and !$([System.String]::Copy('%(Identity)').Equals('AppEvents'))" + /> + + + <_KapuschFacebookInteropIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropSourceRoot)', 'kfb.xcframework')) + <_KapuschFacebookLoginStubIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropSourceRoot)', 'kfbloginstub.xcframework')) <_KapuschFacebookShareInteropIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropSourceRoot)', 'kfbshare.xcframework')) + <_KapuschFacebookShareStubIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropSourceRoot)', 'kfbsharestub.xcframework')) + <_KapuschFacebookAppEventsIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropSourceRoot)', 'kfbappevents.xcframework')) <_KapuschFacebookInteropFbDir>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropSourceRoot)', 'fb')) <_KapuschFacebookInteropSlice Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">ios-arm64_x86_64-simulator + + <_KapuschFacebookInteropSlice Condition="'$(_KapuschFacebookInteropSlice)' == '' and Exists('$(_KapuschFacebookInteropFbDir)/FBSDKCoreKit.xcframework/ios-arm64_arm64e')">ios-arm64_arm64e <_KapuschFacebookInteropSlice Condition="'$(_KapuschFacebookInteropSlice)' == ''">ios-arm64 + + + Static true + + + Static + true + Static true + + + Static + true + + + + Static + true + Framework true @@ -77,9 +130,14 @@ > <_KapuschFacebookInteropIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropPackageRoot)', 'kfb.xcframework')) + <_KapuschFacebookLoginStubIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropPackageRoot)', 'kfbloginstub.xcframework')) <_KapuschFacebookShareInteropIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropPackageRoot)', 'kfbshare.xcframework')) + <_KapuschFacebookShareStubIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropPackageRoot)', 'kfbsharestub.xcframework')) + <_KapuschFacebookAppEventsIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropPackageRoot)', 'kfbappevents.xcframework')) <_KapuschFacebookInteropFbDir>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropPackageRoot)', 'fb')) <_KapuschFacebookInteropSlice Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">ios-arm64_x86_64-simulator + + <_KapuschFacebookInteropSlice Condition="'$(_KapuschFacebookInteropSlice)' == '' and Exists('$(_KapuschFacebookInteropFbDir)/FBSDKCoreKit.xcframework/ios-arm64_arm64e')">ios-arm64_arm64e <_KapuschFacebookInteropSlice Condition="'$(_KapuschFacebookInteropSlice)' == ''">ios-arm64 @@ -87,10 +145,25 @@ Static true + + + Static + true + Static true + + + Static + true + + + + Static + true + Framework true diff --git a/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md b/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md index f528b92..af411eb 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md +++ b/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md @@ -1,13 +1,19 @@ # Kapusch.Facebook.iOS This package provides: -- managed APIs for Facebook Login and photo sharing on iOS, and +- managed APIs for Facebook Login, photo sharing and Meta App Events on iOS, and - the required Facebook iOS SDK `xcframework`s packaged for .NET iOS. -Set `KapuschFacebookFeatures` to `Login` (default), `Share`, or `Login;Share`. -SDK 18.0.2 requires FBAEMKit whenever ShareKit/CoreKit is selected. +Set `KapuschFacebookFeatures` to `Login` (default), `Share`, `AppEvents` or a +combination such as `Login;Share`. `FacebookAppEvents` works with every selection. +Bundles Facebook iOS SDK 19.0.0 (iOS 15.0+). CoreKit, which every feature +uses, requires FBAEMKit. Photo sharing uses ShareKit's native-app dialog, guarantees one managed completion per request, and avoids reading the general pasteboard when cleaning Meta's app-bridge marker on return. +App Events never enables anything on its own: ship the Info.plist switches off, +initialize after consent and see `Docs/Integration.md` for the consent recipe and +for avoiding double counting with server-side purchase events. + See the repo `README.md` and `Docs/Integration.md` for integration steps. diff --git a/tests/FeatureSelection.proj b/tests/FeatureSelection.proj index ca4e472..0839b25 100644 --- a/tests/FeatureSelection.proj +++ b/tests/FeatureSelection.proj @@ -17,5 +17,9 @@ Condition="'$(_KapuschFacebookShareEnabled)' != '$(ExpectedShareEnabled)'" Text="Expected Share=$(ExpectedShareEnabled), got $(_KapuschFacebookShareEnabled) for '$(KapuschFacebookFeatures)'." /> + diff --git a/tests/validate-feature-selection.sh b/tests/validate-feature-selection.sh index c570c58..177379f 100644 --- a/tests/validate-feature-selection.sh +++ b/tests/validate-feature-selection.sh @@ -4,7 +4,7 @@ set -euo pipefail project="tests/FeatureSelection.proj" share_source="src/Kapusch.FacebookApisForiOSComponents/Facebook/NativeFacebookShare.iOS.cs" -share_interop_source="src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.swift" +share_interop_source="src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m" contains_fixed_text() { local pattern="$1" @@ -17,6 +17,21 @@ contains_fixed_text() { fi } +login_source="src/Kapusch.FacebookApisForiOSComponents/Facebook/NativeFacebookLogin.iOS.cs" +if contains_fixed_text "NativeLibrary.GetExport" "$login_source"; then + echo "Facebook Login must use static __Internal imports so iOS retains its native symbols." >&2 + exit 1 +fi + +for symbol in \ + kfb_facebook_initialize \ + kfb_facebook_handle_open_url \ + kfb_facebook_signin_start \ + kfb_facebook_signout; do + contains_fixed_text "EntryPoint = \"$symbol\"" "$login_source" \ + || { echo "Missing static Facebook Login import: $symbol" >&2; exit 1; } +done + if contains_fixed_text "NativeLibrary.GetExport" "$share_source"; then echo "Facebook Share must use static __Internal imports so iOS retains its native symbols." >&2 exit 1 @@ -30,21 +45,33 @@ for symbol in \ || { echo "Missing static Facebook Share import: $symbol" >&2; exit 1; } done -contains_fixed_text "guard !didFinish else { return }" "$share_interop_source" \ +contains_fixed_text "if (_didFinish) {" "$share_interop_source" \ || { echo "Facebook Share callbacks must be idempotent." >&2; exit 1; } -contains_fixed_text "if !shown && !delegate.didFinish" "$share_interop_source" \ +contains_fixed_text "if (!shown && !delegate.didFinish)" "$share_interop_source" \ || { echo "Facebook Share must not invoke a second callback after ShareKit reports a synchronous failure." >&2; exit 1; } -contains_fixed_text "dialog.mode = .native" "$share_interop_source" \ +contains_fixed_text "dialog.mode = FBSDKShareDialogModeNative;" "$share_interop_source" \ || { echo "Facebook photo share must use the native Facebook dialog instead of the deprecated iOS share sheet." >&2; exit 1; } -contains_fixed_text "FacebookBridgePasteboard.clearPendingShareDataWithoutReading()" "$share_interop_source" \ +contains_fixed_text "KFBClearPendingShareDataWithoutReading();" "$share_interop_source" \ || { echo "Facebook native share callbacks must clear bridge data without triggering an iOS paste read." >&2; exit 1; } -if contains_fixed_text "data(forPasteboardType:" "$share_interop_source"; then - echo "Facebook Share wrapper must never read the general pasteboard." >&2 - exit 1 -fi +for setting in KFBRegistrySettingAutoLogAppEvents KFBRegistrySettingAdvertiserIDCollection KFBRegistrySettingAdvertiserTracking; do + contains_fixed_text "KFBExplicitAppEventsSetting($setting) == nil" "$share_interop_source" \ + || { echo "Facebook Share must not override the App Events setting $setting configured by the app." >&2; exit 1; } +done +for pasteboard_read in "dataForPasteboardType:" "valueForPasteboardType:"; do + if contains_fixed_text "$pasteboard_read" "$share_interop_source"; then + echo "Facebook Share wrapper must never read the general pasteboard." >&2 + exit 1 + fi +done dotnet msbuild "$project" -t:ValidateFeatureSelection -p:ExpectedLoginEnabled=True -p:ExpectedShareEnabled=False dotnet msbuild "$project" -t:ValidateFeatureSelection -p:RequestedFacebookFeatures=Share -p:ExpectedLoginEnabled=False -p:ExpectedShareEnabled=True dotnet msbuild "$project" -t:ValidateFeatureSelection -p:RequestedFacebookFeatures=Login%3BShare -p:ExpectedLoginEnabled=True -p:ExpectedShareEnabled=True + +dotnet msbuild "$project" -t:ValidateFeatureSelection -p:RequestedFacebookFeatures=AppEvents -p:ExpectedLoginEnabled=False -p:ExpectedShareEnabled=False -p:ExpectedAppEventsEnabled=True + +dotnet msbuild "$project" -t:ValidateFeatureSelection -p:RequestedFacebookFeatures=Login%3BShare%3BAppEvents -p:ExpectedLoginEnabled=True -p:ExpectedShareEnabled=True -p:ExpectedAppEventsEnabled=True + +dotnet msbuild "$project" -t:ValidateFeatureSelection -p:ExpectedLoginEnabled=True -p:ExpectedShareEnabled=False -p:ExpectedAppEventsEnabled=False