From 28d1b368c786a8cef0eea8635355603fd7b8a19c Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 22:47:35 +0100 Subject: [PATCH 01/16] build(deps): bump Facebook Android SDK to 19.0.0 Pin facebook-core, -common, -bolts, -login and -share 19.0.0 from Maven Central with their SHA-256 digests (cross-checked against the published .sha1 files). 19.0.0 adds the Google Play Billing Library 8 auto-logging wrapper and the AutoLogMetaDataEnabled / AutoLogProductImprovementEnabled controls. The AAR manifests and POM dependency sets are unchanged from 18.1.3 except installreferrer 1.0 -> 2.2 in facebook-core. Declare facebook-core explicitly so the interop module compiles against the module it calls directly. Co-Authored-By: Claude Opus 5.5 --- DependencyLocks/Android/lockstate.txt | 10 +++++----- .../Native/Android/facebookinterop/build.gradle.kts | 6 ++++-- 2 files changed, 9 insertions(+), 7 deletions(-) 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/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/build.gradle.kts b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/build.gradle.kts index 67d5ae3..42cbc3e 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/build.gradle.kts +++ b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/build.gradle.kts @@ -24,6 +24,8 @@ android { } dependencies { - implementation("com.facebook.android:facebook-login:18.1.3") - implementation("com.facebook.android:facebook-share:18.1.3") + // Keep every Facebook module on the same version as DependencyLocks/Android/lockstate.txt. + implementation("com.facebook.android:facebook-core:19.0.0") + implementation("com.facebook.android:facebook-login:19.0.0") + implementation("com.facebook.android:facebook-share:19.0.0") } From 0c3bc425cace7269ceb6c871704db8ab8377a596 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 22:48:53 +0100 Subject: [PATCH 02/16] feat(appevents): add native App Events facade Add FacebookAppEventsInterop, a static Java facade over FacebookSdk and AppEventsLogger for SDK initialization, the consent-relevant settings (auto-init, auto-log, advertiser ID, metadata and product-improvement collection, data processing options), event and purchase logging, flush and flush behavior, anonymous ID, user ID and hashed user data, plus diagnostic logging. The facade lives in the existing interop AAR because facebook-core is already always injected, so App Events needs no new native artifact. It uses plain static Java methods (equivalent to a Kotlin object with @JvmStatic) to keep the module Java-only. Purchase amounts cross JNI as decimal strings to avoid double rounding. The existing consumer keep rule already covers the new class; its comment now explains the JNI reachability. Co-Authored-By: Claude Opus 5.5 --- .../facebookinterop/consumer-rules.pro | 4 +- .../FacebookAppEventsInterop.java | 242 ++++++++++++++++++ 2 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/consumer-rules.pro b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/consumer-rules.pro index 5d1f31a..8eecc14 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/consumer-rules.pro +++ b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/consumer-rules.pro @@ -1,2 +1,4 @@ -# Keep public surface (interop activity + receiver) +# Keep public surface (interop activities, receiver and App Events facade). +# FacebookAppEventsInterop is reached only from managed code through JNI by +# name, so R8 cannot see its callers and must not rename or remove it. -keep class com.kapusch.facebook.androidinterop.** { *; } diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java new file mode 100644 index 0000000..50efa2e --- /dev/null +++ b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java @@ -0,0 +1,242 @@ +package com.kapusch.facebook.androidinterop; + +import android.app.Application; +import android.content.Context; +import android.os.Bundle; + +import com.facebook.FacebookSdk; +import com.facebook.LoggingBehavior; +import com.facebook.appevents.AppEventsLogger; + +import java.math.BigDecimal; +import java.util.Currency; + +/** + * Thin static facade over the Facebook App Events API, called from managed code through JNI. + * + *

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 static final Object LOGGER_LOCK = new Object(); + private static volatile AppEventsLogger logger; + + private FacebookAppEventsInterop() {} + + /** + * Initializes the SDK if the FacebookInitProvider did not (for example when the consumer removed + * it from the merged manifest), then marks it fully initialized for this process. + */ + @SuppressWarnings("deprecation") + public static void initializeSdk(Context context) { + Context applicationContext = requireContext(context).getApplicationContext(); + if (!FacebookSdk.isInitialized()) { + FacebookSdk.sdkInitialize(applicationContext); + } + FacebookSdk.fullyInitialize(); + } + + public static boolean isSdkInitialized() { + return FacebookSdk.isInitialized(); + } + + public static boolean isSdkFullyInitialized() { + return FacebookSdk.isFullyInitialized(); + } + + public static void setAutoInitEnabled(boolean enabled) { + FacebookSdk.setAutoInitEnabled(enabled); + } + + public static boolean isAutoInitEnabled() { + return FacebookSdk.getAutoInitEnabled(); + } + + public static void setAutoLogAppEventsEnabled(boolean enabled) { + // Enabling starts activity tracking, which needs the application context the SDK only + // knows once initialized. Fail with an actionable message instead of an SDK internal one. + if (enabled) { + requireInitialized("setAutoLogAppEventsEnabled(true)"); + } + FacebookSdk.setAutoLogAppEventsEnabled(enabled); + } + + public static boolean isAutoLogAppEventsEnabled() { + return FacebookSdk.getAutoLogAppEventsEnabled(); + } + + public static void setAdvertiserIdCollectionEnabled(boolean enabled) { + FacebookSdk.setAdvertiserIDCollectionEnabled(enabled); + } + + public static boolean isAdvertiserIdCollectionEnabled() { + return FacebookSdk.getAdvertiserIDCollectionEnabled(); + } + + public static void setAutoLogMetaDataEnabled(boolean enabled) { + FacebookSdk.setAutoLogMetaDataEnabled(enabled); + } + + public static boolean isAutoLogMetaDataEnabled() { + return FacebookSdk.getAutoLogMetaDataEnabled(); + } + + public static void setAutoLogProductImprovementEnabled(boolean enabled) { + FacebookSdk.setAutoLogProductImprovementEnabled(enabled); + } + + public static boolean isAutoLogProductImprovementEnabled() { + return FacebookSdk.getAutoLogProductImprovementEnabled(); + } + + public static void setDataProcessingOptions(String[] options, int country, int state) { + 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) { + AppEventsLogger.setUserID(userId); + } + + public static String getUserId() { + return AppEventsLogger.getUserID(); + } + + public static void clearUserId() { + AppEventsLogger.clearUserID(); + } + + /** Every value is hashed by the SDK before it is persisted or sent. */ + 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 + ) { + AppEventsLogger.setUserData( + email, + firstName, + lastName, + phone, + dateOfBirth, + gender, + city, + state, + zip, + country, + externalId + ); + } + + public static void clearUserData() { + AppEventsLogger.clearUserData(); + } + + private static AppEventsLogger getLogger() { + AppEventsLogger current = logger; + if (current != null) { + return current; + } + requireInitialized("App Events logging"); + synchronized (LOGGER_LOCK) { + if (logger == null) { + logger = AppEventsLogger.newLogger(FacebookSdk.getApplicationContext()); + } + return logger; + } + } + + 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 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; + } +} From a45e271b4cdd8f932d671947225a6eb7095b0008 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 22:52:04 +0100 Subject: [PATCH 03/16] feat(appevents): add managed App Events API Add FacebookAppEvents, a static JNI bridge to FacebookAppEventsInterop following the Kapusch.PostHog.Android pattern (JNIEnv.FindClass plus cached static method IDs, local references released after each call): - SDK lifecycle: InitializeSdk, IsSdkInitialized, IsSdkFullyInitialized - consent switches: auto-init, auto-log App Events, advertiser ID collection, SDK 19 metadata and product-improvement collection, data processing options (LDU) - events: ActivateApp, LogEvent, LogPurchase and typed LogStartTrial, LogSubscribe and LogCompleteRegistration helpers, Flush and flush behavior - identity: GetAnonymousId, Set/Get/ClearUserId, Set/ClearUserData with SDK-side hashing - diagnostics: SetDebugLoggingEnabled (debug mode + APP_EVENTS logging) Parameters become an Android Bundle; decimals and purchase amounts are sent as invariant strings because the SDK stringifies every value. The standard event, parameter and value names are generated from AppEventsConstants in the bundled facebook-core AAR by scripts/app-event-constants.py, which also verifies them in CI. Co-Authored-By: Claude Opus 5.5 --- scripts/app-event-constants.py | 125 +++++ .../Facebook/FacebookAppEventConstants.cs | 249 +++++++++ .../Facebook/FacebookAppEvents.cs | 517 ++++++++++++++++++ .../Facebook/FacebookDataProcessingOptions.cs | 8 + .../Facebook/FacebookFlushBehavior.cs | 11 + .../Facebook/FacebookUserData.cs | 45 ++ 6 files changed, 955 insertions(+) create mode 100755 scripts/app-event-constants.py create mode 100644 src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventConstants.cs create mode 100644 src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs create mode 100644 src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookDataProcessingOptions.cs create mode 100644 src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookFlushBehavior.cs create mode 100644 src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookUserData.cs diff --git a/scripts/app-event-constants.py b/scripts/app-event-constants.py new file mode 100755 index 0000000..0192b79 --- /dev/null +++ b/scripts/app-event-constants.py @@ -0,0 +1,125 @@ +#!/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 .", +} + + +def pascal(name: str) -> str: + return "".join(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/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventConstants.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventConstants.cs new file mode 100644 index 0000000..300daa0 --- /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..e6ac212 --- /dev/null +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs @@ -0,0 +1,517 @@ +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. +/// +/// +/// Calls that need an initialized SDK throw when the +/// SDK was not initialized (the default FacebookInitProvider initializes it at process start). +/// 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 IsAutoLogMetaDataEnabled => 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 FacebookFlushBehavior FlushBehavior => + (FacebookFlushBehavior) + 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), then marks it fully initialized for this + /// process. Unlike , nothing is persisted. + /// + /// Any context; the application context is used. Defaults to the application context. + public static void InitializeSdk(Context? context = null) => + CallVoid("initializeSdk", "(Landroid/content/Context;)V", new JValue(ResolveContext(context))); + + /// + /// Sets the persisted auto-init flag. also fully initializes the SDK + /// (FacebookSdk.setAutoInitEnabled). + /// + 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. + /// + 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 (screen title and AppLink URL attached to + /// events, SDK 19+). also discards metadata already collected. + /// + public static void SetAutoLogMetaDataEnabled(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. + /// + 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(FacebookFlushBehavior behavior) + { + if (!Enum.IsDefined(behavior)) + { + throw new ArgumentOutOfRangeException(nameof(behavior), behavior, "Unknown flush behavior."); + } + + CallVoid("setFlushBehavior", "(I)V", new JValue((int)behavior)); + } + + /// + /// Logs app activation and the one-time install ping, then starts automatic activate/deactivate + /// tracking (AppEventsLogger.activateApp). Call it when automatic logging is disabled and + /// the consumer still wants install and launch measurement. + /// + 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 name, + IReadOnlyDictionary? parameters = null, + double? valueToSum = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (valueToSum is { } value && !double.IsFinite(value)) + { + throw new ArgumentOutOfRangeException(nameof(valueToSum), value, "valueToSum must be finite."); + } + + using var bundle = ToBundle(parameters); + var eventName = JNIEnv.NewString(name); + try + { + if (valueToSum is { } sum) + { + CallVoid( + "logEventWithValue", + "(Ljava/lang/String;DLandroid/os/Bundle;)V", + new JValue(eventName), + new JValue(sum), + new JValue(bundle?.Handle ?? IntPtr.Zero) + ); + } + else + { + CallVoid( + "logEvent", + "(Ljava/lang/String;Landroid/os/Bundle;)V", + new JValue(eventName), + new JValue(bundle?.Handle ?? IntPtr.Zero) + ); + } + } + finally + { + JNIEnv.DeleteLocalRef(eventName); + } + } + + /// + /// 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 extra parameters. Explicit arguments win over the same keys here. + public static void LogCompleteRegistration( + string? registrationMethod = null, + IReadOnlyDictionary? parameters = null + ) + { + var merged = Merge(parameters); + if (!string.IsNullOrWhiteSpace(registrationMethod)) + { + merged[FacebookAppEventParameterNames.RegistrationMethod] = registrationMethod; + } + + LogEvent(FacebookAppEventNames.CompletedRegistration, 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. + /// + public static string GetAnonymousId(Context? context = null) => + CallString("getAnonymousId", "(Landroid/content/Context;)Ljava/lang/String;", new JValue(ResolveContext(context))) + ?? string.Empty; + + /// 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 before persisting or sending it. Fields left + /// are cleared. + /// + public static void SetUserData(FacebookUserData 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/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/Facebook/FacebookFlushBehavior.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookFlushBehavior.cs new file mode 100644 index 0000000..f57a67e --- /dev/null +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookFlushBehavior.cs @@ -0,0 +1,11 @@ +namespace Kapusch.Facebook.Android; + +/// Mirrors AppEventsLogger.FlushBehavior. +public enum FacebookFlushBehavior +{ + /// 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/FacebookUserData.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookUserData.cs new file mode 100644 index 0000000..6ac6064 --- /dev/null +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookUserData.cs @@ -0,0 +1,45 @@ +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. +/// +/// +/// 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 FacebookUserData +{ + /// 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. + public string? ExternalId { get; init; } +} From ee9d71c1f4bc9dd1a24918955d047d49bfc49302 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 22:54:07 +0100 Subject: [PATCH 04/16] feat(packaging): add selectable AppEvents feature Accept AppEvents in KapuschFacebookFeatures (alone or combined with Login and Share). The default stays Login, so existing consumers get the same AARs and manifest as before. AppEvents injects no extra AAR: the facade ships in the wrapper and facebook-core is always injected. Selecting it adds: - buildTransitive/proguard/kapusch-facebook-appevents.pro, an R8 rule that keeps the public Play Billing API facebook-core looks up by name through reflection for purchase auto-logging (its own consumer rules do not), so the rule never reaches Login- or Share-only apps. .NET for Android passes -dontobfuscate by default, so the risk it covers is shrinking of members only reached by reflection; - warning KFBA001 when no Play Install Referrer library is referenced, because facebook-core reads the install referrer only when its classes are present. The package does not bundle installreferrer so apps keep a single copy shared with other SDKs; the warning can be silenced with KapuschFacebookAndroidSuppressInstallReferrerWarning. Reword the package description. The feature selection test now covers AppEvents and checks the R8 rule file. Co-Authored-By: Claude Opus 5.5 --- ...ch.FacebookApisForAndroidComponents.csproj | 2 +- .../Kapusch.Facebook.Android.props | 1 + .../Kapusch.Facebook.Android.targets | 25 +++++++++++++++++++ .../proguard/kapusch-facebook-appevents.pro | 9 +++++++ tests/FeatureSelection.proj | 8 ++++++ tests/validate-feature-selection.sh | 19 +++++++++++--- 6 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 src/Kapusch.FacebookApisForAndroidComponents/buildTransitive/proguard/kapusch-facebook-appevents.pro diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj b/src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj index d18f67f..2f10c26 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 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/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/validate-feature-selection.sh b/tests/validate-feature-selection.sh index 6f3bc49..874dd7a 100644 --- a/tests/validate-feature-selection.sh +++ b/tests/validate-feature-selection.sh @@ -4,8 +4,21 @@ 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" From 5a2d338522b6d2c2659541d59ad97d15eac73734 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:08:04 +0100 Subject: [PATCH 05/16] feat(sample): exercise App Events with consent gating and R8 Select Login;Share;AppEvents in the sample, import the package .props so source mode reproduces NuGet's import order (without it the default Login feature was never applied and facebook-login was not injected), and reference Xamarin.Google.Android.InstallReferrer. The manifest template now disables every automatic collection switch; the activity shows the SDK flags and anonymous ID and has buttons to grant or withdraw consent, log StartTrial, Subscribe and a custom event, and flush. Release builds shrink with R8 and write a mapping file so CI can prove the JNI-only facade survives. Co-Authored-By: Claude Opus 5.5 --- .../Kapusch.Facebook.Android.Sample.csproj | 14 +++ .../Properties/AndroidManifest.template.xml | 6 + .../Kapusch.Facebook.Android.Sample/README.md | 28 ++++- .../src/MainActivity.cs | 112 ++++++++++++++++-- 4 files changed, 150 insertions(+), 10 deletions(-) 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..5fe2382 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,13 @@ com.kapusch.facebookandroid.sample Kapusch Facebook Android Sample true + Login;Share;AppEvents + + + + r8 + apk + true <_SampleManifest>$(MSBuildThisFileDirectory)Properties/AndroidManifest.xml @@ -34,6 +46,8 @@ + + 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; "Withdraw ads consent" +reverts them. Debug builds enable `SetDebugLoggingEnabled(true)`, so logged and +flushed events appear in `adb logcat`. Use Meta Events Manager > Test events to +confirm delivery. diff --git a/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs b/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs index 41b6189..cbfd0c4 100644 --- a/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs +++ b/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs @@ -10,27 +10,121 @@ 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.SetAutoInitEnabled(true); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(true); + // Keep automatic logging off (no purchase auto-logging) and log activation explicitly; this + // still sends the one-time install ping. + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.ActivateApp(); + } + + private static void WithdrawConsent() + { + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.SetAdvertiserIdCollectionEnabled(false); + FacebookAppEvents.SetAutoInitEnabled(false); + FacebookAppEvents.ClearUserId(); + FacebookAppEvents.ClearUserData(); + } + + 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.IsAutoLogMetaDataEnabled}, 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) From 8109429c8827efc72c9150f5b61a39611557a4c3 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:08:14 +0100 Subject: [PATCH 06/16] ci: validate package, JNI contract, R8 output and public tree Add deterministic validators and run them in CI (and the release checks in publish before any push): - scripts/validate-package.py: required entries, android/deps equal to the lockstate set with matching SHA-256 and one SDK version, no bundled AndroidX/Kotlin/Google artifacts, wrapper classes and keep rule present, default feature still Login, AppEvents wired; - scripts/verify-interop.py jni: every JNI call in FacebookAppEvents.cs exists with the same descriptor in the built wrapper AAR; - scripts/verify-interop.py apk: the R8-shrunk sample APK still defines the facade, every method the managed bridge calls and the SDK entry points (parsed from dex, no Android build-tools needed); - scripts/verify-public.py: real Meta app ID, client token and email detection, plus private terms read only from an untracked source (the KAPUSCH_PRIVATE_TERMS secret in CI, or .git/info/private-terms locally) so the scanner never publishes them. Failures name the file and the term index, never the term, because CI logs are public; - scripts/app-event-constants.py now runs in CI as well; - tests/InstallReferrerCheck.proj covers KFBA001. The PR workflow builds the sample in Release with R8 (arm64 only to bound CI time). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 24 +++++ .github/workflows/publish.yml | 14 +++ .github/workflows/sample-android.yml | 7 ++ .gitignore | 3 + scripts/validate-package.py | 109 +++++++++++++++++++ scripts/verify-interop.py | 154 +++++++++++++++++++++++++++ scripts/verify-public.py | 85 +++++++++++++++ tests/InstallReferrerCheck.proj | 23 ++++ tests/validate-feature-selection.sh | 16 +++ 9 files changed, 435 insertions(+) create mode 100755 scripts/validate-package.py create mode 100755 scripts/verify-interop.py create mode 100755 scripts/verify-public.py create mode 100644 tests/InstallReferrerCheck.proj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2ea1b9..92d495e 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:RuntimeIdentifiers=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/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/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 874dd7a..8b02e2b 100644 --- a/tests/validate-feature-selection.sh +++ b/tests/validate-feature-selection.sh @@ -22,3 +22,19 @@ check -p:RequestedFacebookFeatures=Login%3BAppEvents -p:ExpectedLoginEnabled=Tru 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" From 78a364006b1f70c5f8eb901446afb946d5128b6b Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:10:14 +0100 Subject: [PATCH 07/16] docs(appevents): document App Events integration and consent gating Integration.md now covers feature selection, the dependencies the app provides (Play Install Referrer via the managed binding, optional advertising ID), the five SDK manifest switches including the SDK 19 AutoLogMetaDataEnabled and AutoLogProductImprovementEnabled keys, a consent-gating recipe (off in the manifest, applied on every launch because runtime values persist, withdrawal path, optional removal of FacebookInitProvider), event logging, the double-counting warning for server-side purchase events, identity, diagnostics, R8, Android gaps, and upgrade notes for the 19.0.0 behaviour changes. SourceMode.md now imports the package .props like NuGet does. README, the NuGet readme, AGENTS.md, CONTRIBUTING.md, Release.md, the PR template and THIRD_PARTY_NOTICES describe the new feature, the dependency ingestion paths and the validators. Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 1 + AGENTS.md | 9 +- CONTRIBUTING.md | 6 + Docs/Integration.md | 212 +++++++++++++++++- Docs/Release.md | 2 +- Docs/SourceMode.md | 9 +- README.md | 30 ++- THIRD_PARTY_NOTICES.md | 4 + .../nuget-readme.md | 9 +- 9 files changed, 268 insertions(+), 14 deletions(-) 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/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/Docs/Integration.md b/Docs/Integration.md index 39c6ba6..e1758c9 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -1,8 +1,24 @@ # 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 + +``` ## Required AndroidManifest entries @@ -13,7 +29,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 +41,7 @@ Then parse the `Intent` extras: - `AndroidFacebookInterop.ExtraErrorCode` - `AndroidFacebookInterop.ExtraErrorMessage` -## Sign-out +### Sign-out Call `AndroidFacebookInterop.SendSignOutBroadcast(context)`. @@ -43,3 +59,191 @@ 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` | `SetAutoLogMetaDataEnabled`, `IsAutoLogMetaDataEnabled` | 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. + +### 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: + + ```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() + // still sends the one-time install ping and starts activate/deactivate tracking. + FacebookAppEvents.SetAutoLogAppEventsEnabled(false); + FacebookAppEvents.ActivateApp(); + } + ``` + +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 logging events manually as well. 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`. +For the strictest setup, remove it and initialize after consent: + +```xml + + +``` + +```csharp +FacebookAppEvents.InitializeSdk(); // before any App Events, Login or Share call +``` + +Without the provider, every SDK feature (including Login and Share) needs +`InitializeSdk()` first. Calls that need an initialized SDK throw +`Java.Lang.IllegalStateException` otherwise. + +### 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"); +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 + `FacebookFlushBehavior.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, for + example to forward to a server-side integration. +- `SetUserId` / `ClearUserId` attach or remove a user ID on subsequent events. +- `SetUserData(new FacebookUserData { Email = ..., ExternalId = ... })` enables + advanced matching; the SDK normalizes and SHA-256 hashes every value before + persisting or sending it. 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. + +### 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/src/Kapusch.FacebookApisForAndroidComponents/nuget-readme.md b/src/Kapusch.FacebookApisForAndroidComponents/nuget-readme.md index 7c9eb6c..5df06de 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. ## 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. From 6d30582e3c36debb44d348eea7d53783e4b8c4e2 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:13:11 +0100 Subject: [PATCH 08/16] ci: narrow the R8 sample build to arm64 through a sample property Passing RuntimeIdentifiers globally also reached the referenced library project and broke its build. The sample now maps SampleRuntimeIdentifiers to its own RuntimeIdentifiers, which CI sets to android-arm64 to keep the R8 job short. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- .../Kapusch.Facebook.Android.Sample.csproj | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92d495e..fe9b0ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: run: | dotnet build samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj \ -c Release \ - -p:RuntimeIdentifiers=android-arm64 + -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 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 5fe2382..cf23e6c 100644 --- a/samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj +++ b/samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj @@ -21,6 +21,10 @@ apk true + + + $(SampleRuntimeIdentifiers) + <_SampleManifest>$(MSBuildThisFileDirectory)Properties/AndroidManifest.xml <_SampleManifestTemplate>$(MSBuildThisFileDirectory)Properties/AndroidManifest.template.xml From 175613e19be4d5621142cf4c35a14894c069d4dc Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:27:23 +0100 Subject: [PATCH 09/16] fix(sample): add LocalBroadcastManager so the sample runs A runtime smoke test of the R8-shrunk sample on an API 36 emulator crashed at startup with NoClassDefFoundError for androidx.localbroadcastmanager.content.LocalBroadcastManager: facebook-core needs it (via legacy-support-core-utils) and a bare AppCompat app does not bring it. This predates App Events; the sample was only ever built, never launched. Integration.md now lists the AndroidX/Kotlin runtime dependencies the app provides, and records what the same smoke test showed with AutoInitEnabled=false: the SDK still prepares its startup Graph requests and rejects them before processing a response. Co-Authored-By: Claude Opus 5.5 --- Docs/Integration.md | 20 +++++++++++++++++-- .../Kapusch.Facebook.Android.Sample.csproj | 2 ++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Docs/Integration.md b/Docs/Integration.md index e1758c9..df611ed 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -20,6 +20,19 @@ same for every selection. ``` +## 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 You must include the required Meta/Facebook Android SDK entries in your app manifest (example only; use resources, do not hardcode secrets): @@ -151,8 +164,11 @@ an empty array opts out explicitly. 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`. -For the strictest setup, remove it and initialize after consent: +`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 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 cf23e6c..00f11e0 100644 --- a/samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj +++ b/samples/Kapusch.Facebook.Android.Sample/Kapusch.Facebook.Android.Sample.csproj @@ -50,6 +50,8 @@ + + From 2897d14214861f60a65d2279fbe3cb32489f88cf Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:27:59 +0100 Subject: [PATCH 10/16] docs(appevents): state that SetUserData merges null fields UserDataStore only stores the non-null fields it receives, so null properties keep their previously stored hash rather than clearing it. Document that, and that values already shaped like SHA-256 hashes are kept as is; ClearUserData remains the way to remove everything. Co-Authored-By: Claude Opus 5.5 --- Docs/Integration.md | 4 +++- .../Facebook/FacebookAppEvents.cs | 5 +++-- .../Facebook/FacebookUserData.cs | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Docs/Integration.md b/Docs/Integration.md index df611ed..7afb502 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -225,7 +225,9 @@ the SDK. When a server already sends trial, subscription or purchase events: - `SetUserId` / `ClearUserId` attach or remove a user ID on subsequent events. - `SetUserData(new FacebookUserData { Email = ..., ExternalId = ... })` enables advanced matching; the SDK normalizes and SHA-256 hashes every value before - persisting or sending it. Collect such data only with a legal basis. + persisting or sending it. `null` properties keep their stored value, so use + `ClearUserData()` to remove everything. Collect such data only with a legal + basis. ### Diagnostics diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs index e6ac212..fa46f25 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs @@ -325,8 +325,9 @@ public static void SetUserId(string? userId) /// /// Sets advanced-matching user data (AppEventsLogger.setUserData). The SDK normalizes and - /// SHA-256 hashes every value before persisting or sending it. Fields left - /// are cleared. + /// SHA-256 hashes every value (values that already look like SHA-256 hashes are kept as is) before + /// persisting or sending it. Fields left keep their previously stored value; + /// call to remove everything. /// public static void SetUserData(FacebookUserData userData) { diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookUserData.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookUserData.cs index 6ac6064..6512713 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookUserData.cs +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookUserData.cs @@ -3,6 +3,7 @@ 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. +/// properties leave the stored value unchanged. /// /// /// Advanced matching sends personal data (hashed) to Meta. Collect it only with a legal basis and From 8f2ac4a8bd1a18b44c027671e1c4aa51b89487d2 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:28:22 +0100 Subject: [PATCH 11/16] docs(appevents): order ActivateApp after full initialization publishInstallAsync is not gated by automatic logging, but the SDK rejects Graph responses until it is fully initialized, so an install ping sent earlier is not recorded as delivered and only retried on a later activation. Say so on ActivateApp and in the consent recipe. Co-Authored-By: Claude Opus 5.5 --- Docs/Integration.md | 1 + .../Facebook/FacebookAppEvents.cs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Docs/Integration.md b/Docs/Integration.md index 7afb502..5fe89d5 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -145,6 +145,7 @@ an empty array opts out explicitly. // FacebookAppEvents.SetAutoLogAppEventsEnabled(true); // ... or keep automatic logging off and log activation yourself. ActivateApp() // still sends the one-time install ping and starts activate/deactivate tracking. + // Call it after auto-init is enabled, or the SDK discards the ping response. FacebookAppEvents.SetAutoLogAppEventsEnabled(false); FacebookAppEvents.ActivateApp(); } diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs index fa46f25..8fc760d 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs @@ -148,7 +148,9 @@ public static void SetFlushBehavior(FacebookFlushBehavior behavior) /// /// Logs app activation and the one-time install ping, then starts automatic activate/deactivate /// tracking (AppEventsLogger.activateApp). Call it when automatic logging is disabled and - /// the consumer still wants install and launch measurement. + /// 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. /// public static void ActivateApp(Context? context = null) => CallVoid("activateApp", "(Landroid/content/Context;)V", new JValue(ResolveContext(context))); From eb4a0ad0520887b07596ccd43dbbd109fb205a2d Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:48:20 +0100 Subject: [PATCH 12/16] fix(appevents): create a logger per call so events follow the current login AppEventsLogger captures AccessToken.getCurrentAccessToken() when it is constructed and sends every event it logs under that token. The facade cached one logger for the whole process, so with Facebook Login in the same package every event logged after a sign-out kept carrying the signed-out user's token, and a first event logged before sign-in meant events never carried the signed-in user's token. Build the logger per call instead. Construction is cheap: the event queue and flush state are static in the SDK. Document a manual log-in/log-out check in the sample README. Co-Authored-By: Claude Opus 5.5 --- .../Kapusch.Facebook.Android.Sample/README.md | 16 +++++++++++++++ .../FacebookAppEventsInterop.java | 20 +++++++------------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/samples/Kapusch.Facebook.Android.Sample/README.md b/samples/Kapusch.Facebook.Android.Sample/README.md index 8308d14..c756c69 100644 --- a/samples/Kapusch.Facebook.Android.Sample/README.md +++ b/samples/Kapusch.Facebook.Android.Sample/README.md @@ -49,3 +49,19 @@ calls `ActivateApp()` with automatic logging still off; "Withdraw ads consent" reverts them. 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/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java index 50efa2e..5480cbd 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java +++ b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java @@ -19,9 +19,6 @@ * caller sets it explicitly. */ public final class FacebookAppEventsInterop { - private static final Object LOGGER_LOCK = new Object(); - private static volatile AppEventsLogger logger; - private FacebookAppEventsInterop() {} /** @@ -202,18 +199,15 @@ public static void 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() { - AppEventsLogger current = logger; - if (current != null) { - return current; - } requireInitialized("App Events logging"); - synchronized (LOGGER_LOCK) { - if (logger == null) { - logger = AppEventsLogger.newLogger(FacebookSdk.getApplicationContext()); - } - return logger; - } + return AppEventsLogger.newLogger(FacebookSdk.getApplicationContext()); } private static void requireInitialized(String operation) { From 961ed1817ed034b4552ad8a0a248262a2f97e81e Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:49:51 +0100 Subject: [PATCH 13/16] fix(appevents): fail before SDK init and apply LDU in InitializeSdk FacebookSdk.setDataProcessingOptions reads the application context that sdkInitialize stores. Called earlier, it throws inside CrashShield, which swallows the error while CrashShield is not enabled, so in the strictest setup (FacebookInitProvider removed, InitializeSdk after consent) SetDataProcessingOptions(LDU) followed by InitializeSdk() silently dropped Limited Data Use. The other UserSettingsManager-backed setters only kept their value in memory, and the user ID and user data calls failed on an SDK background thread. - Every setter, identity call and event call now throws IllegalStateException until the SDK is initialized, so a consent setting is never silently lost. - InitializeSdk accepts data processing options and applies them between sdkInitialize and fullyInitialize, the only point where they reach the SDK's first Graph requests. The existing call shape InitializeSdk() is unchanged. - Integration.md documents the order for the provider-removed setup. Co-Authored-By: Claude Opus 5.5 --- Docs/Integration.md | 16 ++++-- .../Facebook/FacebookAppEvents.cs | 55 ++++++++++++++++--- .../FacebookAppEventsInterop.java | 38 ++++++++++--- 3 files changed, 90 insertions(+), 19 deletions(-) diff --git a/Docs/Integration.md b/Docs/Integration.md index 5fe89d5..6efde67 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -119,7 +119,8 @@ value, and a persisted value wins over the manifest on later launches. `SetDataProcessingOptions(new[] { FacebookDataProcessingOptions.LimitedDataUse })` enables Limited Data Use (country and state `0` let Meta geolocate the user); -an empty array opts out explicitly. +an empty array opts out explicitly. Like every setter, it needs an initialized +SDK (see the strictest setup below). ### Consent-gating recipe @@ -177,12 +178,19 @@ initialize after consent: ``` ```csharp -FacebookAppEvents.InitializeSdk(); // before any App Events, Login or Share call +// 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. Calls that need an initialized SDK throw -`Java.Lang.IllegalStateException` otherwise. +`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 diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs index 8fc760d..8f91621 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs @@ -18,10 +18,12 @@ namespace Kapusch.Facebook.Android; /// meta-data on later launches. Apply the consumer's consent state explicitly on every launch. /// /// -/// Calls that need an initialized SDK throw when the -/// SDK was not initialized (the default FacebookInitProvider initializes it at process start). -/// Invalid arguments rejected by the Java layer surface as -/// subclasses. +/// 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 @@ -67,12 +69,49 @@ public static class FacebookAppEvents /// /// Initializes the SDK when the FacebookInitProvider did not (for example when the consumer - /// removed the provider to defer the SDK until consent), then marks it fully initialized for this + /// 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. - public static void InitializeSdk(Context? context = null) => - CallVoid("initializeSdk", "(Landroid/content/Context;)V", new JValue(ResolveContext(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 @@ -106,6 +145,8 @@ public static void SetAutoLogProductImprovementEnabled(bool 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) { diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java index 5480cbd..731061e 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java +++ b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java @@ -23,14 +23,24 @@ private FacebookAppEventsInterop() {} /** * Initializes the SDK if the FacebookInitProvider did not (for example when the consumer removed - * it from the merged manifest), then marks it fully initialized for this process. + * 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) { + 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(); } @@ -43,6 +53,7 @@ public static boolean isSdkFullyInitialized() { } public static void setAutoInitEnabled(boolean enabled) { + requireInitialized("setAutoInitEnabled"); FacebookSdk.setAutoInitEnabled(enabled); } @@ -51,11 +62,7 @@ public static boolean isAutoInitEnabled() { } public static void setAutoLogAppEventsEnabled(boolean enabled) { - // Enabling starts activity tracking, which needs the application context the SDK only - // knows once initialized. Fail with an actionable message instead of an SDK internal one. - if (enabled) { - requireInitialized("setAutoLogAppEventsEnabled(true)"); - } + requireInitialized("setAutoLogAppEventsEnabled"); FacebookSdk.setAutoLogAppEventsEnabled(enabled); } @@ -64,6 +71,7 @@ public static boolean isAutoLogAppEventsEnabled() { } public static void setAdvertiserIdCollectionEnabled(boolean enabled) { + requireInitialized("setAdvertiserIdCollectionEnabled"); FacebookSdk.setAdvertiserIDCollectionEnabled(enabled); } @@ -72,6 +80,7 @@ public static boolean isAdvertiserIdCollectionEnabled() { } public static void setAutoLogMetaDataEnabled(boolean enabled) { + requireInitialized("setAutoLogMetaDataEnabled"); FacebookSdk.setAutoLogMetaDataEnabled(enabled); } @@ -80,6 +89,7 @@ public static boolean isAutoLogMetaDataEnabled() { } public static void setAutoLogProductImprovementEnabled(boolean enabled) { + requireInitialized("setAutoLogProductImprovementEnabled"); FacebookSdk.setAutoLogProductImprovementEnabled(enabled); } @@ -88,6 +98,7 @@ public static boolean isAutoLogProductImprovementEnabled() { } public static void setDataProcessingOptions(String[] options, int country, int state) { + requireInitialized("setDataProcessingOptions"); FacebookSdk.setDataProcessingOptions(options != null ? options : new String[0], country, state); } @@ -155,14 +166,17 @@ public static String getAnonymousId(Context context) { } 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(); } @@ -180,6 +194,7 @@ public static void setUserData( String country, String externalId ) { + requireInitialized("setUserData"); AppEventsLogger.setUserData( email, firstName, @@ -196,6 +211,7 @@ public static void setUserData( } public static void clearUserData() { + requireInitialized("clearUserData"); AppEventsLogger.clearUserData(); } @@ -210,12 +226,18 @@ private static AppEventsLogger getLogger() { 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 initializeSdk first." + + " manifest or call FacebookAppEvents.InitializeSdk first." ); } } From ad17e1200f9181ed15d02ba50c7690736a84b8c6 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:53:01 +0100 Subject: [PATCH 14/16] refactor(appevents): align the managed API with the iOS package Shared code that targets both Kapusch.Facebook.Android and Kapusch.Facebook.iOS did not compile on both. Adopt the iOS names, which already followed the event-name and registration-value shape Meta documents: - LogEvent takes eventName (named arguments now work on both); - LogCompleteRegistration takes value and currencyIsoCode (Meta's CompleteRegistration accepts valueToSum and fb_currency), between registrationMethod and parameters as on iOS; - FacebookFlushBehavior and FacebookUserData become FacebookAppEventsFlushBehavior and FacebookAppEventsUserData; - SetAutoLogMetaDataEnabled/IsAutoLogMetaDataEnabled become SetMetadataCollectionEnabled/IsMetadataCollectionEnabled (the manifest key is unchanged); - GetAnonymousId returns string? instead of an empty-string fallback; - SetUserData replaces every field, so a null property clears it as on iOS. AppEventsLogger.setUserData alone merges, so the facade clears first; both calls run in order on the SDK's single-threaded executor; - the constants generator spells AppLink, IPhone and IPad like the iOS constants (ProductAppLinkIPhoneUrl, ...). Nothing has been released with the old names. Integration.md lists the shared surface and the Android-only members (Context arguments, SetAutoInitEnabled, IsSdk*Initialized, FacebookAppEventsUserData.ExternalId). Co-Authored-By: Claude Opus 5.5 --- Docs/Integration.md | 46 +++++++++++++---- .../src/MainActivity.cs | 2 +- scripts/app-event-constants.py | 7 ++- .../Facebook/FacebookAppEventConstants.cs | 30 +++++------ .../Facebook/FacebookAppEvents.cs | 51 ++++++++++--------- ...r.cs => FacebookAppEventsFlushBehavior.cs} | 2 +- ...erData.cs => FacebookAppEventsUserData.cs} | 10 ++-- .../FacebookAppEventsInterop.java | 8 ++- .../nuget-readme.md | 2 +- 9 files changed, 103 insertions(+), 55 deletions(-) rename src/Kapusch.FacebookApisForAndroidComponents/Facebook/{FacebookFlushBehavior.cs => FacebookAppEventsFlushBehavior.cs} (89%) rename src/Kapusch.FacebookApisForAndroidComponents/Facebook/{FacebookUserData.cs => FacebookAppEventsUserData.cs} (76%) diff --git a/Docs/Integration.md b/Docs/Integration.md index 6efde67..66ecae2 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -114,7 +114,7 @@ value, and a persisted value wins over the manifest on later launches. | `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` | `SetAutoLogMetaDataEnabled`, `IsAutoLogMetaDataEnabled` | SDK 19.0.0+: attaches the screen title and AppLink URL to events. `false` also discards what was collected. | +| `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 })` @@ -199,6 +199,7 @@ FacebookAppEvents.LogStartTrial(value: 0m, currencyIsoCode: "EUR", orderId: subs 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" } @@ -213,7 +214,7 @@ FacebookAppEvents.LogEvent( 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 - `FacebookFlushBehavior.ExplicitOnly`. `Flush()` is not a delivery receipt. + `FacebookAppEventsFlushBehavior.ExplicitOnly`. `Flush()` is not a delivery receipt. ### Avoid double counting with server-side events @@ -229,14 +230,14 @@ the SDK. When a server already sends trial, subscription or purchase events: ### Identity -- `GetAnonymousId()` returns the SDK's persisted anonymous app/device ID, for - example to forward to a server-side integration. +- `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 FacebookUserData { Email = ..., ExternalId = ... })` enables - advanced matching; the SDK normalizes and SHA-256 hashes every value before - persisting or sending it. `null` properties keep their stored value, so use - `ClearUserData()` to remove everything. Collect such data only with a legal - basis. +- `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 @@ -253,6 +254,33 @@ only through JNI), facebook-core ships its own consumer rules, and the 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, diff --git a/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs b/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs index cbfd0c4..9128cef 100644 --- a/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs +++ b/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs @@ -109,7 +109,7 @@ private void RefreshStatus() $"SDK initialized: {FacebookAppEvents.IsSdkInitialized}, fully: {FacebookAppEvents.IsSdkFullyInitialized}\n" + $"Auto-init: {FacebookAppEvents.IsAutoInitEnabled}, auto-log: {FacebookAppEvents.IsAutoLogAppEventsEnabled}, " + $"advertiser ID: {FacebookAppEvents.IsAdvertiserIdCollectionEnabled}\n" - + $"Metadata: {FacebookAppEvents.IsAutoLogMetaDataEnabled}, product improvement: {FacebookAppEvents.IsAutoLogProductImprovementEnabled}\n" + + $"Metadata: {FacebookAppEvents.IsMetadataCollectionEnabled}, product improvement: {FacebookAppEvents.IsAutoLogProductImprovementEnabled}\n" + $"Anonymous ID: {FacebookAppEvents.GetAnonymousId()}"; } diff --git a/scripts/app-event-constants.py b/scripts/app-event-constants.py index 0192b79..671c50a 100755 --- a/scripts/app-event-constants.py +++ b/scripts/app-event-constants.py @@ -35,8 +35,13 @@ } +# 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(part[:1].upper() + part[1:].lower() for part in name.split("_") if part) + 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]]]: diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventConstants.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventConstants.cs index 300daa0..389efac 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventConstants.cs +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventConstants.cs @@ -193,49 +193,49 @@ public static class FacebookAppEventParameterNames public const string ProductCategory = "fb_product_category"; ///

AppEventsConstants.EVENT_PARAM_PRODUCT_APPLINK_IOS_URL. - public const string ProductApplinkIosUrl = "fb_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"; + 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"; + 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"; + 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"; + 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"; + 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"; + 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"; + 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"; + 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"; + 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"; + 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"; + 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"; + 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"; + 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"; + 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). diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs index 8f91621..e955939 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs @@ -55,7 +55,7 @@ public static class FacebookAppEvents public static bool IsAdvertiserIdCollectionEnabled => CallBool("isAdvertiserIdCollectionEnabled"); /// Gets the app metadata collection flag (com.facebook.sdk.AutoLogMetaDataEnabled, SDK 19+). - public static bool IsAutoLogMetaDataEnabled => CallBool("isAutoLogMetaDataEnabled"); + public static bool IsMetadataCollectionEnabled => CallBool("isAutoLogMetaDataEnabled"); /// /// Gets the product-improvement data flag (com.facebook.sdk.AutoLogProductImprovementEnabled, SDK 19+). @@ -63,8 +63,8 @@ public static class FacebookAppEvents public static bool IsAutoLogProductImprovementEnabled => CallBool("isAutoLogProductImprovementEnabled"); /// Gets the current App Events flush behavior. - public static FacebookFlushBehavior FlushBehavior => - (FacebookFlushBehavior) + public static FacebookAppEventsFlushBehavior FlushBehavior => + (FacebookAppEventsFlushBehavior) JNIEnv.CallStaticIntMethod(InteropClass.Value, Method("getFlushBehavior", "()I")); /// @@ -132,10 +132,11 @@ public static void SetAdvertiserIdCollectionEnabled(bool enabled) => CallVoid("setAdvertiserIdCollectionEnabled", "(Z)V", new JValue(enabled)); /// - /// Sets the persisted app metadata collection flag (screen title and AppLink URL attached to - /// events, SDK 19+). also discards metadata already collected. + /// 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 SetAutoLogMetaDataEnabled(bool enabled) => + public static void SetMetadataCollectionEnabled(bool enabled) => CallVoid("setAutoLogMetaDataEnabled", "(Z)V", new JValue(enabled)); /// Sets the persisted product-improvement data flag (SDK 19+). @@ -176,7 +177,7 @@ 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(FacebookFlushBehavior behavior) + public static void SetFlushBehavior(FacebookAppEventsFlushBehavior behavior) { if (!Enum.IsDefined(behavior)) { @@ -197,7 +198,7 @@ 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. + /// Event name, for example a constant. /// /// Optional parameters. Values must be , (sent as /// "1"/"0"), an integral type, , or @@ -205,19 +206,19 @@ public static void ActivateApp(Context? context = null) => /// /// Optional value Meta sums for this event (for example a price). public static void LogEvent( - string name, + string eventName, IReadOnlyDictionary? parameters = null, double? valueToSum = null ) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); + 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 eventName = JNIEnv.NewString(name); + var name = JNIEnv.NewString(eventName); try { if (valueToSum is { } sum) @@ -225,7 +226,7 @@ public static void LogEvent( CallVoid( "logEventWithValue", "(Ljava/lang/String;DLandroid/os/Bundle;)V", - new JValue(eventName), + new JValue(name), new JValue(sum), new JValue(bundle?.Handle ?? IntPtr.Zero) ); @@ -235,21 +236,21 @@ public static void LogEvent( CallVoid( "logEvent", "(Ljava/lang/String;Landroid/os/Bundle;)V", - new JValue(eventName), + new JValue(name), new JValue(bundle?.Handle ?? IntPtr.Zero) ); } } finally { - JNIEnv.DeleteLocalRef(eventName); + 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 . + /// unless the flush behavior is . /// /// Purchase amount. /// ISO 4217 currency code, for example EUR. @@ -311,9 +312,13 @@ public static void LogSubscribe( /// 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 ) { @@ -323,7 +328,7 @@ public static void LogCompleteRegistration( merged[FacebookAppEventParameterNames.RegistrationMethod] = registrationMethod; } - LogEvent(FacebookAppEventNames.CompletedRegistration, merged); + LogValuedEvent(FacebookAppEventNames.CompletedRegistration, value, currencyIsoCode, orderId: null, merged); } /// Sends queued events now (AppEventsLogger.flush). Not a delivery receipt. @@ -331,11 +336,11 @@ public static void LogCompleteRegistration( /// /// Gets the SDK's persisted anonymous app/device ID (AppEventsLogger.getAnonymousAppDeviceGUID), - /// for example to forward to a server-side integration. + /// 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))) - ?? string.Empty; + 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;"); @@ -369,10 +374,10 @@ public static void SetUserId(string? userId) /// /// 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. Fields left keep their previously stored value; - /// call to remove everything. + /// persisting or sending it. Replaces every field at once: a property + /// clears that field, as in the iOS package. /// - public static void SetUserData(FacebookUserData userData) + public static void SetUserData(FacebookAppEventsUserData userData) { ArgumentNullException.ThrowIfNull(userData); string?[] values = diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookFlushBehavior.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsFlushBehavior.cs similarity index 89% rename from src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookFlushBehavior.cs rename to src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsFlushBehavior.cs index f57a67e..fb1cad0 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookFlushBehavior.cs +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsFlushBehavior.cs @@ -1,7 +1,7 @@ namespace Kapusch.Facebook.Android; /// Mirrors AppEventsLogger.FlushBehavior. -public enum FacebookFlushBehavior +public enum FacebookAppEventsFlushBehavior { /// The SDK flushes periodically and when enough events are queued (default). Auto = 0, diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookUserData.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsUserData.cs similarity index 76% rename from src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookUserData.cs rename to src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsUserData.cs index 6512713..7fb5fb7 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookUserData.cs +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEventsUserData.cs @@ -3,13 +3,14 @@ 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. -/// properties leave the stored value unchanged. +/// 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 FacebookUserData +public sealed record FacebookAppEventsUserData { /// Email address. public string? Email { get; init; } @@ -41,6 +42,9 @@ public sealed record FacebookUserData /// Two-letter ISO 3166-1 country code. public string? Country { get; init; } - /// An ID representing the user in the consumer's own systems. + /// + /// 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/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java index 731061e..b3d91a3 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java +++ b/src/Kapusch.FacebookApisForAndroidComponents/Native/Android/facebookinterop/src/main/java/com/kapusch/facebook/androidinterop/FacebookAppEventsInterop.java @@ -180,7 +180,12 @@ public static void clearUserId() { AppEventsLogger.clearUserID(); } - /** Every value is hashed by the SDK before it is persisted or sent. */ + /** + * 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, @@ -195,6 +200,7 @@ public static void setUserData( String externalId ) { requireInitialized("setUserData"); + AppEventsLogger.clearUserData(); AppEventsLogger.setUserData( email, firstName, diff --git a/src/Kapusch.FacebookApisForAndroidComponents/nuget-readme.md b/src/Kapusch.FacebookApisForAndroidComponents/nuget-readme.md index 5df06de..17ef0e0 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/nuget-readme.md +++ b/src/Kapusch.FacebookApisForAndroidComponents/nuget-readme.md @@ -4,7 +4,7 @@ This package embeds a small Android interop AAR and the required Facebook Androi 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. +`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. From 75fb96ce50fb7c994391545cb7bae307669ae4cc Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:54:16 +0100 Subject: [PATCH 15/16] docs(appevents): state that withdrawal applies at the next process start The bundled 19.0.0 bytecode shows that turning the switches off does not stop the SDK in the running process: setAutoInitEnabled(false) never clears the fully-initialized state, so queued and new events keep flushing, and the ActivityLifecycleCallbacks registered by ActivateApp (or automatic logging) keep logging activation and deactivation on every resume without checking either flag. - The consent recipe says withdrawal fully applies on the next process start, and recommends gating the app's own Log* calls, setting ExplicitOnly without further Flush() calls, and notes that queued events stay on disk until a later fully initialized session. - ActivateApp only sends the install ping and registers activation tracking, so the activity already on screen is not counted. Document calling it on every launch before the first activity resumes, and that the session in which consent is first granted is counted from the next resume. - The sample and its README follow the same wording, and the sample's withdrawal sets ExplicitOnly. A facade-level kill switch was not added: it would be an Android-only API, and gating at the call site already makes manual events stop at once. Co-Authored-By: Claude Opus 5.5 --- Docs/Integration.md | 33 ++++++++++++++++--- .../Kapusch.Facebook.Android.Sample/README.md | 6 ++-- .../src/MainActivity.cs | 10 ++++-- .../Facebook/FacebookAppEvents.cs | 27 +++++++++++---- 4 files changed, 61 insertions(+), 15 deletions(-) diff --git a/Docs/Integration.md b/Docs/Integration.md index 66ecae2..87589ea 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -135,7 +135,8 @@ SDK (see the strictest setup below). ``` 2. On every launch, apply the stored consent explicitly, because the runtime - values persist across launches: + values persist across launches. Do it before the first activity resumes + (`Application.OnCreate`, or `MainActivity.OnCreate` before `OnResume`): ```csharp if (consentGranted) @@ -145,13 +146,19 @@ SDK (see the strictest setup below). // Either let the SDK log automatically ... // FacebookAppEvents.SetAutoLogAppEventsEnabled(true); // ... or keep automatic logging off and log activation yourself. ActivateApp() - // still sends the one-time install ping and starts activate/deactivate tracking. - // Call it after auto-init is enabled, or the SDK discards the ping response. + // 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 @@ -160,10 +167,26 @@ SDK (see the strictest setup below). 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); ``` - Stop logging events manually as well. The SDK cannot delete events it - already sent; that goes through Meta's data deletion process. + 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 diff --git a/samples/Kapusch.Facebook.Android.Sample/README.md b/samples/Kapusch.Facebook.Android.Sample/README.md index c756c69..c9dc5b2 100644 --- a/samples/Kapusch.Facebook.Android.Sample/README.md +++ b/samples/Kapusch.Facebook.Android.Sample/README.md @@ -45,8 +45,10 @@ 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; "Withdraw ads consent" -reverts them. Debug builds enable `SetDebugLoggingEnabled(true)`, so logged and +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. diff --git a/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs b/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs index 9128cef..a5ffbd0 100644 --- a/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs +++ b/samples/Kapusch.Facebook.Android.Sample/src/MainActivity.cs @@ -83,10 +83,13 @@ private void AddButton(LinearLayout layout, string text, Action action) 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; this - // still sends the one-time install ping. + // 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(); } @@ -98,6 +101,9 @@ private static void WithdrawConsent() 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() diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs index e955939..69942a7 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs +++ b/src/Kapusch.FacebookApisForAndroidComponents/Facebook/FacebookAppEvents.cs @@ -18,6 +18,13 @@ namespace Kapusch.Facebook.Android; /// 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 @@ -115,7 +122,8 @@ public static void InitializeSdk( /// /// Sets the persisted auto-init flag. also fully initializes the SDK - /// (FacebookSdk.setAutoInitEnabled). + /// (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)); @@ -123,6 +131,8 @@ public static void InitializeSdk( /// 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)); @@ -188,11 +198,16 @@ public static void SetFlushBehavior(FacebookAppEventsFlushBehavior behavior) } /// - /// Logs app activation and the one-time install ping, then starts automatic activate/deactivate - /// tracking (AppEventsLogger.activateApp). Call 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. + /// 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))); From c7a795b6ab72d42a7c75dfa9bb97ba5ba8df886b Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:54:21 +0100 Subject: [PATCH 16/16] chore(release): bump package version to 1.2.0 Co-Authored-By: Claude Opus 5.5 --- .../Kapusch.FacebookApisForAndroidComponents.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj b/src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj index 2f10c26..7ba31a2 100644 --- a/src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj +++ b/src/Kapusch.FacebookApisForAndroidComponents/Kapusch.FacebookApisForAndroidComponents.csproj @@ -15,7 +15,7 @@ $(NoWarn);NU5128 Kapusch.Facebook.Android Kapusch.Facebook.Android - 1.1.0 + 1.2.0 nuget-readme.md