From 6c9b10c3915949a83223e2b7f8881ab971adf5f0 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 22:54:29 +0100 Subject: [PATCH 01/18] refactor(ios): build interop from locked SDK xcframeworks with Objective-C facades Meta now builds the Facebook iOS SDK binaries with Xcode releases newer than the ones available on CI runners, and a Swift facade can only import the SDK's Swift interfaces with the exact Swift compiler Meta used. - Port the Login and Share facades from Swift to Objective-C. They call the SDK through its Objective-C headers (including the generated -Swift.h headers), keep the same C entry points and keep the same behaviour. - Replace the SwiftPM dependency with release archives pinned by URL and SHA-256 in DependencyLocks/iOS/lockstate.txt (still SDK 18.0.2). The hashes equal the upstream Package.swift remoteChecksum values. - Build each facade with clang + libtool per slice, then wrap it in the same kfb/kfbshare xcframeworks as before. - Port the Share source invariants to the Objective-C implementation. - Cache the pinned archives in CI instead of the SwiftPM scratch. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +- .github/workflows/publish.yml | 6 +- .gitignore | 3 +- AGENTS.md | 6 +- CONTRIBUTING.md | 8 +- DependencyLocks/iOS/lockstate.txt | 10 + Docs/Release.md | 2 +- README.md | 12 +- .../Package.resolved | 15 - .../KapuschFacebookAuthInterop/Package.swift | 42 --- .../KapuschFacebookAuthInterop/Interop.m | 239 +++++++++++++++ .../KapuschFacebookAuthInterop/Interop.swift | 273 ------------------ .../KapuschFacebookShareInterop/Interop.m | 217 ++++++++++++++ .../KapuschFacebookShareInterop/Interop.swift | 217 -------------- .../include/KapuschFacebookAuthInterop.h | 33 ++- .../include/KapuschFacebookInteropExport.h | 19 ++ .../include/KapuschFacebookShareInterop.h | 21 +- .../Native/iOS/build.sh | 223 ++++++++------ .../iOS/collect-facebook-xcframeworks.sh | 37 ++- .../iOS/restore-facebook-xcframeworks.sh | 81 ++++++ tests/validate-feature-selection.sh | 20 +- 21 files changed, 809 insertions(+), 681 deletions(-) create mode 100644 DependencyLocks/iOS/lockstate.txt delete mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Package.resolved delete mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Package.swift create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m delete mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.swift create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m delete mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.swift create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookInteropExport.h create mode 100755 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/restore-facebook-xcframeworks.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a07793..6badc85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,12 +34,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: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 15bdae9..9771dee 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -88,12 +88,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: | 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..90c0517 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,13 @@ - 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) 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..cdd72f8 --- /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/v18.0.2/FBSDKCoreKit_Basics-Dynamic_XCFramework.zip c57eb4255c86b60ac0a19cd4c42694220f41985e104ced9cb1c998b650de1bf3 FBSDKCoreKit_Basics-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v18.0.2/FBAEMKit-Dynamic_XCFramework.zip 2a2409a08917b6cfab27a66333ecb73c27b8852d45b2985e4281f68554c0dd5f FBAEMKit-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v18.0.2/FBSDKCoreKit-Dynamic_XCFramework.zip a647fa24789d5c579ca8c7088c9394d5b410194d8c7e269f8d3f7cf3c33279fd FBSDKCoreKit-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v18.0.2/FBSDKLoginKit-Dynamic_XCFramework.zip 0f984f04366fcc141e901de6c635fef40c7c496c015c0d1121e03c5913431d52 FBSDKLoginKit-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v18.0.2/FBSDKShareKit-Dynamic_XCFramework.zip dad67fc33428f9c793252a71607c08c6ce9748b6804d0b4241c63ab444c8fef8 FBSDKShareKit-Dynamic_XCFramework.zip 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/README.md b/README.md index 03436e7..cadd51e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,16 @@ Facebook iOS SDK 18.0.2 links `FBSDKCoreKit` to `FBAEMKit` non-weakly. Therefore 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 +48,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: diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Package.resolved b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Package.resolved deleted file mode 100644 index cbd500e..0000000 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "4a1b378c0ad0f27417c6595a92de2e449324cddc294f9537a804632e6039b503", - "pins" : [ - { - "identity" : "facebook-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/facebook/facebook-ios-sdk", - "state" : { - "revision" : "32da5bdef917ccd845fcf319c5fb67c654459d27", - "version" : "18.0.2" - } - } - ], - "version" : 3 -} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Package.swift b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Package.swift deleted file mode 100644 index 092bbff..0000000 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Package.swift +++ /dev/null @@ -1,42 +0,0 @@ -// swift-tools-version: 6.0 -import PackageDescription - -let package = Package( - name: "KapuschFacebookAuthInterop", - platforms: [ - .iOS(.v15), - ], - products: [ - .library( - name: "KapuschFacebookAuthInterop", - type: .static, - targets: ["KapuschFacebookAuthInterop"] - ), - .library( - name: "KapuschFacebookShareInterop", - type: .static, - targets: ["KapuschFacebookShareInterop"] - ), - ], - dependencies: [ - .package(url: "https://github.com/facebook/facebook-ios-sdk", exact: "18.0.2"), - ], - targets: [ - .target( - name: "KapuschFacebookAuthInterop", - dependencies: [ - .product(name: "FacebookLogin", package: "facebook-ios-sdk"), - .product(name: "FacebookCore", package: "facebook-ios-sdk"), - ], - path: "Sources/KapuschFacebookAuthInterop" - ), - .target( - name: "KapuschFacebookShareInterop", - dependencies: [ - .product(name: "FacebookShare", package: "facebook-ios-sdk"), - .product(name: "FacebookCore", package: "facebook-ios-sdk"), - ], - path: "Sources/KapuschFacebookShareInterop" - ), - ] -) 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..6d949d8 --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m @@ -0,0 +1,239 @@ +// 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" + +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]; +} + +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/KapuschFacebookShareInterop/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m new file mode 100644 index 0000000..70965b8 --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m @@ -0,0 +1,217 @@ +// 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" + +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); + FBSDKSettings *settings = FBSDKSettings.sharedSettings; + settings.isAutoLogAppEventsEnabled = NO; + settings.isAdvertiserIDCollectionEnabled = trackingAllowed; + settings.isAdvertiserTrackingEnabled = trackingAllowed; + if (KFBShareSdkInitialized) { + return; + } + + UIApplication *application = (__bridge UIApplication *)applicationPtr; + [FBSDKApplicationDelegate.sharedInstance application:application + didFinishLaunchingWithOptions:nil]; + 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/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/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..86f6f18 100755 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh @@ -1,103 +1,146 @@ #!/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/kfbshare.xcframework (Share) +# 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 KapuschFacebookShareInterop "$SOURCES_DIR/KapuschFacebookShareInterop" "$BUILD_DIR/kfbshare.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/tests/validate-feature-selection.sh b/tests/validate-feature-selection.sh index c570c58..baad13f 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" @@ -30,18 +30,20 @@ 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 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 From 4d52a826a18ec096b7711e11a57f8a79702a4157 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 22:59:56 +0100 Subject: [PATCH 02/18] fix(packaging): link a Share placeholder when Share is not selected NativeFacebookShare binds its entry points with static __Internal imports, so the .NET iOS build passes them to the native linker as required symbols for every app. A Login-only build (the default selection) therefore failed to link with "Undefined symbols: _kfb_facebook_share_*" since 1.1.0. When Share is not selected, inject kfbsharestub.xcframework instead of kfbshare.xcframework. It defines the same three entry points on top of Foundation only: configuring logs an error, sharing completes with Failed/share_feature_not_selected and URL handling returns false. Share selections are unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 + .github/workflows/publish.yml | 1 + Docs/Integration.md | 6 +++ ...apusch.FacebookApisForiOSComponents.csproj | 11 +++++ .../KapuschFacebookShareStub/Interop.m | 41 +++++++++++++++++++ .../Native/iOS/build.sh | 2 + .../Kapusch.Facebook.iOS.targets | 16 ++++++++ 7 files changed, 78 insertions(+) create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareStub/Interop.m diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6badc85..bae7b8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,7 @@ jobs: "buildTransitive/Kapusch.Facebook.iOS.targets", "kfb.xcframework/Info.plist", "kfbshare.xcframework/Info.plist", + "kfbsharestub.xcframework/Info.plist", "fb/FBAEMKit.xcframework/Info.plist", "fb/FBSDKCoreKit.xcframework/Info.plist", "fb/FBSDKCoreKit_Basics.xcframework/Info.plist", diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9771dee..f5af756 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -130,6 +130,7 @@ jobs: "buildTransitive/Kapusch.Facebook.iOS.targets", "kfb.xcframework/Info.plist", "kfbshare.xcframework/Info.plist", + "kfbsharestub.xcframework/Info.plist", "fb/FBAEMKit.xcframework/Info.plist", "fb/FBSDKCoreKit.xcframework/Info.plist", "fb/FBSDKCoreKit_Basics.xcframework/Info.plist", diff --git a/Docs/Integration.md b/Docs/Integration.md index 89d9009..3957d1a 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -53,6 +53,12 @@ leaving unrelated pasteboard content untouched. - `Share`: photo-share wrapper and ShareKit. - `Login;Share`: both managed/native entry points. +When `Share` is not selected, the package links a small Foundation-only +placeholder (`kfbsharestub.xcframework`) for the `NativeFacebookShare` 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`; no ShareKit code is shipped. + At SDK 18.0.2, ShareKit's CoreKit dependency requires FBAEMKit. Treat this as a privacy-review blocker if the consumer's artifact policy excludes FBAEMKit. diff --git a/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj b/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj index e7a2f17..5b5df6b 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj +++ b/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj @@ -26,6 +26,10 @@ Condition="'$(InteropIosShareWrapperDir)' == '' and Exists('Native/iOS/build/kfbshare.xcframework')" >Native/iOS/build/kfbshare.xcframework + Native/iOS/build/kfbsharestub.xcframework Native/iOS/build/fb + + + @@ -69,6 +76,10 @@ Condition="'$(InteropIosShareWrapperDir)' == '' or !Exists('$(InteropIosShareWrapperDir)')" Text="Missing iOS share wrapper. Build it first via src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh or provide InteropIosShareWrapperDir." /> + <_KapuschFacebookInteropIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropSourceRoot)', 'kfb.xcframework')) <_KapuschFacebookShareInteropIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropSourceRoot)', 'kfbshare.xcframework')) + <_KapuschFacebookShareStubIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropSourceRoot)', 'kfbsharestub.xcframework')) <_KapuschFacebookInteropFbDir>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropSourceRoot)', 'fb')) <_KapuschFacebookInteropSlice Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">ios-arm64_x86_64-simulator <_KapuschFacebookInteropSlice Condition="'$(_KapuschFacebookInteropSlice)' == ''">ios-arm64 @@ -30,6 +31,10 @@ Condition="'$(_KapuschFacebookShareEnabled)' == 'True' and !Exists('$(_KapuschFacebookShareInteropIosWrapper)')" Text="Kapusch.Facebook.iOS source-mode: missing share wrapper xcframework at '$(_KapuschFacebookShareInteropIosWrapper)'. Build it first via src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh." /> + Static true + + + Static + true + Framework true @@ -78,6 +88,7 @@ <_KapuschFacebookInteropIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropPackageRoot)', 'kfb.xcframework')) <_KapuschFacebookShareInteropIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropPackageRoot)', 'kfbshare.xcframework')) + <_KapuschFacebookShareStubIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropPackageRoot)', 'kfbsharestub.xcframework')) <_KapuschFacebookInteropFbDir>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropPackageRoot)', 'fb')) <_KapuschFacebookInteropSlice Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">ios-arm64_x86_64-simulator <_KapuschFacebookInteropSlice Condition="'$(_KapuschFacebookInteropSlice)' == ''">ios-arm64 @@ -91,6 +102,11 @@ Static true + + + Static + true + Framework true From bbf7dcd8a39dec2d34a181697bfdc90ce3a01fd3 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 21:58:56 +0100 Subject: [PATCH 03/18] feat(app-events): add App Events native facade Add kfbappevents.xcframework, an Objective-C facade over FBSDKAppEvents, FBSDKSettings and FBSDKApplicationDelegate exported as kfb_appevents_* C symbols: - SDK lifecycle: initialize, finished-launching hook, URL and user-activity forwarding (AEM / deep links), SDK version. - Settings: auto-log app events, advertiser ID collection, advertiser tracking (ignored by the SDK on iOS 17+), SKAdNetwork reporting, data processing options (LDU) and app-events debug logging. - Logging: activateApp, logEvent (optional valueToSum), logPurchase, flush and flush behaviour. Parameters travel as a JSON object of string or number values. - Identity: anonymous ID, user ID and advanced-matching user data. It only depends on FBSDKCoreKit, which every feature selection already links, so the package injects it for every selection: the managed App Events entry points will be static __Internal imports and must always resolve. The facade never changes a setting on its own. Values set through it are recorded and applied again after SDK initialization so the SDK persists them even when they were set before initialization. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 + .github/workflows/publish.yml | 1 + ...apusch.FacebookApisForiOSComponents.csproj | 11 + .../KapuschFacebookAppEventsInterop/Interop.m | 457 ++++++++++++++++++ .../include/KapuschFacebookAppEventsInterop.h | 59 +++ .../Native/iOS/build.sh | 2 + .../Kapusch.Facebook.iOS.targets | 16 + 7 files changed, 547 insertions(+) create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bae7b8c..a7800f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,7 @@ jobs: "kfb.xcframework/Info.plist", "kfbshare.xcframework/Info.plist", "kfbsharestub.xcframework/Info.plist", + "kfbappevents.xcframework/Info.plist", "fb/FBAEMKit.xcframework/Info.plist", "fb/FBSDKCoreKit.xcframework/Info.plist", "fb/FBSDKCoreKit_Basics.xcframework/Info.plist", diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f5af756..e81ccef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -131,6 +131,7 @@ jobs: "kfb.xcframework/Info.plist", "kfbshare.xcframework/Info.plist", "kfbsharestub.xcframework/Info.plist", + "kfbappevents.xcframework/Info.plist", "fb/FBAEMKit.xcframework/Info.plist", "fb/FBSDKCoreKit.xcframework/Info.plist", "fb/FBSDKCoreKit_Basics.xcframework/Info.plist", diff --git a/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj b/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj index 5b5df6b..22f9f37 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj +++ b/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj @@ -30,6 +30,10 @@ Condition="'$(InteropIosShareStubWrapperDir)' == '' and Exists('Native/iOS/build/kfbsharestub.xcframework')" >Native/iOS/build/kfbsharestub.xcframework + Native/iOS/build/kfbappevents.xcframework Native/iOS/build/fb + + + @@ -80,6 +87,10 @@ Condition="'$(InteropIosShareStubWrapperDir)' == '' or !Exists('$(InteropIosShareStubWrapperDir)')" Text="Missing iOS share placeholder. Build it first via src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh or provide InteropIosShareStubWrapperDir." /> + +#include + +#include "KapuschFacebookAppEventsInterop.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. +@interface KFBAppEventsSettingsRegistry : NSObject ++ (void)recordValue:(BOOL)value forSetting:(NSString *)setting; ++ (nullable NSNumber *)explicitValueForSetting:(NSString *)setting; ++ (NSDictionary *)explicitValues; +@end + +@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]; + } +} + +@end + +static NSString *const KFBSettingAutoLogAppEvents = @"autoLogAppEvents"; +static NSString *const KFBSettingAdvertiserIDCollection = @"advertiserIDCollection"; +static NSString *const KFBSettingAdvertiserTracking = @"advertiserTracking"; +static NSString *const KFBSettingSKAdNetworkReport = @"skAdNetworkReport"; + +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; + } +} + +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_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/include/KapuschFacebookAppEventsInterop.h b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h new file mode 100644 index 0000000..72d559b --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h @@ -0,0 +1,59 @@ +// 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_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/build.sh b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh index cd4048a..acfbed3 100755 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh @@ -6,6 +6,7 @@ set -euo pipefail # build/kfb.xcframework (Login) # 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 @@ -146,3 +147,4 @@ build_xcframework() { build_xcframework KapuschFacebookAuthInterop "$SOURCES_DIR/KapuschFacebookAuthInterop" "$BUILD_DIR/kfb.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/buildTransitive/Kapusch.Facebook.iOS.targets b/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets index 859b62e..1739b9b 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets +++ b/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets @@ -19,6 +19,7 @@ <_KapuschFacebookInteropIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropSourceRoot)', 'kfb.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)' == ''">ios-arm64 @@ -35,6 +36,10 @@ Condition="'$(_KapuschFacebookShareEnabled)' != 'True' and !Exists('$(_KapuschFacebookShareStubIosWrapper)')" Text="Kapusch.Facebook.iOS source-mode: missing share placeholder xcframework at '$(_KapuschFacebookShareStubIosWrapper)'. Build it first via src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh." /> + Static true + + + Static + true + Framework true @@ -89,6 +99,7 @@ <_KapuschFacebookInteropIosWrapper>$([System.IO.Path]::Combine('$(_KapuschFacebookInteropPackageRoot)', 'kfb.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)' == ''">ios-arm64 @@ -107,6 +118,11 @@ Static true + + + Static + true + Framework true From c8571ab7951f1092333ab0f87a0a959d85722e06 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 21:59:11 +0100 Subject: [PATCH 04/18] feat(app-events): add managed FacebookAppEvents API and constants Expose the App Events facade as Kapusch.Facebook.iOS.FacebookAppEvents, bound with static __Internal LibraryImport entry points: - InitializeSdk / FinishedLaunching, HandleOpenUrl and ContinueUserActivity forwarding for AEM and deep links, SdkVersion. - Set/Is pairs for automatic logging, advertiser ID collection, advertiser tracking (ignored by the SDK on iOS 17+) and SKAdNetwork reporting; SetDataProcessingOptions and SetDebugLoggingEnabled. - ActivateApp, LogEvent (parameters + optional valueToSum), LogPurchase, typed LogStartTrial / LogSubscribe / LogCompleteRegistration helpers, Flush and flush behaviour. - GetAnonymousId, Set/Clear/GetUserId and advanced-matching user data. Parameters accept strings, numbers and booleans (sent as "1"/"0") and are serialized with Utf8JsonWriter (trim and AOT safe). Add constants for every event name, parameter name, parameter value and user-data type declared by the bundled SDK headers, plus the Android-public activate/deactivate and _valueToSum names. Co-Authored-By: Claude Opus 5.5 --- .../Facebook/FacebookAppEventNames.cs | 136 ++++ .../FacebookAppEventParameterNames.cs | 256 ++++++++ .../Facebook/FacebookAppEvents.iOS.cs | 596 ++++++++++++++++++ .../Facebook/FacebookAppEventsTypes.cs | 49 ++ 4 files changed, 1037 insertions(+) create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventNames.cs create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventParameterNames.cs create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs 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..82c209d --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventParameterNames.cs @@ -0,0 +1,256 @@ +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"; +} + +/// 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/FacebookAppEvents.iOS.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs new file mode 100644 index 0000000..aeacf88 --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs @@ -0,0 +1,596 @@ +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(); + + /// + /// 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_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..314972b --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs @@ -0,0 +1,49 @@ +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; } +} From 81e3039030f05a07b9b908b060cb06297357f7a7 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:28:43 +0100 Subject: [PATCH 05/18] feat(packaging): accept the AppEvents feature selection - Parse AppEvents in KapuschFacebookFeatures. App Events only needs FBSDKCoreKit, which every selection links, so its facade is always linked; selecting AppEvents alone produces a CoreKit-only build without LoginKit or ShareKit. The default selection stays Login. - Warn about unknown (or wrongly cased) feature names, which were silently ignored before. - Cover the AppEvents, Login;Share;AppEvents and default selections in the feature-selection test. Co-Authored-By: Claude Opus 5.5 --- .../Kapusch.Facebook.iOS.targets | 21 +++++++++++++++++++ tests/FeatureSelection.proj | 4 ++++ tests/validate-feature-selection.sh | 6 ++++++ 3 files changed, 31 insertions(+) diff --git a/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets b/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets index 1739b9b..a9fa6d2 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'))" + /> + + + + diff --git a/tests/validate-feature-selection.sh b/tests/validate-feature-selection.sh index baad13f..630eef6 100644 --- a/tests/validate-feature-selection.sh +++ b/tests/validate-feature-selection.sh @@ -50,3 +50,9 @@ dotnet msbuild "$project" -t:ValidateFeatureSelection -p:ExpectedLoginEnabled=Tr 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 From 76708add771a6ea2383312917bf3b5636bf0457b Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:30:40 +0100 Subject: [PATCH 06/18] fix(share): keep App Events settings configured by the app NativeFacebookShare.ConfigureAndInitialize forces automatic app events logging off and advertiser ID collection / advertiser tracking to trackingAllowed. Once the app manages these settings through FacebookAppEvents (for example after a consent decision), Share must not silently override them. - The App Events facade records explicit values in the Objective-C class KFBAppEventsSettingsRegistry. Share and Login look it up through the Objective-C runtime, so there is no link-time dependency. - Share only applies its historical defaults to settings the app did not set explicitly. Apps that never call FacebookAppEvents keep the exact previous behaviour. - After Share or Login initializes the SDK, the recorded values are applied again so the SDK persists them (no-op when nothing was set). - Guard the behaviour in the Share source invariants. Co-Authored-By: Claude Opus 5.5 --- Docs/Integration.md | 5 +- .../KapuschFacebookAppEventsInterop/Interop.m | 19 ++++++-- .../KapuschFacebookAuthInterop/Interop.m | 3 ++ .../KapuschFacebookShareInterop/Interop.m | 25 ++++++++-- .../include/KapuschFacebookSettingsRegistry.h | 47 +++++++++++++++++++ tests/validate-feature-selection.sh | 4 ++ 6 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookSettingsRegistry.h diff --git a/Docs/Integration.md b/Docs/Integration.md index 3957d1a..8133f62 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -31,7 +31,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)`. diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m index c82278c..43c9813 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m @@ -13,6 +13,7 @@ #include #include "KapuschFacebookAppEventsInterop.h" +#include "KapuschFacebookSettingsRegistry.h" static os_log_t KFBAppEventsLog(void) { @@ -35,12 +36,18 @@ static os_log_t KFBAppEventsLog(void) // - 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 @@ -74,11 +81,17 @@ + (nullable NSNumber *)explicitValueForSetting:(NSString *)setting } } ++ (void)applyExplicitValues +{ + KFBReapplyExplicitSettings(); +} + @end -static NSString *const KFBSettingAutoLogAppEvents = @"autoLogAppEvents"; -static NSString *const KFBSettingAdvertiserIDCollection = @"advertiserIDCollection"; -static NSString *const KFBSettingAdvertiserTracking = @"advertiserTracking"; +// 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 void KFBApplySetting(NSString *setting, BOOL value) diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m index 6d949d8..dea4b42 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m @@ -11,6 +11,7 @@ @import FBSDKLoginKit; #include "KapuschFacebookAuthInterop.h" +#include "KapuschFacebookSettingsRegistry.h" typedef NS_ENUM(int32_t, KFBProviderStatus) { KFBProviderStatusSuccess = 0, @@ -85,6 +86,8 @@ KFB_EXPORT void kfb_facebook_initialize(void *applicationPtr, void *launchOption [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) diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m index 70965b8..f50c311 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m @@ -10,6 +10,7 @@ @import FBSDKShareKit; #include "KapuschFacebookShareInterop.h" +#include "KapuschFacebookSettingsRegistry.h" typedef NS_ENUM(int32_t, KFBShareStatus) { KFBShareStatusSuccess = 0, @@ -133,10 +134,27 @@ KFB_EXPORT void kfb_facebook_share_configure_and_initialize(void *applicationPtr 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; - settings.isAutoLogAppEventsEnabled = NO; - settings.isAdvertiserIDCollectionEnabled = trackingAllowed; - settings.isAdvertiserTrackingEnabled = trackingAllowed; + 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; } @@ -144,6 +162,7 @@ KFB_EXPORT void kfb_facebook_share_configure_and_initialize(void *applicationPtr UIApplication *application = (__bridge UIApplication *)applicationPtr; [FBSDKApplicationDelegate.sharedInstance application:application didFinishLaunchingWithOptions:nil]; + KFBApplyExplicitAppEventsSettings(); KFBShareSdkInitialized = YES; } 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/tests/validate-feature-selection.sh b/tests/validate-feature-selection.sh index 630eef6..5f84759 100644 --- a/tests/validate-feature-selection.sh +++ b/tests/validate-feature-selection.sh @@ -38,6 +38,10 @@ contains_fixed_text "dialog.mode = FBSDKShareDialogModeNative;" "$share_interop_ || { echo "Facebook photo share must use the native Facebook dialog instead of the deprecated iOS share sheet." >&2; exit 1; } 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; } +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 From ce92aed2798c8aa3c5ad772eda4f9fc62c806f57 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:32:17 +0100 Subject: [PATCH 07/18] ci: validate package contents, App Events bindings and public tree - scripts/validate-package.py replaces the inline layout check in CI and publish. On top of the layout (now including kfbsharestub and kfbappevents) it checks that both slices of each wrapper export every entry point of its header, that FacebookAppEvents binds every kfb_appevents_* symbol with a static __Internal import, that the managed constants cover every App Events name of the bundled SDK headers and that each constant value exists in the bundled FBSDKCoreKit binary. - scripts/verify-public.py fails on real-looking Meta app IDs, fb URL schemes, client tokens and email addresses in tracked files. Private terms are never tracked: they are read at run time from the KAPUSCH_PRIVATE_TERMS environment variable (a repository secret in CI) or the untracked .git/info/private-terms file, and failures only name the file and the term's position. The check is skipped with a notice when no term is configured. - Move FacebookDataProcessingOptions next to the other App Events types: it is a caller-supplied value, not an SDK constant. Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 3 + .github/workflows/ci.yml | 54 +----- .github/workflows/publish.yml | 53 +---- AGENTS.md | 6 + scripts/validate-package.py | 181 ++++++++++++++++++ scripts/verify-public.py | 85 ++++++++ .../FacebookAppEventParameterNames.cs | 7 - .../Facebook/FacebookAppEventsTypes.cs | 7 + 8 files changed, 296 insertions(+), 100 deletions(-) create mode 100755 scripts/validate-package.py create mode 100755 scripts/verify-public.py 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 a7800f9..4971477 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,11 @@ jobs: with: dotnet-version: "10.0.203" + - name: Scan public tree + env: + KAPUSCH_PRIVATE_TERMS: ${{ secrets.KAPUSCH_PRIVATE_TERMS }} + run: python3 scripts/verify-public.py + - name: Validate feature selection run: bash tests/validate-feature-selection.sh @@ -55,53 +60,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", - "kfbsharestub.xcframework/Info.plist", - "kfbappevents.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 e81ccef..5ed2f88 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -75,6 +75,11 @@ jobs: with: dotnet-version: "10.0.203" + - name: Scan public tree + env: + KAPUSCH_PRIVATE_TERMS: ${{ secrets.KAPUSCH_PRIVATE_TERMS }} + run: python3 scripts/verify-public.py + - name: Validate feature selection run: bash tests/validate-feature-selection.sh @@ -110,52 +115,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", - "kfbsharestub.xcframework/Info.plist", - "kfbappevents.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/AGENTS.md b/AGENTS.md index 90c0517..fa6f5b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,12 @@ - `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 real Facebook credentials or emails in tracked files, and no private term read from `KAPUSCH_PRIVATE_TERMS` or the untracked `.git/info/private-terms`. +- 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/scripts/validate-package.py b/scripts/validate-package.py new file mode 100755 index 0000000..0003db7 --- /dev/null +++ b/scripts/validate-package.py @@ -0,0 +1,181 @@ +#!/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 App Events / Share 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"), + "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", "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..de94dd1 --- /dev/null +++ b/scripts/verify-public.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Fail when a tracked file contains a real-looking credential, an email or a private term. + +This repository is a public, generic wrapper. The generic checks below are tracked. Terms that +must never appear (for example the names of private consuming apps) are not: they are read at run +time from an untracked source so the scanner never publishes them. + +Private terms, one per line or comma-separated, matched case-insensitively as literal text: + - the KAPUSCH_PRIVATE_TERMS environment variable (CI reads it from a repository secret); + - the file /info/private-terms ('#' starts a comment). +The private-term check is skipped, with a notice, when neither source provides a term. +Failures name the file and the term's position, never the term itself. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +PATTERNS = { + # Meta app IDs are 15-16 digit numbers; docs and samples must not carry a real one. + "Meta app ID or fb URL scheme": re.compile( + rb"(?i)(?:FacebookAppID\s*|\bfb)[1-9][0-9]{14,15}\b" + ), + # Client tokens are 32 hex characters; an all-zero placeholder is allowed. + "Meta client token": re.compile(rb"(?i)FacebookClientToken\s*(?!0{32}<)[0-9a-f]{32}<"), + "email address": re.compile(rb"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z.]{2,}"), +} +# Files whose own source would match the generic patterns. Private terms are checked everywhere. +GENERIC_SKIP = {"scripts/verify-public.py"} + + +def split_terms(text: str) -> list[str]: + terms = [] + for line in text.splitlines(): + line = line.split("#", 1)[0] + terms.extend(part.strip() for part in line.split(",")) + return [term for term in terms if term] + + +def load_private_terms() -> list[str]: + terms = split_terms(os.environ.get("KAPUSCH_PRIVATE_TERMS", "")) + git_dir = subprocess.check_output(["git", "rev-parse", "--absolute-git-dir"], cwd=ROOT, text=True).strip() + terms_file = Path(git_dir) / "info" / "private-terms" + if terms_file.is_file(): + terms.extend(split_terms(terms_file.read_text(encoding="utf-8"))) + return list(dict.fromkeys(terms)) + + +def main() -> int: + files = [f for f in subprocess.check_output(["git", "ls-files", "-z"], cwd=ROOT).decode().split("\0") if f] + private = [ + (f"private term #{index}", re.compile(re.escape(term.encode()), re.IGNORECASE)) + for index, term in enumerate(load_private_terms(), start=1) + ] + errors = [] + for name in files: + path = ROOT / name + if not path.is_file(): + continue + data = path.read_bytes() + checks = list(private) + if name not in GENERIC_SKIP: + checks.extend(PATTERNS.items()) + for label, pattern in checks: + if pattern.search(data): + errors.append(f"{name}: {label}") + if errors: + print("Public tree scan failed:\n " + "\n ".join(errors), file=sys.stderr) + return 1 + if private: + scope = f"{len(private)} private term(s)" + else: + scope = "no private terms configured (set KAPUSCH_PRIVATE_TERMS or .git/info/private-terms)" + print(f"Public tree scan passed ({len(files)} tracked files, {scope})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventParameterNames.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventParameterNames.cs index 82c209d..067481f 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventParameterNames.cs +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventParameterNames.cs @@ -247,10 +247,3 @@ public static class FacebookAppEventUserDataTypes /// Advertiser-side user identifier. iOS only in the native SDK API. FBSDKAppEventExternalId public const string ExternalId = "external_id"; } - -/// 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/FacebookAppEventsTypes.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs index 314972b..4f19429 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs @@ -47,3 +47,10 @@ public sealed record FacebookAppEventsUserData /// 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"; +} From 5ae4aa03dd51cabc0c117090ebf71b6ac8758d4b Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:33:35 +0100 Subject: [PATCH 08/18] docs(app-events): document App Events integration and consent gating - Integration.md: AppEvents feature selection; Info.plist switches with SDK defaults (FacebookAutoLogAppEventsEnabled, FacebookAdvertiserIDCollectionEnabled, FacebookSKAdNetworkReportEnabled) and the missing iOS AutoInit counterpart; a consent recipe (switches off in Info.plist, initialize after consent, manual ActivateApp, withdrawal steps); AEM URL / Universal Link forwarding; the double-counting rule for server-side purchase events; single SKAdNetwork owner; diagnostics and platform gaps. - README, NuGet readme, SourceMode and third-party notices: App Events, new xcframeworks, pinned archives and the CoreKit tracking manifest. Co-Authored-By: Claude Opus 5.5 --- Docs/Integration.md | 153 +++++++++++++++++- Docs/SourceMode.md | 7 +- README.md | 13 +- THIRD_PARTY_NOTICES.md | 8 +- ...apusch.FacebookApisForiOSComponents.csproj | 2 +- .../nuget-readme.md | 12 +- 6 files changed, 183 insertions(+), 12 deletions(-) diff --git a/Docs/Integration.md b/Docs/Integration.md index 8133f62..a22ef7e 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -52,9 +52,19 @@ 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. +- `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` is not selected, the package links a small Foundation-only placeholder (`kfbsharestub.xcframework`) for the `NativeFacebookShare` entry @@ -65,7 +75,146 @@ Calling `NativeFacebookShare.SharePhotoAsync(...)` then completes with At SDK 18.0.2, ShareKit's CoreKit dependency requires 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) | +| `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 + ``` + +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` @@ -75,7 +224,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/SourceMode.md b/Docs/SourceMode.md index 395c733..8932a26 100644 --- a/Docs/SourceMode.md +++ b/Docs/SourceMode.md @@ -18,7 +18,10 @@ 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/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 +31,8 @@ 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`) +- `InteropIosShareWrapperDir`, `InteropIosShareStubWrapperDir`, `InteropIosAppEventsWrapperDir` + (paths to `kfbshare`, `kfbsharestub` and `kfbappevents` xcframeworks) - `FacebookIosFrameworksDir` (path to the folder containing `fb/*.xcframework`) Notes: diff --git a/README.md b/README.md index cadd51e..7a0b49c 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,16 @@ 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 `Share` currently includes `ShareKit`, `CoreKit`, `CoreKit_Basics` and `FBAEMKit`. @@ -61,7 +65,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/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj b/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj index 22f9f37..593c3ee 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 diff --git a/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md b/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md index f528b92..fa66de8 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 18.0.2. 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. From cb98007258b1dbc28a987ad5ab7e0df1d8980e39 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:47:04 +0100 Subject: [PATCH 09/18] feat(sample): demonstrate the App Events consent flow - Select Login;AppEvents in the sample (overridable with KapuschFacebookFeatures) so it links the App Events facade explicitly. - Ship the App Events switches off in Info.template.plist and add buttons for the consent recipe of Docs/Integration.md: grant consent (initialize, enable advertiser ID collection and debug logging, manual activation), log StartTrial and a custom event, flush and withdraw consent. Log activations from OnActivated after consent and forward Universal Links. - Without a FacebookAppID in the local, gitignored Info.plist the App Events buttons only print a message. Co-Authored-By: Claude Opus 5.5 --- .../AppDelegate.cs | 123 ++++++++++++++++++ .../Info.template.plist | 7 + .../Kapusch.Facebook.iOS.Sample.csproj | 1 + samples/Kapusch.Facebook.iOS.Sample/README.md | 15 ++- 4 files changed, 142 insertions(+), 4 deletions(-) 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..3a4a400 100644 --- a/samples/Kapusch.Facebook.iOS.Sample/Info.template.plist +++ b/samples/Kapusch.Facebook.iOS.Sample/Info.template.plist @@ -24,5 +24,12 @@ UIInterfaceOrientationPortrait + + FacebookAutoLogAppEventsEnabled + + FacebookAdvertiserIDCollectionEnabled + 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. From defb904b340f1ab31d2d184e781aca851253509a Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:52:36 +0100 Subject: [PATCH 10/18] fix(login): bind Login entry points with static __Internal imports NativeFacebookLogin resolved kfb_facebook_* with a run-time lookup in the main program. Nothing referenced those symbols at link time, so the native linker dead-stripped them: Initialize and HandleOpenUrl silently did nothing and SignInAsync could not reach the SDK. A Login;Share build of master showed only the three Share symbols in the app executable. - Bind the four Login entry points with [LibraryImport("__Internal")], like Share and App Events. The public API and its best-effort semantics are unchanged. - When Login is not selected, link kfbloginstub.xcframework (Foundation only), which defines the same entry points: SignInAsync completes with Failed/login_feature_not_selected. - Validate the static imports and package the placeholder. Verified with nm on simulator builds for Login;AppEvents, AppEvents and Share: every kfb_* entry point is present in the executable. Co-Authored-By: Claude Opus 5.5 --- Docs/Integration.md | 11 ++-- Docs/SourceMode.md | 6 +- scripts/validate-package.py | 7 +- .../Facebook/NativeFacebookLogin.iOS.cs | 64 ++++++++----------- ...apusch.FacebookApisForiOSComponents.csproj | 11 ++++ .../KapuschFacebookLoginStub/Interop.m | 54 ++++++++++++++++ .../Native/iOS/build.sh | 2 + .../Kapusch.Facebook.iOS.targets | 16 +++++ tests/validate-feature-selection.sh | 15 +++++ 9 files changed, 139 insertions(+), 47 deletions(-) create mode 100644 src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookLoginStub/Interop.m diff --git a/Docs/Integration.md b/Docs/Integration.md index a22ef7e..4f860fc 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -66,11 +66,12 @@ case-sensitive; unknown values produce a build warning): every feature links, so its small facade (`kfbappevents.xcframework`) is always linked. Selecting `AppEvents` matters when you want neither Login nor Share. -When `Share` is not selected, the package links a small Foundation-only -placeholder (`kfbsharestub.xcframework`) for the `NativeFacebookShare` 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`; no ShareKit code is shipped. +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 18.0.2, ShareKit's CoreKit dependency requires FBAEMKit. Treat this as a privacy-review blocker if the consumer's artifact policy excludes FBAEMKit. diff --git a/Docs/SourceMode.md b/Docs/SourceMode.md index 8932a26..eadda38 100644 --- a/Docs/SourceMode.md +++ b/Docs/SourceMode.md @@ -19,6 +19,7 @@ Use GitHub Packages pre-release and consume the package. This produces: - `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) @@ -31,8 +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`) -- `InteropIosShareWrapperDir`, `InteropIosShareStubWrapperDir`, `InteropIosAppEventsWrapperDir` - (paths to `kfbshare`, `kfbsharestub` and `kfbappevents` xcframeworks) +- `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/scripts/validate-package.py b/scripts/validate-package.py index 0003db7..de3f227 100755 --- a/scripts/validate-package.py +++ b/scripts/validate-package.py @@ -10,8 +10,8 @@ 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 App Events / Share binding resolves - symbols at run time. + 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. """ @@ -32,6 +32,7 @@ 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"), @@ -118,7 +119,7 @@ def check_app_events_bindings() -> None: 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", "NativeFacebookShare.iOS.cs"]: + 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") 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 593c3ee..427e5f8 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj +++ b/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj @@ -22,6 +22,10 @@ Condition="'$(InteropIosWrapperDir)' == '' and Exists('Native/iOS/build/kfb.xcframework')" >Native/iOS/build/kfb.xcframework + Native/iOS/build/kfbloginstub.xcframework Native/iOS/build/kfbshare.xcframework + + + @@ -79,6 +86,10 @@ Condition="'$(InteropIosWrapperDir)' == '' or !Exists('$(InteropIosWrapperDir)')" Text="Missing iOS native wrapper. Build it first via src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh or provide InteropIosWrapperDir." /> + <_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')) @@ -49,6 +50,10 @@ Condition="'$(_KapuschFacebookLoginEnabled)' == 'True' and !Exists('$(_KapuschFacebookInteropIosWrapper)')" Text="Kapusch.Facebook.iOS source-mode: missing wrapper xcframework at '$(_KapuschFacebookInteropIosWrapper)'. Build it first via src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh." /> + Static true + + + Static + true + Static true @@ -118,6 +128,7 @@ > <_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')) @@ -130,6 +141,11 @@ Static true + + + Static + true + Static true diff --git a/tests/validate-feature-selection.sh b/tests/validate-feature-selection.sh index 5f84759..177379f 100644 --- a/tests/validate-feature-selection.sh +++ b/tests/validate-feature-selection.sh @@ -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 From 578bf17f67deb966347b735fef1a85677952e900 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:05:35 +0100 Subject: [PATCH 11/18] fix(app-events): refuse calls the SDK would drop before initialization Before FBSDKApplicationDelegate configured it, the Facebook iOS SDK silently drops logged events (only a log line), ignores advanced-matching user data, overwrites a user ID set earlier with the persisted one, and answers setting getters with defaults. Some of those defaults are cached for the whole process: reading IsSKAdNetworkReportEnabled before initialization pinned it to true whatever FacebookSKAdNetworkReportEnabled said, and IsAdvertiserTrackingEnabled could pin "unspecified" on iOS 15-16. - The App Events registry now records whether a wrapper of this package initialized the SDK. App Events, Login and Share report it right after application:didFinishLaunchingWithOptions: (KFBNotifyAppEventsSdkInitialized, which also re-applies the recorded settings as before). - New FacebookAppEvents.IsSdkInitialized (kfb_appevents_is_sdk_initialized). - The event methods, the identity methods and the setting getters throw InvalidOperationException until then, like Kapusch.Facebook.Android. Setters, data processing options, flush behaviour, debug logging, the anonymous ID and the SDK version keep working at any time. - HandleOpenUrl and ContinueUserActivity return false without forwarding before initialization: forwarding stores campaign IDs and feeds AEM, which must wait for consent. - Guard the Login and Share notifications in the source invariants. Co-Authored-By: Claude Opus 5.5 --- .../Facebook/FacebookAppEvents.iOS.cs | 120 ++++++++++++++++-- .../KapuschFacebookAppEventsInterop/Interop.m | 43 ++++++- .../KapuschFacebookAuthInterop/Interop.m | 5 +- .../KapuschFacebookShareInterop/Interop.m | 4 +- .../include/KapuschFacebookAppEventsInterop.h | 1 + .../include/KapuschFacebookSettingsRegistry.h | 14 +- tests/validate-feature-selection.sh | 5 + 7 files changed, 164 insertions(+), 28 deletions(-) diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs index aeacf88..6d18059 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs @@ -27,6 +27,16 @@ namespace Kapusch.Facebook.iOS; /// app calls , , /// NativeFacebookLogin.Initialize or NativeFacebookShare.ConfigureAndInitialize. /// +/// +/// Until one of them ran (), the SDK silently drops events and +/// identity changes and reports default setting values. The event methods, the identity +/// methods and the setting getters therefore throw +/// before initialization, like the Android package; and +/// return without forwarding. +/// The setters, , , +/// , and +/// work at any time. +/// /// public static unsafe partial class FacebookAppEvents { @@ -34,10 +44,18 @@ public static unsafe partial class FacebookAppEvents // SDK lifecycle. + /// + /// Gets whether the SDK was initialized in this process through this package + /// (, , + /// NativeFacebookLogin.Initialize or NativeFacebookShare.ConfigureAndInitialize). + /// + public static bool IsSdkInitialized => IsSdkInitializedNative(); + /// /// 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. + /// options). Idempotent. Runs synchronously on the main thread; when called from another + /// thread it is queued on the main thread and becomes + /// once it ran. /// /// /// Initialization contacts Meta to load the app's SDK configuration, even when automatic @@ -64,7 +82,8 @@ public static bool FinishedLaunching(IntPtr uiApplicationHandle, IntPtr launchOp /// /// Forwards UIApplicationDelegate.OpenUrl (and scene URL contexts) to the SDK, for - /// Aggregated Event Measurement and App Links. Must be called on the main thread. + /// Aggregated Event Measurement and App Links. Must be called on the main thread. Before the + /// SDK is initialized the URL is not forwarded, so nothing is recorded before consent. /// /// when the SDK handled the URL. public static bool HandleOpenUrl(IntPtr uiApplicationHandle, IntPtr nsUrlHandle, IntPtr optionsHandle) @@ -78,7 +97,7 @@ public static bool HandleOpenUrl(IntPtr uiApplicationHandle, IntPtr nsUrlHandle, /// /// 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. + /// be called on the main thread. Before the SDK is initialized the activity is not forwarded. /// /// when the SDK handled the activity. public static bool ContinueUserActivity(IntPtr uiApplicationHandle, IntPtr nsUserActivityHandle) @@ -103,7 +122,15 @@ public static bool ContinueUserActivity(IntPtr uiApplicationHandle, IntPtr nsUse public static void SetAutoLogAppEventsEnabled(bool enabled) => SetAutoLogAppEventsEnabledNative(enabled); /// Current automatic app event logging setting. - public static bool IsAutoLogAppEventsEnabled => IsAutoLogAppEventsEnabledNative(); + public static bool IsAutoLogAppEventsEnabled + { + get + { + // Checked first: before initialization the SDK reports defaults and caches some of them. + EnsureSdkInitialized(); + return IsAutoLogAppEventsEnabledNative(); + } + } /// /// Enables or disables advertiser ID (IDFA) collection @@ -113,7 +140,15 @@ public static bool ContinueUserActivity(IntPtr uiApplicationHandle, IntPtr nsUse public static void SetAdvertiserIdCollectionEnabled(bool enabled) => SetAdvertiserIdCollectionEnabledNative(enabled); /// Current advertiser ID collection setting. - public static bool IsAdvertiserIdCollectionEnabled => IsAdvertiserIdCollectionEnabledNative(); + public static bool IsAdvertiserIdCollectionEnabled + { + get + { + // Checked first: before initialization the SDK reports defaults and caches some of them. + EnsureSdkInitialized(); + return IsAdvertiserIdCollectionEnabledNative(); + } + } /// /// Sets the advertiser tracking flag. Only used on iOS 15 and 16: on iOS 17 and later the SDK @@ -122,7 +157,15 @@ public static bool ContinueUserActivity(IntPtr uiApplicationHandle, IntPtr nsUse 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(); + public static bool IsAdvertiserTrackingEnabled + { + get + { + // Checked first: before initialization the SDK reports defaults and caches some of them. + EnsureSdkInitialized(); + return IsAdvertiserTrackingEnabledNative(); + } + } /// /// Enables or disables SKAdNetwork reporting by the SDK (FacebookSKAdNetworkReportEnabled). @@ -131,7 +174,15 @@ public static bool ContinueUserActivity(IntPtr uiApplicationHandle, IntPtr nsUse public static void SetSKAdNetworkReportEnabled(bool enabled) => SetSKAdNetworkReportEnabledNative(enabled); /// Current SKAdNetwork reporting setting. - public static bool IsSKAdNetworkReportEnabled => IsSKAdNetworkReportEnabledNative(); + public static bool IsSKAdNetworkReportEnabled + { + get + { + // Checked first: before initialization the SDK reports defaults and caches some of them. + EnsureSdkInitialized(); + return IsSKAdNetworkReportEnabledNative(); + } + } /// /// Sets data processing options, for example @@ -174,7 +225,11 @@ public static void SetDataProcessingOptions(string[] options, int country = 0, i /// 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(); + public static void ActivateApp() + { + EnsureSdkInitialized(); + ActivateAppNative(); + } /// Logs an app event. /// A constant or a custom name (2-40 characters: letters, digits, _, - or spaces). @@ -192,6 +247,7 @@ public static void LogEvent( throw new ArgumentOutOfRangeException(nameof(valueToSum), "Value to sum must be finite."); var json = SerializeParameters(parameters); + EnsureSdkInitialized(); bool logged; fixed (byte* parametersJson = json) { @@ -223,6 +279,7 @@ public static void LogPurchase( { var currency = NormalizeCurrency(currencyIsoCode, nameof(currencyIsoCode)); var json = SerializeParameters(parameters); + EnsureSdkInitialized(); bool logged; fixed (byte* parametersJson = json) logged = LogPurchaseNative(decimal.ToDouble(amount), currency, parametersJson); @@ -281,7 +338,11 @@ public static void LogCompleteRegistration( } /// Sends queued events now. - public static void Flush() => FlushNative(); + public static void Flush() + { + EnsureSdkInitialized(); + FlushNative(); + } /// Sets when queued events are sent. public static void SetFlushBehavior(FacebookAppEventsFlushBehavior behavior) @@ -308,13 +369,21 @@ public static void SetFlushBehavior(FacebookAppEventsFlushBehavior behavior) /// 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); + public static void SetUserId(string? userId) + { + EnsureSdkInitialized(); + SetUserIdNative(string.IsNullOrEmpty(userId) ? null : userId); + } /// Clears the custom user ID. - public static void ClearUserId() => SetUserIdNative(null); + public static void ClearUserId() => SetUserId(null); /// Current custom user ID, if any. - public static string? GetUserId() => TakeNativeString(CopyUserIdNative()); + public static string? GetUserId() + { + EnsureSdkInitialized(); + return TakeNativeString(CopyUserIdNative()); + } /// /// Sets advanced-matching user data. Replaces the ten standard fields at once; a @@ -323,6 +392,7 @@ public static void SetFlushBehavior(FacebookAppEventsFlushBehavior behavior) public static void SetUserData(FacebookAppEventsUserData userData) { ArgumentNullException.ThrowIfNull(userData); + EnsureSdkInitialized(); SetUserDataNative( userData.Email, userData.FirstName, @@ -346,11 +416,16 @@ public static void SetUserDataValue(string userDataType, string? value) if (string.IsNullOrWhiteSpace(userDataType)) throw new ArgumentException("User data type is required.", nameof(userDataType)); + EnsureSdkInitialized(); SetUserDataForTypeNative(userDataType, value); } /// Clears all advanced-matching user data. - public static void ClearUserData() => ClearUserDataNative(); + public static void ClearUserData() + { + EnsureSdkInitialized(); + ClearUserDataNative(); + } /// Clears one advanced-matching value. public static void ClearUserDataValue(string userDataType) @@ -358,11 +433,24 @@ public static void ClearUserDataValue(string userDataType) if (string.IsNullOrWhiteSpace(userDataType)) throw new ArgumentException("User data type is required.", nameof(userDataType)); + EnsureSdkInitialized(); ClearUserDataForTypeNative(userDataType); } // Helpers. + private static void EnsureSdkInitialized() + { + if (!IsSdkInitializedNative()) + { + throw new InvalidOperationException( + "The Facebook SDK is not initialized: call FacebookAppEvents.InitializeSdk() (or FinishedLaunching, " + + "NativeFacebookLogin.Initialize, NativeFacebookShare.ConfigureAndInitialize) first. " + + "Before initialization the SDK would drop this call silently." + ); + } + } + private static void LogValueEvent( string eventName, decimal? value, @@ -497,6 +585,10 @@ private static byte[] NullTerminated(ArrayBufferWriter buffer) [return: MarshalAs(UnmanagedType.U1)] private static partial bool ContinueUserActivityNative(IntPtr uiApplicationHandle, IntPtr nsUserActivityHandle); + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_sdk_initialized")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsSdkInitializedNative(); + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_copy_sdk_version")] private static partial IntPtr CopySdkVersionNative(); diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m index 43c9813..ff784f6 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m @@ -28,14 +28,19 @@ static os_log_t KFBAppEventsLog(void) #pragma mark - Explicit settings registry -// Settings changed through this facade, by name, with their last value. +// Settings changed through this facade, by name, with their last value, and +// whether a wrapper of this package initialized the SDK in this process. // // - 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. +// setting the app configured explicitly, and report when they initialized +// the SDK. +// - Before initialization the SDK silently drops events and identity calls and +// reports default setting values (caching some of them for the process), so +// the managed layer refuses those calls until sdkDidInitialize ran. // // The class name, selectors and setting names are a contract with // KapuschFacebookSettingsRegistry.h; keep them in sync. @@ -43,7 +48,8 @@ @interface KFBAppEventsSettingsRegistry : NSObject + (void)recordValue:(BOOL)value forSetting:(NSString *)setting; + (nullable NSNumber *)explicitValueForSetting:(NSString *)setting; + (NSDictionary *)explicitValues; -+ (void)applyExplicitValues; ++ (BOOL)isSdkInitialized; ++ (void)sdkDidInitialize; @end static void KFBReapplyExplicitSettings(void); @@ -81,8 +87,22 @@ + (nullable NSNumber *)explicitValueForSetting:(NSString *)setting } } -+ (void)applyExplicitValues +static BOOL KFBSdkInitialized = NO; + ++ (BOOL)isSdkInitialized +{ + @synchronized(self) { + return KFBSdkInitialized; + } +} + +// Called on the main thread right after FBSDKApplicationDelegate configured the +// SDK (synchronously, inside application:didFinishLaunchingWithOptions:). ++ (void)sdkDidInitialize { + @synchronized(self) { + KFBSdkInitialized = YES; + } KFBReapplyExplicitSettings(); } @@ -212,7 +232,7 @@ static BOOL KFBFinishLaunching(UIApplication *application, NSDictionary *launchO { BOOL result = [FBSDKApplicationDelegate.sharedInstance application:application didFinishLaunchingWithOptions:launchOptions]; - KFBReapplyExplicitSettings(); + [KFBAppEventsSettingsRegistry sdkDidInitialize]; return result; } @@ -232,8 +252,18 @@ KFB_EXPORT bool kfb_appevents_finished_launching(void *applicationPtr, void *lau return KFBFinishLaunching(application, launchOptions); } +KFB_EXPORT bool kfb_appevents_is_sdk_initialized(void) +{ + return KFBAppEventsSettingsRegistry.isSdkInitialized; +} + +// URL forwarding is refused until the SDK is initialized: forwarding stores +// campaign identifiers and feeds AEM, which must wait for the app's consent. KFB_EXPORT bool kfb_appevents_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr) { + if (!KFBAppEventsSettingsRegistry.isSdkInitialized) { + return false; + } UIApplication *application = (__bridge UIApplication *)applicationPtr; NSURL *url = (__bridge NSURL *)urlPtr; return [FBSDKApplicationDelegate.sharedInstance application:application @@ -243,6 +273,9 @@ KFB_EXPORT bool kfb_appevents_handle_open_url(void *applicationPtr, void *urlPtr KFB_EXPORT bool kfb_appevents_continue_user_activity(void *applicationPtr, void *userActivityPtr) { + if (!KFBAppEventsSettingsRegistry.isSdkInitialized) { + return false; + } UIApplication *application = (__bridge UIApplication *)applicationPtr; NSUserActivity *userActivity = (__bridge NSUserActivity *)userActivityPtr; return [FBSDKApplicationDelegate.sharedInstance application:application diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m index dea4b42..2186ecf 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m @@ -86,8 +86,9 @@ KFB_EXPORT void kfb_facebook_initialize(void *applicationPtr, void *launchOption [FBSDKApplicationDelegate.sharedInstance application:application didFinishLaunchingWithOptions:launchOptions]; - // No-op unless the app changed settings through FacebookAppEvents. - KFBApplyExplicitAppEventsSettings(); + // Marks the SDK initialized for FacebookAppEvents and re-applies the + // settings the app set through it (no-op when it set none). + KFBNotifyAppEventsSdkInitialized(); } KFB_EXPORT bool kfb_facebook_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr) diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m index f50c311..4245630 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.m @@ -162,7 +162,9 @@ KFB_EXPORT void kfb_facebook_share_configure_and_initialize(void *applicationPtr UIApplication *application = (__bridge UIApplication *)applicationPtr; [FBSDKApplicationDelegate.sharedInstance application:application didFinishLaunchingWithOptions:nil]; - KFBApplyExplicitAppEventsSettings(); + // Marks the SDK initialized for FacebookAppEvents and re-applies the + // settings the app set through it (no-op when it set none). + KFBNotifyAppEventsSdkInitialized(); KFBShareSdkInitialized = YES; } diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h index 72d559b..9c4e074 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h @@ -15,6 +15,7 @@ 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 bool kfb_appevents_is_sdk_initialized(void); KFB_EXPORT const char *kfb_appevents_copy_sdk_version(void); // Settings (persisted by the SDK across launches). diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookSettingsRegistry.h b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookSettingsRegistry.h index d50ff53..4e7705c 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookSettingsRegistry.h +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookSettingsRegistry.h @@ -5,8 +5,9 @@ // 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. +// - both report when they initialized the SDK, which marks it initialized for +// FacebookAppEvents and applies the recorded values again, so the SDK +// persists values that were set before initialization. #pragma once @@ -20,7 +21,7 @@ static NSString *const KFBRegistrySettingAdvertiserTracking = @"advertiserTracki @protocol KFBAppEventsSettingsRegistryLookup + (nullable NSNumber *)explicitValueForSetting:(NSString *)setting; -+ (void)applyExplicitValues; ++ (void)sdkDidInitialize; @end static inline Class _Nullable KFBAppEventsSettingsRegistryClass(void) @@ -28,7 +29,7 @@ static inline Class _Nullable KFBAppEventsSe Class registry = NSClassFromString(@"KFBAppEventsSettingsRegistry"); if (registry == Nil || ![registry respondsToSelector:@selector(explicitValueForSetting:)] - || ![registry respondsToSelector:@selector(applyExplicitValues)]) { + || ![registry respondsToSelector:@selector(sdkDidInitialize)]) { return Nil; } return (Class)registry; @@ -39,9 +40,10 @@ static inline NSNumber *_Nullable KFBExplicitAppEventsSetting(NSString *setting) return [KFBAppEventsSettingsRegistryClass() explicitValueForSetting:setting]; } -static inline void KFBApplyExplicitAppEventsSettings(void) +// Call right after FBSDKApplicationDelegate application:didFinishLaunchingWithOptions:. +static inline void KFBNotifyAppEventsSdkInitialized(void) { - [KFBAppEventsSettingsRegistryClass() applyExplicitValues]; + [KFBAppEventsSettingsRegistryClass() sdkDidInitialize]; } NS_ASSUME_NONNULL_END diff --git a/tests/validate-feature-selection.sh b/tests/validate-feature-selection.sh index 177379f..13b79b0 100644 --- a/tests/validate-feature-selection.sh +++ b/tests/validate-feature-selection.sh @@ -57,6 +57,11 @@ for setting in KFBRegistrySettingAutoLogAppEvents KFBRegistrySettingAdvertiserID 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 +login_interop_source="src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAuthInterop/Interop.m" +for initializing_source in "$login_interop_source" "$share_interop_source"; do + contains_fixed_text "KFBNotifyAppEventsSdkInitialized();" "$initializing_source" \ + || { echo "$initializing_source must report SDK initialization to FacebookAppEvents." >&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 From c6417f40416a6e276bb08133ed50f3d0e13f16a4 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:07:32 +0100 Subject: [PATCH 12/18] feat(app-events): align InitializeSdk, user data and parameters with Android Kapusch.Facebook.Android 1.2.0-rc.1 already ships the shared App Events API. A few iOS signatures and encodings still differed: - InitializeSdk(dataProcessingOptions, dataProcessingCountry, dataProcessingState), like Android without its Context. The options are set before initialization, so they apply to its first requests, and again right after it. - SetDataProcessingOptions is now recorded like the boolean settings and re-applied after initialization. Before, options set before initialization stayed in memory only and were lost at the next launch. - FacebookAppEventsUserData.ExternalId. SetUserData now replaces every field, external ID included (set or cleared after the ten-field SDK setter, which keeps the stored one), so a null property clears it as on Android. SetUserDataValue stays available for single fields. - Event parameters: decimal and ulong values are sent as exact text and floats keep their shortest form (a float 0.1 was sent as 0.10000000149011612), as on Android. Unsupported values are reported against the public "parameters" argument. Co-Authored-By: Claude Opus 5.5 --- .../Facebook/FacebookAppEvents.iOS.cs | 69 ++++++++++++++----- .../Facebook/FacebookAppEventsTypes.cs | 14 +++- .../KapuschFacebookAppEventsInterop/Interop.m | 59 ++++++++++++++-- .../include/KapuschFacebookAppEventsInterop.h | 3 +- 4 files changed, 116 insertions(+), 29 deletions(-) diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs index 6d18059..c6d9456 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs @@ -53,15 +53,34 @@ public static unsafe partial class FacebookAppEvents /// /// Initializes the SDK (application:didFinishLaunchingWithOptions: with no launch - /// options). Idempotent. Runs synchronously on the main thread; when called from another - /// thread it is queued on the main thread and becomes - /// once it ran. + /// options), after applying . Idempotent. Runs + /// synchronously on the main thread; when called from another thread it is queued on the + /// main thread and becomes once it ran. /// /// /// 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. + /// event logging is disabled. Call it only once the user has consented. The data processing + /// options are set before initialization, so they already apply to those first requests, + /// and again right after it, so the SDK persists them. Same signature as the Android package, + /// without its Context. /// - public static void InitializeSdk() => InitializeSdkNative(); + /// + /// Options such as . + /// leaves the current options unchanged; an empty array clears them. + /// + /// Country for the options; 0 lets Meta geolocate the user. + /// State for the options; 0 lets Meta geolocate the user. + public static void InitializeSdk( + string[]? dataProcessingOptions = null, + int dataProcessingCountry = 0, + int dataProcessingState = 0 + ) + { + if (dataProcessingOptions is not null) + SetDataProcessingOptions(dataProcessingOptions, dataProcessingCountry, dataProcessingState); + + InitializeSdkNative(); + } /// /// Forwards UIApplicationDelegate.FinishedLaunching to the SDK, including the launch @@ -187,7 +206,9 @@ public static bool IsSKAdNetworkReportEnabled /// /// 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. + /// geolocate the user. An empty array explicitly disables Limited Data Use. Works before + /// initialization (the options are applied again once the SDK is initialized, so it persists + /// them); prefer passing them to . /// public static void SetDataProcessingOptions(string[] options, int country = 0, int state = 0) { @@ -233,7 +254,11 @@ public static void ActivateApp() /// 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 parameters. Values must be , (sent as + /// "1"/"0"), an integral type, a finite or , + /// or (sent as exact text, like ), as in the Android package. + /// /// Optional value summed across events of this name, for example a price. public static void LogEvent( string eventName, @@ -386,8 +411,9 @@ public static void SetUserId(string? userId) } /// - /// Sets advanced-matching user data. Replaces the ten standard fields at once; a - /// property clears that field. The SDK hashes the values. + /// Sets advanced-matching user data. Replaces every field at once, external ID included; a + /// property clears that field, as in the Android package. The SDK + /// normalizes and SHA-256 hashes the values before persisting or sending them. /// public static void SetUserData(FacebookAppEventsUserData userData) { @@ -403,7 +429,8 @@ public static void SetUserData(FacebookAppEventsUserData userData) userData.City, userData.State, userData.Zip, - userData.Country + userData.Country, + userData.ExternalId ); } @@ -527,21 +554,24 @@ private static void WriteParameterValue(Utf8JsonWriter writer, string key, objec writer.WriteNumberValue(Convert.ToInt64(value, CultureInfo.InvariantCulture)); break; case ulong unsignedLong: - writer.WriteNumberValue(unsignedLong); + // Above long.MaxValue a JSON number would lose precision; send it as text like Android. + writer.WriteStringValue(unsignedLong.ToString(CultureInfo.InvariantCulture)); + break; + case float single when float.IsFinite(single): + // The float overload keeps the shortest round-trip form (0.1, not 0.100000001...). + writer.WriteNumberValue(single); 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)); + case double number when double.IsFinite(number): writer.WriteNumberValue(number); break; case decimal amount: - writer.WriteNumberValue(amount); + // The SDK would turn a JSON number into a double; keep decimals exact, like Android. + writer.WriteStringValue(amount.ToString(CultureInfo.InvariantCulture)); break; default: throw new ArgumentException( - $"Parameter '{key}' must be a string, a number or a boolean.", - nameof(value) + $"Unsupported value for parameter '{key}'. Use a string, bool, integral, finite floating-point or decimal value.", + "parameters" ); } } @@ -671,7 +701,8 @@ private static partial void SetUserDataNative( string? city, string? state, string? zip, - string? country + string? country, + string? externalId ); [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_user_data_for_type", StringMarshalling = StringMarshalling.Utf8)] diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs index 4f19429..6c47947 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs @@ -11,10 +11,15 @@ public enum FacebookAppEventsFlushBehavior } /// -/// 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. +/// Advanced-matching user data passed to . Pass raw +/// values: the SDK normalizes and hashes (SHA-256) each one on the device before persisting or +/// sending it. replaces every field at once, so a +/// property clears that field (same as the Android package). /// +/// +/// Advanced matching sends personal data (hashed) to Meta. Collect it only with a legal basis and +/// clear it with when that basis ends. +/// public sealed record FacebookAppEventsUserData { /// Email address. @@ -46,6 +51,9 @@ public sealed record FacebookAppEventsUserData /// Country code (ISO 3166-1 alpha-2). public string? Country { get; init; } + + /// An ID representing the user in the app's own systems (external_id). + public string? ExternalId { get; init; } } /// Data processing options for . diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m index ff784f6..df82e07 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookAppEventsInterop/Interop.m @@ -28,8 +28,9 @@ static os_log_t KFBAppEventsLog(void) #pragma mark - Explicit settings registry -// Settings changed through this facade, by name, with their last value, and -// whether a wrapper of this package initialized the SDK in this process. +// Settings and data processing options changed through this facade (last +// values), and whether a wrapper of this package initialized the SDK in this +// process. // // - 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 @@ -48,6 +49,8 @@ @interface KFBAppEventsSettingsRegistry : NSObject + (void)recordValue:(BOOL)value forSetting:(NSString *)setting; + (nullable NSNumber *)explicitValueForSetting:(NSString *)setting; + (NSDictionary *)explicitValues; ++ (void)recordDataProcessingOptions:(NSArray *)options country:(int32_t)country state:(int32_t)state; ++ (nullable NSDictionary *)explicitDataProcessingOptions; + (BOOL)isSdkInitialized; + (void)sdkDidInitialize; @end @@ -87,6 +90,26 @@ + (nullable NSNumber *)explicitValueForSetting:(NSString *)setting } } +static NSDictionary *KFBExplicitDataProcessingOptions = nil; + ++ (void)recordDataProcessingOptions:(NSArray *)options country:(int32_t)country state:(int32_t)state +{ + @synchronized(self) { + KFBExplicitDataProcessingOptions = @{ + @"options" : [options copy], + @"country" : @(country), + @"state" : @(state), + }; + } +} + ++ (nullable NSDictionary *)explicitDataProcessingOptions +{ + @synchronized(self) { + return KFBExplicitDataProcessingOptions; + } +} + static BOOL KFBSdkInitialized = NO; + (BOOL)isSdkInitialized @@ -139,12 +162,24 @@ static void KFBSetExplicitSetting(NSString *setting, bool value) KFBApplySetting(setting, value); } +static void KFBApplyDataProcessingOptions(NSArray *options, int32_t country, int32_t state) +{ + [FBSDKSettings.sharedSettings setDataProcessingOptions:options country:country state:state]; +} + static void KFBReapplyExplicitSettings(void) { [[KFBAppEventsSettingsRegistry explicitValues] enumerateKeysAndObjectsUsingBlock:^(NSString *setting, NSNumber *value, BOOL *stop) { KFBApplySetting(setting, value.boolValue); }]; + NSDictionary *dataProcessingOptions = KFBAppEventsSettingsRegistry.explicitDataProcessingOptions; + if (dataProcessingOptions != nil) { + KFBApplyDataProcessingOptions( + dataProcessingOptions[@"options"], + [dataProcessingOptions[@"country"] intValue], + [dataProcessingOptions[@"state"] intValue]); + } } #pragma mark - Helpers @@ -345,9 +380,11 @@ KFB_EXPORT void kfb_appevents_set_data_processing_options(const char *optionsJso return; } } - [FBSDKSettings.sharedSettings setDataProcessingOptions:(NSArray *)value - country:country - state:state]; + // Before initialization the SDK keeps the options in memory only (they still + // apply to the requests initialization sends); they are applied again once + // the SDK is initialized so it persists them. + [KFBAppEventsSettingsRegistry recordDataProcessingOptions:(NSArray *)value country:country state:state]; + KFBApplyDataProcessingOptions((NSArray *)value, country, state); } static NSSet *KFBDebugLoggingBehaviors(void) @@ -460,8 +497,12 @@ KFB_EXPORT void kfb_appevents_set_user_data( const char *city, const char *state, const char *zip, - const char *country) + const char *country, + const char *externalId) { + // The ten-field setter keeps the stored external ID; set or clear it after + // it so every field is replaced, like the Android package. Both calls are + // queued in order on the SDK's serial user-data queue. [FBSDKAppEvents.shared setUserEmail:KFBStringOrNil(email) firstName:KFBStringOrNil(firstName) lastName:KFBStringOrNil(lastName) @@ -472,6 +513,12 @@ KFB_EXPORT void kfb_appevents_set_user_data( state:KFBStringOrNil(state) zip:KFBStringOrNil(zip) country:KFBStringOrNil(country)]; + NSString *externalIdValue = KFBStringOrNil(externalId); + if (externalIdValue != nil) { + [FBSDKAppEvents.shared setUserData:externalIdValue forType:FBSDKAppEventExternalId]; + } else { + [FBSDKAppEvents.shared clearUserDataForType:FBSDKAppEventExternalId]; + } } KFB_EXPORT void kfb_appevents_set_user_data_for_type(const char *type, const char *value) diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h index 9c4e074..6ceefae 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h @@ -52,7 +52,8 @@ KFB_EXPORT void kfb_appevents_set_user_data( const char *city, const char *state, const char *zip, - const char *country); + const char *country, + const char *externalId); 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); From ebbe864582c3f8c85c3b0768d2964966d77eff2b Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:10:52 +0100 Subject: [PATCH 13/18] docs(app-events): describe SDK 18.0.2 consent semantics truthfully - Server override: with SDK 18.0.2 an automatic-logging value set for the app at Meta overrides FacebookAutoLogAppEventsEnabled and SetAutoLogAppEventsEnabled once the app configuration is cached (SDK 19 changed this). Tell integrators to turn it off at Meta too and document IsAutoLogAppEventsEnabled as the effective value. - Initialization: what the SDK does before it is initialized, which FacebookAppEvents calls now throw or return false, and which work. - Withdrawal: what stops at once (app logging, IDFA, user ID and user data also for queued events, flushing with ExplicitOnly) and what only stops later (server override, StoreKit observation, the initialized SDK, the non-persisted flush behaviour when Login or Share initialize the SDK). - Remove claims the 18.0.2 sources do not support: purchase amounts are not rounded to three decimals, the activation threshold is Meta's session timeout (60 s by default), the advertiser tracking flag is also ignored before iOS 17 once Meta's domain configuration enables ATT handling. - Document the event parameter encoding and an API parity table with Kapusch.Facebook.Android, with the intentional gaps: no auto-init on iOS, no SDK 19 metadata switches in 18.0.2, iOS-only forwarding, advertiser tracking, SKAdNetwork and single-field user data. - CoreKit links FBAEMKit for every feature; Login and Share privacy manifests declare tracking too. Co-Authored-By: Claude Opus 5.5 --- Docs/Integration.md | 170 +++++++++++++----- THIRD_PARTY_NOTICES.md | 6 +- .../Facebook/FacebookAppEvents.iOS.cs | 40 +++-- 3 files changed, 156 insertions(+), 60 deletions(-) diff --git a/Docs/Integration.md b/Docs/Integration.md index 4f860fc..8bb83dc 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -73,15 +73,17 @@ 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 18.0.2, ShareKit's CoreKit dependency requires FBAEMKit. Treat this as a -privacy-review blocker if the consumer's artifact policy excludes FBAEMKit. +At SDK 18.0.2, CoreKit (required by every feature, App Events included) links +FBAEMKit. Treat this as a privacy-review blocker if the consumer's artifact policy +excludes FBAEMKit. ## 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`. +`FBSDKApplicationDelegate` of the bundled Facebook iOS SDK 18.0.2. Event and parameter +names are available as constants in `FacebookAppEventNames`, +`FacebookAppEventParameterNames`, `FacebookAppEventParameterValues` and +`FacebookAppEventUserDataTypes`. The API mirrors `Kapusch.Facebook.Android` (section 4.8). ### 4.1 Info.plist keys @@ -92,40 +94,64 @@ 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 | +| `FacebookAutoLogAppEventsEnabled` | `true` | `SetAutoLogAppEventsEnabled` | Automatic activation / install events and, if enabled for the app at Meta, automatic in-app purchase events. See the server override below. | | `FacebookAdvertiserIDCollectionEnabled` | `true` | `SetAdvertiserIdCollectionEnabled` | IDFA collection (all zeros until App Tracking Transparency is authorized) | | `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). +**Server override (SDK 18.0.2).** Once the SDK has downloaded the app's configuration +from Meta (after initialization; the configuration is cached across launches), an +automatic-logging value set for the app on Meta's side (App Dashboard / Events +Manager) takes precedence over `FacebookAutoLogAppEventsEnabled` and +`SetAutoLogAppEventsEnabled`, in both directions. Facebook iOS SDK 19 changed this so a +local `false` always wins, but this package bundles 18.0.2. Turn automatic logging off +at Meta as well when the app must control it, and read `IsAutoLogAppEventsEnabled`, +which returns the effective value. + +The metadata switches of SDK 19 (`FBSDKAutoLogMetaDataEnabled`, +`FBSDKAutoLogProductImprovementEnabled`) do not exist in 18.0.2, which does not +collect that data. Android's `com.facebook.sdk.AutoInitEnabled` has no iOS counterpart +either: 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. +The privacy manifests of `FBSDKCoreKit`, `FBSDKLoginKit` and `FBSDKShareKit` declare +`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 +### 4.2 Initialization and 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: +(SDK and app configuration requests) even when automatic logging is off. It happens when +the app calls `FacebookAppEvents.InitializeSdk(...)` or `FinishedLaunching(...)`, +`NativeFacebookLogin.Initialize(...)` or `NativeFacebookShare.ConfigureAndInitialize(...)`; +`FacebookAppEvents.IsSdkInitialized` then returns `true`. + +Until then, the SDK would silently drop events, user IDs and user data, and report +default setting values (caching some of them for the whole process). `FacebookAppEvents` +therefore throws `InvalidOperationException` from `ActivateApp`, the `Log*` methods, +`Flush`, the user ID / user data methods and the `Is…Enabled` getters before +initialization, and `HandleOpenUrl` / `ContinueUserActivity` return `false` without +forwarding. The `Set…Enabled` setters, `SetDataProcessingOptions`, `SetFlushBehavior`, +`SetDebugLoggingEnabled`, `GetAnonymousId` and `SdkVersion` work at any time: values set +before initialization apply immediately and are applied again once the SDK is +initialized, so the SDK persists them. + +A consent-first integration therefore: -1. Ships with every switch off in `Info.plist`: +1. Ships with the switches off in `Info.plist`: ```xml FacebookAutoLogAppEventsEnabled FacebookAdvertiserIDCollectionEnabled ``` -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. + and turns automatic logging off at Meta too (server override, section 4.1). +2. Does not initialize the SDK before the user consents. Login and Share initialize it + as well, 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.InitializeSdk(); // or InitializeSdk([FacebookDataProcessingOptions.LimitedDataUse]) FacebookAppEvents.SetAdvertiserIdCollectionEnabled(true); // Keep automatic logging off when a server reports purchases (section 4.4): FacebookAppEvents.SetAutoLogAppEventsEnabled(false); @@ -133,28 +159,50 @@ integration therefore: ``` 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: + (`OnActivated`), because automatic activation logging is off. The SDK only logs a new + activation when the app was inactive for longer than Meta's session timeout (60 + seconds by default). +4. On withdrawal, while the SDK is initialized: ```csharp + FacebookAppEvents.SetFlushBehavior(FacebookAppEventsFlushBehavior.ExplicitOnly); FacebookAppEvents.SetAutoLogAppEventsEnabled(false); FacebookAppEvents.SetAdvertiserIdCollectionEnabled(false); FacebookAppEvents.ClearUserId(); FacebookAppEvents.ClearUserData(); - // and stop calling ActivateApp / Log* methods. + // and stop calling ActivateApp / Log* / Flush. ``` - The SDK cannot delete events already sent; that goes through Meta's data deletion - process. + What stops at once: + - activation and event logging by the app (on iOS the SDK only logs an activation when + `ActivateApp` runs or when automatic logging is on); + - IDFA collection, the user ID and the advanced-matching user data, including for + events still queued; + - sending queued events: with `ExplicitOnly`, the SDK no longer flushes on its + 15-second timer or when the app becomes active, and keeps them on the device. + + What only stops later: + - automatic logging is checked at each activation, but a value set at Meta overrides + the local `false` (section 4.1); StoreKit transaction observation for automatic + purchase logging only stops when the SDK reloads its configuration, the next time + the app becomes active; + - the SDK stays initialized, and keeps its timers and configuration requests, until + the process ends; there is no shutdown API; + - the flush behaviour is not persisted. On the next launches, do not initialize the + SDK. If Login or Share initializes it anyway, call + `SetFlushBehavior(FacebookAppEventsFlushBehavior.ExplicitOnly)` before them, or the + events queued before the withdrawal are sent at the first activation. + + If the SDK is not initialized in the current process, nothing is sent and the identity + methods throw: data the SDK stored on the device stays there, unused, until the SDK is + initialized again. 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. +For Limited Data Use, pass the options to `InitializeSdk` +(`InitializeSdk([FacebookDataProcessingOptions.LimitedDataUse])`, country/state 0 lets +Meta geolocate the user) so they also apply to the initialization requests, or call +`SetDataProcessingOptions(...)` at any time; an empty array disables it. ### 4.3 Deep links and Aggregated Event Measurement @@ -171,11 +219,12 @@ public override bool ContinueUserActivity( ``` With a scene delegate, forward `OpenUrlContexts` and `ContinueUserActivity` the same -way. When consent is already known at launch, call +way. Both return `false` without forwarding until the SDK is initialized, so nothing is +recorded before consent. 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. +SDK entry point (whatever the App Events consent); call only one of them per URL. ### 4.4 Avoid double counting with server-side events @@ -187,10 +236,11 @@ per event: 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 + - keep automatic logging off (`FacebookAutoLogAppEventsEnabled=false` and at Meta, + section 4.1) and call `ActivateApp()` yourself: iOS only observes StoreKit + transactions when automatic logging is effectively 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 +- With SDK 18, automatic logging on iOS 15+ can observe StoreKit 2 transactions at each activation, renewals included, so leaving it on next to a server integration double-counts subscriptions. @@ -208,12 +258,44 @@ 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. +### 4.7 Event parameters + +`LogEvent` and the typed helpers accept `string`, `bool` (sent as `"1"` / `"0"`), +integral, finite `float` / `double` and `decimal` values. `decimal` and `ulong` values +are sent as exact text, like on Android. Event and parameter names must match Meta's +rules (2-40 characters: letters, digits, `_`, `-` or spaces); the SDK drops an event +with an invalid name or parameter and only logs it (enable `SetDebugLoggingEnabled`). + +### 4.8 API parity with Kapusch.Facebook.Android + +Same names and signatures on both platforms: `IsSdkInitialized`, `SetAutoLogAppEventsEnabled` / +`IsAutoLogAppEventsEnabled`, `SetAdvertiserIdCollectionEnabled` / +`IsAdvertiserIdCollectionEnabled`, `SetDataProcessingOptions`, `SetDebugLoggingEnabled`, +`SetFlushBehavior` / `FlushBehavior`, `LogEvent`, `LogPurchase`, `LogStartTrial`, +`LogSubscribe`, `LogCompleteRegistration`, `Flush`, `GetUserId` / `SetUserId` / +`ClearUserId`, `SetUserData` / `ClearUserData`, `FacebookAppEventsUserData`, +`FacebookAppEventsFlushBehavior`, `FacebookDataProcessingOptions` and the shared +constants. + +| Android | iOS | Why | +|---|---|---| +| `InitializeSdk(context, options, country, state)`, `ActivateApp(context)`, `GetAnonymousId(context)` | Same without `context` | No Android context on iOS | +| `SetAutoInitEnabled`, `IsAutoInitEnabled`, `IsSdkFullyInitialized` | None | The iOS SDK has no auto-initialization | +| `SetMetadataCollectionEnabled` / `IsMetadataCollectionEnabled`, `SetAutoLogProductImprovementEnabled` / `IsAutoLogProductImprovementEnabled` | None | SDK 19 switches; the bundled iOS SDK 18.0.2 has neither the switches nor that data collection | +| None | `FinishedLaunching`, `HandleOpenUrl`, `ContinueUserActivity` | iOS app-delegate forwarding for AEM and deep links | +| None | `SetAdvertiserTrackingEnabled` / `IsAdvertiserTrackingEnabled` | iOS-only flag, ignored by the SDK on iOS 17+ (it reads the ATT status) and on earlier versions once Meta's domain configuration enables ATT handling | +| None | `SetSKAdNetworkReportEnabled` / `IsSKAdNetworkReportEnabled` | SKAdNetwork is iOS-only | +| None | `SetUserDataValue` / `ClearUserDataValue`, `SdkVersion` | iOS SDK single-field user data API; diagnostics | + +Behaviour differences: + +- Before initialization, the Android setters throw; the iOS setters are recorded and + applied again once the SDK is initialized. +- After withdrawal, the Android SDK keeps logging activations on activity resumes until + the process ends; the iOS SDK only logs them when `ActivateApp` runs or automatic + logging is on. +- On iOS, the automatic-logging value set at Meta overrides the local one (SDK 18.0.2, + section 4.1). ## 5) Limited Login diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3dc62c3..e86a335 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -11,9 +11,9 @@ The NuGet package includes Facebook SDK `xcframework`s under `fb/`, taken unmodi 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. +The privacy manifests of `FBSDKCoreKit`, `FBSDKLoginKit` and `FBSDKShareKit` declare +`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/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs index c6d9456..4cbeaf3 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs @@ -134,13 +134,19 @@ public static bool ContinueUserActivity(IntPtr uiApplicationHandle, IntPtr nsUse /// /// 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. + /// app activation, install attribution ping and, when enabled for the app at Meta, in-app + /// purchases. When disabled, call to keep install and activation + /// events. /// + /// + /// With the bundled SDK 18.0.2, an automatic-logging value set for the app at Meta (App + /// Dashboard / Events Manager) overrides this local value once the SDK downloaded the app's + /// configuration. Turn it off at Meta too when the app must control it, and check + /// . + /// public static void SetAutoLogAppEventsEnabled(bool enabled) => SetAutoLogAppEventsEnabledNative(enabled); - /// Current automatic app event logging setting. + /// Effective automatic app event logging setting, server override included. public static bool IsAutoLogAppEventsEnabled { get @@ -170,12 +176,13 @@ public static bool IsAdvertiserIdCollectionEnabled } /// - /// 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. + /// Sets the advertiser tracking flag. The SDK ignores it on iOS 17 and later, where it reads + /// the App Tracking Transparency status instead, and on earlier versions once Meta's domain + /// configuration enables that ATT handling. /// public static void SetAdvertiserTrackingEnabled(bool enabled) => SetAdvertiserTrackingEnabledNative(enabled); - /// Current advertiser tracking flag (derived from App Tracking Transparency on iOS 17+). + /// Current advertiser tracking flag (derived from App Tracking Transparency when the SDK ignores the flag). public static bool IsAdvertiserTrackingEnabled { get @@ -243,8 +250,10 @@ public static void SetDataProcessingOptions(string[] options, int country = 0, i /// /// 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. + /// Call it each time the app becomes active when automatic logging is disabled. The SDK only + /// logs a new activation (with the previous session's deactivation) when the app was inactive + /// for longer than Meta's session timeout, 60 seconds by default. Runs on the main thread; + /// when called from another thread it is queued on the main thread. /// public static void ActivateApp() { @@ -289,8 +298,9 @@ public static void LogEvent( } /// - /// Logs fb_mobile_purchase. The SDK rounds the amount to three decimals and flushes - /// immediately unless the flush behaviour is . + /// Logs fb_mobile_purchase with fb_currency and the amount as value to sum + /// (passed to the SDK as a ). The SDK flushes purchases immediately unless + /// the flush behaviour is . /// Do not use it for purchases that a server already reports to Meta. /// /// Purchase amount in . @@ -369,7 +379,10 @@ public static void Flush() FlushNative(); } - /// Sets when queued events are sent. + /// + /// Sets when queued events are sent. Not persisted: the SDK starts every launch with + /// . Works before initialization. + /// public static void SetFlushBehavior(FacebookAppEventsFlushBehavior behavior) { if (!Enum.IsDefined(behavior)) @@ -386,7 +399,8 @@ public static void SetFlushBehavior(FacebookAppEventsFlushBehavior behavior) /// /// Anonymous, SDK-generated install identifier (AppEvents.shared.anonymousID), for - /// example for server-side attribution partners. Generated and persisted on first access. + /// example for server-side attribution partners. Generated and persisted on the device on first + /// access, also before initialization; reading it sends nothing. /// public static string? GetAnonymousId() => TakeNativeString(CopyAnonymousIdNative()); From e1fc0805b55ac71c0fce86f94ca2617788bb9a98 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:20:40 +0100 Subject: [PATCH 14/18] fix(sample): follow the documented App Events withdrawal steps - Withdrawal switches flushing to explicit only, so events queued before it stay on the device, and skips the identity calls when the SDK is not initialized (they now throw; nothing is sent in that case anyway). - Granting consent again restores automatic flushing and the log line shows IsSdkInitialized. - The README states that the sample initializes the SDK at launch for its Login button, unlike a consent-first app. Co-Authored-By: Claude Opus 5.5 --- samples/Kapusch.Facebook.iOS.Sample/AppDelegate.cs | 13 +++++++++++-- samples/Kapusch.Facebook.iOS.Sample/README.md | 9 +++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/samples/Kapusch.Facebook.iOS.Sample/AppDelegate.cs b/samples/Kapusch.Facebook.iOS.Sample/AppDelegate.cs index e1e5f2e..63703ad 100644 --- a/samples/Kapusch.Facebook.iOS.Sample/AppDelegate.cs +++ b/samples/Kapusch.Facebook.iOS.Sample/AppDelegate.cs @@ -127,6 +127,8 @@ private void GrantAppEventsConsent() return; FacebookAppEvents.SetDebugLoggingEnabled(true); + // A previous withdrawal in this process switched flushing to explicit only. + FacebookAppEvents.SetFlushBehavior(FacebookAppEventsFlushBehavior.Auto); FacebookAppEvents.InitializeSdk(); FacebookAppEvents.SetAdvertiserIdCollectionEnabled(true); FacebookAppEvents.SetAutoLogAppEventsEnabled(false); @@ -136,15 +138,22 @@ private void GrantAppEventsConsent() Console.WriteLine( $"App Events: SDK {FacebookAppEvents.SdkVersion}, anonymous ID {FacebookAppEvents.GetAnonymousId()}, " + $"autoLog={FacebookAppEvents.IsAutoLogAppEventsEnabled}, " - + $"advertiserId={FacebookAppEvents.IsAdvertiserIdCollectionEnabled}" + + $"advertiserId={FacebookAppEvents.IsAdvertiserIdCollectionEnabled}, " + + $"initialized={FacebookAppEvents.IsSdkInitialized}" ); } private void WithdrawAppEventsConsent() { _appEventsConsented = false; - if (!HasFacebookConfiguration()) + // Keep events queued before the withdrawal on the device (not persisted across launches). + FacebookAppEvents.SetFlushBehavior(FacebookAppEventsFlushBehavior.ExplicitOnly); + if (!FacebookAppEvents.IsSdkInitialized) + { + // Nothing is sent while the SDK is not initialized; the identity methods would throw. + Console.WriteLine("App Events: consent withdrawn (SDK not initialized)."); return; + } FacebookAppEvents.SetAutoLogAppEventsEnabled(false); FacebookAppEvents.SetAdvertiserIdCollectionEnabled(false); diff --git a/samples/Kapusch.Facebook.iOS.Sample/README.md b/samples/Kapusch.Facebook.iOS.Sample/README.md index b53933f..b211a26 100644 --- a/samples/Kapusch.Facebook.iOS.Sample/README.md +++ b/samples/Kapusch.Facebook.iOS.Sample/README.md @@ -35,5 +35,10 @@ 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. +and Events Manager > Test Events. "App Events: withdraw consent" follows the withdrawal steps of +`Docs/Integration.md` (explicit-only flushing, switches off, user ID and user data cleared). Without +a `FacebookAppID` the App Events buttons only print a message. + +The sample also calls `NativeFacebookLogin.Initialize` at launch for its Login button, which +initializes the SDK before any App Events consent; a consent-first app initializes it only after +consent (`Docs/Integration.md`, section 4.2). From 0203dda02ca0b32ad9e589938022d394f0946237 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:21:16 +0100 Subject: [PATCH 15/18] ci: check the SDK slices and privacy manifests the targets reference Kapusch.Facebook.iOS.targets bundles each SDK framework's PrivacyInfo.xcprivacy from hard-coded ios-arm64 and ios-arm64_x86_64-simulator slices. An SDK release that renames a slice (SDK 19 ships ios-arm64_arm64e) would make every consuming build fail on a missing BundleResource. validate-package.py now fails when a packaged SDK xcframework lacks one of those slices or its privacy manifest. Co-Authored-By: Claude Opus 5.5 --- scripts/validate-package.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/validate-package.py b/scripts/validate-package.py index de3f227..d77fc2b 100755 --- a/scripts/validate-package.py +++ b/scripts/validate-package.py @@ -6,7 +6,10 @@ 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. + nothing is flattened at the root and no unwanted SDK kit is shipped. Each + SDK xcframework has the device and simulator slices that + buildTransitive/Kapusch.Facebook.iOS.targets references, each with the + privacy manifest the targets bundle. 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 @@ -46,6 +49,9 @@ "FBSDKShareKit", ] +# Slice directories hard-coded by buildTransitive/Kapusch.Facebook.iOS.targets. +TARGET_SLICES = ["ios-arm64", "ios-arm64_x86_64-simulator"] + FORBIDDEN_PREFIXES = [ "fb/FBSDKGamingServicesKit.xcframework/", ] @@ -92,6 +98,17 @@ def check_layout(names: set[str]) -> None: if "Info.plist" in names: fail("a wrapper appears flattened (found 'Info.plist' at the package root)") + for framework in FACEBOOK_FRAMEWORKS: + for slice_name in TARGET_SLICES: + manifest = f"fb/{framework}.xcframework/{slice_name}/{framework}.framework/PrivacyInfo.xcprivacy" + if manifest not in names: + fail(f"missing {manifest} (referenced by Kapusch.Facebook.iOS.targets)") + + targets = (SRC / "buildTransitive" / "Kapusch.Facebook.iOS.targets").read_text() + for slice_name in TARGET_SLICES: + if slice_name not in targets: + fail(f"Kapusch.Facebook.iOS.targets no longer references the {slice_name} slice; update TARGET_SLICES") + for prefix in FORBIDDEN_PREFIXES: if any(name.startswith(prefix) for name in names): fail(f"forbidden package content: {prefix}") From 27f045cc10f4b254a1867031bd86c5ceb3120b29 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:21:29 +0100 Subject: [PATCH 16/18] ci: build the sample and check it keeps every native entry point The Login dead-stripping bug fixed earlier in this branch was only visible in a built app: the package and its static libraries were fine. CI now builds the sample for the simulator (Login;AppEvents, so the Share placeholder is linked too) and scripts/verify-app-symbols.py checks that the executable contains every kfb_* entry point declared by the wrapper headers. Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 1 + .github/workflows/ci.yml | 9 ++++++ AGENTS.md | 1 + scripts/verify-app-symbols.py | 48 ++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+) create mode 100755 scripts/verify-app-symbols.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 87ec433..2983828 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,4 +11,5 @@ - [ ] Validated package: `python3 scripts/validate-package.py` - [ ] Public tree scan: `python3 scripts/verify-public.py` - [ ] Feature selection: `bash tests/validate-feature-selection.sh` +- [ ] Sample built and entry points checked: `python3 scripts/verify-app-symbols.py /Kapusch.Facebook.iOS.Sample` - [ ] Updated docs if behavior/integration changed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4971477..27d36a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,15 @@ jobs: - name: Validate package run: python3 scripts/validate-package.py + - name: Build sample (simulator) and check its native entry points + run: | + dotnet build samples/Kapusch.Facebook.iOS.Sample/Kapusch.Facebook.iOS.Sample.csproj \ + -c Debug \ + -p:RuntimeIdentifier=iossimulator-arm64 \ + -p:EnableCodeSigning=false + python3 scripts/verify-app-symbols.py \ + samples/Kapusch.Facebook.iOS.Sample/bin/Debug/net10.0-ios/iossimulator-arm64/Kapusch.Facebook.iOS.Sample.app/Kapusch.Facebook.iOS.Sample + - uses: actions/upload-artifact@v4 with: name: nuget diff --git a/AGENTS.md b/AGENTS.md index fa6f5b6..5d5ccba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ - `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 real Facebook credentials or emails in tracked files, and no private term read from `KAPUSCH_PRIVATE_TERMS` or the untracked `.git/info/private-terms`. +- `python3 scripts/verify-app-symbols.py ` — a built app (CI builds the sample) keeps every `kfb_*` entry point. - When package contents or native entry points change, update `scripts/validate-package.py` in the same commit. ## Safety diff --git a/scripts/verify-app-symbols.py b/scripts/verify-app-symbols.py new file mode 100755 index 0000000..7d328bc --- /dev/null +++ b/scripts/verify-app-symbols.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Checks that a built app executable contains every kfb_* entry point. + +Usage: python3 scripts/verify-app-symbols.py path/to/App.app/AppExecutable + +The managed bindings use static __Internal imports, so the .NET iOS build must +keep every entry point of the wrapper headers in the app, whatever +KapuschFacebookFeatures selects (the Login and Share placeholders define the +same symbols). A missing symbol means the native linker dead-stripped it and the +managed call would fail at run time. +""" +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +HEADERS = ROOT / "src" / "Kapusch.FacebookApisForiOSComponents" / "Native" / "iOS" / "KapuschFacebookAuthInterop" / "include" +WRAPPER_HEADERS = [ + "KapuschFacebookAuthInterop.h", + "KapuschFacebookShareInterop.h", + "KapuschFacebookAppEventsInterop.h", +] + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + executable = Path(sys.argv[1]) + expected = set() + for header in WRAPPER_HEADERS: + text = (HEADERS / header).read_text() + expected.update(re.findall(r"^KFB_EXPORT\s+[^;{]*?\b(kfb_[a-z0-9_]+)\s*\(", text, re.MULTILINE)) + output = subprocess.check_output(["nm", "-gUj", str(executable)], text=True) + present = {line.strip().lstrip("_") for line in output.splitlines()} + missing = sorted(expected - present) + if missing: + print(f"ERROR: {executable.name} lacks {len(missing)} entry point(s): {', '.join(missing)}", file=sys.stderr) + return 1 + print(f"OK: {executable.name} contains all {len(expected)} kfb_* entry points.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 37e8f645366afe92e00635a2d92041bd5c4e69d4 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:21:55 +0100 Subject: [PATCH 17/18] docs: state the bundled SDK version and that every selection ships FBAEMKit App Events made CoreKit, and with it FBAEMKit, part of every feature selection; the README still described it as a Share-only dependency and did not name the bundled SDK release. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7a0b49c..d440146 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,11 @@ such as `Login;Share`. The default remains `Login` for compatibility. `FacebookA 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 -`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. +The package bundles Facebook iOS SDK **18.0.2** (see `DependencyLocks/iOS/lockstate.txt`). +That release links `FBSDKCoreKit` to `FBAEMKit` non-weakly. Therefore every selection +includes `CoreKit`, `CoreKit_Basics` and `FBAEMKit`, plus `LoginKit` and/or `ShareKit` +when selected. A consumer that forbids `FBAEMKit` must block Store delivery; this +repository does not strip or conceal the dependency. ## Upstream dependency ingestion From 2da6bd1332510afdb430937b2d41928a9dfee9d2 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:22:00 +0100 Subject: [PATCH 18/18] chore(release): set package version to 1.2.0 1.2.0 adds App Events and keeps Facebook iOS SDK 18.0.2. It is first published as 1.2.0-rc.1 from a v1.2.0-rc.1 tag on master. Co-Authored-By: Claude Opus 5.5 --- .../Kapusch.FacebookApisForiOSComponents.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj b/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj index 427e5f8..ef091de 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj +++ b/src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj @@ -16,7 +16,7 @@ $(NoWarn);NU5128 Kapusch.Facebook.iOS Kapusch.Facebook.iOS - 1.1.0 + 1.2.0 nuget-readme.md