diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cc9d28f..fce533a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,4 +8,5 @@ - [ ] Built wrapper: `bash src/Kapusch.FacebookApisForAndroidComponents/Native/Android/build.sh` - [ ] Restored pinned AARs: `bash src/Kapusch.FacebookApisForAndroidComponents/Native/Android/restore-facebook-aars.sh` - [ ] Packed NuGet: `dotnet pack src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj -c Release -o artifacts/nuget` +- [ ] Ran the validators listed in `README.md` - [ ] Updated docs if behavior/integration changed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2ea1b9..fe9b0ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,12 @@ jobs: with: dotnet-version: "10.0.203" + - name: Scan public tree + env: + # Optional; the scan skips private terms when the secret is not set. + KAPUSCH_PRIVATE_TERMS: ${{ secrets.KAPUSCH_PRIVATE_TERMS }} + run: python3 scripts/verify-public.py + - name: Validate feature selection run: bash tests/validate-feature-selection.sh @@ -51,11 +57,29 @@ jobs: run: | bash src/Kapusch.FacebookApisForAndroidComponents/Native/Android/restore-facebook-aars.sh + - name: Verify App Events constants and JNI contract + run: | + python3 scripts/app-event-constants.py + python3 scripts/verify-interop.py jni + - name: Pack run: | dotnet pack src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj \ -c Release \ -o artifacts/nuget + + - name: Validate package + run: python3 scripts/validate-package.py artifacts/nuget + + - name: Build sample with R8 and verify the shrunk APK + run: | + dotnet build samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj \ + -c Release \ + -p:SampleRuntimeIdentifiers=android-arm64 + python3 scripts/verify-interop.py apk \ + samples/Kapusch.Facebook.Android.Sample/bin/Release/net10.0-android/com.kapusch.facebookandroid.sample-Signed.apk \ + --feature Login --feature Share + - uses: actions/upload-artifact@v4 with: name: nuget diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8218a2a..122aeee 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -72,6 +72,12 @@ jobs: with: dotnet-version: "10.0.203" + - name: Scan public tree + env: + # Optional; the scan skips private terms when the secret is not set. + KAPUSCH_PRIVATE_TERMS: ${{ secrets.KAPUSCH_PRIVATE_TERMS }} + run: python3 scripts/verify-public.py + - name: Validate feature selection run: bash tests/validate-feature-selection.sh @@ -105,6 +111,11 @@ jobs: run: | bash src/Kapusch.FacebookApisForAndroidComponents/Native/Android/restore-facebook-aars.sh + - name: Verify App Events constants and JNI contract + run: | + python3 scripts/app-event-constants.py + python3 scripts/verify-interop.py jni + - name: Pack run: | dotnet pack src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj \ @@ -112,6 +123,9 @@ jobs: -o artifacts/nuget \ /p:PackageVersion="${{ steps.version.outputs.version }}" + - name: Validate package + run: python3 scripts/validate-package.py artifacts/nuget + - name: Push to NuGet.org if: steps.version.outputs.publish_target == 'nuget' uses: NuGet/login@v1 diff --git a/.github/workflows/sample-android.yml b/.github/workflows/sample-android.yml index 3c09242..ef37113 100644 --- a/.github/workflows/sample-android.yml +++ b/.github/workflows/sample-android.yml @@ -50,3 +50,10 @@ jobs: - name: Build Android sample run: | dotnet build samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj -c Debug + + - name: Build Android sample with R8 + run: | + dotnet build samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj -c Release + python3 scripts/verify-interop.py apk \ + samples/Kapusch.Facebook.Android.Sample/bin/Release/net10.0-android/com.kapusch.facebookandroid.sample-Signed.apk \ + --feature Login --feature Share diff --git a/.gitignore b/.gitignore index e7f1b91..b6ac767 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ samples/**/secrets.* # Sample generated files samples/**/AndroidManifest.xml samples/**/strings.xml + +# Python +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 73941c4..a54f027 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,18 +1,23 @@ # Kapusch.FacebookApisForAndroidComponents — AI Working Agreement ## Goals -- Produce a reproducible Android NuGet package for selectable Facebook Login and Share interop. +- Produce a reproducible Android NuGet package for selectable Facebook Login, Share and App Events interop. - Do not commit secrets. ## Packaging constraints - Public OSS repo: keep docs/sample generic and not app-specific. - The NuGet ships the required `.aar` artifacts and injects them into consuming apps via `buildTransitive` `AndroidAarLibrary`. - Consuming apps must not download native deps at build time. +- Do not bundle shared dependencies (AndroidX, Kotlin, Play Install Referrer, Google Play services); consumers get them from managed `Xamarin.*` NuGets. +- The default feature selection stays `Login`; new features must not change what existing consumers inject. +- The App Events facade is reached only through JNI: keep `consumer-rules.pro`, `scripts/verify-interop.py` and `FacebookAppEvents.cs` in sync, and regenerate the constants with `scripts/app-event-constants.py --write` after an SDK bump. - This repo may download dependencies during CI/build, but consuming apps must not. ## Repo layout - `src/Kapusch.FacebookApisForAndroidComponents/` — NuGet project (managed API + buildTransitive MSBuild) - `src/Kapusch.FacebookApisForAndroidComponents/Native/Android/` — Gradle wrapper library + scripts (repo-only) +- `scripts/` — deterministic validators (package layout, JNI contract, R8 output, constants, public-tree scan) +- `tests/` — MSBuild feature-selection and warning tests - `DependencyLocks/` — pinned third-party artifacts (URLs + SHA256) - `Docs/` — integration docs - `samples/` — optional sample template (no secrets committed) @@ -20,6 +25,8 @@ ## Safety - Do not add new dependency ingestion paths without documenting them in `README.md`. - Do not commit real app ids/secrets. +- Keep the tree generic: no application or company names, real Meta app ids, client tokens or emails (`scripts/verify-public.py`). +- When package contents change, update `scripts/validate-package.py` in the same commit. ## Branches and releases - `master` is the only long-lived branch and the source of every NuGet.org release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0d26c6..9bd696c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,8 @@ Pack the NuGet: - `dotnet pack src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj -c Release -o artifacts/nuget` +Run the validators listed in `README.md` before opening a PR; CI runs the same set. + ## Formatting - C#: follow `.editorconfig` (tabs, LF). @@ -38,6 +40,10 @@ Pack the NuGet: - `src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/build.gradle.kts` - `DependencyLocks/Android/lockstate.txt` (URLs + SHA256) + Then regenerate the App Events constants with + `python3 scripts/app-event-constants.py --write`, review the upstream changelog + for new manifest switches, and rebuild the sample with R8. + See `Docs/Release.md` before changing versions, tags, or publishing workflows. ## License diff --git a/DependencyLocks/Android/lockstate.txt b/DependencyLocks/Android/lockstate.txt index 8070eb7..fb3adfe 100644 --- a/DependencyLocks/Android/lockstate.txt +++ b/DependencyLocks/Android/lockstate.txt @@ -1,8 +1,8 @@ # Pinned Android artifacts for deterministic restore. # Format: -https://repo1.maven.org/maven2/com/facebook/android/facebook-login/18.1.3/facebook-login-18.1.3.aar 30a0bb79b74244228c63101c26353ad61a973e156ca55b712b59fe56fc952100 facebook-login-18.1.3.aar -https://repo1.maven.org/maven2/com/facebook/android/facebook-core/18.1.3/facebook-core-18.1.3.aar ef480916149e078f955195981130b05746ca33b41618911e7093aa32c03d601b facebook-core-18.1.3.aar -https://repo1.maven.org/maven2/com/facebook/android/facebook-common/18.1.3/facebook-common-18.1.3.aar 62adfb1cd05184065d69033a9f917709b392004f87a8aa1cc1c4f0e9fb5ca781 facebook-common-18.1.3.aar -https://repo1.maven.org/maven2/com/facebook/android/facebook-bolts/18.1.3/facebook-bolts-18.1.3.aar 05c6bae0130487e63add05854a3bbd21465c06a295a2104b3948d8759536b699 facebook-bolts-18.1.3.aar -https://repo1.maven.org/maven2/com/facebook/android/facebook-share/18.1.3/facebook-share-18.1.3.aar cc3bc76fdb987ac245ddc54b37096e2afcfb438872487034027639dd4564aef7 facebook-share-18.1.3.aar +https://repo1.maven.org/maven2/com/facebook/android/facebook-login/19.0.0/facebook-login-19.0.0.aar 87f10f0efd2b0c5b1c25a0026bf7f37128aa5a1d9195af34966a76cfb54b7ffa facebook-login-19.0.0.aar +https://repo1.maven.org/maven2/com/facebook/android/facebook-core/19.0.0/facebook-core-19.0.0.aar 22782e58bdb0f0c4cfffb10f19b5c75f8b7355c10e21ac6a33a6161ab7a2dcab facebook-core-19.0.0.aar +https://repo1.maven.org/maven2/com/facebook/android/facebook-common/19.0.0/facebook-common-19.0.0.aar fedb3bec84d53d5866ac33aac4c190265fa5330c142a324bf809ce634ab67498 facebook-common-19.0.0.aar +https://repo1.maven.org/maven2/com/facebook/android/facebook-bolts/19.0.0/facebook-bolts-19.0.0.aar ce8e67054c959b451707507ad737bc2f4f166ac026d710f1af33999475878cc2 facebook-bolts-19.0.0.aar +https://repo1.maven.org/maven2/com/facebook/android/facebook-share/19.0.0/facebook-share-19.0.0.aar 0495c6b30643abc2e69e23790089330a90a1ace1e3d862f5e5887077380dcc8d facebook-share-19.0.0.aar diff --git a/Docs/Integration.md b/Docs/Integration.md index 39c6ba6..87589ea 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -1,8 +1,37 @@ # Integration (Android) -This package injects a small Android Activity (provided by an embedded AAR) that runs the Facebook Login flow and returns its result via `Intent` extras. +`Kapusch.Facebook.Android` injects a small interop AAR plus the pinned Facebook +Android SDK AARs into the consuming app. Select the features with +`KapuschFacebookFeatures`: -Set `KapuschFacebookFeatures` to `Login` (default), `Share`, or `Login;Share`. +| Value | Injected Facebook AARs | Managed entry points | +| --- | --- | --- | +| `Login` (default) | core, common, bolts, login | `AndroidFacebookInterop` (Login activity, sign-out) | +| `Share` | core, common, bolts, share | `AndroidFacebookInterop` (Share activity) | +| `AppEvents` | core, common, bolts | `FacebookAppEvents`, `FacebookAppEventNames`, `FacebookAppEventParameterNames` | + +Combine values with `;`, for example `Login;AppEvents`. facebook-core is always +injected, so the Facebook SDK initialization and its manifest entries are the +same for every selection. + +```xml + + Login;AppEvents + +``` + +## Runtime dependencies the app provides + +The package redistributes only the Facebook AARs. Their POMs also declare +AndroidX and Kotlin runtime dependencies (annotation, core-ktx, +legacy-support-core-utils including `LocalBroadcastManager`, appcompat, +cardview, browser, activity, fragment, kotlin-stdlib), which the app must +provide through managed `Xamarin.AndroidX.*` / `Xamarin.Kotlin.StdLib` +packages. .NET MAUI apps usually have most of them already; a bare +`net-android` app needs at least `Xamarin.AndroidX.AppCompat`, +`Xamarin.AndroidX.CardView` and `Xamarin.AndroidX.LocalBroadcastManager` +(facebook-core crashes at startup with `NoClassDefFoundError` without the +latter). See `samples/Kapusch.Facebook.Android.Sample`. ## Required AndroidManifest entries @@ -13,7 +42,7 @@ You must include the required Meta/Facebook Android SDK entries in your app mani You also need to declare `com.facebook.FacebookActivity` and `com.facebook.CustomTabActivity` as per Facebook SDK documentation. -## Launching the flow +## Login From your app, start the interop Activity: - `AndroidFacebookInterop.LoginActivityClassName` @@ -25,7 +54,7 @@ Then parse the `Intent` extras: - `AndroidFacebookInterop.ExtraErrorCode` - `AndroidFacebookInterop.ExtraErrorMessage` -## Sign-out +### Sign-out Call `AndroidFacebookInterop.SendSignOutBroadcast(context)`. @@ -43,3 +72,256 @@ supported dialog mode is available, `ExtraStatus` is `failed` and `share_dialog_unavailable_native_<0|1>_web_<0|1>`. Consumers may use that diagnostic to offer an explicit Android image-intent fallback. The wrapper does not silently change transport. + +## App Events + +`FacebookAppEvents` is a thin JNI bridge to `FacebookSdk` and +`AppEventsLogger`. The SDK keeps its own disk queue, batching and retries; the +wrapper adds no queue and changes no SDK default. + +### Dependencies the app provides + +- **Play Install Referrer (recommended).** facebook-core sends the Play install + referrer with the install ping and events only when the + `com.android.installreferrer` classes are in the app. Add the managed binding: + + ```xml + + ``` + + The package does not bundle `installreferrer-2.2.aar`: other SDKs need the + same library, and two copies of it fail the build with duplicate classes. + That binding downloads the AAR from Google Maven on the first build + (Xamarin.Build.Download). With `AppEvents` selected and no install referrer + library referenced, the build emits warning `KFBA001`; set + `KapuschFacebookAndroidSuppressInstallReferrerWarning=true` to silence it. +- **Google advertising ID (optional).** facebook-core reads the advertising ID + through `com.google.android.gms.ads.identifier.AdvertisingIdClient` when that + class is present (for example via `Xamarin.GooglePlayServices.Ads.Identifier`) + and advertiser ID collection is enabled. + +facebook-core also merges `com.google.android.gms.permission.AD_ID` and four +`android.permission.ACCESS_ADSERVICES_*` permissions into the app manifest. +Remove the ones you do not want with `tools:node="remove"`. + +### Manifest switches + +All of these default to `true` in the SDK. The runtime setters persist their +value, and a persisted value wins over the manifest on later launches. + +| Manifest `meta-data` (`com.facebook.sdk.`) | Runtime API | Effect | +| --- | --- | --- | +| `AutoInitEnabled` | `SetAutoInitEnabled`, `IsAutoInitEnabled` | `false` keeps the SDK from fully initializing; the SDK does not process Graph API responses until then. | +| `AutoLogAppEventsEnabled` | `SetAutoLogAppEventsEnabled`, `IsAutoLogAppEventsEnabled` | Automatic install ping, app activation and, when Events Manager allows it, Google Play purchase and subscription events. Since 19.0.0 a local `false` wins over Events Manager. | +| `AdvertiserIDCollectionEnabled` | `SetAdvertiserIdCollectionEnabled`, `IsAdvertiserIdCollectionEnabled` | Advertising ID collection. | +| `AutoLogMetaDataEnabled` | `SetMetadataCollectionEnabled`, `IsMetadataCollectionEnabled` | SDK 19.0.0+: attaches the screen title and AppLink URL to events. `false` also discards what was collected. | +| `AutoLogProductImprovementEnabled` | `SetAutoLogProductImprovementEnabled`, `IsAutoLogProductImprovementEnabled` | SDK 19.0.0+: control for product-improvement data. The 19.0.0 changelog says the SDK does not collect such data yet. | + +`SetDataProcessingOptions(new[] { FacebookDataProcessingOptions.LimitedDataUse })` +enables Limited Data Use (country and state `0` let Meta geolocate the user); +an empty array opts out explicitly. Like every setter, it needs an initialized +SDK (see the strictest setup below). + +### Consent-gating recipe + +1. Ship every switch off in the app manifest: + + ```xml + + + + + + ``` + +2. On every launch, apply the stored consent explicitly, because the runtime + values persist across launches. Do it before the first activity resumes + (`Application.OnCreate`, or `MainActivity.OnCreate` before `OnResume`): + + ```csharp + if (consentGranted) + { + FacebookAppEvents.SetAutoInitEnabled(true); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(true); + // Either let the SDK log automatically ... + // FacebookAppEvents.SetAutoLogAppEventsEnabled(true); + // ... or keep automatic logging off and log activation yourself. ActivateApp() + // sends the one-time install ping and starts activation tracking, which logs + // fb_mobile_activate_app from the next activity resume on. Call it after + // auto-init is enabled, or the SDK discards the ping response. + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.ActivateApp(); + } + ``` + + When consent is first granted from a dialog, the activity is already + resumed, so that session's activation is only counted from the next resume. + Do not log `fb_mobile_activate_app` yourself to make up for it: the SDK owns + session counting. + +3. On withdrawal, turn everything off and clear the identifiers you set: + + ```csharp + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(false); + FacebookAppEvents.SetAutoInitEnabled(false); + FacebookAppEvents.ClearUserId(); + FacebookAppEvents.ClearUserData(); + // Stop sending for the rest of this process (in memory only; resets on restart). + FacebookAppEvents.SetFlushBehavior(FacebookAppEventsFlushBehavior.ExplicitOnly); + ``` + + Withdrawal fully takes effect on the next process start. Until then the + SDK stays fully initialized: it keeps flushing queued events and, once + `ActivateApp()` or automatic logging started activity tracking, keeps + logging `fb_mobile_activate_app` and deactivation on every resume; none of + the switches above stops that. So: + + - gate your own `Log*` calls on the consent state, so manual events stop at + once; + - set `ExplicitOnly` and do not call `Flush()` until the process restarts, + so nothing more is sent in this process; + - events already queued (including the activation events logged meanwhile) + stay in the SDK's disk queue, and a later fully initialized session (after + consent is granted again) sends them. + + The SDK cannot delete events it already sent; that goes through Meta's data + deletion process. + +The `FacebookInitProvider` merged from facebook-core still calls +`FacebookSdk.sdkInitialize` at process start, with `AutoInitEnabled=false`. The +SDK then still prepares its startup Graph requests and rejects them before +processing a response (logcat: `GraphRequest can't be used when Facebook SDK +isn't fully initialized`). For the strictest setup, remove the provider and +initialize after consent: + +```xml + + +``` + +```csharp +// After consent, before any App Events, Login or Share call. +FacebookAppEvents.InitializeSdk( + dataProcessingOptions: limitedDataUse ? new[] { FacebookDataProcessingOptions.LimitedDataUse } : null +); +``` + +Without the provider, every SDK feature (including Login and Share) needs +`InitializeSdk()` first. Every `FacebookAppEvents` setter, identity call and +event call throws `Java.Lang.IllegalStateException` before it; the SDK itself +would drop them silently. Apply data processing options through +`InitializeSdk`, which sets them after `sdkInitialize` and before the SDK's +first Graph requests, and the other switches after it (their manifest values +apply until then). + +### Logging events + +```csharp +FacebookAppEvents.LogStartTrial(value: 0m, currencyIsoCode: "EUR", orderId: subscriptionId); +FacebookAppEvents.LogSubscribe(value: 4.99m, currencyIsoCode: "EUR", orderId: subscriptionId); +FacebookAppEvents.LogPurchase(4.99m, "EUR"); +FacebookAppEvents.LogCompleteRegistration(registrationMethod: "email"); +// value and currencyIsoCode are optional on every typed helper; a value needs a currency. +FacebookAppEvents.LogEvent( + FacebookAppEventNames.ViewedContent, + new Dictionary { [FacebookAppEventParameterNames.ContentId] = "item-1" } +); +``` + +- `FacebookAppEventNames`, `FacebookAppEventParameterNames` and + `FacebookAppEventParameterValues` mirror `AppEventsConstants` of the bundled + SDK exactly (generated by `scripts/app-event-constants.py`). Note that the + subscription event is `Subscribe`, not `fb_mobile_subscribe`. +- Parameter values may be `string`, `bool` (sent as `"1"`/`"0"`), integral + types, finite `float`/`double`, or `decimal`. +- `fb_order_id` is the ID shared by every event of one subscription. +- `LogPurchase` flushes immediately unless the flush behavior is + `FacebookAppEventsFlushBehavior.ExplicitOnly`. `Flush()` is not a delivery receipt. + +### Avoid double counting with server-side events + +Do not rely on Meta to deduplicate SDK events against server-side events +(Conversions API, or a subscription platform's Meta integration): that needs an +event ID shared by both sides, and such integrations do not coordinate one with +the SDK. When a server already sends trial, subscription or purchase events: + +- do not log those events from the app as well; +- disable automatic purchase logging: keep `AutoLogAppEventsEnabled=false` and + call `ActivateApp()` (install and activation are still measured), and/or set + "Log In-App Events Automatically" to "No" in the Meta App Dashboard. + +### Identity + +- `GetAnonymousId()` returns the SDK's persisted anonymous app/device ID (or + `null`), for example to forward to a server-side integration. +- `SetUserId` / `ClearUserId` attach or remove a user ID on subsequent events. +- `SetUserData(new FacebookAppEventsUserData { Email = ..., ExternalId = ... })` + enables advanced matching; the SDK normalizes and SHA-256 hashes every value + before persisting or sending it. Each call replaces all stored fields: a + `null` property clears that field. `ClearUserData()` removes everything. + Collect such data only with a legal basis. + +### Diagnostics + +`FacebookAppEvents.SetDebugLoggingEnabled(true)` turns on SDK debug mode and the +`APP_EVENTS` logging behavior, so logged and flushed events appear in +`adb logcat`. Confirm delivery in Meta Events Manager > Test events. Turn it off +in production builds. + +### R8 and shrinking + +Nothing to configure. The interop AAR keeps its own classes (they are reached +only through JNI), facebook-core ships its own consumer rules, and the +`AppEvents` feature adds `buildTransitive/proguard/kapusch-facebook-appevents.pro`, +which keeps the Play Billing API that facebook-core calls by reflection for +purchase auto-logging. CI builds the sample with R8 and checks the shrunk APK. + +### Shared surface with the iOS package + +`Kapusch.Facebook.iOS` exposes the same App Events names, so code shared by +both platforms compiles against either package (only the namespace differs: +`Kapusch.Facebook.Android` or `Kapusch.Facebook.iOS`): + +- `FacebookAppEvents.InitializeSdk()`, `ActivateApp()`, `LogEvent(eventName, + parameters, valueToSum)`, `LogPurchase`, `LogStartTrial`, `LogSubscribe`, + `LogCompleteRegistration(registrationMethod, value, currencyIsoCode, + parameters)`, `Flush()`, `SetFlushBehavior` / `FlushBehavior`; +- `SetAutoLogAppEventsEnabled`, `SetAdvertiserIdCollectionEnabled`, + `SetMetadataCollectionEnabled`, `SetAutoLogProductImprovementEnabled`, + `SetDataProcessingOptions`, `SetDebugLoggingEnabled` and the matching `Is*` + properties; +- `GetAnonymousId()`, `SetUserId`, `GetUserId`, `ClearUserId`, `SetUserData` + (a `null` field clears it on both), `ClearUserData`; +- `FacebookAppEventNames`, `FacebookAppEventParameterNames`, + `FacebookAppEventParameterValues`, `FacebookAppEventsFlushBehavior`, + `FacebookAppEventsUserData`, `FacebookDataProcessingOptions`. + +Android-only members: the optional `Context` and data processing arguments of +`InitializeSdk`, `ActivateApp` and `GetAnonymousId`, `SetAutoInitEnabled` / +`IsAutoInitEnabled`, `IsSdkInitialized`, `IsSdkFullyInitialized` and +`FacebookAppEventsUserData.ExternalId` (iOS sets the external ID through +`SetUserDataValue`). Errors differ too: Android throws +`Java.Lang.IllegalStateException` before SDK initialization. + +### Not exposed on Android + +Push-notification open logging, product catalog items, WebView augmentation, +codeless setup and manual install referrer injection are not wrapped. The +iOS-only concepts (App Tracking Transparency, SKAdNetwork, Aggregated Event +Measurement and URL forwarding) have no Android equivalent. + +## Upgrading to 1.2.0 + +1.2.0 moves every Facebook AAR from 18.1.3 to 19.0.0. The default feature +selection, the injected AARs and the merged manifest are unchanged, but the SDK +itself behaves differently: + +- With automatic logging on (the SDK default), 19.0.0 can auto-log Google Play + Billing Library 8 purchases and subscriptions when Meta enables it server + side; 18.x had no Billing 8 support. Apps that also send those events from a + server should turn automatic purchase logging off as described above. +- From 2026-10-12 (per the 19.0.0 changelog), events carry the screen title and + AppLink URL unless `AutoLogMetaDataEnabled` is `false`. +- A local `AutoLogAppEventsEnabled=false` now wins over the Events Manager + setting. diff --git a/Docs/Release.md b/Docs/Release.md index 6d4bebb..72723aa 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 native wrapper, restore the locked AARs, pack the NuGet and inspect its selected modules. +2. Build the native wrapper, restore the locked AARs, pack the NuGet and run the validators listed in `README.md` (package layout, JNI contract, constants, public tree, R8 sample). 3. Open a PR to `master` and require CI to pass. 4. Merge and fetch the resulting `origin/master` commit. 5. Create the RC or stable tag on that exact commit and push only the new tag. diff --git a/Docs/SourceMode.md b/Docs/SourceMode.md index 06fecc4..a275fe2 100644 --- a/Docs/SourceMode.md +++ b/Docs/SourceMode.md @@ -8,6 +8,11 @@ For local development, you can reference the project directly and inject the nat - `bash src/Kapusch.FacebookApisForAndroidComponents/Native/Android/build.sh` - `bash src/Kapusch.FacebookApisForAndroidComponents/Native/Android/restore-facebook-aars.sh` 2. Reference `src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj` from your app. -3. Import the build targets: - - `src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/Kapusch.Facebook.Android.targets` +3. Import the build files in NuGet's order: + - `src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/Kapusch.Facebook.Android.props` at the top of your project (it sets the default `KapuschFacebookFeatures=Login`), + - `src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/Kapusch.Facebook.Android.targets` at the bottom. 4. Set `UseKapuschFacebookAndroidInteropFromSource=true` in your app project. + +Without the `.props` import, `KapuschFacebookFeatures` stays empty unless you +set it, and only the always-injected core AARs are added. See +`samples/Kapusch.Facebook.Android.Sample` for a working setup. diff --git a/README.md b/README.md index b0783b0..09e9833 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,26 @@ Public OSS repository that packages selected Facebook Android features into a co ## What this repo ships A NuGet package that: -- provides managed entry points for Facebook Login and photo sharing, and -- redistributes the required **Facebook Android SDK AARs** inside the `.nupkg` (classic/native packaging), +- provides managed entry points for Facebook Login, photo sharing and App Events, +- redistributes the required **Facebook Android SDK AARs** (19.0.0) inside the `.nupkg` (classic/native packaging), - injects the AARs into consuming apps via `buildTransitive` `AndroidAarLibrary` items. -Select `KapuschFacebookFeatures=Login`, `Share`, or `Login;Share`. The default is +Select any combination of `Login`, `Share` and `AppEvents` with +`KapuschFacebookFeatures` (for example `Login;AppEvents`). The default is `Login`; only selected native AARs are injected into the consuming artifact. +`AppEvents` needs no extra AAR (facebook-core is always injected) and adds an R8 +rule for purchase auto-logging. + +## Dependency ingestion paths + +- Facebook AARs: downloaded at repo build time from Maven Central by + `restore-facebook-aars.sh`, verified against `DependencyLocks/Android/lockstate.txt`. +- The interop AAR compiles against the same Facebook version through Gradle. +- The sample (repo-only) references `Xamarin.Google.Android.InstallReferrer`, which + downloads `installreferrer-2.2.aar` from Google Maven on its first build. +- Not bundled: AndroidX, Kotlin, Play Install Referrer and Google Play services. + Consumers get them from their own graph (managed `Xamarin.*` NuGets); see + `Docs/Integration.md` for the App Events recommendations. ## Third-party licenses @@ -43,6 +57,14 @@ Restore pinned Facebook AARs (repo-only): Pack the NuGet: - `dotnet pack src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj -c Release -o artifacts/nuget` +Validate (all run in CI): +- `python3 scripts/verify-public.py` (no real ids or emails; private terms come from the `KAPUSCH_PRIVATE_TERMS` variable or the untracked `.git/info/private-terms`) +- `bash tests/validate-feature-selection.sh` +- `python3 scripts/app-event-constants.py` (C# constants match the bundled SDK) +- `python3 scripts/verify-interop.py jni` (C# JNI calls match the Java facade) +- `python3 scripts/validate-package.py artifacts/nuget` +- `python3 scripts/verify-interop.py apk --feature Login --feature Share` (facade survives R8) + ## Consumption - Install the package from NuGet.org for public release tags. @@ -51,7 +73,7 @@ Pack the NuGet: ## CI -- PR CI is build-only. +- PR CI builds, packs, runs the validators and builds the sample with R8. - Publishing is handled by `.github/workflows/publish.yml` with channel routing: - tag `vX.Y.Z` on `master` -> NuGet.org (stable) - tag `vX.Y.Z-rc.N` on `master` -> NuGet.org (pre-release) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e4b5cca..36a313f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -10,4 +10,8 @@ This repository and its NuGet package (`Kapusch.Facebook.Android`) redistribute The NuGet package includes pinned Facebook SDK `.aar` files under `android/deps/`. The exact pinned artifacts (URLs + SHA256) are tracked in `DependencyLocks/Android/lockstate.txt`. +The Facebook SDK declares `com.android.installreferrer:installreferrer`, AndroidX and Kotlin +runtime dependencies. This package does not redistribute them; consuming apps obtain them +from their own dependency graph. + If you are redistributing this package further, ensure you comply with the upstream license terms. diff --git a/samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj b/samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj index 512115f..00f11e0 100644 --- a/samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj +++ b/samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj @@ -1,4 +1,9 @@ + + net10.0-android Exe @@ -8,6 +13,17 @@ com.kapusch.facebookandroid.sample Kapusch Facebook Android Sample true + Login;Share;AppEvents + + + + r8 + apk + true + + + + $(SampleRuntimeIdentifiers) <_SampleManifest>$(MSBuildThisFileDirectory)Properties/AndroidManifest.xml @@ -34,6 +50,10 @@ + + + + diff --git a/samples/Kapusch.Facebook.Android.Sample/Properties/AndroidManifest.template.xml b/samples/Kapusch.Facebook.Android.Sample/Properties/AndroidManifest.template.xml index 25e92f7..9ecf0c7 100644 --- a/samples/Kapusch.Facebook.Android.Sample/Properties/AndroidManifest.template.xml +++ b/samples/Kapusch.Facebook.Android.Sample/Properties/AndroidManifest.template.xml @@ -11,6 +11,12 @@ android:name="com.facebook.sdk.ClientToken" android:value="@string/facebook_client_token" /> + + + + + + ` scheme in +the untracked `strings.xml` only; never commit them. + +The manifest template sets every automatic collection switch to `false` +(`AutoInitEnabled`, `AutoLogAppEventsEnabled`, `AdvertiserIDCollectionEnabled`, +`AutoLogMetaDataEnabled`, `AutoLogProductImprovementEnabled`). The sample's +"Grant ads consent" button enables auto-init and advertiser ID collection, then +calls `ActivateApp()` with automatic logging still off (activation is logged +from the next resume on); "Withdraw ads consent" reverts them and sets the +flush behavior to `ExplicitOnly`, because the SDK keeps tracking activation +until the process restarts. Debug builds enable `SetDebugLoggingEnabled(true)`, so logged and +flushed events appear in `adb logcat`. Use Meta Events Manager > Test events to +confirm delivery. + +### Access token attribution check + +The facade creates a new `AppEventsLogger` for every call, because a logger tags +every event with the Facebook access token that was current when the logger was +created. To check that events follow the current login state, use a real Meta +app ID and: + +1. log a custom event and flush; +2. sign in with Facebook Login, log another event and flush; +3. sign out, log a third event and flush. + +Inspect the `access_token` field of each `POST //activities` request +(an HTTPS proxy or the Android Studio Network Inspector; the SDK redacts tokens +in logcat). Only the request flushed in step 2 may carry the signed-in user's +token; steps 1 and 3 use the app's client token. diff --git a/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs b/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs index 41b6189..a5ffbd0 100644 --- a/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs +++ b/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs @@ -10,27 +10,127 @@ namespace Kapusch.Facebook.Android.Sample; public sealed class MainActivity : Activity { private const int LoginRequestCode = 4242; + private TextView? _status; protected override void OnCreate(Bundle? savedInstanceState) { base.OnCreate(savedInstanceState); - var button = new Button(this) { Text = "Start Facebook Login" }; + var layout = new LinearLayout(this) { Orientation = Orientation.Vertical }; + _status = new TextView(this); + layout.AddView(_status); + + AddButton(layout, "Start Facebook Login", StartLogin); + + // App Events: the manifest template disables every automatic collection switch, so nothing + // is sent until the user grants consent here. A real app persists the choice and re-applies + // it on every launch because the SDK persists runtime values over the manifest defaults. + AddButton(layout, "Grant ads consent", GrantConsent); + AddButton(layout, "Withdraw ads consent", WithdrawConsent); + AddButton( + layout, + "Log StartTrial (test values)", + () => FacebookAppEvents.LogStartTrial(value: 0m, currencyIsoCode: "USD", orderId: "sample-order") + ); + AddButton( + layout, + "Log Subscribe (test values)", + () => FacebookAppEvents.LogSubscribe(value: 9.99m, currencyIsoCode: "USD", orderId: "sample-order") + ); + AddButton( + layout, + "Log custom event", + () => + FacebookAppEvents.LogEvent( + FacebookAppEventNames.ViewedContent, + new Dictionary + { + [FacebookAppEventParameterNames.ContentType] = "sample", + [FacebookAppEventParameterNames.ContentId] = "item-1", + } + ) + ); + AddButton(layout, "Flush", FacebookAppEvents.Flush); + + var scroll = new ScrollView(this); + scroll.AddView(layout); + SetContentView(scroll); + +#if DEBUG + FacebookAppEvents.SetDebugLoggingEnabled(true); +#endif + RefreshStatus(); + } + + private void AddButton(LinearLayout layout, string text, Action action) + { + var button = new Button(this) { Text = text }; button.Click += (_, _) => { - var packageName = PackageName; - if (string.IsNullOrWhiteSpace(packageName)) + try + { + action(); + } + catch (Exception ex) { - Toast.MakeText(this, "Missing package name", ToastLength.Short)?.Show(); - return; + Toast.MakeText(this, $"{ex.GetType().Name}: {ex.Message}", ToastLength.Long)?.Show(); } - var intent = new Intent(); - intent.SetClassName(packageName, AndroidFacebookInterop.LoginActivityClassName); - StartActivityForResult(intent, LoginRequestCode); + RefreshStatus(); }; + layout.AddView(button); + } + + private static void GrantConsent() + { + FacebookAppEvents.SetFlushBehavior(FacebookAppEventsFlushBehavior.Auto); + FacebookAppEvents.SetAutoInitEnabled(true); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(true); + // Keep automatic logging off (no purchase auto-logging) and log activation explicitly. + // ActivateApp sends the one-time install ping and logs activation from the next activity + // resume on; this screen is already resumed, so the current session is not counted. A real + // app calls it on every launch with consent, before the first activity resumes. + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.ActivateApp(); + } + + private static void WithdrawConsent() + { + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(false); + FacebookAppEvents.SetAutoInitEnabled(false); + FacebookAppEvents.ClearUserId(); + FacebookAppEvents.ClearUserData(); + // The SDK stays fully initialized and keeps tracking activation until the process restarts. + // Stop sending for this process; a real app also stops calling the Log* methods. + FacebookAppEvents.SetFlushBehavior(FacebookAppEventsFlushBehavior.ExplicitOnly); + } + + private void RefreshStatus() + { + if (_status is null) + return; + + _status.Text = + $"SDK initialized: {FacebookAppEvents.IsSdkInitialized}, fully: {FacebookAppEvents.IsSdkFullyInitialized}\n" + + $"Auto-init: {FacebookAppEvents.IsAutoInitEnabled}, auto-log: {FacebookAppEvents.IsAutoLogAppEventsEnabled}, " + + $"advertiser ID: {FacebookAppEvents.IsAdvertiserIdCollectionEnabled}\n" + + $"Metadata: {FacebookAppEvents.IsMetadataCollectionEnabled}, product improvement: {FacebookAppEvents.IsAutoLogProductImprovementEnabled}\n" + + $"Anonymous ID: {FacebookAppEvents.GetAnonymousId()}"; + } + + private void StartLogin() + { + var packageName = PackageName; + if (string.IsNullOrWhiteSpace(packageName)) + { + Toast.MakeText(this, "Missing package name", ToastLength.Short)?.Show(); + return; + } - SetContentView(button); + var intent = new Intent(); + intent.SetClassName(packageName, AndroidFacebookInterop.LoginActivityClassName); + StartActivityForResult(intent, LoginRequestCode); } protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data) diff --git a/scripts/app-event-constants.py b/scripts/app-event-constants.py new file mode 100755 index 0000000..671c50a --- /dev/null +++ b/scripts/app-event-constants.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Generate or verify the managed App Events constants from the bundled facebook-core AAR. + +The C# constants must match com.facebook.appevents.AppEventsConstants of the exact SDK the +package ships. This script reads the constants with `javap -constants` from the restored +facebook-core AAR and either rewrites the C# file (--write) or fails when it drifted (default). + +Usage: + python3 scripts/app-event-constants.py [--write] [--deps ] +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_DEPS = ROOT / "src/Kapusch.FacebookApisForAndroidComponents/Native/Android/build/deps" +OUTPUT = ROOT / "src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventConstants.cs" +JAVA_CLASS = "com.facebook.appevents.AppEventsConstants" +CONSTANT = re.compile(r'public static final java\.lang\.String (\w+) = "([^"]*)";') + +# Upstream EVENT_NAME_PURCHASED is deprecated in favour of logPurchase; keep the constant (it is +# the documented standard event name) but point callers to the dedicated API. +NOTES = { + "EVENT_NAME_PURCHASED": "Prefer , which adds the currency.", + "EVENT_NAME_START_TRIAL": "See .", + "EVENT_NAME_SUBSCRIBE": "See .", + "EVENT_NAME_COMPLETED_REGISTRATION": "See .", +} + + +# Words whose casing follows the iOS package (and .NET naming) instead of plain title case, so +# shared code can use the same constant names on both platforms. +WORDS = {"APPLINK": "AppLink", "IPHONE": "IPhone", "IPAD": "IPad"} + + +def pascal(name: str) -> str: + return "".join(WORDS.get(part, part[:1].upper() + part[1:].lower()) for part in name.split("_") if part) + + +def read_constants(deps: Path) -> tuple[str, list[tuple[str, str]]]: + aars = sorted(deps.glob("facebook-core-*.aar")) + if len(aars) != 1: + raise SystemExit(f"Expected exactly one facebook-core AAR in {deps}, found {len(aars)}. Run restore-facebook-aars.sh.") + aar = aars[0] + version = aar.stem.removeprefix("facebook-core-") + with tempfile.TemporaryDirectory() as tmp: + jar = Path(tmp) / "classes.jar" + with zipfile.ZipFile(aar) as archive: + jar.write_bytes(archive.read("classes.jar")) + output = subprocess.check_output(["javap", "-constants", "-cp", str(jar), JAVA_CLASS], text=True) + constants = CONSTANT.findall(output) + if not constants: + raise SystemExit(f"No String constants found in {JAVA_CLASS}.") + return version, constants + + +def render(version: str, constants: list[tuple[str, str]]) -> str: + groups = { + "FacebookAppEventNames": ("EVENT_NAME_", "Standard App Events names"), + "FacebookAppEventParameterNames": ("EVENT_PARAM_", "Standard App Events parameter names"), + "FacebookAppEventParameterValues": ("EVENT_PARAM_VALUE_", "Canonical App Events parameter values"), + } + lines = [ + "// ", + f"// Generated by scripts/app-event-constants.py from {JAVA_CLASS}", + f"// in facebook-core {version}. Do not edit by hand: run the script with --write.", + "// ", + "", + "namespace Kapusch.Facebook.Android;", + ] + for class_name, (prefix, summary) in groups.items(): + members = [] + for java_name, value in constants: + if not java_name.startswith(prefix): + continue + if prefix == "EVENT_PARAM_" and java_name.startswith("EVENT_PARAM_VALUE_") and java_name != "EVENT_PARAM_VALUE_TO_SUM": + continue + if prefix == "EVENT_PARAM_VALUE_" and java_name == "EVENT_PARAM_VALUE_TO_SUM": + continue + members.append((pascal(java_name.removeprefix(prefix)), java_name, value)) + lines += [ + "", + f"/// {summary}, mirrored from AppEventsConstants of the bundled Facebook Android SDK ({version}).", + f"public static class {class_name}", + "{", + ] + for index, (member, java_name, value) in enumerate(members): + note = NOTES.get(java_name) + summary_text = f"AppEventsConstants.{java_name}." + (f" {note}" if note else "") + if index: + lines.append("") + lines.append(f"\t/// {summary_text}") + lines.append(f'\tpublic const string {member} = "{value}";') + lines.append("}") + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--write", action="store_true", help="rewrite the C# constants file") + parser.add_argument("--deps", type=Path, default=DEFAULT_DEPS) + args = parser.parse_args() + + version, constants = read_constants(args.deps) + expected = render(version, constants) + if args.write: + OUTPUT.write_text(expected, encoding="utf-8") + print(f"Wrote {OUTPUT.relative_to(ROOT)} ({len(constants)} constants, facebook-core {version})") + return 0 + actual = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else "" + if actual != expected: + print( + f"{OUTPUT.relative_to(ROOT)} does not match AppEventsConstants of facebook-core {version}. " + "Run: python3 scripts/app-event-constants.py --write", + file=sys.stderr, + ) + return 1 + print(f"App Events constants match facebook-core {version} ({len(constants)} constants)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate-package.py b/scripts/validate-package.py new file mode 100755 index 0000000..55a9dbf --- /dev/null +++ b/scripts/validate-package.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Validate the packed Kapusch.Facebook.Android nupkg against the repository contract. + +Checks the required entries, that android/deps holds exactly the locked Facebook AARs with their +locked SHA-256 digests (one SDK version), that no shared AndroidX/Kotlin/Google artifacts are +bundled, that the wrapper AAR carries the App Events facade and its consumer keep rule, and that +the default feature selection is still Login. + +Usage: + python3 scripts/validate-package.py [artifacts/nuget] +""" + +from __future__ import annotations + +import hashlib +import re +import sys +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +LOCK_FILE = ROOT / "DependencyLocks/Android/lockstate.txt" +PACKAGE_ID = "Kapusch.Facebook.Android" +WRAPPER = "android/aar/kfb-facebookinterop-release.aar" +REQUIRED = { + "THIRD_PARTY_NOTICES.md", + "nuget-readme.md", + WRAPPER, + "buildTransitive/Kapusch.Facebook.Android.props", + "buildTransitive/Kapusch.Facebook.Android.targets", + "buildTransitive/proguard/kapusch-facebook-appevents.pro", +} +WRAPPER_CLASSES = { + "com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.class", + "com/kapusch/facebook/androidinterop/FacebookLoginActivity.class", + "com/kapusch/facebook/androidinterop/FacebookShareActivity.class", + "com/kapusch/facebook/androidinterop/FacebookLogoutReceiver.class", +} +# Shared dependencies must come from the consumer's graph (managed Xamarin.* NuGets). +FORBIDDEN = re.compile(r"(?i)(androidx|kotlin|installreferrer|billing|play-services|gson)") + + +def read_lock() -> dict[str, str]: + entries = {} + for line in LOCK_FILE.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + _, sha, filename = line.split() + entries[filename] = sha + return entries + + +def main() -> int: + folder = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "artifacts/nuget" + packages = sorted(folder.glob(f"{PACKAGE_ID}.*.nupkg")) + if len(packages) != 1: + raise SystemExit(f"Expected exactly one {PACKAGE_ID} nupkg in {folder}, found {len(packages)}") + package = packages[0] + errors: list[str] = [] + lock = read_lock() + + with zipfile.ZipFile(package) as archive: + names = set(archive.namelist()) + errors += [f"missing entry {entry}" for entry in sorted(REQUIRED - names)] + if not any(re.fullmatch(r"lib/net10\.0-android[0-9.]*/Kapusch\.Facebook\.Android\.dll", n) for n in names): + errors.append("missing lib/net10.0-android*/Kapusch.Facebook.Android.dll") + + deps = {n.removeprefix("android/deps/"): n for n in names if n.startswith("android/deps/") and not n.endswith("/")} + if set(deps) != set(lock): + errors.append(f"android/deps {sorted(deps)} does not match lockstate {sorted(lock)}") + for filename, entry in deps.items(): + digest = hashlib.sha256(archive.read(entry)).hexdigest() + if lock.get(filename) != digest: + errors.append(f"{entry} SHA-256 {digest} does not match lockstate") + versions = {re.sub(r"^facebook-[a-z]+-(.+)\.aar$", r"\1", f) for f in lock} + if len(versions) != 1: + errors.append(f"locked Facebook AARs mix versions: {sorted(versions)}") + + for name in sorted(names): + if name.startswith("android/") and FORBIDDEN.search(name): + errors.append(f"shared dependency must not be bundled: {name}") + + if WRAPPER in names: + with zipfile.ZipFile(archive.open(WRAPPER)) as wrapper: + wrapper_names = set(wrapper.namelist()) + with zipfile.ZipFile(wrapper.open("classes.jar")) as classes: + missing = WRAPPER_CLASSES - set(classes.namelist()) + errors += [f"wrapper AAR misses {c}" for c in sorted(missing)] + rules = wrapper.read("proguard.txt").decode() if "proguard.txt" in wrapper_names else "" + if "-keep class com.kapusch.facebook.androidinterop.** { *; }" not in rules: + errors.append("wrapper AAR proguard.txt must keep com.kapusch.facebook.androidinterop.**") + + props = archive.read("buildTransitive/Kapusch.Facebook.Android.props").decode() + if ">Login" not in props: + errors.append("default KapuschFacebookFeatures must stay Login") + targets = archive.read("buildTransitive/Kapusch.Facebook.Android.targets").decode() + if "';AppEvents;'" not in targets or "kapusch-facebook-appevents.pro" not in targets: + errors.append("targets must wire the AppEvents feature and its R8 rules") + + if errors: + print(f"{package.name} failed validation:\n " + "\n ".join(errors), file=sys.stderr) + return 1 + print(f"{package.name} validated ({len(names)} entries, Facebook SDK {versions.pop()})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify-interop.py b/scripts/verify-interop.py new file mode 100755 index 0000000..4075352 --- /dev/null +++ b/scripts/verify-interop.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Verify the JNI contract between the managed API and the Java App Events facade. + +Subcommands: + jni Every (method, signature) the C# bridge calls must exist as a public static method of + FacebookAppEventsInterop in the built wrapper AAR (checked with `javap -s`). + apk An APK built with R8 must still define the facade and every method the C# bridge calls, + plus the Facebook SDK entry points it relies on (parsed from the dex files). + +Usage: + python3 scripts/verify-interop.py jni [--aar ] + python3 scripts/verify-interop.py apk [--feature Login] [--feature Share] +""" + +from __future__ import annotations + +import argparse +import re +import struct +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MANAGED = ROOT / "src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs" +DEFAULT_AAR = ROOT / "src/Kapusch.FacebookApisForAndroidComponents/Native/Android/build/aar/kfb-facebookinterop-release.aar" +JAVA_CLASS = "com.kapusch.facebook.androidinterop.FacebookAppEventsInterop" +INTEROP_DESCRIPTOR = "Lcom/kapusch/facebook/androidinterop/FacebookAppEventsInterop;" + +# Upstream classes the facade calls directly or that bootstrap the SDK. +REQUIRED_CLASSES = { + "Lcom/facebook/FacebookSdk;", + "Lcom/facebook/appevents/AppEventsLogger;", + "Lcom/facebook/internal/FacebookInitProvider;", +} +FEATURE_CLASSES = { + "Login": {"Lcom/kapusch/facebook/androidinterop/FacebookLoginActivity;", "Lcom/facebook/login/LoginManager;"}, + "Share": {"Lcom/kapusch/facebook/androidinterop/FacebookShareActivity;", "Lcom/facebook/share/widget/ShareDialog;"}, +} + + +def managed_calls() -> set[tuple[str, str]]: + source = MANAGED.read_text(encoding="utf-8") + calls = set(re.findall(r'(?:CallVoid|CallString|Method)\(\s*"(\w+)",\s*"([^"]+)"', source)) + calls |= {(name, "()Z") for name in re.findall(r'CallBool\("(\w+)"\)', source)} + if not calls: + raise SystemExit(f"No JNI calls found in {MANAGED.relative_to(ROOT)}") + return calls + + +def verify_jni(aar: Path) -> int: + if not aar.exists(): + raise SystemExit(f"Missing wrapper AAR {aar}. Run Native/Android/build.sh first.") + with tempfile.TemporaryDirectory() as tmp: + jar = Path(tmp) / "classes.jar" + with zipfile.ZipFile(aar) as archive: + jar.write_bytes(archive.read("classes.jar")) + output = subprocess.check_output(["javap", "-s", "-public", "-cp", str(jar), JAVA_CLASS], text=True) + exported: set[tuple[str, str]] = set() + pending: str | None = None + for line in output.splitlines(): + match = re.match(r"\s*public static \S+ (\w+)\(", line) + if match: + pending = match.group(1) + continue + descriptor = re.match(r"\s*descriptor: (\S+)", line) + if descriptor and pending: + exported.add((pending, descriptor.group(1))) + pending = None + missing = sorted(managed_calls() - exported) + if missing: + for name, signature in missing: + print(f"Missing Java method {JAVA_CLASS}.{name}{signature}", file=sys.stderr) + return 1 + print(f"JNI contract verified ({len(managed_calls())} managed calls match {JAVA_CLASS})") + return 0 + + +def uleb128(data: bytes, offset: int) -> tuple[int, int]: + result = shift = 0 + while True: + byte = data[offset] + offset += 1 + result |= (byte & 0x7F) << shift + if byte < 0x80: + return result, offset + shift += 7 + + +def dex_symbols(data: bytes) -> tuple[set[str], set[tuple[str, str]]]: + def u32(offset: int) -> int: + return struct.unpack_from(" int: + defined: set[str] = set() + methods: set[tuple[str, str]] = set() + with zipfile.ZipFile(apk) as archive: + dex_files = [name for name in archive.namelist() if re.fullmatch(r"classes\d*\.dex", name)] + if not dex_files: + raise SystemExit(f"No dex files in {apk}") + for name in dex_files: + classes, refs = dex_symbols(archive.read(name)) + defined |= classes + methods |= refs + required = set(REQUIRED_CLASSES) | {INTEROP_DESCRIPTOR} + for feature in features: + required |= FEATURE_CLASSES[feature] + errors = [f"Class not defined in dex: {descriptor}" for descriptor in sorted(required - defined)] + for name, _ in sorted(managed_calls()): + if (INTEROP_DESCRIPTOR, name) not in methods: + errors.append(f"Facade method removed by R8: {name}") + if errors: + print("\n".join(errors), file=sys.stderr) + return 1 + print(f"{apk.name}: facade and {len(required)} required classes survived R8 ({len(dex_files)} dex files)") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command", required=True) + jni = sub.add_parser("jni") + jni.add_argument("--aar", type=Path, default=DEFAULT_AAR) + apk = sub.add_parser("apk") + apk.add_argument("apk", type=Path) + apk.add_argument("--feature", action="append", default=[], choices=sorted(FEATURE_CLASSES)) + args = parser.parse_args() + if args.command == "jni": + return verify_jni(args.aar) + return verify_apk(args.apk, args.feature) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify-public.py b/scripts/verify-public.py new file mode 100755 index 0000000..bba0b2e --- /dev/null +++ b/scripts/verify-public.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Fail when a tracked file contains a real-looking credential, an email or a private term. + +This repository is a public, generic wrapper. The generic checks below are tracked. Terms that +must never appear (for example the names of private consuming apps) are not: they are read at run +time from an untracked source so the scanner never publishes them. + +Private terms, one per line or comma-separated, matched case-insensitively as literal text: + - the KAPUSCH_PRIVATE_TERMS environment variable (CI reads it from a repository secret); + - the file /info/private-terms ('#' starts a comment). +The private-term check is skipped, with a notice, when neither source provides a term. +Failures name the file and the term's position, never the term itself. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +PATTERNS = { + # Meta app IDs are 15-16 digit numbers; samples must keep the all-zero placeholders. + "Meta app ID or fb URL scheme": re.compile( + rb"(?i)(?:facebook_app_id\">|ApplicationId\"\s+android:value=\"|\bfb)[1-9][0-9]{14,15}\b" + ), + # Client tokens are 32 hex characters; the sample keeps an all-zero placeholder. + "Meta client token": re.compile(rb"(?i)client_token\">(?!0{32}<)[0-9a-f]{32}<"), + "email address": re.compile(rb"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z.]{2,}"), +} +# Files whose own source would match the generic patterns. Private terms are checked everywhere. +GENERIC_SKIP = {"scripts/verify-public.py"} + + +def split_terms(text: str) -> list[str]: + terms = [] + for line in text.splitlines(): + line = line.split("#", 1)[0] + terms.extend(part.strip() for part in line.split(",")) + return [term for term in terms if term] + + +def load_private_terms() -> list[str]: + terms = split_terms(os.environ.get("KAPUSCH_PRIVATE_TERMS", "")) + git_dir = subprocess.check_output(["git", "rev-parse", "--absolute-git-dir"], cwd=ROOT, text=True).strip() + terms_file = Path(git_dir) / "info" / "private-terms" + if terms_file.is_file(): + terms.extend(split_terms(terms_file.read_text(encoding="utf-8"))) + return list(dict.fromkeys(terms)) + + +def main() -> int: + files = [f for f in subprocess.check_output(["git", "ls-files", "-z"], cwd=ROOT).decode().split("\0") if f] + private = [ + (f"private term #{index}", re.compile(re.escape(term.encode()), re.IGNORECASE)) + for index, term in enumerate(load_private_terms(), start=1) + ] + errors = [] + for name in files: + path = ROOT / name + if not path.is_file(): + continue + data = path.read_bytes() + checks = list(private) + if name not in GENERIC_SKIP: + checks.extend(PATTERNS.items()) + for label, pattern in checks: + if pattern.search(data): + errors.append(f"{name}: {label}") + if errors: + print("Public tree scan failed:\n " + "\n ".join(errors), file=sys.stderr) + return 1 + if private: + scope = f"{len(private)} private term(s)" + else: + scope = "no private terms configured (set KAPUSCH_PRIVATE_TERMS or .git/info/private-terms)" + print(f"Public tree scan passed ({len(files)} tracked files, {scope})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventConstants.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventConstants.cs new file mode 100644 index 0000000..389efac --- /dev/null +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventConstants.cs @@ -0,0 +1,249 @@ +// +// Generated by scripts/app-event-constants.py from com.facebook.appevents.AppEventsConstants +// in facebook-core 19.0.0. Do not edit by hand: run the script with --write. +// + +namespace Kapusch.Facebook.Android; + +/// Standard App Events names, mirrored from AppEventsConstants of the bundled Facebook Android SDK (19.0.0). +public static class FacebookAppEventNames +{ + /// AppEventsConstants.EVENT_NAME_ACTIVATED_APP. + public const string ActivatedApp = "fb_mobile_activate_app"; + + /// AppEventsConstants.EVENT_NAME_DEACTIVATED_APP. + public const string DeactivatedApp = "fb_mobile_deactivate_app"; + + /// AppEventsConstants.EVENT_NAME_SESSION_INTERRUPTIONS. + public const string SessionInterruptions = "fb_mobile_app_interruptions"; + + /// AppEventsConstants.EVENT_NAME_TIME_BETWEEN_SESSIONS. + public const string TimeBetweenSessions = "fb_mobile_time_between_sessions"; + + /// AppEventsConstants.EVENT_NAME_COMPLETED_REGISTRATION. See . + public const string CompletedRegistration = "fb_mobile_complete_registration"; + + /// AppEventsConstants.EVENT_NAME_VIEWED_CONTENT. + public const string ViewedContent = "fb_mobile_content_view"; + + /// AppEventsConstants.EVENT_NAME_SEARCHED. + public const string Searched = "fb_mobile_search"; + + /// AppEventsConstants.EVENT_NAME_RATED. + public const string Rated = "fb_mobile_rate"; + + /// AppEventsConstants.EVENT_NAME_COMPLETED_TUTORIAL. + public const string CompletedTutorial = "fb_mobile_tutorial_completion"; + + /// AppEventsConstants.EVENT_NAME_PUSH_TOKEN_OBTAINED. + public const string PushTokenObtained = "fb_mobile_obtain_push_token"; + + /// AppEventsConstants.EVENT_NAME_ADDED_TO_CART. + public const string AddedToCart = "fb_mobile_add_to_cart"; + + /// AppEventsConstants.EVENT_NAME_ADDED_TO_WISHLIST. + public const string AddedToWishlist = "fb_mobile_add_to_wishlist"; + + /// AppEventsConstants.EVENT_NAME_INITIATED_CHECKOUT. + public const string InitiatedCheckout = "fb_mobile_initiated_checkout"; + + /// AppEventsConstants.EVENT_NAME_ADDED_PAYMENT_INFO. + public const string AddedPaymentInfo = "fb_mobile_add_payment_info"; + + /// AppEventsConstants.EVENT_NAME_PURCHASED. Prefer , which adds the currency. + public const string Purchased = "fb_mobile_purchase"; + + /// AppEventsConstants.EVENT_NAME_ACHIEVED_LEVEL. + public const string AchievedLevel = "fb_mobile_level_achieved"; + + /// AppEventsConstants.EVENT_NAME_UNLOCKED_ACHIEVEMENT. + public const string UnlockedAchievement = "fb_mobile_achievement_unlocked"; + + /// AppEventsConstants.EVENT_NAME_SPENT_CREDITS. + public const string SpentCredits = "fb_mobile_spent_credits"; + + /// AppEventsConstants.EVENT_NAME_CONTACT. + public const string Contact = "Contact"; + + /// AppEventsConstants.EVENT_NAME_CUSTOMIZE_PRODUCT. + public const string CustomizeProduct = "CustomizeProduct"; + + /// AppEventsConstants.EVENT_NAME_DONATE. + public const string Donate = "Donate"; + + /// AppEventsConstants.EVENT_NAME_FIND_LOCATION. + public const string FindLocation = "FindLocation"; + + /// AppEventsConstants.EVENT_NAME_SCHEDULE. + public const string Schedule = "Schedule"; + + /// AppEventsConstants.EVENT_NAME_START_TRIAL. See . + public const string StartTrial = "StartTrial"; + + /// AppEventsConstants.EVENT_NAME_SUBMIT_APPLICATION. + public const string SubmitApplication = "SubmitApplication"; + + /// AppEventsConstants.EVENT_NAME_SUBSCRIBE. See . + public const string Subscribe = "Subscribe"; + + /// AppEventsConstants.EVENT_NAME_AD_IMPRESSION. + public const string AdImpression = "AdImpression"; + + /// AppEventsConstants.EVENT_NAME_AD_CLICK. + public const string AdClick = "AdClick"; + + /// AppEventsConstants.EVENT_NAME_LIVE_STREAMING_START. + public const string LiveStreamingStart = "fb_sdk_live_streaming_start"; + + /// AppEventsConstants.EVENT_NAME_LIVE_STREAMING_STOP. + public const string LiveStreamingStop = "fb_sdk_live_streaming_stop"; + + /// AppEventsConstants.EVENT_NAME_LIVE_STREAMING_PAUSE. + public const string LiveStreamingPause = "fb_sdk_live_streaming_pause"; + + /// AppEventsConstants.EVENT_NAME_LIVE_STREAMING_RESUME. + public const string LiveStreamingResume = "fb_sdk_live_streaming_resume"; + + /// AppEventsConstants.EVENT_NAME_LIVE_STREAMING_ERROR. + public const string LiveStreamingError = "fb_sdk_live_streaming_error"; + + /// AppEventsConstants.EVENT_NAME_LIVE_STREAMING_UPDATE_STATUS. + public const string LiveStreamingUpdateStatus = "fb_sdk_live_streaming_update_status"; + + /// AppEventsConstants.EVENT_NAME_PRODUCT_CATALOG_UPDATE. + public const string ProductCatalogUpdate = "fb_mobile_catalog_update"; +} + +/// Standard App Events parameter names, mirrored from AppEventsConstants of the bundled Facebook Android SDK (19.0.0). +public static class FacebookAppEventParameterNames +{ + /// AppEventsConstants.EVENT_PARAM_LIVE_STREAMING_PREV_STATUS. + public const string LiveStreamingPrevStatus = "live_streaming_prev_status"; + + /// AppEventsConstants.EVENT_PARAM_LIVE_STREAMING_STATUS. + public const string LiveStreamingStatus = "live_streaming_status"; + + /// AppEventsConstants.EVENT_PARAM_LIVE_STREAMING_ERROR. + public const string LiveStreamingError = "live_streaming_error"; + + /// AppEventsConstants.EVENT_PARAM_CURRENCY. + public const string Currency = "fb_currency"; + + /// AppEventsConstants.EVENT_PARAM_REGISTRATION_METHOD. + public const string RegistrationMethod = "fb_registration_method"; + + /// AppEventsConstants.EVENT_PARAM_CONTENT_TYPE. + public const string ContentType = "fb_content_type"; + + /// AppEventsConstants.EVENT_PARAM_CONTENT. + public const string Content = "fb_content"; + + /// AppEventsConstants.EVENT_PARAM_CONTENT_ID. + public const string ContentId = "fb_content_id"; + + /// AppEventsConstants.EVENT_PARAM_SEARCH_STRING. + public const string SearchString = "fb_search_string"; + + /// AppEventsConstants.EVENT_PARAM_SUCCESS. + public const string Success = "fb_success"; + + /// AppEventsConstants.EVENT_PARAM_MAX_RATING_VALUE. + public const string MaxRatingValue = "fb_max_rating_value"; + + /// AppEventsConstants.EVENT_PARAM_PAYMENT_INFO_AVAILABLE. + public const string PaymentInfoAvailable = "fb_payment_info_available"; + + /// AppEventsConstants.EVENT_PARAM_NUM_ITEMS. + public const string NumItems = "fb_num_items"; + + /// AppEventsConstants.EVENT_PARAM_LEVEL. + public const string Level = "fb_level"; + + /// AppEventsConstants.EVENT_PARAM_DESCRIPTION. + public const string Description = "fb_description"; + + /// AppEventsConstants.EVENT_PARAM_SOURCE_APPLICATION. + public const string SourceApplication = "fb_mobile_launch_source"; + + /// AppEventsConstants.EVENT_PARAM_AD_TYPE. + public const string AdType = "ad_type"; + + /// AppEventsConstants.EVENT_PARAM_ORDER_ID. + public const string OrderId = "fb_order_id"; + + /// AppEventsConstants.EVENT_PARAM_VALUE_TO_SUM. + public const string ValueToSum = "_valueToSum"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_CUSTOM_LABEL_0. + public const string ProductCustomLabel0 = "fb_product_custom_label_0"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_CUSTOM_LABEL_1. + public const string ProductCustomLabel1 = "fb_product_custom_label_1"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_CUSTOM_LABEL_2. + public const string ProductCustomLabel2 = "fb_product_custom_label_2"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_CUSTOM_LABEL_3. + public const string ProductCustomLabel3 = "fb_product_custom_label_3"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_CUSTOM_LABEL_4. + public const string ProductCustomLabel4 = "fb_product_custom_label_4"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_CATEGORY. + public const string ProductCategory = "fb_product_category"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_IOS_URL. + public const string ProductAppLinkIosUrl = "fb_product_applink_ios_url"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_IOS_APP_STORE_ID. + public const string ProductAppLinkIosAppStoreId = "fb_product_applink_ios_app_store_id"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_IOS_APP_NAME. + public const string ProductAppLinkIosAppName = "fb_product_applink_ios_app_name"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_IPHONE_URL. + public const string ProductAppLinkIPhoneUrl = "fb_product_applink_iphone_url"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_IPHONE_APP_STORE_ID. + public const string ProductAppLinkIPhoneAppStoreId = "fb_product_applink_iphone_app_store_id"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_IPHONE_APP_NAME. + public const string ProductAppLinkIPhoneAppName = "fb_product_applink_iphone_app_name"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_IPAD_URL. + public const string ProductAppLinkIPadUrl = "fb_product_applink_ipad_url"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_IPAD_APP_STORE_ID. + public const string ProductAppLinkIPadAppStoreId = "fb_product_applink_ipad_app_store_id"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_IPAD_APP_NAME. + public const string ProductAppLinkIPadAppName = "fb_product_applink_ipad_app_name"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_ANDROID_URL. + public const string ProductAppLinkAndroidUrl = "fb_product_applink_android_url"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_ANDROID_PACKAGE. + public const string ProductAppLinkAndroidPackage = "fb_product_applink_android_package"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_ANDROID_APP_NAME. + public const string ProductAppLinkAndroidAppName = "fb_product_applink_android_app_name"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_WINDOWS_PHONE_URL. + public const string ProductAppLinkWindowsPhoneUrl = "fb_product_applink_windows_phone_url"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_WINDOWS_PHONE_APP_ID. + public const string ProductAppLinkWindowsPhoneAppId = "fb_product_applink_windows_phone_app_id"; + + /// AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_WINDOWS_PHONE_APP_NAME. + public const string ProductAppLinkWindowsPhoneAppName = "fb_product_applink_windows_phone_app_name"; +} + +/// Canonical App Events parameter values, mirrored from AppEventsConstants of the bundled Facebook Android SDK (19.0.0). +public static class FacebookAppEventParameterValues +{ + /// AppEventsConstants.EVENT_PARAM_VALUE_YES. + public const string Yes = "1"; + + /// AppEventsConstants.EVENT_PARAM_VALUE_NO. + public const string No = "0"; +} diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs new file mode 100644 index 0000000..69942a7 --- /dev/null +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs @@ -0,0 +1,581 @@ +using System.Collections.Concurrent; +using System.Globalization; +using Android.Content; +using Android.OS; +using Android.Runtime; + +namespace Kapusch.Facebook.Android; + +/// +/// Thin JNI bridge to the Facebook Android SDK App Events API (FacebookSdk and +/// AppEventsLogger). The upstream SDK owns persistence, batching and retries; this class +/// adds no queue and changes no default. +/// +/// +/// +/// Every setter maps one to one to the upstream SDK. Runtime values written by the +/// Set*Enabled methods are persisted by the SDK and take precedence over the manifest +/// meta-data on later launches. Apply the consumer's consent state explicitly on every launch. +/// +/// +/// Turning switches off does not stop the SDK for the current process: it stays fully initialized, +/// keeps flushing queued events and, once or automatic logging started +/// activity tracking, keeps logging activation on every activity resume. Withdrawal fully applies +/// on the next process start. Until then, stop calling the Log* methods and set +/// without calling . +/// +/// +/// Every setter, the identity methods and the event methods throw +/// until FacebookSdk.sdkInitialize ran (the +/// default FacebookInitProvider runs it at process start). Before that the SDK would drop +/// them silently. When the provider is removed, call first and pass the +/// data processing options to it. Invalid arguments rejected by the Java layer surface as +/// subclasses. +/// +/// +public static class FacebookAppEvents +{ + private const string InteropClassName = "com/kapusch/facebook/androidinterop/FacebookAppEventsInterop"; + + private static readonly Lazy InteropClass = new( + () => JNIEnv.FindClass(InteropClassName), + LazyThreadSafetyMode.ExecutionAndPublication + ); + private static readonly ConcurrentDictionary MethodIds = new(StringComparer.Ordinal); + + /// Gets whether FacebookSdk.sdkInitialize already ran in this process. + public static bool IsSdkInitialized => CallBool("isSdkInitialized"); + + /// + /// Gets whether the SDK is fully initialized (auto-init enabled, or + /// called). Until then the SDK refuses to process Graph API responses. + /// + public static bool IsSdkFullyInitialized => CallBool("isSdkFullyInitialized"); + + /// Gets the SDK auto-init flag (com.facebook.sdk.AutoInitEnabled). + public static bool IsAutoInitEnabled => CallBool("isAutoInitEnabled"); + + /// Gets the automatic App Events logging flag (com.facebook.sdk.AutoLogAppEventsEnabled). + public static bool IsAutoLogAppEventsEnabled => CallBool("isAutoLogAppEventsEnabled"); + + /// Gets the advertiser ID collection flag (com.facebook.sdk.AdvertiserIDCollectionEnabled). + public static bool IsAdvertiserIdCollectionEnabled => CallBool("isAdvertiserIdCollectionEnabled"); + + /// Gets the app metadata collection flag (com.facebook.sdk.AutoLogMetaDataEnabled, SDK 19+). + public static bool IsMetadataCollectionEnabled => CallBool("isAutoLogMetaDataEnabled"); + + /// + /// Gets the product-improvement data flag (com.facebook.sdk.AutoLogProductImprovementEnabled, SDK 19+). + /// + public static bool IsAutoLogProductImprovementEnabled => CallBool("isAutoLogProductImprovementEnabled"); + + /// Gets the current App Events flush behavior. + public static FacebookAppEventsFlushBehavior FlushBehavior => + (FacebookAppEventsFlushBehavior) + JNIEnv.CallStaticIntMethod(InteropClass.Value, Method("getFlushBehavior", "()I")); + + /// + /// Initializes the SDK when the FacebookInitProvider did not (for example when the consumer + /// removed the provider to defer the SDK until consent), applies + /// , then marks the SDK fully initialized for this + /// process. Unlike , nothing is persisted. + /// + /// + /// Pass Limited Data Use here rather than through when the + /// provider is removed: the setter needs an initialized SDK, and full initialization starts the + /// SDK's first Graph requests, so this is the only point where the options apply to them. + /// + /// Any context; the application context is used. Defaults to the application context. + /// + /// Options such as . + /// leaves the current options unchanged; an empty array clears them. + /// + /// Country for the options; 0 lets Meta geolocate the user. + /// State for the options; 0 lets Meta geolocate the user. + public static void InitializeSdk( + Context? context = null, + string[]? dataProcessingOptions = null, + int dataProcessingCountry = 0, + int dataProcessingState = 0 + ) + { + var array = dataProcessingOptions is null ? IntPtr.Zero : JNIEnv.NewArray(dataProcessingOptions); + try + { + CallVoid( + "initializeSdk", + "(Landroid/content/Context;[Ljava/lang/String;II)V", + new JValue(ResolveContext(context)), + new JValue(array), + new JValue(dataProcessingCountry), + new JValue(dataProcessingState) + ); + } + finally + { + if (array != IntPtr.Zero) + { + JNIEnv.DeleteLocalRef(array); + } + } + } + + /// + /// Sets the persisted auto-init flag. also fully initializes the SDK + /// (FacebookSdk.setAutoInitEnabled). only affects the next process + /// start: an SDK already fully initialized stays so, and keeps flushing queued events. + /// + public static void SetAutoInitEnabled(bool enabled) => CallVoid("setAutoInitEnabled", "(Z)V", new JValue(enabled)); + + /// + /// Sets the persisted automatic App Events logging flag. starts activity + /// tracking, which requires an initialized SDK. Automatic logging covers the install ping, app + /// activation and, when enabled in Events Manager, in-app purchases and subscriptions. + /// does not stop activity tracking already started in this process; it + /// applies from the next process start. + /// + public static void SetAutoLogAppEventsEnabled(bool enabled) => + CallVoid("setAutoLogAppEventsEnabled", "(Z)V", new JValue(enabled)); + + /// Sets the persisted advertiser ID (GAID) collection flag. + public static void SetAdvertiserIdCollectionEnabled(bool enabled) => + CallVoid("setAdvertiserIdCollectionEnabled", "(Z)V", new JValue(enabled)); + + /// + /// Sets the persisted app metadata collection flag (FacebookSdk.setAutoLogMetaDataEnabled: + /// screen title and AppLink URL attached to events, SDK 19+). also discards + /// metadata already collected. Named like the iOS package's equivalent. + /// + public static void SetMetadataCollectionEnabled(bool enabled) => + CallVoid("setAutoLogMetaDataEnabled", "(Z)V", new JValue(enabled)); + + /// Sets the persisted product-improvement data flag (SDK 19+). + public static void SetAutoLogProductImprovementEnabled(bool enabled) => + CallVoid("setAutoLogProductImprovementEnabled", "(Z)V", new JValue(enabled)); + + /// + /// Sets data processing options, for example . + /// An empty array explicitly opts out. Country and state 0 let Meta geolocate the user. + /// Requires an initialized SDK; with the FacebookInitProvider removed, pass the options to + /// instead. + /// + public static void SetDataProcessingOptions(string[] options, int country = 0, int state = 0) + { + ArgumentNullException.ThrowIfNull(options); + var array = JNIEnv.NewArray(options); + try + { + CallVoid( + "setDataProcessingOptions", + "([Ljava/lang/String;II)V", + new JValue(array), + new JValue(country), + new JValue(state) + ); + } + finally + { + JNIEnv.DeleteLocalRef(array); + } + } + + /// + /// Enables or disables SDK debug mode and the APP_EVENTS logging behavior, so logged and + /// flushed events appear in logcat. Intended for diagnostics only. + /// + public static void SetDebugLoggingEnabled(bool enabled) => + CallVoid("setDebugLoggingEnabled", "(Z)V", new JValue(enabled)); + + /// Sets when queued events are flushed to Meta. + public static void SetFlushBehavior(FacebookAppEventsFlushBehavior behavior) + { + if (!Enum.IsDefined(behavior)) + { + throw new ArgumentOutOfRangeException(nameof(behavior), behavior, "Unknown flush behavior."); + } + + CallVoid("setFlushBehavior", "(I)V", new JValue((int)behavior)); + } + + /// + /// Sends the one-time install ping and starts activation tracking + /// (AppEventsLogger.activateApp): the SDK logs fb_mobile_activate_app and + /// deactivation from the next activity resume on. An activity already resumed when this runs is + /// not counted, so call it on every launch before the first activity resumes, for example from + /// Application.OnCreate or MainActivity.OnCreate. When it runs right after a consent + /// dialog, that session is only counted from the next resume. Use it when automatic logging is + /// disabled and the consumer still wants install and launch measurement. Call it once the SDK is + /// fully initialized ( or ); before + /// that the SDK discards the install ping response and only retries on a later activation. + /// Tracking cannot be stopped until the process ends. + /// + public static void ActivateApp(Context? context = null) => + CallVoid("activateApp", "(Landroid/content/Context;)V", new JValue(ResolveContext(context))); + + /// Logs an event (AppEventsLogger.logEvent). + /// Event name, for example a constant. + /// + /// Optional parameters. Values must be , (sent as + /// "1"/"0"), an integral type, , or + /// . + /// + /// Optional value Meta sums for this event (for example a price). + public static void LogEvent( + string eventName, + IReadOnlyDictionary? parameters = null, + double? valueToSum = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(eventName); + if (valueToSum is { } value && !double.IsFinite(value)) + { + throw new ArgumentOutOfRangeException(nameof(valueToSum), value, "valueToSum must be finite."); + } + + using var bundle = ToBundle(parameters); + var name = JNIEnv.NewString(eventName); + try + { + if (valueToSum is { } sum) + { + CallVoid( + "logEventWithValue", + "(Ljava/lang/String;DLandroid/os/Bundle;)V", + new JValue(name), + new JValue(sum), + new JValue(bundle?.Handle ?? IntPtr.Zero) + ); + } + else + { + CallVoid( + "logEvent", + "(Ljava/lang/String;Landroid/os/Bundle;)V", + new JValue(name), + new JValue(bundle?.Handle ?? IntPtr.Zero) + ); + } + } + finally + { + JNIEnv.DeleteLocalRef(name); + } + } + + /// + /// Logs with fb_currency and the amount as + /// value to sum (AppEventsLogger.logPurchase). The SDK flushes purchases immediately + /// unless the flush behavior is . + /// + /// Purchase amount. + /// ISO 4217 currency code, for example EUR. + /// Optional extra parameters. + public static void LogPurchase( + decimal amount, + string currencyIsoCode, + IReadOnlyDictionary? parameters = null + ) + { + var currency = RequireCurrency(currencyIsoCode, nameof(currencyIsoCode)); + using var bundle = ToBundle(parameters); + var amountString = JNIEnv.NewString(amount.ToString(CultureInfo.InvariantCulture)); + var currencyString = JNIEnv.NewString(currency); + try + { + CallVoid( + "logPurchase", + "(Ljava/lang/String;Ljava/lang/String;Landroid/os/Bundle;)V", + new JValue(amountString), + new JValue(currencyString), + new JValue(bundle?.Handle ?? IntPtr.Zero) + ); + } + finally + { + JNIEnv.DeleteLocalRef(amountString); + JNIEnv.DeleteLocalRef(currencyString); + } + } + + /// + /// Logs with Meta's documented parameters. + /// + /// Optional value to sum, typically the price after the trial. Requires . + /// ISO 4217 currency code sent as fb_currency. + /// Optional fb_order_id: one ID shared by all events of a subscription. + /// Optional extra parameters. Explicit arguments win over the same keys here. + public static void LogStartTrial( + decimal? value = null, + string? currencyIsoCode = null, + string? orderId = null, + IReadOnlyDictionary? parameters = null + ) => LogValuedEvent(FacebookAppEventNames.StartTrial, value, currencyIsoCode, orderId, parameters); + + /// + /// Logs with Meta's documented parameters. + /// + /// Optional value to sum, typically the subscription price. Requires . + /// ISO 4217 currency code sent as fb_currency. + /// Optional fb_order_id: one ID shared by all events of a subscription. + /// Optional extra parameters. Explicit arguments win over the same keys here. + public static void LogSubscribe( + decimal? value = null, + string? currencyIsoCode = null, + string? orderId = null, + IReadOnlyDictionary? parameters = null + ) => LogValuedEvent(FacebookAppEventNames.Subscribe, value, currencyIsoCode, orderId, parameters); + + /// Logs . + /// Optional fb_registration_method, for example email. + /// Optional value to sum. Requires . + /// ISO 4217 currency code sent as fb_currency. + /// Optional extra parameters. Explicit arguments win over the same keys here. + public static void LogCompleteRegistration( + string? registrationMethod = null, + decimal? value = null, + string? currencyIsoCode = null, + IReadOnlyDictionary? parameters = null + ) + { + var merged = Merge(parameters); + if (!string.IsNullOrWhiteSpace(registrationMethod)) + { + merged[FacebookAppEventParameterNames.RegistrationMethod] = registrationMethod; + } + + LogValuedEvent(FacebookAppEventNames.CompletedRegistration, value, currencyIsoCode, orderId: null, merged); + } + + /// Sends queued events now (AppEventsLogger.flush). Not a delivery receipt. + public static void Flush() => CallVoid("flush", "()V"); + + /// + /// Gets the SDK's persisted anonymous app/device ID (AppEventsLogger.getAnonymousAppDeviceGUID), + /// for example to forward to a server-side integration. when the SDK + /// returns none. + /// + public static string? GetAnonymousId(Context? context = null) => + CallString("getAnonymousId", "(Landroid/content/Context;)Ljava/lang/String;", new JValue(ResolveContext(context))); + + /// Gets the user ID attached to events, or . + public static string? GetUserId() => CallString("getUserId", "()Ljava/lang/String;"); + + /// + /// Sets the persisted user ID attached to subsequent events (AppEventsLogger.setUserID). + /// clears it. + /// + public static void SetUserId(string? userId) + { + if (userId is null) + { + ClearUserId(); + return; + } + + var value = JNIEnv.NewString(userId); + try + { + CallVoid("setUserId", "(Ljava/lang/String;)V", new JValue(value)); + } + finally + { + JNIEnv.DeleteLocalRef(value); + } + } + + /// Clears the persisted user ID (AppEventsLogger.clearUserID). + public static void ClearUserId() => CallVoid("clearUserId", "()V"); + + /// + /// Sets advanced-matching user data (AppEventsLogger.setUserData). The SDK normalizes and + /// SHA-256 hashes every value (values that already look like SHA-256 hashes are kept as is) before + /// persisting or sending it. Replaces every field at once: a property + /// clears that field, as in the iOS package. + /// + public static void SetUserData(FacebookAppEventsUserData userData) + { + ArgumentNullException.ThrowIfNull(userData); + string?[] values = + [ + userData.Email, + userData.FirstName, + userData.LastName, + userData.Phone, + userData.DateOfBirth, + userData.Gender, + userData.City, + userData.State, + userData.Zip, + userData.Country, + userData.ExternalId, + ]; + var handles = new IntPtr[values.Length]; + try + { + var args = new JValue[values.Length]; + for (var i = 0; i < values.Length; i++) + { + handles[i] = values[i] is null ? IntPtr.Zero : JNIEnv.NewString(values[i]); + args[i] = new JValue(handles[i]); + } + + CallVoid("setUserData", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", args); + } + finally + { + foreach (var handle in handles) + { + if (handle != IntPtr.Zero) + { + JNIEnv.DeleteLocalRef(handle); + } + } + } + } + + /// Clears advanced-matching user data (AppEventsLogger.clearUserData). + public static void ClearUserData() => CallVoid("clearUserData", "()V"); + + private static void LogValuedEvent( + string name, + decimal? value, + string? currencyIsoCode, + string? orderId, + IReadOnlyDictionary? parameters + ) + { + var merged = Merge(parameters); + if (!string.IsNullOrWhiteSpace(currencyIsoCode)) + { + merged[FacebookAppEventParameterNames.Currency] = RequireCurrency( + currencyIsoCode, + nameof(currencyIsoCode) + ); + } + else if (value is not null) + { + throw new ArgumentException("A currency is required when a value is provided.", nameof(currencyIsoCode)); + } + + if (!string.IsNullOrWhiteSpace(orderId)) + { + merged[FacebookAppEventParameterNames.OrderId] = orderId; + } + + LogEvent(name, merged, value is { } amount ? (double)amount : null); + } + + private static Dictionary Merge(IReadOnlyDictionary? parameters) + { + var merged = new Dictionary(StringComparer.Ordinal); + if (parameters is not null) + { + foreach (var (key, value) in parameters) + { + merged[key] = value; + } + } + + return merged; + } + + private static string RequireCurrency(string? currencyIsoCode, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(currencyIsoCode, parameterName); + var code = currencyIsoCode.Trim().ToUpperInvariant(); + if (code.Length != 3 || !code.All(char.IsAsciiLetterUpper)) + { + throw new ArgumentException("Expected a three-letter ISO 4217 currency code.", parameterName); + } + + return code; + } + + private static Bundle? ToBundle(IReadOnlyDictionary? parameters) + { + if (parameters is null || parameters.Count == 0) + { + return null; + } + + var bundle = new Bundle(); + try + { + foreach (var (key, value) in parameters) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("Parameter names must not be empty.", nameof(parameters)); + } + + switch (value) + { + case string text: + bundle.PutString(key, text); + break; + case bool flag: + bundle.PutString( + key, + flag ? FacebookAppEventParameterValues.Yes : FacebookAppEventParameterValues.No + ); + break; + case byte or sbyte or short or ushort or int: + bundle.PutInt(key, Convert.ToInt32(value, CultureInfo.InvariantCulture)); + break; + case uint or long: + bundle.PutLong(key, Convert.ToInt64(value, CultureInfo.InvariantCulture)); + break; + case ulong unsignedLong: + bundle.PutString(key, unsignedLong.ToString(CultureInfo.InvariantCulture)); + break; + case float single when float.IsFinite(single): + bundle.PutFloat(key, single); + break; + case double number when double.IsFinite(number): + bundle.PutDouble(key, number); + break; + case decimal amount: + // The SDK stringifies every value; keep decimals exact. + bundle.PutString(key, amount.ToString(CultureInfo.InvariantCulture)); + break; + default: + throw new ArgumentException( + $"Unsupported value for parameter '{key}'. Use a string, bool, integral, finite floating-point or decimal value.", + nameof(parameters) + ); + } + } + + return bundle; + } + catch + { + bundle.Dispose(); + throw; + } + } + + private static Context ResolveContext(Context? context) => + context + ?? global::Android.App.Application.Context + ?? throw new InvalidOperationException("No Android application context is available."); + + private static IntPtr Method(string name, string signature) => + MethodIds.GetOrAdd( + name + signature, + static (_, state) => JNIEnv.GetStaticMethodID(InteropClass.Value, state.name, state.signature), + (name, signature) + ); + + private static void CallVoid(string name, string signature, params JValue[] args) => + JNIEnv.CallStaticVoidMethod(InteropClass.Value, Method(name, signature), args); + + private static bool CallBool(string name) => + JNIEnv.CallStaticBooleanMethod(InteropClass.Value, Method(name, "()Z")); + + private static string? CallString(string name, string signature, params JValue[] args) => + JNIEnv.GetString( + JNIEnv.CallStaticObjectMethod(InteropClass.Value, Method(name, signature), args), + JniHandleOwnership.TransferLocalRef + ); +} diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsFlushBehavior.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsFlushBehavior.cs new file mode 100644 index 0000000..fb1cad0 --- /dev/null +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsFlushBehavior.cs @@ -0,0 +1,11 @@ +namespace Kapusch.Facebook.Android; + +/// Mirrors AppEventsLogger.FlushBehavior. +public enum FacebookAppEventsFlushBehavior +{ + /// The SDK flushes periodically and when enough events are queued (default). + Auto = 0, + + /// Events are sent only when is called, purchases included. + ExplicitOnly = 1, +} diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsUserData.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsUserData.cs new file mode 100644 index 0000000..7fb5fb7 --- /dev/null +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsUserData.cs @@ -0,0 +1,50 @@ +namespace Kapusch.Facebook.Android; + +/// +/// Advanced-matching user data for . Pass raw values: +/// the Facebook SDK normalizes and SHA-256 hashes each one before persisting or sending it. +/// replaces every field at once, so a +/// property clears that field (same as the iOS package). +/// +/// +/// Advanced matching sends personal data (hashed) to Meta. Collect it only with a legal basis and +/// clear it with when that basis ends. +/// +public sealed record FacebookAppEventsUserData +{ + /// Email address. + 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 as YYYYMMDD. + public string? DateOfBirth { get; init; } + + /// Gender as m or f. + public string? Gender { get; init; } + + /// City. + public string? City { get; init; } + + /// State or region code. + public string? State { get; init; } + + /// Zip or postal code. + public string? Zip { get; init; } + + /// Two-letter ISO 3166-1 country code. + public string? Country { get; init; } + + /// + /// An ID representing the user in the consumer's own systems. Android only: the iOS package sets it + /// with SetUserDataValue(FacebookAppEventUserDataTypes.ExternalId, ...) instead. + /// + public string? ExternalId { get; init; } +} diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookDataProcessingOptions.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookDataProcessingOptions.cs new file mode 100644 index 0000000..f88e460 --- /dev/null +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookDataProcessingOptions.cs @@ -0,0 +1,8 @@ +namespace Kapusch.Facebook.Android; + +/// Values for . +public static class FacebookDataProcessingOptions +{ + /// Limited Data Use, used for the US state privacy laws Meta supports. + public const string LimitedDataUse = "LDU"; +} diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj b/src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj index d18f67f..7ba31a2 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj +++ b/src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj @@ -6,7 +6,7 @@ $(DefaultItemExcludes);Native/** Kapusch.Facebook.Android Kapusch - Facebook Android auth interop wrapper + classic AAR packaging. + Facebook Android SDK interop for Login, photo Share and App Events, with classic AAR packaging. MIT https://github.com/Kapusch/FacebookApisForAndroidComponents true @@ -15,7 +15,7 @@ $(NoWarn);NU5128 Kapusch.Facebook.Android Kapusch.Facebook.Android - 1.1.0 + 1.2.0 nuget-readme.md The upstream SDK owns event persistence, batching and retries. This class adds no queue and + * no defaults: every privacy-relevant switch is left to the upstream manifest keys unless the + * caller sets it explicitly. + */ +public final class FacebookAppEventsInterop { + private FacebookAppEventsInterop() {} + + /** + * Initializes the SDK if the FacebookInitProvider did not (for example when the consumer removed + * it from the merged manifest), applies the data processing options, then marks the SDK fully + * initialized for this process. + * + *

