From af24fb66c4b6862b4d528a0b61cc68e1ec9bc683 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 22:54:29 +0100 Subject: [PATCH 01/12] 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 80372e79e7101db69b5a0a6bd437cd2c6252c365 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 22:59:56 +0100 Subject: [PATCH 02/12] 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 aa1b18be4907c621ce2db0e5733be752c1f80389 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:09:08 +0100 Subject: [PATCH 03/12] build(deps): bump Facebook iOS SDK to 19.0.0 - Pin the 19.0.0 dynamic xcframework archives. Their SHA-256 values equal the upstream Package.swift remoteChecksum values at tag v19.0.0. - SDK 19 builds its device slice as ios-arm64_arm64e. Resolve the device slice from the packaged xcframework so the privacy-manifest resources keep pointing at existing files. - Document the new iOS 15.0 minimum of the SDK. Meta built 19.0.0 with Xcode 27.1 / Swift 6.4. The Objective-C facades compile and link with Xcode 26.x, which a Swift facade cannot do. Co-Authored-By: Claude Opus 5.5 --- DependencyLocks/iOS/lockstate.txt | 10 +++++----- Docs/Integration.md | 5 ++++- README.md | 5 ++++- .../buildTransitive/Kapusch.Facebook.iOS.targets | 4 ++++ .../nuget-readme.md | 3 ++- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/DependencyLocks/iOS/lockstate.txt b/DependencyLocks/iOS/lockstate.txt index cdd72f8..a8254f5 100644 --- a/DependencyLocks/iOS/lockstate.txt +++ b/DependencyLocks/iOS/lockstate.txt @@ -3,8 +3,8 @@ # 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 +https://github.com/facebook/facebook-ios-sdk/releases/download/v19.0.0/FBSDKCoreKit_Basics-Dynamic_XCFramework.zip 7cdfe6a706a4852854aac2dcb24b4aa48ad231f1a3655605ea2333b6d0e2b305 FBSDKCoreKit_Basics-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v19.0.0/FBAEMKit-Dynamic_XCFramework.zip df494f1e8130d975a0dfbfd54d1471c63d86767589bf03b98f6631ad87feb2ad FBAEMKit-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v19.0.0/FBSDKCoreKit-Dynamic_XCFramework.zip 791756b0786b79909de375d7060304d7822edac738218863f07470b1acfcde76 FBSDKCoreKit-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v19.0.0/FBSDKLoginKit-Dynamic_XCFramework.zip d8e23d410d03776f5a2d267f3d935b12a391b878d921c286de9460ac0abad2a9 FBSDKLoginKit-Dynamic_XCFramework.zip +https://github.com/facebook/facebook-ios-sdk/releases/download/v19.0.0/FBSDKShareKit-Dynamic_XCFramework.zip e0c095a0fd6bbf51a1e349de09836f025e73ad907809bae7fcc5ee33934c2ceb FBSDKShareKit-Dynamic_XCFramework.zip diff --git a/Docs/Integration.md b/Docs/Integration.md index 3957d1a..ddfe97b 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -4,6 +4,9 @@ This document applies whether you consume the package via NuGet or via ProjectRe ## 1) Info.plist requirements +The bundled Facebook iOS SDK 19.0.0 requires iOS 15.0 or later +(`SupportedOSPlatformVersion` >= 15.0). + Your iOS app must include: - `FacebookDisplayName` @@ -59,7 +62,7 @@ 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 +At SDK 19.0.0, CoreKit (required by every feature) links FBAEMKit. Treat this as a privacy-review blocker if the consumer's artifact policy excludes FBAEMKit. ## 4) Limited Login diff --git a/README.md b/README.md index cadd51e..792239b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,10 @@ A NuGet package that: Select features with `KapuschFacebookFeatures=Login`, `Share`, or `Login;Share`. The default remains `Login` for compatibility. -Facebook iOS SDK 18.0.2 links `FBSDKCoreKit` to `FBAEMKit` non-weakly. Therefore +The package bundles Facebook iOS SDK **19.0.0** (see `DependencyLocks/iOS/lockstate.txt`). +It requires iOS 15.0 or later, the minimum of that SDK release. + +Facebook iOS SDK 19.0.0 links `FBSDKCoreKit` to `FBAEMKit` non-weakly. Therefore `Share` currently includes `ShareKit`, `CoreKit`, `CoreKit_Basics` and `FBAEMKit`. A consumer that forbids `FBAEMKit` must block Store delivery; this repository does not strip or conceal the dependency. diff --git a/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets b/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets index 859b62e..a3fd624 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets +++ b/src/Kapusch.FacebookApisForiOSComponents/buildTransitive/Kapusch.Facebook.iOS.targets @@ -21,6 +21,8 @@ <_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)' == '' and Exists('$(_KapuschFacebookInteropFbDir)/FBSDKCoreKit.xcframework/ios-arm64_arm64e')">ios-arm64_arm64e <_KapuschFacebookInteropSlice Condition="'$(_KapuschFacebookInteropSlice)' == ''">ios-arm64 $([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)' == '' and Exists('$(_KapuschFacebookInteropFbDir)/FBSDKCoreKit.xcframework/ios-arm64_arm64e')">ios-arm64_arm64e <_KapuschFacebookInteropSlice Condition="'$(_KapuschFacebookInteropSlice)' == ''">ios-arm64 diff --git a/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md b/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md index f528b92..7481d0a 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md +++ b/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md @@ -5,7 +5,8 @@ This package provides: - 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. +Bundles Facebook iOS SDK 19.0.0 (iOS 15.0+). CoreKit, which every feature +uses, requires FBAEMKit. Photo sharing uses ShareKit's native-app dialog, guarantees one managed completion per request, and avoids reading the general pasteboard when cleaning Meta's app-bridge marker on return. From 834b046edbe62afb501d085afccedca4f11f7828 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:12:12 +0100 Subject: [PATCH 04/12] 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 (iOS 15-16 only), SKAdNetwork reporting, SDK 19 metadata collection and product-improvement switches, 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 | 483 ++++++++++++++++++ .../include/KapuschFacebookAppEventsInterop.h | 63 +++ .../Native/iOS/build.sh | 2 + .../Kapusch.Facebook.iOS.targets | 16 + 7 files changed, 577 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 NSString *const KFBSettingMetadataCollection = @"metadataCollection"; +static NSString *const KFBSettingAutoLogProductImprovement = @"autoLogProductImprovement"; + +static void KFBApplySetting(NSString *setting, BOOL value) +{ + FBSDKSettings *settings = FBSDKSettings.sharedSettings; + if ([setting isEqualToString:KFBSettingAutoLogAppEvents]) { + settings.isAutoLogAppEventsEnabled = value; + } else if ([setting isEqualToString:KFBSettingAdvertiserIDCollection]) { + settings.isAdvertiserIDCollectionEnabled = value; + } else if ([setting isEqualToString:KFBSettingAdvertiserTracking]) { + // Deprecated by Meta on iOS 17+, where the SDK reads the App Tracking + // Transparency status instead and ignores this value. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + settings.isAdvertiserTrackingEnabled = value; +#pragma clang diagnostic pop + } else if ([setting isEqualToString:KFBSettingSKAdNetworkReport]) { + settings.isSKAdNetworkReportEnabled = value; + } else if ([setting isEqualToString:KFBSettingMetadataCollection]) { + settings.isMetaDataCollectionEnabled = value; + } else if ([setting isEqualToString:KFBSettingAutoLogProductImprovement]) { + settings.isAutoLogProductImprovementEnabled = value; + } +} + +static void KFBSetExplicitSetting(NSString *setting, bool value) +{ + [KFBAppEventsSettingsRegistry recordValue:value forSetting:setting]; + KFBApplySetting(setting, value); +} + +static void KFBReapplyExplicitSettings(void) +{ + [[KFBAppEventsSettingsRegistry explicitValues] + enumerateKeysAndObjectsUsingBlock:^(NSString *setting, NSNumber *value, BOOL *stop) { + KFBApplySetting(setting, value.boolValue); + }]; +} + +#pragma mark - Helpers + +static void KFBRunOnMainThread(dispatch_block_t block) +{ + if (NSThread.isMainThread) { + block(); + } else { + dispatch_async(dispatch_get_main_queue(), block); + } +} + +static NSString *KFBStringOrNil(const char *value) +{ + return value == NULL ? nil : [NSString stringWithUTF8String:value]; +} + +static const char *KFBCopyString(NSString *value) +{ + if (value == nil) { + return NULL; + } + const char *utf8 = value.UTF8String; + return utf8 == NULL ? NULL : strdup(utf8); +} + +static id KFBParseJson(const char *json) +{ + if (json == NULL) { + return nil; + } + NSData *data = [NSData dataWithBytes:json length:strlen(json)]; + NSError *error = nil; + id value = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if (value == nil) { + os_log_error( + KFBAppEventsLog(), + "Invalid JSON payload from managed code: %{public}@", + error.localizedDescription); + } + return value; +} + +// Parses event parameters. Returns NO when the payload is not a JSON object of +// string or number values; *parameters is nil when no payload was given. +static BOOL KFBParseParameters(const char *json, NSDictionary **parameters) +{ + *parameters = nil; + if (json == NULL) { + return YES; + } + + id value = KFBParseJson(json); + if (![value isKindOfClass:NSDictionary.class]) { + return NO; + } + + __block BOOL valid = YES; + [(NSDictionary *)value enumerateKeysAndObjectsUsingBlock:^(id key, id object, BOOL *stop) { + if (![key isKindOfClass:NSString.class] + || !([object isKindOfClass:NSString.class] || [object isKindOfClass:NSNumber.class])) { + valid = NO; + *stop = YES; + } + }]; + if (!valid) { + os_log_error(KFBAppEventsLog(), "App event parameters must map strings to strings or numbers."); + return NO; + } + + *parameters = (NSDictionary *)value; + return YES; +} + +static NSDictionary *KFBOpenUrlOptions(void *optionsPtr) +{ + NSDictionary *options = optionsPtr == NULL ? nil : (__bridge NSDictionary *)optionsPtr; + return [options isKindOfClass:NSDictionary.class] ? options : @{}; +} + +#pragma mark - SDK lifecycle + +static BOOL KFBFinishLaunching(UIApplication *application, NSDictionary *launchOptions) +{ + BOOL result = [FBSDKApplicationDelegate.sharedInstance application:application + didFinishLaunchingWithOptions:launchOptions]; + KFBReapplyExplicitSettings(); + return result; +} + +KFB_EXPORT void kfb_appevents_initialize_sdk(void) +{ + KFBRunOnMainThread(^{ + KFBFinishLaunching(UIApplication.sharedApplication, nil); + }); +} + +KFB_EXPORT bool kfb_appevents_finished_launching(void *applicationPtr, void *launchOptionsPtr) +{ + UIApplication *application = (__bridge UIApplication *)applicationPtr; + NSDictionary *launchOptions = launchOptionsPtr == NULL + ? nil + : (__bridge NSDictionary *)launchOptionsPtr; + return KFBFinishLaunching(application, launchOptions); +} + +KFB_EXPORT bool kfb_appevents_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr) +{ + UIApplication *application = (__bridge UIApplication *)applicationPtr; + NSURL *url = (__bridge NSURL *)urlPtr; + return [FBSDKApplicationDelegate.sharedInstance application:application + openURL:url + options:KFBOpenUrlOptions(optionsPtr)]; +} + +KFB_EXPORT bool kfb_appevents_continue_user_activity(void *applicationPtr, void *userActivityPtr) +{ + UIApplication *application = (__bridge UIApplication *)applicationPtr; + NSUserActivity *userActivity = (__bridge NSUserActivity *)userActivityPtr; + return [FBSDKApplicationDelegate.sharedInstance application:application + continueUserActivity:userActivity]; +} + +KFB_EXPORT const char *kfb_appevents_copy_sdk_version(void) +{ + return KFBCopyString(FBSDKSettings.sharedSettings.sdkVersion); +} + +#pragma mark - Settings + +KFB_EXPORT void kfb_appevents_set_auto_log_app_events_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingAutoLogAppEvents, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_auto_log_app_events_enabled(void) +{ + return FBSDKSettings.sharedSettings.isAutoLogAppEventsEnabled; +} + +KFB_EXPORT void kfb_appevents_set_advertiser_id_collection_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingAdvertiserIDCollection, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_advertiser_id_collection_enabled(void) +{ + return FBSDKSettings.sharedSettings.isAdvertiserIDCollectionEnabled; +} + +KFB_EXPORT void kfb_appevents_set_advertiser_tracking_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingAdvertiserTracking, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_advertiser_tracking_enabled(void) +{ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + return FBSDKSettings.sharedSettings.isAdvertiserTrackingEnabled; +#pragma clang diagnostic pop +} + +KFB_EXPORT void kfb_appevents_set_skadnetwork_report_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingSKAdNetworkReport, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_skadnetwork_report_enabled(void) +{ + return FBSDKSettings.sharedSettings.isSKAdNetworkReportEnabled; +} + +KFB_EXPORT void kfb_appevents_set_metadata_collection_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingMetadataCollection, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_metadata_collection_enabled(void) +{ + return FBSDKSettings.sharedSettings.isMetaDataCollectionEnabled; +} + +KFB_EXPORT void kfb_appevents_set_auto_log_product_improvement_enabled(bool enabled) +{ + KFBSetExplicitSetting(KFBSettingAutoLogProductImprovement, enabled); +} + +KFB_EXPORT bool kfb_appevents_is_auto_log_product_improvement_enabled(void) +{ + return FBSDKSettings.sharedSettings.isAutoLogProductImprovementEnabled; +} + +KFB_EXPORT void kfb_appevents_set_data_processing_options(const char *optionsJson, int32_t country, int32_t state) +{ + id value = KFBParseJson(optionsJson); + if (![value isKindOfClass:NSArray.class]) { + os_log_error(KFBAppEventsLog(), "Data processing options must be a JSON array of strings."); + return; + } + for (id option in (NSArray *)value) { + if (![option isKindOfClass:NSString.class]) { + os_log_error(KFBAppEventsLog(), "Data processing options must be a JSON array of strings."); + return; + } + } + [FBSDKSettings.sharedSettings setDataProcessingOptions:(NSArray *)value + country:country + state:state]; +} + +static NSSet *KFBDebugLoggingBehaviors(void) +{ + return [NSSet setWithObjects: + FBSDKLoggingBehaviorAppEvents, + FBSDKLoggingBehaviorDeveloperErrors, + FBSDKLoggingBehaviorInformational, + nil]; +} + +KFB_EXPORT void kfb_appevents_set_debug_logging_enabled(bool enabled) +{ + // Remember the behaviours that were active before enabling diagnostics, so + // disabling them restores the SDK defaults instead of silencing everything. + static NSSet *previousBehaviors = nil; + @synchronized(KFBAppEventsSettingsRegistry.class) { + FBSDKSettings *settings = FBSDKSettings.sharedSettings; + if (enabled) { + if (previousBehaviors == nil) { + previousBehaviors = [settings.loggingBehaviors copy]; + } + settings.loggingBehaviors = [settings.loggingBehaviors setByAddingObjectsFromSet:KFBDebugLoggingBehaviors()]; + } else if (previousBehaviors != nil) { + settings.loggingBehaviors = previousBehaviors; + previousBehaviors = nil; + } + } +} + +#pragma mark - Event logging + +KFB_EXPORT void kfb_appevents_activate_app(void) +{ + KFBRunOnMainThread(^{ + [FBSDKAppEvents.shared activateApp]; + }); +} + +KFB_EXPORT bool kfb_appevents_log_event(const char *eventName, const char *parametersJson, bool hasValueToSum, double valueToSum) +{ + NSString *name = KFBStringOrNil(eventName); + NSDictionary *parameters = nil; + if (name.length == 0 || !KFBParseParameters(parametersJson, ¶meters)) { + return false; + } + + if (hasValueToSum) { + [FBSDKAppEvents.shared logEvent:name valueToSum:valueToSum parameters:parameters]; + } else { + [FBSDKAppEvents.shared logEvent:name parameters:parameters]; + } + return true; +} + +KFB_EXPORT bool kfb_appevents_log_purchase(double amount, const char *currency, const char *parametersJson) +{ + NSString *currencyCode = KFBStringOrNil(currency); + NSDictionary *parameters = nil; + if (currencyCode.length == 0 || !KFBParseParameters(parametersJson, ¶meters)) { + return false; + } + + [FBSDKAppEvents.shared logPurchase:amount currency:currencyCode parameters:parameters]; + return true; +} + +KFB_EXPORT void kfb_appevents_flush(void) +{ + [FBSDKAppEvents.shared flush]; +} + +KFB_EXPORT void kfb_appevents_set_flush_behavior(int32_t behavior) +{ + FBSDKAppEvents.shared.flushBehavior = behavior == (int32_t)FBSDKAppEventsFlushBehaviorExplicitOnly + ? FBSDKAppEventsFlushBehaviorExplicitOnly + : FBSDKAppEventsFlushBehaviorAuto; +} + +KFB_EXPORT int32_t kfb_appevents_get_flush_behavior(void) +{ + return (int32_t)FBSDKAppEvents.shared.flushBehavior; +} + +#pragma mark - Identity and advanced matching + +KFB_EXPORT const char *kfb_appevents_copy_anonymous_id(void) +{ + return KFBCopyString(FBSDKAppEvents.shared.anonymousID); +} + +KFB_EXPORT void kfb_appevents_set_user_id(const char *userId) +{ + NSString *value = KFBStringOrNil(userId); + FBSDKAppEvents.shared.userID = value.length == 0 ? nil : value; +} + +KFB_EXPORT const char *kfb_appevents_copy_user_id(void) +{ + return KFBCopyString(FBSDKAppEvents.shared.userID); +} + +KFB_EXPORT void kfb_appevents_set_user_data( + const char *email, + const char *firstName, + const char *lastName, + const char *phone, + const char *dateOfBirth, + const char *gender, + const char *city, + const char *state, + const char *zip, + const char *country) +{ + [FBSDKAppEvents.shared setUserEmail:KFBStringOrNil(email) + firstName:KFBStringOrNil(firstName) + lastName:KFBStringOrNil(lastName) + phone:KFBStringOrNil(phone) + dateOfBirth:KFBStringOrNil(dateOfBirth) + gender:KFBStringOrNil(gender) + city:KFBStringOrNil(city) + state:KFBStringOrNil(state) + zip:KFBStringOrNil(zip) + country:KFBStringOrNil(country)]; +} + +KFB_EXPORT void kfb_appevents_set_user_data_for_type(const char *type, const char *value) +{ + NSString *dataType = KFBStringOrNil(type); + if (dataType.length == 0) { + return; + } + [FBSDKAppEvents.shared setUserData:KFBStringOrNil(value) forType:dataType]; +} + +KFB_EXPORT void kfb_appevents_clear_user_data(void) +{ + [FBSDKAppEvents.shared clearUserData]; +} + +KFB_EXPORT void kfb_appevents_clear_user_data_for_type(const char *type) +{ + NSString *dataType = KFBStringOrNil(type); + if (dataType.length == 0) { + return; + } + [FBSDKAppEvents.shared clearUserDataForType:dataType]; +} + +KFB_EXPORT void kfb_appevents_free(void *pointer) +{ + free(pointer); +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h new file mode 100644 index 0000000..e29837a --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/include/KapuschFacebookAppEventsInterop.h @@ -0,0 +1,63 @@ +// C interop surface of the Facebook App Events static library +// (kfbappevents.xcframework). It only uses FBSDKCoreKit, which every feature +// selection already links. +// +// Strings returned by kfb_appevents_copy_* are allocated with malloc and must +// be released with kfb_appevents_free. Event parameters are passed as a UTF-8 +// JSON object whose values are strings or numbers. + +#pragma once + +#include "KapuschFacebookInteropExport.h" + +// SDK lifecycle. +KFB_EXPORT void kfb_appevents_initialize_sdk(void); +KFB_EXPORT bool kfb_appevents_finished_launching(void *applicationPtr, void *launchOptionsPtr); +KFB_EXPORT bool kfb_appevents_handle_open_url(void *applicationPtr, void *urlPtr, void *optionsPtr); +KFB_EXPORT bool kfb_appevents_continue_user_activity(void *applicationPtr, void *userActivityPtr); +KFB_EXPORT const char *kfb_appevents_copy_sdk_version(void); + +// Settings (persisted by the SDK across launches). +KFB_EXPORT void kfb_appevents_set_auto_log_app_events_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_auto_log_app_events_enabled(void); +KFB_EXPORT void kfb_appevents_set_advertiser_id_collection_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_advertiser_id_collection_enabled(void); +KFB_EXPORT void kfb_appevents_set_advertiser_tracking_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_advertiser_tracking_enabled(void); +KFB_EXPORT void kfb_appevents_set_skadnetwork_report_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_skadnetwork_report_enabled(void); +KFB_EXPORT void kfb_appevents_set_metadata_collection_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_metadata_collection_enabled(void); +KFB_EXPORT void kfb_appevents_set_auto_log_product_improvement_enabled(bool enabled); +KFB_EXPORT bool kfb_appevents_is_auto_log_product_improvement_enabled(void); +KFB_EXPORT void kfb_appevents_set_data_processing_options(const char *optionsJson, int32_t country, int32_t state); +KFB_EXPORT void kfb_appevents_set_debug_logging_enabled(bool enabled); + +// Event logging. +KFB_EXPORT void kfb_appevents_activate_app(void); +KFB_EXPORT bool kfb_appevents_log_event(const char *eventName, const char *parametersJson, bool hasValueToSum, double valueToSum); +KFB_EXPORT bool kfb_appevents_log_purchase(double amount, const char *currency, const char *parametersJson); +KFB_EXPORT void kfb_appevents_flush(void); +KFB_EXPORT void kfb_appevents_set_flush_behavior(int32_t behavior); +KFB_EXPORT int32_t kfb_appevents_get_flush_behavior(void); + +// Identity and advanced matching (the SDK hashes user data before sending it). +KFB_EXPORT const char *kfb_appevents_copy_anonymous_id(void); +KFB_EXPORT void kfb_appevents_set_user_id(const char *userId); +KFB_EXPORT const char *kfb_appevents_copy_user_id(void); +KFB_EXPORT void kfb_appevents_set_user_data( + const char *email, + const char *firstName, + const char *lastName, + const char *phone, + const char *dateOfBirth, + const char *gender, + const char *city, + const char *state, + const char *zip, + const char *country); +KFB_EXPORT void kfb_appevents_set_user_data_for_type(const char *type, const char *value); +KFB_EXPORT void kfb_appevents_clear_user_data(void); +KFB_EXPORT void kfb_appevents_clear_user_data_for_type(const char *type); + +KFB_EXPORT void kfb_appevents_free(void *pointer); diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/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 a3fd624..d1f893e 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 @@ -37,6 +38,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 @@ -91,6 +101,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 @@ -111,6 +122,11 @@ Static true + + + Static + true + Framework true From dd33171eaf9643b928678b7d102c9753d0283b18 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:16:01 +0100 Subject: [PATCH 05/12] 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 (iOS 15-16), SKAdNetwork reporting, metadata collection and product improvement; 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 | 629 ++++++++++++++++++ .../Facebook/FacebookAppEventsTypes.cs | 49 ++ 4 files changed, 1070 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..daf2e2b --- /dev/null +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEvents.iOS.cs @@ -0,0 +1,629 @@ +using System.Buffers; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text.Json; + +namespace Kapusch.Facebook.iOS; + +/// +/// Meta App Events on iOS: SDK initialization, consent-related settings, event logging, +/// identity and AEM / deep-link forwarding. Thin facade over FBSDKAppEvents, +/// FBSDKSettings and FBSDKApplicationDelegate of the bundled Facebook iOS SDK. +/// +/// +/// +/// Available with every KapuschFacebookFeatures selection, because FBSDKCoreKit +/// is always linked. Select AppEvents alone to ship only CoreKit, AEMKit and +/// CoreKit_Basics. +/// +/// +/// The facade never enables anything on its own. Values set through the Set…Enabled +/// methods are persisted by the SDK and override the matching Info.plist keys on later +/// launches. Values set before are applied again once the SDK is +/// initialized, so the SDK persists them as well. +/// +/// +/// iOS has no counterpart of Android's setAutoInitEnabled: the SDK only starts when the +/// app calls , , +/// NativeFacebookLogin.Initialize or NativeFacebookShare.ConfigureAndInitialize. +/// +/// +public static unsafe partial class FacebookAppEvents +{ + private const string NativeLibraryName = "__Internal"; + + // SDK lifecycle. + + /// + /// Initializes the SDK (application:didFinishLaunchingWithOptions: with no launch + /// options). Idempotent. Runs on the main thread; when called from another thread it is + /// queued on the main thread. + /// + /// + /// Initialization contacts Meta to load the app's SDK configuration, even when automatic + /// event logging is disabled. Call it only once the user has consented. + /// + public static void InitializeSdk() => InitializeSdkNative(); + + /// + /// Forwards UIApplicationDelegate.FinishedLaunching to the SDK, including the launch + /// options (a cold-start URL is used for Aggregated Event Measurement). This initializes the + /// SDK like ; only call it at launch when the user has already + /// consented. Must be called on the main thread. + /// + /// UIApplication.Handle. + /// NSDictionary.Handle of the launch options, or . + /// The SDK result: when the SDK was already initialized. + public static bool FinishedLaunching(IntPtr uiApplicationHandle, IntPtr launchOptionsHandle) + { + if (uiApplicationHandle == IntPtr.Zero) + throw new ArgumentException("UIApplication is required.", nameof(uiApplicationHandle)); + + return FinishedLaunchingNative(uiApplicationHandle, launchOptionsHandle); + } + + /// + /// Forwards UIApplicationDelegate.OpenUrl (and scene URL contexts) to the SDK, for + /// Aggregated Event Measurement and App Links. Must be called on the main thread. + /// + /// when the SDK handled the URL. + public static bool HandleOpenUrl(IntPtr uiApplicationHandle, IntPtr nsUrlHandle, IntPtr optionsHandle) + { + if (uiApplicationHandle == IntPtr.Zero || nsUrlHandle == IntPtr.Zero) + return false; + + return HandleOpenUrlNative(uiApplicationHandle, nsUrlHandle, optionsHandle); + } + + /// + /// Forwards UIApplicationDelegate.ContinueUserActivity (Universal Links) to the SDK, + /// for Aggregated Event Measurement. Only browsing-web activities are used by the SDK. Must + /// be called on the main thread. + /// + /// when the SDK handled the activity. + public static bool ContinueUserActivity(IntPtr uiApplicationHandle, IntPtr nsUserActivityHandle) + { + if (uiApplicationHandle == IntPtr.Zero || nsUserActivityHandle == IntPtr.Zero) + return false; + + return ContinueUserActivityNative(uiApplicationHandle, nsUserActivityHandle); + } + + /// Version of the bundled Facebook iOS SDK, for diagnostics. + public static string SdkVersion => TakeNativeString(CopySdkVersionNative()) ?? string.Empty; + + // Settings. + + /// + /// Enables or disables automatic app event logging (FacebookAutoLogAppEventsEnabled): + /// app activation, install attribution ping and, when enabled in the Meta dashboard, + /// in-app purchases. When disabled, call to keep install and + /// activation events. + /// + public static void SetAutoLogAppEventsEnabled(bool enabled) => SetAutoLogAppEventsEnabledNative(enabled); + + /// Current automatic app event logging setting. + public static bool IsAutoLogAppEventsEnabled => IsAutoLogAppEventsEnabledNative(); + + /// + /// Enables or disables advertiser ID (IDFA) collection + /// (FacebookAdvertiserIDCollectionEnabled). iOS only returns a non-zero IDFA once App + /// Tracking Transparency is authorized. + /// + public static void SetAdvertiserIdCollectionEnabled(bool enabled) => SetAdvertiserIdCollectionEnabledNative(enabled); + + /// Current advertiser ID collection setting. + public static bool IsAdvertiserIdCollectionEnabled => IsAdvertiserIdCollectionEnabledNative(); + + /// + /// Sets the advertiser tracking flag. Only used on iOS 15 and 16: on iOS 17 and later the SDK + /// ignores it and reads the App Tracking Transparency status instead. + /// + public static void SetAdvertiserTrackingEnabled(bool enabled) => SetAdvertiserTrackingEnabledNative(enabled); + + /// Current advertiser tracking flag (derived from App Tracking Transparency on iOS 17+). + public static bool IsAdvertiserTrackingEnabled => IsAdvertiserTrackingEnabledNative(); + + /// + /// Enables or disables SKAdNetwork reporting by the SDK (FacebookSKAdNetworkReportEnabled). + /// Disable it when another SDK owns SKAdNetwork conversion values. + /// + public static void SetSKAdNetworkReportEnabled(bool enabled) => SetSKAdNetworkReportEnabledNative(enabled); + + /// Current SKAdNetwork reporting setting. + public static bool IsSKAdNetworkReportEnabled => IsSKAdNetworkReportEnabledNative(); + + /// + /// Enables or disables the SDK 19 metadata collection (screen titles and App Link URLs + /// attached to events; FBSDKAutoLogMetaDataEnabled). + /// + public static void SetMetadataCollectionEnabled(bool enabled) => SetMetadataCollectionEnabledNative(enabled); + + /// Current metadata collection setting. + public static bool IsMetadataCollectionEnabled => IsMetadataCollectionEnabledNative(); + + /// + /// Enables or disables the SDK 19 product-improvement switch + /// (FBSDKAutoLogProductImprovementEnabled). SDK 19.0.0 adds the control only and does + /// not collect such data yet. + /// + public static void SetAutoLogProductImprovementEnabled(bool enabled) => SetAutoLogProductImprovementEnabledNative(enabled); + + /// Current product-improvement setting. + public static bool IsAutoLogProductImprovementEnabled => IsAutoLogProductImprovementEnabledNative(); + + /// + /// Sets data processing options, for example + /// [FacebookDataProcessingOptions.LimitedDataUse] with country and state 0 to let Meta + /// geolocate the user. An empty array explicitly disables Limited Data Use. + /// + public static void SetDataProcessingOptions(string[] options, int country = 0, int state = 0) + { + ArgumentNullException.ThrowIfNull(options); + foreach (var option in options) + { + if (string.IsNullOrWhiteSpace(option)) + throw new ArgumentException("Data processing options cannot be empty.", nameof(options)); + } + + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartArray(); + foreach (var option in options) + writer.WriteStringValue(option); + writer.WriteEndArray(); + } + + fixed (byte* json = NullTerminated(buffer)) + SetDataProcessingOptionsNative(json, country, state); + } + + /// + /// Enables or disables SDK console logging for app events, developer errors and + /// informational messages, for diagnostics (shown in the device log / Xcode console). + /// Disabling restores the logging behaviours that were active before. + /// + public static void SetDebugLoggingEnabled(bool enabled) => SetDebugLoggingEnabledNative(enabled); + + // Event logging. + + /// + /// Logs fb_mobile_activate_app and, once per install, the install attribution ping. + /// Call it when the app becomes active if automatic logging is disabled. The SDK only logs + /// an activation after 60 seconds or more of inactivity. + /// + public static void ActivateApp() => ActivateAppNative(); + + /// Logs an app event. + /// A constant or a custom name (2-40 characters: letters, digits, _, - or spaces). + /// Optional parameters. Values must be strings, numbers or booleans (sent as 1/0). + /// Optional value summed across events of this name, for example a price. + public static void LogEvent( + string eventName, + IReadOnlyDictionary? parameters = null, + double? valueToSum = null + ) + { + if (string.IsNullOrWhiteSpace(eventName)) + throw new ArgumentException("Event name is required.", nameof(eventName)); + if (valueToSum is { } value && !double.IsFinite(value)) + throw new ArgumentOutOfRangeException(nameof(valueToSum), "Value to sum must be finite."); + + var json = SerializeParameters(parameters); + bool logged; + fixed (byte* parametersJson = json) + { + logged = LogEventNative( + eventName, + parametersJson, + valueToSum.HasValue, + valueToSum.GetValueOrDefault() + ); + } + + if (!logged) + throw new InvalidOperationException("The native App Events facade rejected the event."); + } + + /// + /// Logs fb_mobile_purchase. The SDK rounds the amount to three decimals and flushes + /// immediately unless the flush behaviour is . + /// Do not use it for purchases that a server already reports to Meta. + /// + /// Purchase amount in . + /// ISO 4217 currency code, for example EUR. + /// Optional parameters (strings, numbers or booleans). + public static void LogPurchase( + decimal amount, + string currencyIsoCode, + IReadOnlyDictionary? parameters = null + ) + { + var currency = NormalizeCurrency(currencyIsoCode, nameof(currencyIsoCode)); + var json = SerializeParameters(parameters); + bool logged; + fixed (byte* parametersJson = json) + logged = LogPurchaseNative(decimal.ToDouble(amount), currency, parametersJson); + + if (!logged) + throw new InvalidOperationException("The native App Events facade rejected the purchase."); + } + + /// + /// Logs . Do not use it when a server (for + /// example a subscription platform) already sends trial events to Meta. + /// + /// Optional value (for example the price after the trial), sent as valueToSum. + /// ISO 4217 currency code; required with . + /// Optional subscription identifier, sent as fb_order_id. + /// Optional extra parameters. The typed arguments win on conflicts. + public static void LogStartTrial( + decimal? value = null, + string? currencyIsoCode = null, + string? orderId = null, + IReadOnlyDictionary? parameters = null + ) => LogValueEvent(FacebookAppEventNames.StartTrial, value, currencyIsoCode, orderId, parameters); + + /// + /// Logs . Do not use it when a server (for + /// example a subscription platform) already sends subscription events to Meta. + /// + /// Optional subscription price, sent as valueToSum. + /// ISO 4217 currency code; required with . + /// Optional subscription identifier, sent as fb_order_id. + /// Optional extra parameters. The typed arguments win on conflicts. + public static void LogSubscribe( + decimal? value = null, + string? currencyIsoCode = null, + string? orderId = null, + IReadOnlyDictionary? parameters = null + ) => LogValueEvent(FacebookAppEventNames.Subscribe, value, currencyIsoCode, orderId, parameters); + + /// Logs . + /// Optional method, for example email, sent as fb_registration_method. + /// Optional value, sent as valueToSum. + /// ISO 4217 currency code; required with . + /// Optional extra parameters. The typed arguments win on conflicts. + public static void LogCompleteRegistration( + string? registrationMethod = null, + decimal? value = null, + string? currencyIsoCode = null, + IReadOnlyDictionary? parameters = null + ) + { + var merged = MergeParameters(parameters); + if (!string.IsNullOrWhiteSpace(registrationMethod)) + merged[FacebookAppEventParameterNames.RegistrationMethod] = registrationMethod; + + LogValueEvent(FacebookAppEventNames.CompletedRegistration, value, currencyIsoCode, orderId: null, merged); + } + + /// Sends queued events now. + public static void Flush() => FlushNative(); + + /// Sets when queued events are sent. + public static void SetFlushBehavior(FacebookAppEventsFlushBehavior behavior) + { + if (!Enum.IsDefined(behavior)) + throw new ArgumentOutOfRangeException(nameof(behavior)); + + SetFlushBehaviorNative((int)behavior); + } + + /// Current flush behaviour. + public static FacebookAppEventsFlushBehavior FlushBehavior => + (FacebookAppEventsFlushBehavior)GetFlushBehaviorNative(); + + // Identity and advanced matching. + + /// + /// Anonymous, SDK-generated install identifier (AppEvents.shared.anonymousID), for + /// example for server-side attribution partners. Generated and persisted on first access. + /// + public static string? GetAnonymousId() => TakeNativeString(CopyAnonymousIdNative()); + + /// + /// Associates a custom user ID with subsequent events. Persisted by the SDK until cleared. + /// or empty clears it. + /// + public static void SetUserId(string? userId) => SetUserIdNative(string.IsNullOrEmpty(userId) ? null : userId); + + /// Clears the custom user ID. + public static void ClearUserId() => SetUserIdNative(null); + + /// Current custom user ID, if any. + public static string? GetUserId() => TakeNativeString(CopyUserIdNative()); + + /// + /// Sets advanced-matching user data. Replaces the ten standard fields at once; a + /// property clears that field. The SDK hashes the values. + /// + public static void SetUserData(FacebookAppEventsUserData userData) + { + ArgumentNullException.ThrowIfNull(userData); + SetUserDataNative( + userData.Email, + userData.FirstName, + userData.LastName, + userData.Phone, + userData.DateOfBirth, + userData.Gender, + userData.City, + userData.State, + userData.Zip, + userData.Country + ); + } + + /// + /// Sets one advanced-matching value, for example + /// . clears it. + /// + public static void SetUserDataValue(string userDataType, string? value) + { + if (string.IsNullOrWhiteSpace(userDataType)) + throw new ArgumentException("User data type is required.", nameof(userDataType)); + + SetUserDataForTypeNative(userDataType, value); + } + + /// Clears all advanced-matching user data. + public static void ClearUserData() => ClearUserDataNative(); + + /// Clears one advanced-matching value. + public static void ClearUserDataValue(string userDataType) + { + if (string.IsNullOrWhiteSpace(userDataType)) + throw new ArgumentException("User data type is required.", nameof(userDataType)); + + ClearUserDataForTypeNative(userDataType); + } + + // Helpers. + + private static void LogValueEvent( + string eventName, + decimal? value, + string? currencyIsoCode, + string? orderId, + IReadOnlyDictionary? parameters + ) + { + var merged = MergeParameters(parameters); + if (value.HasValue && string.IsNullOrWhiteSpace(currencyIsoCode)) + throw new ArgumentException("A currency is required with a value.", nameof(currencyIsoCode)); + if (!string.IsNullOrWhiteSpace(currencyIsoCode)) + merged[FacebookAppEventParameterNames.Currency] = NormalizeCurrency(currencyIsoCode, nameof(currencyIsoCode)); + if (!string.IsNullOrWhiteSpace(orderId)) + merged[FacebookAppEventParameterNames.OrderId] = orderId; + + LogEvent(eventName, merged, value.HasValue ? decimal.ToDouble(value.Value) : null); + } + + private static Dictionary MergeParameters(IReadOnlyDictionary? parameters) + { + var merged = new Dictionary(StringComparer.Ordinal); + if (parameters is null) + return merged; + + foreach (var (key, value) in parameters) + merged[key] = value; + return merged; + } + + private static string NormalizeCurrency(string? currencyIsoCode, string parameterName) + { + var currency = currencyIsoCode?.Trim(); + if (currency is not { Length: 3 } || !currency.All(char.IsAsciiLetter)) + throw new ArgumentException("Currency must be an ISO 4217 code such as EUR.", parameterName); + + return currency.ToUpperInvariant(); + } + + private static byte[]? SerializeParameters(IReadOnlyDictionary? parameters) + { + if (parameters is null || parameters.Count == 0) + return null; + + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + foreach (var (key, value) in parameters) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Parameter names cannot be empty.", nameof(parameters)); + + writer.WritePropertyName(key); + WriteParameterValue(writer, key, value); + } + writer.WriteEndObject(); + } + + return NullTerminated(buffer); + } + + private static void WriteParameterValue(Utf8JsonWriter writer, string key, object? value) + { + switch (value) + { + case string text: + writer.WriteStringValue(text); + break; + case bool flag: + writer.WriteStringValue(flag ? FacebookAppEventParameterValues.Yes : FacebookAppEventParameterValues.No); + break; + case byte or sbyte or short or ushort or int or uint or long: + writer.WriteNumberValue(Convert.ToInt64(value, CultureInfo.InvariantCulture)); + break; + case ulong unsignedLong: + writer.WriteNumberValue(unsignedLong); + break; + case float or double: + var number = Convert.ToDouble(value, CultureInfo.InvariantCulture); + if (!double.IsFinite(number)) + throw new ArgumentException($"Parameter '{key}' must be a finite number.", nameof(value)); + writer.WriteNumberValue(number); + break; + case decimal amount: + writer.WriteNumberValue(amount); + break; + default: + throw new ArgumentException( + $"Parameter '{key}' must be a string, a number or a boolean.", + nameof(value) + ); + } + } + + private static byte[] NullTerminated(ArrayBufferWriter buffer) + { + var bytes = new byte[buffer.WrittenCount + 1]; + buffer.WrittenSpan.CopyTo(bytes); + return bytes; + } + + private static string? TakeNativeString(IntPtr pointer) + { + if (pointer == IntPtr.Zero) + return null; + + try + { + return Marshal.PtrToStringUTF8(pointer); + } + finally + { + FreeNative(pointer); + } + } + + // Native entry points (kfbappevents.xcframework, always linked). + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_initialize_sdk")] + private static partial void InitializeSdkNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_finished_launching")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool FinishedLaunchingNative(IntPtr uiApplicationHandle, IntPtr launchOptionsHandle); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_handle_open_url")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool HandleOpenUrlNative(IntPtr uiApplicationHandle, IntPtr nsUrlHandle, IntPtr optionsHandle); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_continue_user_activity")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool ContinueUserActivityNative(IntPtr uiApplicationHandle, IntPtr nsUserActivityHandle); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_copy_sdk_version")] + private static partial IntPtr CopySdkVersionNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_auto_log_app_events_enabled")] + private static partial void SetAutoLogAppEventsEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_auto_log_app_events_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsAutoLogAppEventsEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_advertiser_id_collection_enabled")] + private static partial void SetAdvertiserIdCollectionEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_advertiser_id_collection_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsAdvertiserIdCollectionEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_advertiser_tracking_enabled")] + private static partial void SetAdvertiserTrackingEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_advertiser_tracking_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsAdvertiserTrackingEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_skadnetwork_report_enabled")] + private static partial void SetSKAdNetworkReportEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_skadnetwork_report_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsSKAdNetworkReportEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_metadata_collection_enabled")] + private static partial void SetMetadataCollectionEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_metadata_collection_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsMetadataCollectionEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_auto_log_product_improvement_enabled")] + private static partial void SetAutoLogProductImprovementEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_is_auto_log_product_improvement_enabled")] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool IsAutoLogProductImprovementEnabledNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_data_processing_options")] + private static partial void SetDataProcessingOptionsNative(byte* optionsJson, int country, int state); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_debug_logging_enabled")] + private static partial void SetDebugLoggingEnabledNative([MarshalAs(UnmanagedType.U1)] bool enabled); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_activate_app")] + private static partial void ActivateAppNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_log_event", StringMarshalling = StringMarshalling.Utf8)] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool LogEventNative( + string eventName, + byte* parametersJson, + [MarshalAs(UnmanagedType.U1)] bool hasValueToSum, + double valueToSum + ); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_log_purchase", StringMarshalling = StringMarshalling.Utf8)] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool LogPurchaseNative(double amount, string currency, byte* parametersJson); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_flush")] + private static partial void FlushNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_flush_behavior")] + private static partial void SetFlushBehaviorNative(int behavior); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_get_flush_behavior")] + private static partial int GetFlushBehaviorNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_copy_anonymous_id")] + private static partial IntPtr CopyAnonymousIdNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_user_id", StringMarshalling = StringMarshalling.Utf8)] + private static partial void SetUserIdNative(string? userId); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_copy_user_id")] + private static partial IntPtr CopyUserIdNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_user_data", StringMarshalling = StringMarshalling.Utf8)] + private static partial void SetUserDataNative( + string? email, + string? firstName, + string? lastName, + string? phone, + string? dateOfBirth, + string? gender, + string? city, + string? state, + string? zip, + string? country + ); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_set_user_data_for_type", StringMarshalling = StringMarshalling.Utf8)] + private static partial void SetUserDataForTypeNative(string type, string? value); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_clear_user_data")] + private static partial void ClearUserDataNative(); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_clear_user_data_for_type", StringMarshalling = StringMarshalling.Utf8)] + private static partial void ClearUserDataForTypeNative(string type); + + [LibraryImport(NativeLibraryName, EntryPoint = "kfb_appevents_free")] + private static partial void FreeNative(IntPtr pointer); +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/FacebookAppEventsTypes.cs new file mode 100644 index 0000000..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 f0ebb98dc754e5d8d6995e36be8a9122fbd3620e Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:28:43 +0100 Subject: [PATCH 06/12] 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 d1f893e..928780d 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 ae193e1c7f322c53dd73a564bc82ffc40e11155e Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:30:40 +0100 Subject: [PATCH 07/12] 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 ddfe97b..0042b4c 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -34,7 +34,10 @@ Call the interop hooks in your `AppDelegate`: These hooks apply to `KapuschFacebookFeatures=Login`. For Share-only builds, disable Meta automatic initialization and call `NativeFacebookShare.ConfigureAndInitialize(app.Handle, trackingAllowed)` only -after the ATT decision, immediately before the first share. Then call +after the ATT decision, immediately before the first share. +`ConfigureAndInitialize` sets automatic app events logging to off and advertiser +ID collection / advertiser tracking to `trackingAllowed`, except for any of these +settings the app already set through `FacebookAppEvents`, which it leaves as is. Then call `NativeFacebookShare.SharePhotoAsync(...)`; no caption is accepted. Forward Facebook callback URLs to `NativeFacebookShare.HandleOpenUrl(app.Handle, url.Handle, options.Handle)`. 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 afcd557..3057e80 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 NSString *const KFBSettingMetadataCollection = @"metadataCollection"; static NSString *const KFBSettingAutoLogProductImprovement = @"autoLogProductImprovement"; 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 03533791350fd95639a499869b0b7326c4487463 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:32:17 +0100 Subject: [PATCH 08/12] 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 consuming-app names or real Facebook app IDs, URL schemes and client tokens in tracked files. - 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 | 52 +---- .github/workflows/publish.yml | 51 +---- AGENTS.md | 6 + scripts/validate-package.py | 181 ++++++++++++++++++ scripts/verify-public.py | 46 +++++ .../FacebookAppEventParameterNames.cs | 7 - .../Facebook/FacebookAppEventsTypes.cs | 7 + 8 files changed, 253 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..8b5dfa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ jobs: with: dotnet-version: "10.0.203" + - name: Scan public tree + run: python3 scripts/verify-public.py + - name: Validate feature selection run: bash tests/validate-feature-selection.sh @@ -55,53 +58,8 @@ jobs: -c Release \ -o artifacts/nuget - - name: Validate nupkg layout - run: | - python3 - <<'PY' - import glob, sys, zipfile - - nupkgs = glob.glob("artifacts/nuget/*.nupkg") - if not nupkgs: - print("ERROR: no .nupkg found under artifacts/nuget/") - sys.exit(1) - - nupkg = sorted(nupkgs)[-1] - print(f"Validating {nupkg}") - - z = zipfile.ZipFile(nupkg) - names = set(z.namelist()) - - required = [ - "buildTransitive/Kapusch.Facebook.iOS.targets", - "kfb.xcframework/Info.plist", - "kfbshare.xcframework/Info.plist", - "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..1ca0bdd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -75,6 +75,9 @@ jobs: with: dotnet-version: "10.0.203" + - name: Scan public tree + run: python3 scripts/verify-public.py + - name: Validate feature selection run: bash tests/validate-feature-selection.sh @@ -110,52 +113,8 @@ jobs: -o artifacts/nuget \ /p:PackageVersion="${{ steps.version.outputs.version }}" - - name: Validate nupkg layout - run: | - python3 - <<'PY' - import glob, sys, zipfile - - nupkgs = glob.glob("artifacts/nuget/*.nupkg") - if not nupkgs: - print("ERROR: no .nupkg found under artifacts/nuget/") - sys.exit(1) - - nupkg = sorted(nupkgs)[-1] - print(f"Validating {nupkg}") - - z = zipfile.ZipFile(nupkg) - names = set(z.namelist()) - - required = [ - "buildTransitive/Kapusch.Facebook.iOS.targets", - "kfb.xcframework/Info.plist", - "kfbshare.xcframework/Info.plist", - "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..daae7bf 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 consuming-app names or real Facebook credentials in tracked files. +- When package contents or native entry points change, update `scripts/validate-package.py` in the same commit. + ## Safety - Do not add new dependency ingestion paths without documenting them in `README.md`. - Do not commit real app ids/secrets. diff --git a/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..7b8488f --- /dev/null +++ b/scripts/verify-public.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Fails when a tracked file contains a private identifier. + +This is a public, generic package: docs, samples and tests must not carry a +consuming app's name or real Facebook credentials. The app-name pattern is +hex-escaped so this script does not contain the name itself. +""" +from pathlib import Path +import re +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] + +PATTERNS = { + "consuming app name": re.compile(rb"(?i)\x62\x6c\x6f\x6f\x6d[ &_-]*(?:and[ _-]*)?(?:fly|y)"), + "real Facebook URL scheme": re.compile(rb"\bfb[0-9]{8,}\b"), + "real Facebook app ID": re.compile(rb"FacebookAppID\s*[0-9]{8,}"), + "real Facebook client token": re.compile(rb"FacebookClientToken\s*[0-9a-f]{32}"), +} + + +def main() -> int: + files = subprocess.check_output(["git", "ls-files"], cwd=ROOT, text=True).splitlines() + failures = [] + for name in files: + path = ROOT / name + if not path.is_file(): + continue + data = path.read_bytes() + for label, pattern in PATTERNS.items(): + if pattern.search(data): + failures.append(f"{name}: {label}") + + if failures: + print("Private identifiers found in tracked files:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + + print("Public tree scan passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/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 9ba05776755d8844a995130b2969fe93efd64ceb Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:33:35 +0100 Subject: [PATCH 09/12] docs(app-events): document App Events integration and consent gating - Integration.md: AppEvents feature selection; Info.plist switches with SDK defaults (FacebookAutoLogAppEventsEnabled, FacebookAdvertiserIDCollectionEnabled, FBSDKAutoLogMetaDataEnabled, FBSDKAutoLogProductImprovementEnabled, 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 | 157 +++++++++++++++++- Docs/SourceMode.md | 7 +- README.md | 13 +- THIRD_PARTY_NOTICES.md | 8 +- ...apusch.FacebookApisForiOSComponents.csproj | 2 +- .../nuget-readme.md | 9 +- 6 files changed, 185 insertions(+), 11 deletions(-) diff --git a/Docs/Integration.md b/Docs/Integration.md index 0042b4c..2c5f571 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -55,9 +55,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 @@ -68,7 +78,150 @@ Calling `NativeFacebookShare.SharePhotoAsync(...)` then completes with At SDK 19.0.0, CoreKit (required by every feature) links FBAEMKit. Treat this as a privacy-review blocker if the consumer's artifact policy excludes FBAEMKit. -## 4) Limited Login +## 4) App Events (attribution and conversion events) + +`Kapusch.Facebook.iOS.FacebookAppEvents` wraps `FBSDKAppEvents`, `FBSDKSettings` and +`FBSDKApplicationDelegate`. Event and parameter names are available as constants in +`FacebookAppEventNames`, `FacebookAppEventParameterNames`, +`FacebookAppEventParameterValues` and `FacebookAppEventUserDataTypes`. + +### 4.1 Info.plist keys + +Besides the keys of section 1 (`FacebookAppID`, `FacebookClientToken`, +`FacebookDisplayName`, `CFBundleURLTypes` with `fb`), the SDK reads +these booleans. Values set at run time through `FacebookAppEvents` are persisted by the +SDK and override the `Info.plist` values on later launches. + +| Key | SDK default | Runtime API | Controls | +|---|---|---|---| +| `FacebookAutoLogAppEventsEnabled` | `true` | `SetAutoLogAppEventsEnabled` | Automatic activation / install events and, if enabled in the Meta dashboard, automatic in-app purchase events | +| `FacebookAdvertiserIDCollectionEnabled` | `true` | `SetAdvertiserIdCollectionEnabled` | IDFA collection (all zeros until App Tracking Transparency is authorized) | +| `FBSDKAutoLogMetaDataEnabled` | `true` | `SetMetadataCollectionEnabled` | SDK 19 metadata collection: screen titles and App Link URLs attached to events | +| `FBSDKAutoLogProductImprovementEnabled` | `true` | `SetAutoLogProductImprovementEnabled` | SDK 19 product-improvement switch (SDK 19.0.0 adds the control only) | +| `FacebookSKAdNetworkReportEnabled` | `true` | `SetSKAdNetworkReportEnabled` | SKAdNetwork conversion-value updates by the SDK | + +Android's `com.facebook.sdk.AutoInitEnabled` has no iOS counterpart: on iOS the SDK only +starts when the app initializes it (section 4.2). + +If the app requests App Tracking Transparency, add `NSUserTrackingUsageDescription`. +`FBSDKCoreKit`'s privacy manifest declares `NSPrivacyTracking=true` with the tracking +domain `ep1.facebook.com`; reflect it in your App Store privacy answers. iOS blocks that +domain until tracking is authorized, and the SDK then routes app events to Meta's +non-tracking endpoint. + +### 4.2 Consent gating + +The SDK sends nothing before it is initialized, but initialization itself contacts Meta +(SDK configuration requests) even when automatic logging is off. A consent-first +integration therefore: + +1. Ships with every switch off in `Info.plist`: + + ```xml + FacebookAutoLogAppEventsEnabled + FacebookAdvertiserIDCollectionEnabled + FBSDKAutoLogMetaDataEnabled + FBSDKAutoLogProductImprovementEnabled + ``` + +2. Does not call `FacebookAppEvents.InitializeSdk()` (nor + `NativeFacebookLogin.Initialize` / `NativeFacebookShare.ConfigureAndInitialize`) + before the user consents. Login and Share also initialize the SDK, so with those + features the settings above are what keeps app events off. +3. After consent (and after the ATT prompt, if the app shows one): + + ```csharp + FacebookAppEvents.InitializeSdk(); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(true); + // Keep automatic logging off when a server reports purchases (section 4.4): + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.ActivateApp(); // activation + one-time install ping + ``` + + Then call `FacebookAppEvents.ActivateApp()` each time the app becomes active + (`OnActivated`), because automatic activation logging is off. The SDK ignores + activations less than 60 seconds apart. +4. On withdrawal: + + ```csharp + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(false); + FacebookAppEvents.ClearUserId(); + FacebookAppEvents.ClearUserData(); + // and stop calling ActivateApp / Log* methods. + ``` + + The SDK cannot delete events already sent; that goes through Meta's data deletion + process. +5. On later launches, initialize only if consent is still granted. + +Settings set before `InitializeSdk()` apply immediately and are applied again once the +SDK is initialized, so the SDK persists them in both cases. + +For Limited Data Use, call +`FacebookAppEvents.SetDataProcessingOptions([FacebookDataProcessingOptions.LimitedDataUse])` +(country/state 0 lets Meta geolocate the user); an empty array disables it. + +### 4.3 Deep links and Aggregated Event Measurement + +Forward URLs and Universal Links so AEM can attribute iOS users who did not authorize +tracking: + +```csharp +public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options) => + FacebookAppEvents.HandleOpenUrl(app.Handle, url.Handle, options?.Handle ?? IntPtr.Zero); + +public override bool ContinueUserActivity( + UIApplication app, NSUserActivity userActivity, UIApplicationRestorationHandler completionHandler) => + FacebookAppEvents.ContinueUserActivity(app.Handle, userActivity.Handle); +``` + +With a scene delegate, forward `OpenUrlContexts` and `ContinueUserActivity` the same +way. When consent is already known at launch, call +`FacebookAppEvents.FinishedLaunching(app.Handle, launchOptions?.Handle ?? IntPtr.Zero)` +from `FinishedLaunching` instead of `InitializeSdk()`, so a cold-start URL reaches the +SDK. With `Login` selected, `NativeFacebookLogin.HandleOpenUrl` forwards to the same +SDK entry point; call only one of them per URL. + +### 4.4 Avoid double counting with server-side events + +Meta does not deduplicate SDK events against server-side (Conversions API) events +unless both share an `event_id`, which the SDK does not expose. Use exactly one source +per event: + +- When a server (for example a subscription platform integration) sends trial, + subscription or purchase events to Meta, do **not** call `LogStartTrial`, + `LogSubscribe` or `LogPurchase` for them, and disable automatic in-app purchase + logging: + - keep `FacebookAutoLogAppEventsEnabled=false` and call `ActivateApp()` yourself + (iOS only observes StoreKit transactions when automatic logging is on); and/or + - set "Log In-App Events Automatically" to "No" in the Meta App Dashboard. +- Since SDK 18, automatic logging on iOS scans StoreKit 2 transactions at each + activation, renewals included, so leaving it on next to a server integration + double-counts subscriptions. + +### 4.5 SKAdNetwork + +Only one component may update SKAdNetwork conversion values. If another SDK or your +own code owns them, call `FacebookAppEvents.SetSKAdNetworkReportEnabled(false)` (or set +`FacebookSKAdNetworkReportEnabled=false`). An advertised app does not need +`SKAdNetworkItems`; those are for apps that display ads. + +### 4.6 Diagnostics + +`FacebookAppEvents.SetDebugLoggingEnabled(true)` enables the SDK's app-events, +developer-error and informational logs in the device log; `false` restores the +previous logging behaviours. Combine it with Events Manager > Test Events. +`FacebookAppEvents.SdkVersion` returns the bundled SDK version. + +### 4.7 Platform gaps + +- No `SetAutoInitEnabled` on iOS (see 4.1). +- `SetAdvertiserTrackingEnabled` only matters on iOS 15 and 16; iOS 17+ uses the ATT + status. +- `SetUserDataValue(FacebookAppEventUserDataTypes.ExternalId, ...)` is an iOS SDK API. + +## 5) Limited Login For Limited Login, pass: - `FacebookTrackingMode.Limited` @@ -78,7 +231,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 792239b..188f0be 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. The package bundles Facebook iOS SDK **19.0.0** (see `DependencyLocks/iOS/lockstate.txt`). It requires iOS 15.0 or later, the minimum of that SDK release. @@ -64,7 +68,8 @@ Pack the NuGet: - Install the package from NuGet.org for public release tags. - Install the package from GitHub Packages for internal preview builds. -- Follow `Docs/Integration.md` for Info.plist keys and AppDelegate hooks. +- Follow `Docs/Integration.md` for Info.plist keys, AppDelegate hooks, App Events consent + gating and how to avoid double counting with server-side purchase events. ## CI diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4c85a68..3dc62c3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -7,7 +7,13 @@ This repository and its NuGet package (`Kapusch.Facebook.iOS`) redistribute thir - Upstream: facebook-ios-sdk - Source: https://github.com/facebook/facebook-ios-sdk -The NuGet package includes Facebook SDK `xcframework`s under `fb/`. +The NuGet package includes Facebook SDK `xcframework`s under `fb/`, taken unmodified (apart +from removing the Mac Catalyst slice, signatures, dSYMs and Swift module files) from the +release archives pinned in `DependencyLocks/iOS/lockstate.txt`. + +`FBSDKCoreKit`'s privacy manifest declares `NSPrivacyTracking=true` and the tracking domain +`ep1.facebook.com`. Apps that ship this package must reflect it in their own privacy +declarations. Each included `xcframework` contains an upstream `LICENSE` file. If you are redistributing this package further, ensure you comply with the upstream license terms. diff --git a/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 7481d0a..af411eb 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md +++ b/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md @@ -1,14 +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`. +Set `KapuschFacebookFeatures` to `Login` (default), `Share`, `AppEvents` or a +combination such as `Login;Share`. `FacebookAppEvents` works with every selection. Bundles Facebook iOS SDK 19.0.0 (iOS 15.0+). CoreKit, which every feature uses, requires FBAEMKit. Photo sharing uses ShareKit's native-app dialog, guarantees one managed completion per request, and avoids reading the general pasteboard when cleaning Meta's app-bridge marker on return. +App Events never enables anything on its own: ship the Info.plist switches off, +initialize after consent and see `Docs/Integration.md` for the consent recipe and +for avoiding double counting with server-side purchase events. + See the repo `README.md` and `Docs/Integration.md` for integration steps. From 9d09d5582acb743d35de5e24a92032401de3ed27 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:47:04 +0100 Subject: [PATCH 10/12] 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 | 11 ++ .../Kapusch.Facebook.iOS.Sample.csproj | 1 + samples/Kapusch.Facebook.iOS.Sample/README.md | 15 ++- 4 files changed, 146 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..436785c 100644 --- a/samples/Kapusch.Facebook.iOS.Sample/Info.template.plist +++ b/samples/Kapusch.Facebook.iOS.Sample/Info.template.plist @@ -24,5 +24,16 @@ UIInterfaceOrientationPortrait + + FacebookAutoLogAppEventsEnabled + + FacebookAdvertiserIDCollectionEnabled + + FBSDKAutoLogMetaDataEnabled + + FBSDKAutoLogProductImprovementEnabled + diff --git a/samples/Kapusch.Facebook.iOS.Sample/Kapusch.Facebook.iOS.Sample.csproj b/samples/Kapusch.Facebook.iOS.Sample/Kapusch.Facebook.iOS.Sample.csproj index 6e5445d..a392b6b 100644 --- a/samples/Kapusch.Facebook.iOS.Sample/Kapusch.Facebook.iOS.Sample.csproj +++ b/samples/Kapusch.Facebook.iOS.Sample/Kapusch.Facebook.iOS.Sample.csproj @@ -8,6 +8,7 @@ com.kapusch.facebookios.sample Kapusch Facebook iOS Sample true + Login;AppEvents $(NoWarn);CA1422 diff --git a/samples/Kapusch.Facebook.iOS.Sample/README.md b/samples/Kapusch.Facebook.iOS.Sample/README.md index 7e12e9d..b53933f 100644 --- a/samples/Kapusch.Facebook.iOS.Sample/README.md +++ b/samples/Kapusch.Facebook.iOS.Sample/README.md @@ -1,8 +1,9 @@ # Kapusch.Facebook.iOS.Sample Small, buildable iOS sample project for validating: -- Managed API compilation +- Managed API compilation (Login and App Events; the sample selects `Login;AppEvents`) - Native asset injection via `NativeReference` (wrapper + Facebook xcframeworks) +- The App Events consent recipe of `Docs/Integration.md` (grant / log / flush / withdraw buttons) No secrets are committed. @@ -12,10 +13,9 @@ Prereqs: - macOS + Xcode - .NET SDK 10 (`global.json` pins 10.0.100) -Build native assets (repo-only): +Build native assets (repo-only; restores the pinned Facebook SDK archives): ```bash bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh -bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/collect-facebook-xcframeworks.sh ``` Build the sample (simulator, no signing): @@ -29,4 +29,11 @@ dotnet build samples/Kapusch.Facebook.iOS.Sample/Kapusch.Facebook.iOS.Sample.csp ## Runtime notes This sample calls into the interop API but does not include a configured Facebook App ID / URL schemes. -To make the login flow work end-to-end, update the generated `Info.plist` (gitignored) with the required Facebook keys and URL schemes. +To make the login flow or App Events work end-to-end, update the generated `Info.plist` (gitignored) +with `FacebookAppID`, `FacebookClientToken`, `FacebookDisplayName` and the `fb` URL scheme. +Never commit those values. + +The template ships the App Events switches off. "App Events: grant consent" initializes the SDK, +enables advertiser ID collection and SDK debug logging, and logs an activation; check the device log +and Events Manager > Test Events. Without a `FacebookAppID` the App Events buttons only print a +message. From 88e65386441cfcb77b4d2cda56a74783056f4b2a Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:52:36 +0100 Subject: [PATCH 11/12] 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: all 40 kfb_* entry points are 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 2c5f571..79dd462 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -69,11 +69,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 19.0.0, CoreKit (required by every feature) links FBAEMKit. Treat this as a privacy-review blocker if the consumer's artifact policy excludes FBAEMKit. 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')) @@ -51,6 +52,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 @@ -120,6 +130,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')) @@ -134,6 +145,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 d7a6641d0701dde0727e2b46f9b920e40ba798bd Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:52:44 +0100 Subject: [PATCH 12/12] chore(release): set package version to 1.2.0 1.2.0 bundles Facebook iOS SDK 19.0.0 and adds App Events. 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