The options are applied between the two steps because FacebookSdk.setDataProcessingOptions + * needs the application context that sdkInitialize stores, and fullyInitialize starts the SDK's + * first Graph requests, which must already carry Limited Data Use. + * + * @param dataProcessingOptions null leaves the options unchanged; an empty array clears them + */ + @SuppressWarnings("deprecation") + public static void initializeSdk(Context context, String[] dataProcessingOptions, int country, int state) { + Context applicationContext = requireContext(context).getApplicationContext(); + if (!FacebookSdk.isInitialized()) { + FacebookSdk.sdkInitialize(applicationContext); + } + if (dataProcessingOptions != null) { + FacebookSdk.setDataProcessingOptions(dataProcessingOptions, country, state); + } + FacebookSdk.fullyInitialize(); + } + + public static boolean isSdkInitialized() { + return FacebookSdk.isInitialized(); + } + + public static boolean isSdkFullyInitialized() { + return FacebookSdk.isFullyInitialized(); + } + + public static void setAutoInitEnabled(boolean enabled) { + requireInitialized("setAutoInitEnabled"); + FacebookSdk.setAutoInitEnabled(enabled); + } + + public static boolean isAutoInitEnabled() { + return FacebookSdk.getAutoInitEnabled(); + } + + public static void setAutoLogAppEventsEnabled(boolean enabled) { + requireInitialized("setAutoLogAppEventsEnabled"); + FacebookSdk.setAutoLogAppEventsEnabled(enabled); + } + + public static boolean isAutoLogAppEventsEnabled() { + return FacebookSdk.getAutoLogAppEventsEnabled(); + } + + public static void setAdvertiserIdCollectionEnabled(boolean enabled) { + requireInitialized("setAdvertiserIdCollectionEnabled"); + FacebookSdk.setAdvertiserIDCollectionEnabled(enabled); + } + + public static boolean isAdvertiserIdCollectionEnabled() { + return FacebookSdk.getAdvertiserIDCollectionEnabled(); + } + + public static void setAutoLogMetaDataEnabled(boolean enabled) { + requireInitialized("setAutoLogMetaDataEnabled"); + FacebookSdk.setAutoLogMetaDataEnabled(enabled); + } + + public static boolean isAutoLogMetaDataEnabled() { + return FacebookSdk.getAutoLogMetaDataEnabled(); + } + + public static void setAutoLogProductImprovementEnabled(boolean enabled) { + requireInitialized("setAutoLogProductImprovementEnabled"); + FacebookSdk.setAutoLogProductImprovementEnabled(enabled); + } + + public static boolean isAutoLogProductImprovementEnabled() { + return FacebookSdk.getAutoLogProductImprovementEnabled(); + } + + public static void setDataProcessingOptions(String[] options, int country, int state) { + requireInitialized("setDataProcessingOptions"); + FacebookSdk.setDataProcessingOptions(options != null ? options : new String[0], country, state); + } + + public static void setDebugLoggingEnabled(boolean enabled) { + FacebookSdk.setIsDebugEnabled(enabled); + if (enabled) { + FacebookSdk.addLoggingBehavior(LoggingBehavior.APP_EVENTS); + } else { + FacebookSdk.removeLoggingBehavior(LoggingBehavior.APP_EVENTS); + } + } + + /** @param flushBehavior 0 = AUTO, 1 = EXPLICIT_ONLY (mirrors the managed enum). */ + public static void setFlushBehavior(int flushBehavior) { + AppEventsLogger.setFlushBehavior( + flushBehavior == 1 + ? AppEventsLogger.FlushBehavior.EXPLICIT_ONLY + : AppEventsLogger.FlushBehavior.AUTO + ); + } + + /** @return 0 = AUTO, 1 = EXPLICIT_ONLY (mirrors the managed enum). */ + public static int getFlushBehavior() { + return AppEventsLogger.getFlushBehavior() == AppEventsLogger.FlushBehavior.EXPLICIT_ONLY ? 1 : 0; + } + + public static void activateApp(Context context) { + requireInitialized("activateApp"); + Context applicationContext = requireContext(context).getApplicationContext(); + if (!(applicationContext instanceof Application)) { + throw new IllegalStateException("activateApp requires an Application context."); + } + AppEventsLogger.activateApp((Application) applicationContext); + } + + public static void logEvent(String eventName, Bundle parameters) { + getLogger().logEvent(requireName(eventName), parameters); + } + + public static void logEventWithValue(String eventName, double valueToSum, Bundle parameters) { + getLogger().logEvent(requireName(eventName), valueToSum, parameters); + } + + /** + * @param amount purchase amount as an invariant decimal string, so no precision is lost + * crossing JNI + * @param currencyCode ISO 4217 code + */ + public static void logPurchase(String amount, String currencyCode, Bundle parameters) { + if (amount == null || amount.trim().isEmpty()) { + throw new IllegalArgumentException("amount is required."); + } + if (currencyCode == null || currencyCode.trim().isEmpty()) { + throw new IllegalArgumentException("currencyCode is required."); + } + getLogger().logPurchase(new BigDecimal(amount), Currency.getInstance(currencyCode), parameters); + } + + public static void flush() { + getLogger().flush(); + } + + public static String getAnonymousId(Context context) { + return AppEventsLogger.getAnonymousAppDeviceGUID(requireContext(context).getApplicationContext()); + } + + public static void setUserId(String userId) { + requireInitialized("setUserId"); + AppEventsLogger.setUserID(userId); + } + + public static String getUserId() { + requireInitialized("getUserId"); + return AppEventsLogger.getUserID(); + } + + public static void clearUserId() { + requireInitialized("clearUserId"); + AppEventsLogger.clearUserID(); + } + + /** + * Replaces the stored user data: every value is hashed by the SDK before it is persisted or + * sent, and a null value clears its field. AppEventsLogger.setUserData alone merges, keeping the + * stored value of null fields, so clear first. Both calls run in order on the SDK's + * single-threaded App Events executor. + */ + public static void setUserData( + String email, + String firstName, + String lastName, + String phone, + String dateOfBirth, + String gender, + String city, + String state, + String zip, + String country, + String externalId + ) { + requireInitialized("setUserData"); + AppEventsLogger.clearUserData(); + AppEventsLogger.setUserData( + email, + firstName, + lastName, + phone, + dateOfBirth, + gender, + city, + state, + zip, + country, + externalId + ); + } + + public static void clearUserData() { + requireInitialized("clearUserData"); + AppEventsLogger.clearUserData(); + } + + /** + * Returns a new logger for every call. An AppEventsLogger captures the current Facebook access + * token when it is created and tags every event it logs with it, so a cached logger would keep + * attributing events to a user who has since logged out (or never pick up a later login). + * Creating one is cheap: the event queue and the flush state are static in the SDK. + */ + private static AppEventsLogger getLogger() { + requireInitialized("App Events logging"); + return AppEventsLogger.newLogger(FacebookSdk.getApplicationContext()); + } + + /** + * Before sdkInitialize, most of these SDK calls fail inside CrashShield, which swallows the + * error, or on an SDK background thread, and settings are kept in memory only, where the + * persisted value can replace them at initialization. Fail loudly instead so a consent setting + * is never silently dropped. + */ + private static void requireInitialized(String operation) { + if (!FacebookSdk.isInitialized()) { + throw new IllegalStateException( + operation + + " requires an initialized Facebook SDK. Keep FacebookInitProvider in the merged" + + " manifest or call FacebookAppEvents.InitializeSdk first." + ); + } + } + + private static Context requireContext(Context context) { + if (context == null) { + throw new IllegalArgumentException("context is required."); + } + return context; + } + + private static String requireName(String eventName) { + if (eventName == null || eventName.trim().isEmpty()) { + throw new IllegalArgumentException("eventName is required."); + } + return eventName; + } +} diff --git a/src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/Kapusch.Facebook.Android.props b/src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/Kapusch.Facebook.Android.props index 771742b..5a4d62d 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/Kapusch.Facebook.Android.props +++ b/src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/Kapusch.Facebook.Android.props @@ -3,5 +3,6 @@ Login false false + false diff --git a/src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/Kapusch.Facebook.Android.targets b/src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/Kapusch.Facebook.Android.targets index dea5123..0e6c2d7 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/Kapusch.Facebook.Android.targets +++ b/src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/Kapusch.Facebook.Android.targets @@ -5,6 +5,8 @@ <_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;')) + <_KapuschFacebookAppEventsProguardFile>$([System.IO.Path]::Combine('$(MSBuildThisFileDirectory)', 'proguard', 'kapusch-facebook-appevents.pro')) <_KapuschFacebookAndroidPackageRoot>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..')) @@ -39,6 +41,8 @@ + + @@ -60,6 +64,27 @@ + + + + + + + <_KapuschFacebookInstallReferrerReference Include="@(ReferencePath)" Condition="'%(Filename)' == 'Xamarin.Google.Android.InstallReferrer'" /> + <_KapuschFacebookInstallReferrerReference Include="@(AndroidAarLibrary);@(AndroidLibrary);@(AndroidJavaLibrary)" Condition="$([System.String]::Copy('%(Filename)').StartsWith('installreferrer'))" /> + + + diff --git a/src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/proguard/kapusch-facebook-appevents.pro b/src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/proguard/kapusch-facebook-appevents.pro new file mode 100644 index 0000000..05c03ec --- /dev/null +++ b/src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/proguard/kapusch-facebook-appevents.pro @@ -0,0 +1,9 @@ +# Injected by Kapusch.Facebook.Android only when KapuschFacebookFeatures contains AppEvents. +# +# facebook-core logs Google Play Billing purchases and subscriptions automatically (when automatic +# App Events logging and the Events Manager setting allow it) by looking billing classes and +# methods up by name through reflection. facebook-core's own consumer rules do not keep them, so +# R8 can remove members only reached that way (and rename them when an app turns obfuscation on; +# .NET for Android passes -dontobfuscate by default). The SDK then silently skips purchase +# auto-logging. These rules are inert when Play Billing is not in the app. +-keep public class com.android.billingclient.api.** { public *; } diff --git a/src/Kapusch.FacebookApisForAndroidComponents/nuget-readme.md b/src/Kapusch.FacebookApisForAndroidComponents/nuget-readme.md index 7c9eb6c..17ef0e0 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/nuget-readme.md +++ b/src/Kapusch.FacebookApisForAndroidComponents/nuget-readme.md @@ -1,12 +1,17 @@ # Kapusch.Facebook.Android -This package embeds a small Android interop Activity and the required Facebook Android SDK AARs, and injects them into consuming apps via `buildTransitive` MSBuild. +This package embeds a small Android interop AAR and the required Facebook Android SDK AARs (19.0.0), and injects them into consuming apps via `buildTransitive` MSBuild. -Set `KapuschFacebookFeatures` to `Login` (default), `Share`, or `Login;Share`. +Set `KapuschFacebookFeatures` to any combination of `Login` (default), `Share` and `AppEvents`, for example `Login;AppEvents`. + +`AppEvents` exposes `FacebookAppEvents` (SDK initialization, consent switches, `ActivateApp`, `LogEvent`, `LogPurchase`, `LogStartTrial`, `LogSubscribe`, `LogCompleteRegistration`, anonymous ID, user ID and hashed user data) plus `FacebookAppEventNames` and `FacebookAppEventParameterNames` constants. The App Events names match the `Kapusch.Facebook.iOS` package, so shared code compiles on both platforms. ## Notes - No secrets are included. - You must configure your AndroidManifest / resources as required by the Facebook Android SDK. +- The SDK collects automatically by default. To gate App Events on consent, set `com.facebook.sdk.AutoInitEnabled`, `AutoLogAppEventsEnabled`, `AdvertiserIDCollectionEnabled`, `AutoLogMetaDataEnabled` and `AutoLogProductImprovementEnabled` to `false` in the manifest and enable them at runtime. See the repository's `Docs/Integration.md`. +- With `AppEvents`, add `Xamarin.Google.Android.InstallReferrer` so Meta can read the Play install referrer (warning `KFBA001` otherwise). +- Do not log trial, subscription or purchase events from the app when a server already sends them to Meta, and turn automatic purchase logging off in that case. - Photo sharing reports native and web ShareDialog availability through a stable `share_dialog_unavailable_native_<0|1>_web_<0|1>` error code when the SDK cannot open a supported composer. diff --git a/tests/FeatureSelection.proj b/tests/FeatureSelection.proj index a0ffea8..707a094 100644 --- a/tests/FeatureSelection.proj +++ b/tests/FeatureSelection.proj @@ -17,5 +17,13 @@ Condition="'$(_KapuschFacebookShareEnabled)' != '$(ExpectedShareEnabled)'" Text="Expected Share=$(ExpectedShareEnabled), got $(_KapuschFacebookShareEnabled) for '$(KapuschFacebookFeatures)'." /> + + diff --git a/tests/InstallReferrerCheck.proj b/tests/InstallReferrerCheck.proj new file mode 100644 index 0000000..1d3b1cf --- /dev/null +++ b/tests/InstallReferrerCheck.proj @@ -0,0 +1,23 @@ + + + + net10.0-android + + + + + + $(RequestedFacebookFeatures) + + + + + + + + + + + + + diff --git a/tests/validate-feature-selection.sh b/tests/validate-feature-selection.sh index 6f3bc49..8b02e2b 100644 --- a/tests/validate-feature-selection.sh +++ b/tests/validate-feature-selection.sh @@ -4,8 +4,37 @@ set -euo pipefail project="tests/FeatureSelection.proj" -dotnet msbuild "$project" -t:ValidateFeatureSelection -p:ExpectedLoginEnabled=True -p:ExpectedShareEnabled=False +check() { + dotnet msbuild "$project" -nologo -v:q -t:ValidateFeatureSelection "$@" +} -dotnet msbuild "$project" -t:ValidateFeatureSelection -p:RequestedFacebookFeatures=Share -p:ExpectedLoginEnabled=False -p:ExpectedShareEnabled=True +# Default selection must stay Login only for existing consumers. +check -p:ExpectedLoginEnabled=True -p:ExpectedShareEnabled=False -p:ExpectedAppEventsEnabled=False -dotnet msbuild "$project" -t:ValidateFeatureSelection -p:RequestedFacebookFeatures=Login%3BShare -p:ExpectedLoginEnabled=True -p:ExpectedShareEnabled=True +check -p:RequestedFacebookFeatures=Share -p:ExpectedLoginEnabled=False -p:ExpectedShareEnabled=True -p:ExpectedAppEventsEnabled=False + +check -p:RequestedFacebookFeatures=Login%3BShare -p:ExpectedLoginEnabled=True -p:ExpectedShareEnabled=True -p:ExpectedAppEventsEnabled=False + +check -p:RequestedFacebookFeatures=AppEvents -p:ExpectedLoginEnabled=False -p:ExpectedShareEnabled=False -p:ExpectedAppEventsEnabled=True + +check -p:RequestedFacebookFeatures=Login%3BAppEvents -p:ExpectedLoginEnabled=True -p:ExpectedShareEnabled=False -p:ExpectedAppEventsEnabled=True + +check "-p:RequestedFacebookFeatures=Login%3B Share%3B AppEvents" -p:ExpectedLoginEnabled=True -p:ExpectedShareEnabled=True -p:ExpectedAppEventsEnabled=True + +echo "Feature selection validated" + +# KFBA001: AppEvents without a Play Install Referrer library warns; every other case stays silent. +referrer_check() { + dotnet msbuild tests/InstallReferrerCheck.proj -nologo -v:q -t:CheckInstallReferrer -warnaserror "$@" +} + +if referrer_check -p:RequestedFacebookFeatures=AppEvents >/dev/null 2>&1; then + echo "Expected KFBA001 for AppEvents without an install referrer library" >&2 + exit 1 +fi +referrer_check -p:RequestedFacebookFeatures=AppEvents -p:ReferenceInstallReferrer=managed +referrer_check -p:RequestedFacebookFeatures=AppEvents -p:ReferenceInstallReferrer=aar +referrer_check -p:RequestedFacebookFeatures=AppEvents -p:KapuschFacebookAndroidSuppressInstallReferrerWarning=true +referrer_check + +echo "Install referrer check validated"