diff --git a/.claude/skills/alice-pm/SKILL.md b/.claude/skills/alice-pm/SKILL.md index 331ef037..01ce8b3f 100644 --- a/.claude/skills/alice-pm/SKILL.md +++ b/.claude/skills/alice-pm/SKILL.md @@ -322,7 +322,7 @@ Automated via GitHub Actions: `.github/workflows/rc-release.yml`, `rc-smoke.yml` ## Domain-Specific Notes -- Cross-platform parity: any new public Dart API must map to matching method names/behavior in both `AppsflyerSdkPlugin.java` (Android) and `AppsflyerSdkPlugin.m` (iOS) — flag any PRD that only specifies one platform. +- Cross-platform parity: any new public Dart API must map to matching method names/behavior in both `AppsflyerSdkPlugin.kt` (Android) and `AppsflyerSdkPlugin.swift` (iOS) — flag any PRD that only specifies one platform. - This is a published pub.dev package (`appsflyer_sdk`) consumed by third-party apps — breaking changes to the public Dart API require a major version bump and migration notes in `CHANGELOG.md`. - Purchase Connector is optional/self-contained (`lib/src/purchase_connector/`, `ios/PurchaseConnector/`, Android Kotlin) — changes there should not affect core SDK consumers who don't opt in. - Release goes through the RC pipeline (see Release Process above) — any feature landing near a release cut should account for RC-SMOKE validation. diff --git a/.claude/skills/dave-flutter-engineer/SKILL.md b/.claude/skills/dave-flutter-engineer/SKILL.md index 551e8190..4a7ec414 100644 --- a/.claude/skills/dave-flutter-engineer/SKILL.md +++ b/.claude/skills/dave-flutter-engineer/SKILL.md @@ -7,7 +7,7 @@ description: Use when working on AppsFlyer Flutter Plugin code — writing, revi ## Persona -Senior engineer with deep knowledge of AppsFlyer Flutter Plugin. Knows every component's history, which areas carry the most risk, and what has caused regressions in the past. Tech stack: Dart/Flutter plugin (SDK >=2.17.0 <4.0.0, Flutter >=1.10.0) bridging native AppsFlyer SDKs via MethodChannel/EventChannel — Objective-C on iOS (`ios/Classes/`), Java/Kotlin on Android (`android/src/main/java` + `android/src/main/kotlin` for the Purchase Connector). JSON models via `json_annotation`/`json_serializable` + `build_runner`. Testing via `mockito` + `flutter_lints`.. +Senior engineer with deep knowledge of AppsFlyer Flutter Plugin. Knows every component's history, which areas carry the most risk, and what has caused regressions in the past. Tech stack: Dart/Flutter plugin (SDK >=2.17.0 <4.0.0, Flutter >=1.10.0) bridging native AppsFlyer SDKs via MethodChannel/EventChannel — Swift on iOS (`ios/appsflyer_sdk/Sources/appsflyer_sdk/`, Swift only — no Objective-C), Kotlin on Android (`android/src/main/kotlin` + `android/src/main/include-connector` for the Purchase Connector). JSON models via `json_annotation`/`json_serializable` + `build_runner`. Testing via `mockito` + `flutter_lints`.. ## PRD Gate — BLOCKING REQUIREMENT @@ -165,7 +165,7 @@ BLOCKING REQUIREMENT: Include a `Skill('dave-flutter-engineer')` tool call in th ## Domain-Specific Notes - Keep `AppsflyerSdk` as a singleton — do not change the instantiation pattern. -- New SDK method: add Dart method in `lib/src/appsflyer_sdk.dart` (invoke via `_channel.invokeMethod`), implement in `AppsflyerSdkPlugin.java` (Android) and `AppsflyerSdkPlugin.m` (iOS). Keep the method name string identical across all three files. +- New SDK method: add Dart method in `lib/src/appsflyer_sdk.dart` (invoke via `_channel.invokeMethod`), implement in `AppsflyerSdkPlugin.kt` (Android) and `AppsflyerSdkPlugin.swift` (iOS). Keep the method name string identical across all three files. - Callbacks from native → Dart flow through EventChannels defined in `lib/src/callbacks.dart`. - Deep linking (UDL) logic is isolated in `lib/src/udl/` — do not mix with core SDK channel calls. - Purchase Connector is self-contained in `lib/src/purchase_connector/` (Dart models) and `ios/PurchaseConnector/` / `android/.../kotlin/` (native) — keep it that way. diff --git a/.claude/skills/erin-flutter-analyst/SKILL.md b/.claude/skills/erin-flutter-analyst/SKILL.md index 913dff8d..e6331054 100644 --- a/.claude/skills/erin-flutter-analyst/SKILL.md +++ b/.claude/skills/erin-flutter-analyst/SKILL.md @@ -95,5 +95,5 @@ After Erin presents any analysis findings, `alice-pm` is invoked automatically. - Field maps live at the MethodChannel/EventChannel boundary — arguments passed as `Map` between Dart and native. - JSON-serializable Dart models: `lib/src/purchase_connector/` (in-app purchase validation payloads) — regenerate `.g.dart` via `build_runner` after any field change. -- Attribution/conversion data payloads flow through `lib/src/callbacks.dart` — check both the Dart model and the native (Java/ObjC) side that populates the EventChannel data. +- Attribution/conversion data payloads flow through `lib/src/callbacks.dart` — check both the Dart model and the native (Kotlin/Swift) side that populates the EventChannel data. - When mapping fields, verify parity between what Android and iOS native code send — historically a source of drift. diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index 88dd9c2a..e5209c7c 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -120,6 +120,10 @@ jobs: working-directory: example run: flutter pub get + - name: Regenerate iOS project configuration + working-directory: example + run: flutter build ios --config-only + - name: Install CocoaPods working-directory: example/ios run: pod install diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml index 92ebe6df..929d9242 100644 --- a/.github/workflows/lint-test-build.yml +++ b/.github/workflows/lint-test-build.yml @@ -9,10 +9,16 @@ # # What it does: # 1. Lints + format-checks + runs Dart/Flutter unit tests with coverage. -# 2. Builds Android example app (release App Bundle). -# 3. Builds iOS example app (no-codesign release IPA). +# 2. Runs the Android native (JVM) unit tests, then builds the Android example +# app (release App Bundle). +# 3. Builds the iOS example app (no-codesign release IPA). # 4. Caches dependencies for faster subsequent runs. # +# Note on native tests: the Android JVM tests live in the Android job rather +# than the Dart `test` job because they need that job's toolchain (Android SDK +# plus JDK for Gradle), so running them there reuses a setup the job already +# pays for. The iOS XCTest suite is not run in CI — see the Job 3 header. +# # Note on debug builds: debug-mode Android APK and iOS simulator builds used # to live here, but they duplicate exactly what ios-e2e.yml / android-e2e.yml # do before running scenarios. Release-mode builds remain here because E2E @@ -166,9 +172,10 @@ jobs: fail_ci_if_error: false # Don't fail CI if coverage upload fails # =========================================================================== - # Job 2: Build Android Example App + # Job 2: Android Native Tests + Build Example App # =========================================================================== - # Builds the example app for Android to ensure plugin integration works + # Runs the plugin's Android JVM unit tests, then builds the example app to + # ensure plugin integration works # Uses: Ubuntu runner with Java 17 # =========================================================================== @@ -226,7 +233,39 @@ jobs: echo "DEV_KEY=dummy_dev_key" > .env echo "APP_ID=dummy_app_id" >> .env - # Step 8: Build Android App Bundle (release mode, no signing) + # Step 8: Materialize the Gradle wrapper + # `example/android/gradlew` and `gradle-wrapper.jar` are gitignored (the + # standard `flutter create` .gitignore), so a fresh checkout has neither + # and `flutter pub get` does not create them — it only writes + # local.properties. The wrapper is injected by the Flutter tool the first + # time it needs to invoke Gradle, and `--config-only` stops right after + # that injection, so this costs a couple of seconds instead of a build. + - name: Prepare Gradle wrapper + working-directory: example + run: flutter build apk --config-only + + # Step 9: Run the plugin's Android JVM unit tests + # The plugin is included in the example's Gradle build as `:appsflyer_sdk` + # by the Flutter plugin loader, so this runs from example/android. Placed + # ahead of the release build: it is the faster of the two and covers the + # engine-lifecycle logic (AppsFlyerEventBus, AppsFlyerRpcBridge) that an + # appbundle build cannot exercise. + - name: Run Android native unit tests + working-directory: example/android + run: ./gradlew :appsflyer_sdk:testDebugUnitTest --console=plain + + # Step 10: Publish the Gradle test report when the tests fail + - name: Upload Android test report + if: failure() + uses: actions/upload-artifact@v5 + with: + name: android-native-test-report + path: | + example/build/appsflyer_sdk/reports/tests/testDebugUnitTest + example/build/appsflyer_sdk/test-results/testDebugUnitTest + retention-days: 7 + + # Step 11: Build Android App Bundle (release mode, no signing) # App Bundle is the preferred format for Play Store. Catches R8/proguard # regressions a debug APK won't. Debug APK builds were removed because # android-e2e.yml already builds and runs `flutter build apk --debug` @@ -235,7 +274,7 @@ jobs: working-directory: example run: flutter build appbundle --release - # Step 9: Upload App Bundle artifact (optional) + # Step 12: Upload App Bundle artifact (optional) # Useful for manual install testing and archiving release-mode output. - name: Upload App Bundle artifact if: success() @@ -248,7 +287,14 @@ jobs: # =========================================================================== # Job 3: Build iOS Example App # =========================================================================== - # Builds the example app for iOS to ensure plugin integration works + # Builds the example app to ensure plugin integration works + # + # The plugin's XCTest suite is deliberately not run here. It covers Foundation + # behavior rather than plugin code (`RunnerTests` re-implements the function + # under test instead of importing it), so a simulator boot and a second, debug + # build of the app would buy no coverage on a runner that bills at 10x. Wire + # it in once there are XCTests that exercise `AppsflyerSdkPlugin` / + # `AFRPCBridge` directly. # Uses: macOS runner (required for Xcode and iOS builds) # Note: macOS runners consume 10x minutes on private repos # =========================================================================== @@ -291,26 +337,31 @@ jobs: working-directory: example run: flutter pub get - # Step 6: Update CocoaPods repo (ensures latest pod specs) + # Step 6: Regenerate iOS build settings (Flutter.podspec deployment target, etc.) + - name: Regenerate iOS project configuration + working-directory: example + run: flutter build ios --config-only + + # Step 7: Update CocoaPods repo (ensures latest pod specs) # This can be slow, so we only update if needed - name: Update CocoaPods repo working-directory: example/ios run: pod repo update - # Step 7: Install CocoaPods dependencies + # Step 8: Install CocoaPods dependencies # This installs native iOS dependencies including AppsFlyer SDK - name: Install CocoaPods dependencies working-directory: example/ios run: pod install - # Step 8: Create dummy .env file for CI (not committed to repo) + # Step 9: Create dummy .env file for CI (not committed to repo) - name: Create dummy .env for CI build working-directory: example run: | echo "DEV_KEY=dummy_dev_key" > .env echo "APP_ID=dummy_app_id" >> .env - # Step 9: Build iOS IPA without code signing (release mode) + # Step 10: Build iOS IPA without code signing (release mode) # Validates a full release build without requiring certificates. Catches # archive bundling and signing-config issues a simulator-debug build # won't. The simulator-debug build that used to live here was dropped @@ -320,7 +371,7 @@ jobs: working-directory: example run: flutter build ipa --release --no-codesign - # Step 10: Upload build artifacts (optional) + # Step 11: Upload build artifacts (optional) - name: Upload iOS build artifact if: success() uses: actions/upload-artifact@v5 @@ -347,9 +398,9 @@ jobs: echo "===================================" echo "Lint, Test & Build summary" echo "===================================" - echo "Test Job: ${{ needs.test.result }}" - echo "Android Build: ${{ needs.build-android.result }}" - echo "iOS Build: ${{ needs.build-ios.result }}" + echo "Dart unit/lint: ${{ needs.test.result }}" + echo "Android native tests + build: ${{ needs.build-android.result }}" + echo "iOS build: ${{ needs.build-ios.result }}" echo "===================================" # 'skipped' is acceptable for any of the three jobs (skip_unit=true @@ -365,7 +416,7 @@ jobs: exit 1 fi if [[ "$android_result" != "success" && "$android_result" != "skipped" ]]; then - echo "❌ Android release build failed" + echo "❌ Android native tests or release build failed" exit 1 fi if [[ "$ios_result" != "success" && "$ios_result" != "skipped" ]]; then diff --git a/.github/workflows/production-release.yml b/.github/workflows/production-release.yml index 5c155be0..bbaf5747 100644 --- a/.github/workflows/production-release.yml +++ b/.github/workflows/production-release.yml @@ -405,9 +405,11 @@ jobs: ## 📚 Documentation - - [Installation Guide](https://github.com/${{ github.repository }}/blob/master/doc/Installation.md) - - [Basic Integration](https://github.com/${{ github.repository }}/blob/master/doc/BasicIntegration.md) - - [API Documentation](https://github.com/${{ github.repository }}/blob/master/doc/API.md) + - [Documentation index](https://github.com/${{ github.repository }}/blob/master/doc/README.md) + - [Installation](https://github.com/${{ github.repository }}/blob/master/doc/installation-guide.md) + - [Getting started](https://github.com/${{ github.repository }}/blob/master/doc/getting-started.md) + - [API reference](https://github.com/${{ github.repository }}/blob/master/doc/api-reference.md) + - [Migrating v6 → v7](https://github.com/${{ github.repository }}/blob/master/doc/migration-guide.md) - [Sample App](https://github.com/${{ github.repository }}/tree/master/example) ## 🔗 Links diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 0a8dc3b8..ea31c545 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -134,8 +134,8 @@ jobs: VERSION='${{ steps.compute-version.outputs.version }}' echo "Updating PLUGIN_VERSION constants to: $VERSION" - # Android - AppsFlyerConstants.java - ANDROID_FILE="android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java" + # Android - AppsFlyerConstants.kt + ANDROID_FILE="android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerConstants.kt" if [ -f "$ANDROID_FILE" ]; then sed -i "s/PLUGIN_VERSION = \".*\"/PLUGIN_VERSION = \"$VERSION\"/" "$ANDROID_FILE" echo "✅ Android:" && grep "PLUGIN_VERSION" "$ANDROID_FILE" @@ -148,11 +148,14 @@ jobs: echo "✅ Dart:" && grep "PLUGIN_VERSION" "$DART_FILE" fi - # iOS - AppsflyerSdkPlugin.h (#define) - IOS_FILE="ios/Classes/AppsflyerSdkPlugin.h" + # iOS - AppsflyerSdkPlugin.swift + IOS_FILE="ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift" if [ -f "$IOS_FILE" ]; then - sed -i 's/kAppsFlyerPluginVersion[[:space:]]*@"[^"]*"/kAppsFlyerPluginVersion @"'"$VERSION"'"/' "$IOS_FILE" + sed -i "s/kAppsFlyerPluginVersion = \".*\"/kAppsFlyerPluginVersion = \"$VERSION\"/" "$IOS_FILE" echo "✅ iOS:" && grep "kAppsFlyerPluginVersion" "$IOS_FILE" + else + echo "::error::iOS plugin version file not found: $IOS_FILE" >&2 + exit 1 fi - name: Commit and push version changes diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml index 68094829..c53da6b8 100644 --- a/.github/workflows/rc-release.yml +++ b/.github/workflows/rc-release.yml @@ -318,8 +318,8 @@ jobs: VERSION='${{ needs.validate-release.outputs.version }}' echo "Updating PLUGIN_VERSION constants to: $VERSION" - # Android - AppsFlyerConstants.java - ANDROID_FILE="android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java" + # Android - AppsFlyerConstants.kt + ANDROID_FILE="android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerConstants.kt" if [ -f "$ANDROID_FILE" ]; then sed -i.bak "s/PLUGIN_VERSION = \".*\"/PLUGIN_VERSION = \"$VERSION\"/" "$ANDROID_FILE" rm "$ANDROID_FILE.bak" @@ -334,12 +334,15 @@ jobs: echo "✅ Dart:" && grep "PLUGIN_VERSION" "$DART_FILE" fi - # iOS - AppsflyerSdkPlugin.h (#define) - IOS_FILE="ios/Classes/AppsflyerSdkPlugin.h" + # iOS - AppsflyerSdkPlugin.swift + IOS_FILE="ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift" if [ -f "$IOS_FILE" ]; then - sed -i.bak 's/kAppsFlyerPluginVersion[[:space:]]*@"[^"]*"/kAppsFlyerPluginVersion @"'"$VERSION"'"/' "$IOS_FILE" + sed -i.bak "s/kAppsFlyerPluginVersion = \".*\"/kAppsFlyerPluginVersion = \"$VERSION\"/" "$IOS_FILE" rm "$IOS_FILE.bak" echo "✅ iOS:" && grep "kAppsFlyerPluginVersion" "$IOS_FILE" + else + echo "::error::iOS plugin version file not found: $IOS_FILE" >&2 + exit 1 fi - name: Update README SDK and Purchase Connector versions diff --git a/.pubignore b/.pubignore index 55f04723..0af394ac 100644 --- a/.pubignore +++ b/.pubignore @@ -7,7 +7,15 @@ package.json package-lock.json node_modules/ local.properties +build/ +coverage/ +.githooks/ .cursor/ +.claude/ +CLAUDE.md +ai-delivery-workflow-templat/ +internal-docs/ +output.af-quiz-me/ # Example app files that shouldn't be in package example/.env @@ -20,7 +28,6 @@ example/android/local.properties # CI/CD files .github/ -.travis.yml # RC pipeline scaffolding (never part of the pub.dev artifact) example_rc_smoke/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 28c95424..00000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -os: - - linux -language: minimal -env: - global: - - FLUTTER_HOME=$HOME/flutter - -before_install: -- export PATH="$PATH:$FLUTTER_HOME/bin" -- git clone https://github.com/flutter/flutter.git -b stable --depth=1 $FLUTTER_HOME -- flutter config --no-analytics -- flutter doctor -v -install: -- flutter pub get -script: -- flutter test test -cache: - directories: - - "$HOME/.pub-cache" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 944fa510..e438b503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,170 @@ # Versions +## 7.0.1 + +Migration to **AppsFlyer SDK 7**. This is a major release with intentional +breaking changes. See [doc/migration-guide.md](doc/migration-guide.md) for +removed APIs, renames, lifecycle changes, and upgrade instructions. + +- Flutter plugin version **7.0.1** +- Minimum Flutter version **3.24.0** +- Minimum Dart version **3.5.0** (and earlier than Dart **4.0.0**) +- Android AppsFlyer SDK **7.0.1** +- iOS AppsFlyer SDK **7.0.1** +- Android Purchase Connector **2.2.0** +- iOS Purchase Connector **7.0.1** +- iOS minimum deployment target **13.0** +- Android minimum API level **21** +- Documentation remains organized under [`doc/`](doc/README.md), with the + complete upgrade catalog in [doc/migration-guide.md](doc/migration-guide.md). + +**BREAKING** + +- Replaced `AppsflyerSdk(options)` and `AppsFlyerOptions` with the shared + `AppsFlyerSdk.instance` singleton and explicit configuration methods. +- Replaced `initSdk(...)` with `init(devKey:, appId:)`. `appId` is required on + iOS, optional on Android, and is not sent to the Android SDK. +- Replaced `startSDK(...)` with `await start()`. Initialization no longer sends + a session. Call `registerSessionReadyListener(onReady)` and call `start()` + once for each readiness event. +- Replaced callback registration flags with explicit listener registration that + takes the callback as an argument: `registerConversionListener(onSuccess:, + onFailure:)`, `registerDeepLinkListener(onDeepLink)`, and + `registerSessionReadyListener(onReady)`. The plugin holds one callback per + event and replaces it on re-registration, matching the native SDKs; no event + stream is exposed, so a single native event cannot fan out to several + handlers in the app. +- `registerDeepLinkListener(onDeepLink)` must be called **before** `init()`. + Android decides once per install, while `init()` processes the launch intent, + whether to send the deferred deep-link resolution request, and skips it when no + listener is registered yet. The other listeners are still registered after + `init()`. +- Native and plugin failures from operations that await a result are surfaced as + `AppsFlyerException` (`int? code`, `String message`). Non-numeric platform + codes leave `code` as `null`. `MissingPluginException` is not converted. + Failures from fire-and-forget calls are not reported. +- `start`, `logEvent`, `generateInviteLink`, and + `validateAndLogInAppPurchase` expose `awaitResponse` where the native SDK + supports waiting for a completion callback. It defaults to `false` for `start` + and `logEvent`, and to `true` for the two result-producing APIs. +- Removed OAOA callbacks and registration. Use Unified Deep Linking. +- Replaced `performOnDeepLinking()` with + `performDeepLinking(url, {shouldTriggerSession})`. +- Replaced legacy V1 Android/iOS purchase validation and + `validateAndLogInAppPurchaseV2(...)` with + `validateAndLogInAppPurchase(AFPurchaseDetails, ...)`. Use + `AFAndroidPurchaseDetails` with a Play purchase token or + `AFIOSPurchaseDetails` with an App Store transaction ID. +- Replaced the cross-platform `sendPushNotificationData(Map)` shape with + platform-specific Android `sendPushNotificationData(...)` and iOS + `handlePushNotification(pushPayload)` APIs. +- Replaced `setConsentDataV2(...)` and the `AppsFlyerConsent` wrapper with the + flat `setConsentData(...)` API and its four SDK 7 consent fields. +- Renamed platform APIs to match the final SDK 7 surface, including + `enableDebug`, `setDisableSKAdNetwork`, + `setDisableAppleAdsAttribution`, `setDisableIDFVCollection`, + `setUseReceiptValidationSandbox`, and `setUseUninstallSandbox`. +- `setUserFbLoginId` now accepts an `int`; mediation values use + `AFMediationNetwork`; invite-link fields use `referrerCustomerId` and + `userParams`. +- Removed SDK 6 APIs that no longer exist in SDK 7 or are not exposed by the + plugin, including `onAppOpenAttribution`, `setUserEmails`, `EmailCryptType`, + raw IMEI and Android ID setters, V1 purchase validation, `setPushNotification`, + `enableUninstallTracking`, `waitForCustomerUserId`, and + `setCustomerIdAndLogSession`. +- Runtime configuration setters must be re-applied on every cold start before + `start()`. +- Every platform-only method now throws `AppsFlyerException` when called outside + its supported platform, instead of some logging a warning and returning a safe + default while others threw. The plugin no longer keeps its own table of which + platform implements what — calls are forwarded and the native RPC layer + answers, so the surface stays correct as the native SDKs change. Code that + relied on an off-platform call being a silent no-op must guard it with + `Platform.isAndroid` / `Platform.isIOS` or catch the exception. The exception + `code` comes from the native layer and currently differs: Android reports + `422`, iOS reports `404`. +- `getHostName()` and `getHostPrefix()` now return non-nullable `Future` + on Android; unexpected native null replies throw `AppsFlyerException` instead + of surfacing as `null`. +- RPC helpers split into `_invokeNullableRpc` and `_invokeRpc`; + bool getters such as `isSessionReady`, `isStopped`, and `isPreInstalledApp` + no longer coerce an unexpected native `null` to `false`. +- `setSharingFilterForPartners(null)` and `setSharingFilterForPartners([])` on + Android are forwarded to the native RPC layer instead of being ignored in Dart. + Clearing currently surfaces as `AppsFlyerException` from the Android RPC bridge + until the native validation fix lands. +- `setConsentData` no longer validates GDPR-required fields in Dart; incomplete + payloads are forwarded to the native RPC layer (iOS rejects them today; + Android validation is tracked separately). +- `setInstallId()` requires `AppsFlyerAllowCustomInstallId=YES` in iOS + `Info.plist` and must be called before `init()` on iOS. Android requires + `APPSFLYER_ALLOW_CUSTOM_INSTALL_ID=true` in `AndroidManifest.xml` and the call + must follow `init()`. +- Android Purchase Connector `2.2.0` requires Google Play Billing Library `8.x`. + The Flutter plugin does not add Billing Library; the app or its IAP plugin + must provide it. iOS Purchase Connector requires CocoaPods, while Core-only + apps can use Swift Package Manager. +- Remove legacy `SingleInstallBroadcastReceiver` and + `MultipleInstallBroadcastReceiver` manifest entries. The plugin already + includes Google Play Install Referrer `2.2`. +- Removed the Core CocoaPods Objective-C public headers + (`AppsflyerSdkPlugin.h`, `AppsFlyerAttribution.h`, + `AppsFlyerStreamHandler.h`, `FlutterAppDelegate+AppsFlyerStreamHandler.h`). + The iOS bridge is Swift-only; host apps that imported those headers must + rely on automatic plugin registration or import + `` instead. See + [migration guide — iOS: Objective-C public headers removed](doc/migration-guide.md#ios-objective-c-public-headers-removed). + +See the [v6 → v7 migration guide](doc/migration-guide.md) for the complete +replacement table. + +**Added** + +- Added `AppsFlyerSdk.instance`, `init(...)`, + `registerSessionReadyListener(onReady)`, `unregisterSessionReadyListener()`, + `isSessionReady()`, and `start()` for the SDK 7 lifecycle. +- Added the cross-platform `enableDebug(bool)` toggle and Android-only + `setLogLevel(AFLogLevel)`. +- Added typed event callbacks (`OnConversionDataSuccess`, + `OnConversionDataFailure`, `OnDeepLinkReceived`, `OnSessionReady`) and + `AppsFlyerException`. +- Added hashed-PII setters `setUserEmail`, `setUserPhone`, + `setUserFirstName`, and `setUserLastName`, plus integer + `setUserFbLoginId` and `clearUserPii`. +- Added `performDeepLinking`, `setDeepLinkTimeout`, + `appendParametersToDeepLinkingURL`, and iOS-only + `setFacebookDeferredAppLink`. +- Added the `AFPurchaseDetails` interface with dedicated + `AFAndroidPurchaseDetails` and `AFIOSPurchaseDetails` implementations. +- Added Android-only `logSession`, `setPreinstallAttribution`, `setAppId`, + `isStopped`, `isPreInstalledApp`, `getAttributionId`, + `unregisterDeeplinkListener`, and `unregisterConversionListener` APIs. + On Android, `unregisterDeeplinkListener` does not reliably stop subsequent + deep-link events; do not depend on it as an effective unsubscribe. +- Added iOS-only `setDisableAppleAdsAttribution`, + `setDisableIDFVCollection`, `setShouldCollectDeviceName`, and + `setUseUninstallSandbox` APIs. +- Added `logInvite`, `logLocation`, `setInstallId`, and awaitable + `generateInviteLink` support. + +**Fixed** + +- Purchase Connector: removed stray `[AppsFlyer_PC_Debug]` `print` logging that shipped in release builds; the callback handler now accepts both JSON-string and already-decoded `Map` payloads and logs (instead of throwing) on an unrecognized callback name. +- Purchase Connector (**Android**): subscription and in-app validation-result listeners (`setSubscriptionValidationResultListener` / `setInAppValidationResultListener`) never fired — the Dart callback-name constants used a `#` separator while the native side invokes the channel with `:`, so the handler's `switch` never matched. Aligned the Dart constants to `:`, so these result listeners now deliver. +- **Android**: native events emitted while no Flutter engine was attached were + lost. The native SDK keeps the listener registered by a detached engine + (`subscribeForDeepLink` and `registerConversionListener` overwrite a single + reference, and deep links have no unsubscribe API), so a deep link or + conversion-data callback arriving after the Activity was destroyed — for + example a link tapped after leaving the app with the back button — was written + into the buffer of a plugin instance Dart could no longer reach. Buffering and + replay moved to a process-scoped relay, so those events are delivered to the + next subscriber in the order they were published. +- Optional Android Purchase Connector state is cleared when the Flutter engine + detaches. +- Corrected iOS ad-mediation identifiers for custom and direct monetization. +- Updated documentation and examples for the final SDK 7 API. + ## 6.18.0 - Updated Android SDK from 6.17.6 to 6.18.0 diff --git a/CLAUDE.md b/CLAUDE.md index b2002a36..0cf23bc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,9 @@ # AppsFlyer Flutter Plugin ## Overview -Flutter plugin providing mobile attribution and analytics for iOS and Android. Bridges native AppsFlyer SDKs (iOS v6.17.9, Android v6.17.6) via Dart MethodChannel/EventChannel. Supports Flutter 2+ with null safety. +Flutter plugin providing mobile attribution and analytics for iOS and Android. +The core bridge uses native AppsFlyer SDK 7.0.1, Android RPC 7.0.1, and iOS +AppsFlyerRPC 7.0.12. It requires Flutter 3.24+ and Dart 3.5+. ## Starting a feature @@ -29,12 +31,12 @@ For everything outside of feature delivery, invoke skills directly: ## Architecture - `lib/src/appsflyer_sdk.dart` — Main SDK class (singleton, MethodChannel/EventChannel bridge) -- `lib/src/callbacks.dart` — Attribution and event callback handlers +- `lib/src/appsflyer_event.dart` — Native RPC event envelope - `lib/src/udl/deeplink.dart` — Unified Deep Linking (UDL) implementation - `lib/src/purchase_connector/` — In-app purchase validation models -- `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` — Android entry point -- `android/src/main/kotlin/` — Kotlin Purchase Connector for Android -- `ios/Classes/AppsflyerSdkPlugin.m` — iOS entry point (Objective-C) +- `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` — Android entry point +- `android/src/main/include-connector/` — Optional Kotlin Purchase Connector bridge +- `ios/appsflyer_sdk/Sources/appsflyer_sdk/` — iOS RPC entry point (Swift) - `ios/PurchaseConnector/` — iOS purchase validation module - `test/` — Dart unit tests (mockito) - `example/` — Full Flutter example app (iOS + Android) @@ -55,24 +57,67 @@ flutter pub run build_runner build # Regenerate JSON serialization code - **JSON serialization**: Uses `json_annotation` + `json_serializable`. After changing annotated model classes, run `build_runner` to regenerate `.g.dart` files. Commit the generated files. - **Testing**: `mockito` for mocking. Add tests in `test/` for new public API. - Dart null safety is required — all new code must be null-safe. -- Keep `AppsflyerSdk` as a singleton; do not change the instantiation pattern. +- Keep `AppsFlyerSdk.instance` as the production singleton. ## Key Patterns -- New SDK method: add Dart method in `appsflyer_sdk.dart` (invoke via `_channel.invokeMethod`), implement in `AppsflyerSdkPlugin.java` (Android) and `AppsflyerSdkPlugin.m` (iOS). Keep method name strings consistent across all three files. -- Callbacks from native → Dart flow through EventChannels defined in `callbacks.dart`. +- New core SDK methods must map to existing native RPC capabilities. Add the + Dart RPC call and only platform adaptation required by the RPC contracts; + keep business behavior in the native SDK/RPC modules. +- Native RPC callbacks flow through the `af-events` EventChannel as + `_AppsFlyerEvent` envelopes parsed on the Dart side. - Deep linking (UDL) logic is isolated in `lib/src/udl/` — do not mix with core SDK channel calls. - Purchase Connector is self-contained in `lib/src/purchase_connector/` (Dart models) and `ios/PurchaseConnector/` / `android/.../kotlin/` (native). ## Testing - Run `flutter test test` for the Dart unit test suite. +- Run `./gradlew :appsflyer_sdk:testDebugUnitTest` from `example/android` for the Android native tests. On a fresh clone, run `flutter build apk --config-only` from `example/` first: the Gradle wrapper is gitignored and is only written once the Flutter tool invokes Gradle. +- Run `xcodebuild test -workspace ios/Runner.xcworkspace -scheme Runner -destination "id="` from `example/` for the iOS native tests. - Integration testing requires running the `example/` app on a device/emulator. -- CI uses Travis CI (`.travis.yml`) on Linux with Flutter stable. +- CI is GitHub Actions (`.github/workflows/lint-test-build.yml`): the Dart suite on Linux, plus the Android native tests inside the Android build job. The iOS XCTest suite is not run in CI — run it locally. ## Notes - SDK version is set in `pubspec.yaml` and native dependency specs (podspec / `build.gradle`). - Generated files (`*.g.dart`) must be committed — run `build_runner` after model changes. -- `doc/` and `example/` should be kept in sync with API changes. -- iOS native layer is Objective-C; Kotlin is used only for the Android Purchase Connector. +- `doc/` and `example/` must be kept in sync with API changes — see Documentation review. +- The iOS core bridge is Swift only — no Objective-C. `AFRPCBridge.swift` reaches the + `@MainActor`-isolated `AppsFlyerRPCBridge` via `MainActor.assumeIsolated`; Swift and + Kotlin implement the optional Purchase Connector bridges. + +## Documentation review + +Applies to every code change, including pure refactors. + +After every code change, identify and review all related documentation. Update +documentation whenever the change affects documented behavior, public APIs, +parameters, configuration, architecture, workflows, examples, compatibility, or +user-visible output. If no documentation update is needed, explicitly state which +documentation was reviewed and why it remains accurate. + +Where to look, by what changed: + +| Changed | Review | +|---------|--------| +| Public Dart API surface | dartdoc on the member, `doc/api-reference.md`, `README.md`, `CHANGELOG.md` | +| Platform-specific behavior or availability | `doc/api-reference.md`, the affected `internal-docs/features/F-NNN-*.md` | +| Breaking change or removed API | `doc/migration-guide.md`, `CHANGELOG.md` — see also the API Removal Rule | +| Behavior of a catalogued feature | matching `internal-docs/features/F-NNN-*.md` plus `internal-docs/features/INDEX.md` | +| Architecture, channels, or RPC transport | `internal-docs/ARCHITECTURE.md` and the Architecture section above | +| Setup, configuration, or native dependencies | `doc/installation-guide.md`, `doc/getting-started.md` | +| Anything the sample app demonstrates | `example/` and `example/README.md` | + +Rules: + +- Correct outdated references and examples in the files you review, including stale + method names, signatures, return types, and snippets that no longer compile. +- Never hand-edit generated output. Regenerate it — `*.g.dart` via + `flutter pub run build_runner build`. +- Leave accurate documentation alone. An unnecessary documentation edit is a defect. + +Every task summary must state: + +- which documentation files were reviewed; +- which documentation files were updated; +- if none were updated, why the existing documentation remains accurate. ## Maintenance bypass diff --git a/README.md b/README.md index 093afce8..747088df 100644 --- a/README.md +++ b/README.md @@ -5,55 +5,53 @@ [![pub package](https://img.shields.io/pub/v/appsflyer_sdk.svg)](https://pub.dartlang.org/packages/appsflyer_sdk) ![Coverage](https://raw.githubusercontent.com/AppsFlyerSDK/appsflyer-flutter-plugin/master/coverage_badge.svg) +Flutter plugin for the AppsFlyer mobile attribution and analytics SDK on **Android** +and **iOS**. + 🛠 In order for us to provide optimal support, please contact AppsFlyer support through the Customer Assistant Chatbot for assistance with troubleshooting issues or product guidance.
To do so, please follow [this article](https://support.appsflyer.com/hc/en-us/articles/23583984402193-Using-the-Customer-Assistant-Chatbot) ## SDK Versions -- Android AppsFlyer SDK **v6.18.1** -- iOS AppsFlyer SDK **v6.18.1** - -### Purchase Connector versions - -- Android 2.2.0 -- iOS 6.17.9 +This plugin release bundles: -## ❗❗ Breaking changes when updating to v6.x.x❗❗ +- Android AppsFlyer SDK **v7.0.1** +- iOS AppsFlyer SDK **v7.0.1** -If you have used one of the removed/changed APIs, please check the integration guide for the updated instructions. - -- From version `6.11.2`, the `setPushNotification` will not work in iOS. [Please use our new API `sendPushNotificationData` when receiving a notification on flutter side](/doc/API.md#sendPushNotificationData). - -- From version `6.8.0`, the `enableLocationCollection` has been removed from the plugin. +### Purchase Connector versions -- From version `6.4.0`, UDL (Unified deep link) now as a dedicated class with getters for handling the deeplink result. -[Check the full UDL guide](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/blob/master/doc/Guides.md#-3-unified-deep-linking). -`setSharingFilter` & `setSharingFilterForAllPartners` APIs are deprecated. -Instead use the [new API `setSharingFilterForPartners`](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/blob/RD-69098/update6.4.0%26more/doc/API.md#setSharingFilterForPartners). +When Purchase Connector is enabled in your app: -- From version `6.3.5+2`, Remove stream from the plugin (no change is needed if you use callbacks for handling deeplink). +- Android **2.2.0** +- iOS **7.0.1** -- From version `6.2.3+2`, Flutter 2 is supported, including null safety. -`6.2.4-flutterv1` will use iOS SDK 6.2.4 with Flutter V1. +## ❗❗ Breaking changes when updating to v7.x.x ❗❗ -- From version `6.0.0`, we have renamed the following APIs: +Version `7.0.1` targets AppsFlyer SDK **7.0.1** on Android and iOS. The public +Flutter API changed in this major release. -|Before v6 | v6 | -|-------------------------------|-----------------------------| -| trackEvent | logEvent | -| stopTracking | stop | -| validateAndTrackInAppPurchase | validateAndLogInAppPurchase | +- **Minimum supported versions: Flutter `3.24.0`, Dart `3.5.0` (and earlier than + `4.0.0`), Android API 21, and iOS 13.0.** -- From version `6.1.2+4`, we have renamed the following APIs: +Use `AppsFlyerSdk.instance`, call `init(devKey:, appId:)` (`appId` is required +on iOS and optional on Android), register the listeners you need with their +callbacks, and call `start()` from the session-ready callback: -|Before v6.1.2+4 | v6.1.2+4 | -|-------------------------------|-----------------------------| -| validateAndLogInAppPurchase | validateAndLogInAppIosPurchase/validateAndLogInAppAndroidPurchase | +```dart +final appsflyerSdk = AppsFlyerSdk.instance; -### Important notice +await appsflyerSdk.init( + devKey: '', + appId: '', +); +await appsflyerSdk.registerSessionReadyListener(() async { + await appsflyerSdk.start(); +}); +``` -- Switch `ConversionData` and `OnAppOpenAttribution` to be based on callbacks instead of streams from plugin version `6.0.5+2`. +All removed APIs, renamed APIs, lifecycle changes, and upgrade instructions are +documented in [doc/migration-guide.md](doc/migration-guide.md). ## AD_ID permission for Android @@ -64,12 +62,15 @@ You can read more about it in the [Android SDK installation guide](https://dev.a ## 📖 Guides -- [Adding the SDK to your project](/doc/Installation.md) -- [Initializing the SDK](/doc/BasicIntegration.md) -- [In-app Events](/doc/InAppEvents.md) -- [Deep Linking](/doc/DeepLink.md) -- [Advanced APIs](/doc/AdvancedAPI.md) -- [Testing the integration](/doc/Testing.md) -- [Purchase Connector](/doc/PurchaseConnector.md) <- **New addition** -- [APIs](/doc/API.md) -- [Sample App](/example) +- [Documentation index](doc/README.md) +- [Migrating from v6 to v7](doc/migration-guide.md) +- [Adding the SDK to your project](doc/installation-guide.md) +- [Getting started (init & session)](doc/getting-started.md) +- [In-app events & ad revenue](doc/in-app-events.md) +- [Deep linking](doc/deep-linking.md) +- [Advanced features](doc/advanced-features.md) +- [Consent & DMA compliance](doc/consent-dma.md) +- [Testing & troubleshooting](doc/testing-and-troubleshooting.md) +- [Purchase Connector](doc/purchase-connector.md) +- [API reference](doc/api-reference.md) +- [Sample App](example/) diff --git a/analysis_options.yaml b/analysis_options.yaml index 732a61cd..2f3585ad 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,9 +1,14 @@ include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - example_rc_smoke/** + linter: rules: public_member_api_docs: false constant_identifier_names: false lines_longer_than_80_chars: false omit_local_variable_types: false - avoid_positional_boolean_parameters: false \ No newline at end of file + avoid_positional_boolean_parameters: false + use_string_in_part_of_directives: false diff --git a/android/build.gradle b/android/build.gradle index c384b4ae..33289332 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -20,8 +20,8 @@ def includeConnector = project.findProperty('appsflyer.enable_purchase_connector android { defaultConfig { - minSdkVersion 19 - compileSdk 35 + minSdkVersion 21 + compileSdk 36 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" multiDexEnabled true @@ -34,10 +34,12 @@ android { sourceSets { main { - java.srcDirs = ['src/main/java'] - java.srcDirs += includeConnector ? ['src/main/include-connector'] : ['src/main/exlude-connector'] + java.srcDirs = ['src/main/java', 'src/main/kotlin'] + java.srcDirs += includeConnector ? ['src/main/include-connector'] : ['src/main/exclude-connector'] + } + test { + java.srcDirs = ['src/test/kotlin'] } - includeConnector ? ['src/main/include-connector'] : ['src/main/exlude-connector'] } compileOptions { @@ -52,11 +54,16 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation 'androidx.appcompat:appcompat:1.0.0' - implementation 'com.appsflyer:af-android-sdk:6.18.1' + + implementation platform("com.appsflyer:af-android-sdk-bom:7.0.1") + implementation "com.appsflyer:af-android-sdk" + implementation "com.appsflyer:af-android-sdk-base" + implementation "com.appsflyer:af-android-plugin-bridge" + implementation 'com.android.installreferrer:installreferrer:2.2' -// implementation 'androidx.core:core-ktx:1.13.1' if (includeConnector) { implementation 'com.appsflyer:purchase-connector:2.2.0' } + + testImplementation 'junit:junit:4.13.2' } \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e411586a..5c82cb03 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/src/main/exlude-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt b/android/src/main/exclude-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt similarity index 100% rename from android/src/main/exlude-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt rename to android/src/main/exclude-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt diff --git a/android/src/main/include-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt b/android/src/main/include-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt index bd2ff55d..c6463fb3 100644 --- a/android/src/main/include-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt +++ b/android/src/main/include-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt @@ -1,193 +1,158 @@ package com.appsflyer.appsflyersdk -import android.content.Context import android.os.Handler import android.os.Looper import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import org.json.JSONObject -import java.lang.ref.WeakReference /** - * A Flutter plugin that establishes a bridge between the Flutter appsflyer SDK and the Native Android Purchase Connector. + * Bridges the optional Purchase Connector to Flutter over `af-purchase-connector`. * - * This plugin utilizes MethodChannels to communicate between Flutter and native Android, - * passing method calls and event callbacks. - * - * @property methodChannel used to set up the communication channel between Flutter and Android. - * @property contextRef a Weak Reference to the application context when the plugin is first attached. Used to build the Appsflyer's Purchase Connector. - * @property connectorWrapper wraps the Appsflyer's Android purchase client and bridge map conversion methods. Used to perform various operations (configure, start/stop observing transactions). - * @property arsListener an object of [MappedValidationResultListener] that handles SubscriptionPurchaseValidationResultListener responses and failures. Lazily initialized. - * @property viapListener an object of [MappedValidationResultListener] that handles InAppValidationResultListener responses and failures. Lazily initialized. + * Engine state is keyed by [FlutterPlugin.FlutterPluginBinding] so multiple Flutter engines + * (add-to-app / [FlutterEngineGroup]) do not share one channel or connector instance. */ -object AppsFlyerPurchaseConnector : FlutterPlugin, MethodChannel.MethodCallHandler { - private var methodChannel: MethodChannel? = null - private var contextRef: WeakReference? = null - private var connectorWrapper: ConnectorWrapper? = null - private val handler by lazy { Handler(Looper.getMainLooper()) } - - private val arsListener: MappedValidationResultListener by lazy { - object : MappedValidationResultListener { - override fun onFailure(result: String, error: Throwable?) { - val resMap = mapOf("result" to result, "error" to error?.toMap()) - methodChannel?.invokeMethodOnUI( - "SubscriptionPurchaseValidationResultListener:onFailure", - resMap - ) - } +object AppsFlyerPurchaseConnector : FlutterPlugin { - override fun onResponse(p0: Map?) { - methodChannel?.invokeMethodOnUI( - "SubscriptionPurchaseValidationResultListener:onResponse", - p0 - ) - } - } - } + private val uiThreadHandler = Handler(Looper.getMainLooper()) + private val attachmentsLock = Any() + private val attachments = mutableMapOf() - private val viapListener: MappedValidationResultListener by lazy { - object : MappedValidationResultListener { - override fun onFailure(result: String, error: Throwable?) { - val resMap = mapOf("result" to result, "error" to error?.toMap()) - methodChannel?.invokeMethodOnUI("InAppValidationResultListener:onFailure", resMap) - } + private class EngineAttachment( + val binding: FlutterPlugin.FlutterPluginBinding, + ) { + val methodChannel: MethodChannel = MethodChannel( + binding.binaryMessenger, + AF_PURCHASE_CONNECTOR_CHANNEL + ) + var connectorWrapper: ConnectorWrapper? = null + + val subscriptionListener: MappedValidationResultListener = createValidationListener( + "SubscriptionPurchaseValidationResultListener:onFailure", + "SubscriptionPurchaseValidationResultListener:onResponse" + ) + val inAppListener: MappedValidationResultListener = createValidationListener( + "InAppValidationResultListener:onFailure", + "InAppValidationResultListener:onResponse" + ) - override fun onResponse(p0: Map?) { - methodChannel?.invokeMethodOnUI("InAppValidationResultListener:onResponse", p0) + private fun createValidationListener( + failureMethod: String, + responseMethod: String + ): MappedValidationResultListener { + return object : MappedValidationResultListener { + override fun onFailure(result: String, error: Throwable?) { + val resMap = mapOf("result" to result, "error" to error?.toMap()) + methodChannel.invokeMethodOnUI(failureMethod, resMap) + } + + override fun onResponse(payload: Map?) { + methodChannel.invokeMethodOnUI(responseMethod, payload) + } } } - } - private fun MethodChannel?.invokeMethodOnUI(method: String, args: Any?) = this?.let { - handler.post { - val data = if (args is Map<*, *>) { - JSONObject(args).toString() - } else { - args - } - it.invokeMethod(method, data) + fun dispose() { + runCatching { connectorWrapper?.stopObservingTransactions() } + methodChannel.setMethodCallHandler(null) + connectorWrapper = null } } - - /** - * Called when the plugin is attached to the Flutter engine. - * - * It sets up the MethodChannel and retains the application context. - * - * @param binding The binding provides access to the binary messenger and application context. - */ override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - methodChannel = - MethodChannel( - binding.binaryMessenger, - AppsFlyerConstants.AF_PURCHASE_CONNECTOR_CHANNEL - ).also { - it.setMethodCallHandler(this) - } - contextRef = WeakReference(binding.applicationContext) + val attachment = EngineAttachment(binding) + attachment.methodChannel.setMethodCallHandler { call, result -> + handleMethodCall(attachment, call, result) + } + synchronized(attachmentsLock) { + attachments.remove(binding)?.dispose() + attachments[binding] = attachment + } } - /** - * Called when the plugin is detached from the Flutter engine. - * - * @param binding The binding that was provided in [onAttachedToEngine]. - */ - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) = Unit - - /** - * Handles incoming method calls from Flutter. - * - * It either triggers a connector operation or returns an unimplemented error. - * Supported operations are configuring, starting and stopping observing transactions. - * - * @param call The method call from Flutter. - * @param result The result to be returned to Flutter. - */ - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + val attachment = synchronized(attachmentsLock) { + attachments.remove(binding) + } ?: return + attachment.dispose() + } + + private fun handleMethodCall( + attachment: EngineAttachment, + call: MethodCall, + result: MethodChannel.Result + ) { when (call.method) { - "startObservingTransactions" -> startObservingTransactions(result) - "stopObservingTransactions" -> stopObservingTransactions(result) - "configure" -> configure(call, result) + "startObservingTransactions" -> startObservingTransactions(attachment, result) + "stopObservingTransactions" -> stopObservingTransactions(attachment, result) + "configure" -> configure(attachment, call, result) else -> result.notImplemented() } } - /** - * Configures the purchase connector with the parameters sent from Flutter. - * - * @param call The method call from Flutter. - * @param result The result to be returned to Flutter. - */ - private fun configure(call: MethodCall, result: MethodChannel.Result) { - if (connectorWrapper == null) { - contextRef?.get()?.let { ctx -> - val logSubs = call.getBoolean(AppsFlyerConstants.LOG_SUBS_KEY) - val logInApps = call.getBoolean(AppsFlyerConstants.LOG_IN_APP_KEY) - val sandbox = call.getBoolean(AppsFlyerConstants.SANDBOX_KEY) - - android.util.Log.d("AppsFlyer_PC_Config", "Native received - logSubs: $logSubs, logInApps: $logInApps, sandbox: $sandbox") - android.util.Log.d("AppsFlyer_PC_Config", "Arguments received: ${call.arguments}") - - connectorWrapper = ConnectorWrapper( - ctx, logSubs, logInApps, sandbox, - arsListener, viapListener - ) - result.success(null) - } ?: run { - result.error("402", "Missing context. Is plugin attached to engine?", null) - } - - } else { + private fun configure( + attachment: EngineAttachment, + call: MethodCall, + result: MethodChannel.Result + ) { + if (attachment.connectorWrapper != null) { result.error("401", "Connector already configured", null) + return } + + val context = attachment.binding.applicationContext + val logSubs = call.getBoolean(LOG_SUBS_KEY) + val logInApps = call.getBoolean(LOG_IN_APP_KEY) + val sandbox = call.getBoolean(SANDBOX_KEY) + + attachment.connectorWrapper = ConnectorWrapper( + context, + logSubs, + logInApps, + sandbox, + attachment.subscriptionListener, + attachment.inAppListener + ) + result.success(null) } - /** - * Starts observing transactions. - * - * @param result The result to be returned to Flutter. - */ - private fun startObservingTransactions(result: MethodChannel.Result) = - connectorOperation(result) { - it.startObservingTransactions() - } + private fun startObservingTransactions( + attachment: EngineAttachment, + result: MethodChannel.Result + ) = connectorOperation(attachment, result) { it.startObservingTransactions() } - /** - * Stops observing transactions. - * - * @param result The result to be returned to Flutter. - */ - private fun stopObservingTransactions(result: MethodChannel.Result) = - connectorOperation(result) { - it.stopObservingTransactions() - } + private fun stopObservingTransactions( + attachment: EngineAttachment, + result: MethodChannel.Result + ) = connectorOperation(attachment, result) { it.stopObservingTransactions() } - /** - * Performs a specified operation on the connector after confirming that the connector has been configured. - * - * @param result The result to be returned to Flutter. - * @param exc The operation to be performed on the connector. - */ private fun connectorOperation( + attachment: EngineAttachment, result: MethodChannel.Result, - exc: (connectorWrapper: ConnectorWrapper) -> Unit + operation: (ConnectorWrapper) -> Unit ) { - if (connectorWrapper != null) { - exc(connectorWrapper!!) + val connector = attachment.connectorWrapper + if (connector != null) { + operation(connector) result.success(null) } else { result.error("404", "Connector not configured, did you called `configure` first?", null) } } - /** - * Converts a [Throwable] to a Map that can be returned to Flutter. - * - * @return A map representing the [Throwable]. - */ + private fun MethodChannel.invokeMethodOnUI(method: String, args: Any?) { + uiThreadHandler.post { + val data = if (args is Map<*, *>) { + JSONObject(args).toString() + } else { + args + } + invokeMethod(method, data) + } + } + private fun Throwable.toMap(): Map { return mapOf( "type" to this::class.simpleName, @@ -197,24 +162,15 @@ object AppsFlyerPurchaseConnector : FlutterPlugin, MethodChannel.MethodCallHandl ) } - /** - * Attempts to get a Boolean argument from the method call. - * - * If unsuccessful, it returns the default value. - * - * @param key The key for the argument. - * @param defValue The default value to be returned if the argument does not exist. - * @return The value of the argument or the default value if the argument does not exist. - */ private fun MethodCall.getBoolean(key: String, defValue: Boolean = false): Boolean { return try { - val value = argument(key) - android.util.Log.d("AppsFlyer_PC_Config", "Extracted $key = $value") - value ?: defValue + argument(key) ?: defValue } catch (e: Exception) { - android.util.Log.w("AppsFlyer_PC_Config", "Failed to extract $key, using default $defValue. Error: ${e.message}") + android.util.Log.w( + AF_PLUGIN_TAG, + "Purchase Connector: failed to read '$key', using default $defValue: ${e.message}" + ) defValue } } - -} \ No newline at end of file +} diff --git a/android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java b/android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java deleted file mode 100644 index b9f96c91..00000000 --- a/android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.appsflyer.appsflyersdk; - -public final class AppsFlyerConstants { - final static String PLUGIN_VERSION = "6.18.1"; - final static String AF_APP_INVITE_ONE_LINK = "appInviteOneLink"; - final static String AF_HOST_PREFIX = "hostPrefix"; - final static String AF_HOST_NAME = "hostName"; - final static String AF_IS_DEBUG = "isDebug"; - final static String AF_MANUAL_START = "manualStart"; - final static String AF_DEV_KEY = "afDevKey"; - final static String AF_EVENT_NAME = "eventName"; - final static String AF_EVENT_VALUES = "eventValues"; - final static String AF_ON_INSTALL_CONVERSION_DATA_LOADED = "onInstallConversionDataLoaded"; - final static String AF_ON_APP_OPEN_ATTRIBUTION = "onAppOpenAttribution"; - final static String AF_SUCCESS = "success"; - final static String AF_FAILURE = "failure"; - final static String AF_GCD = "GCD"; - final static String AF_UDL = "UDL"; - final static String AF_VALIDATE_PURCHASE = "validatePurchase"; - final static String AF_GCD_CALLBACK = "onInstallConversionData"; - final static String AF_OAOA_CALLBACK = "onAppOpenAttribution"; - final static String AF_UDL_CALLBACK = "onDeepLinking"; - final static String DISABLE_ADVERTISING_IDENTIFIER = "disableAdvertisingIdentifier"; - - final static String AF_EVENTS_CHANNEL = "af-events"; - final static String AF_METHOD_CHANNEL = "af-api"; - final static String AF_CALLBACK_CHANNEL = "callbacks"; - - final static String AF_BROADCAST_ACTION_NAME = "com.appsflyer.appsflyersdk"; - - final static String AF_PLUGIN_TAG = "AppsFlyer_FlutterPlugin"; - - // Purchase Connector constants - final static String AF_PURCHASE_CONNECTOR_CHANNEL = "af-purchase-connector"; - final static String CONFIGURE_KEY = "configure"; - final static String LOG_SUBS_KEY = "logSubscriptionPurchase"; - final static String LOG_IN_APP_KEY = "logInAppPurchase"; - final static String SANDBOX_KEY = "sandbox"; - final static String VALIDATION_INFO = "validationInfo"; - final static String ERROR = "error"; - final static String RESULT = "result"; - - // Purchase Connector listeners - final static String SUBSCRIPTION_PURCHASE_VALIDATION_RESULT_LISTENER_ON_RESPONSE = "SubscriptionPurchaseValidationResultListener:onResponse"; - final static String SUBSCRIPTION_PURCHASE_VALIDATION_RESULT_LISTENER_ON_FAILURE = "SubscriptionPurchaseValidationResultListener:onFailure"; - final static String IN_APP_VALIDATION_RESULT_LISTENER_ON_RESPONSE = "InAppValidationResultListener:onResponse"; - final static String IN_APP_VALIDATION_RESULT_LISTENER_ON_FAILURE = "InAppValidationResultListener:onFailure"; - final static String DID_RECEIVE_PURCHASE_REVENUE_VALIDATION_INFO = "didReceivePurchaseRevenueValidationInfo"; - - // Purchase Connector error messages - final static String MISSING_CONFIGURATION_EXCEPTION_MSG = "Configuration is missing. Call PurchaseConnector.configure() first."; - final static String RE_CONFIGURE_ERROR_MSG = "PurchaseConnector already configured."; -} diff --git a/android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java b/android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java deleted file mode 100644 index b7564218..00000000 --- a/android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java +++ /dev/null @@ -1,1278 +0,0 @@ -package com.appsflyer.appsflyersdk; - -import android.app.Activity; -import android.app.Application; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; - -import com.appsflyer.AFAdRevenueData; -import com.appsflyer.AFLogger; -import com.appsflyer.AFPurchaseDetails; -import com.appsflyer.AFPurchaseType; -import com.appsflyer.AppsFlyerConsent; -import com.appsflyer.AppsFlyerConversionListener; -import com.appsflyer.AppsFlyerInAppPurchaseValidatorListener; -import com.appsflyer.AppsFlyerInAppPurchaseValidationCallback; -import com.appsflyer.AppsFlyerLib; -import com.appsflyer.AppsFlyerProperties; -import com.appsflyer.MediationNetwork; -import com.appsflyer.deeplink.DeepLinkListener; -import com.appsflyer.deeplink.DeepLinkResult; -import com.appsflyer.share.CrossPromotionHelper; -import com.appsflyer.share.LinkGenerator; -import com.appsflyer.share.ShareInviteHelper; -import com.appsflyer.internal.platform_extension.Plugin; -import com.appsflyer.internal.platform_extension.PluginInfo; -import com.appsflyer.attribution.AppsFlyerRequestListener; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.security.InvalidParameterException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -import io.flutter.embedding.engine.plugins.FlutterPlugin; -import io.flutter.embedding.engine.plugins.activity.ActivityAware; -import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.EventChannel; -import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugin.common.MethodChannel.MethodCallHandler; -import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.plugin.common.PluginRegistry; - -import static com.appsflyer.appsflyersdk.AppsFlyerConstants.AF_EVENTS_CHANNEL; -import static com.appsflyer.appsflyersdk.AppsFlyerConstants.AF_FAILURE; -import static com.appsflyer.appsflyersdk.AppsFlyerConstants.AF_PLUGIN_TAG; -import static com.appsflyer.appsflyersdk.AppsFlyerConstants.AF_SUCCESS; - -import androidx.annotation.NonNull; - -/** - * AppsflyerSdkPlugin - */ -public class AppsflyerSdkPlugin implements MethodCallHandler, FlutterPlugin, ActivityAware { - // RD-65582 - private static boolean saveCallbacks; - private static Map cachedOnConversionDataSuccess; - private static Map cachedOnAppOpenAttribution; - private static String cachedOnAttributionFailure; - private static String cachedOnConversionDataFail; - private static DeepLinkResult cachedDeepLinkResult; - - final Handler uiThreadHandler = new Handler(Looper.getMainLooper()); - private EventChannel mEventChannel; - /** - * Plugin registration. - */ - //private FlutterView mFlutterView; - private Context mContext; - private Application mApplication; - private MethodChannel mMethodChannel; - private MethodChannel mCallbackChannel; - private Activity activity; - private Boolean gcdCallback = false; - private Boolean oaoaCallback = false; - private Boolean udlCallback = false; - private Boolean validatePurchaseCallback = false; - private Boolean isFacebookDeferredApplinksEnabled = false; - private Boolean isSetDisableAdvertisingIdentifiersEnable = false; - private Map> mCallbacks = new HashMap<>(); - - PluginRegistry.NewIntentListener onNewIntentListener = new PluginRegistry.NewIntentListener() { - @Override - public boolean onNewIntent(Intent intent) { - if (activity != null) { - activity.setIntent(intent); - } - // Forward the intent to the SDK before its own onResume auto-handler - // runs and stamps the URI with af_consumed=true. Without this, warm-app - // VIEW intents get silently consumed and the registered DeepLinkListener - // never fires for the Dart side. - if (mApplication != null) { - AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication); - } - return false; - } - }; - - private final AppsFlyerConversionListener afConversionListener = new AppsFlyerConversionListener() { - @Override - public void onConversionDataSuccess(Map map) { - if (saveCallbacks) { - cachedOnConversionDataSuccess = map; - return; - } - if (gcdCallback) { - JSONObject dataObj = new JSONObject(replaceNullValues(map)); - runOnUIThread(dataObj, AppsFlyerConstants.AF_GCD_CALLBACK, AF_SUCCESS); - } - } - - @Override - public void onConversionDataFail(String s) { - if (saveCallbacks) { - cachedOnConversionDataFail = s; - return; - } - if (gcdCallback) { - JSONObject obj = buildJsonResponse(s, AF_FAILURE); - runOnUIThread(obj, AppsFlyerConstants.AF_GCD_CALLBACK, AF_FAILURE); - } - } - - @Override - public void onAppOpenAttribution(Map map) { - if (saveCallbacks) { - cachedOnAppOpenAttribution = map; - return; - } - Map objMap = (Map) map; - if (oaoaCallback) { - JSONObject obj = new JSONObject(replaceNullValues(objMap)); - runOnUIThread(obj, AppsFlyerConstants.AF_OAOA_CALLBACK, AF_SUCCESS); - } - } - - @Override - public void onAttributionFailure(String errorMessage) { - if (saveCallbacks) { - cachedOnAttributionFailure = errorMessage; - return; - } - if (oaoaCallback) { - JSONObject obj = buildJsonResponse(errorMessage, AF_FAILURE); - runOnUIThread(obj, AppsFlyerConstants.AF_OAOA_CALLBACK, AF_FAILURE); - } - } - }; - private final DeepLinkListener afDeepLinkListener = new DeepLinkListener() { - - @Override - public void onDeepLinking(DeepLinkResult deepLinkResult) { - if (saveCallbacks) { - cachedDeepLinkResult = deepLinkResult; - return; - } - if (udlCallback) { - runOnUIThread(deepLinkResult, AppsFlyerConstants.AF_UDL_CALLBACK, AF_SUCCESS); - } - } - }; - - private final MethodCallHandler callbacksHandler = new MethodCallHandler() { - @Override - public void onMethodCall(MethodCall call, Result result) { - final String method = call.method; - if ("startListening".equals(method)) { - startListening(call.arguments, result); - } else { - result.notImplemented(); - } - } - }; - - private void onAttachedToEngine(Context applicationContext, BinaryMessenger messenger) { - this.mContext = applicationContext; - this.mEventChannel = new EventChannel(messenger, AF_EVENTS_CHANNEL); - mMethodChannel = new MethodChannel(messenger, AppsFlyerConstants.AF_METHOD_CHANNEL); - mMethodChannel.setMethodCallHandler(this); - mCallbackChannel = new MethodChannel(messenger, AppsFlyerConstants.AF_CALLBACK_CHANNEL); - mCallbackChannel.setMethodCallHandler(callbacksHandler); - } - - - private void startListening(Object arguments, Result rawResult) { - // Get callback id - String callbackName = (String) arguments; - if (callbackName.equals(AppsFlyerConstants.AF_GCD_CALLBACK)) { - gcdCallback = true; - } - if (callbackName.equals(AppsFlyerConstants.AF_OAOA_CALLBACK)) { - oaoaCallback = true; - } - if (callbackName.equals(AppsFlyerConstants.AF_UDL_CALLBACK)) { - udlCallback = true; - } - if (callbackName.equals(AppsFlyerConstants.AF_VALIDATE_PURCHASE)) { - validatePurchaseCallback = true; - } - Map args = new HashMap<>(); - args.put("id", callbackName); - mCallbacks.put(callbackName, args); - - rawResult.success(null); - } - - @Override - public void onMethodCall(MethodCall call, Result result) { - if (activity == null) { - Log.d(AF_PLUGIN_TAG, LogMessages.ACTIVITY_NOT_ATTACHED_TO_ENGINE); - result.error("NO_ACTIVITY", "The current activity is null", null); - return; - } - final String method = call.method; - switch (method) { - case "initSdk": - initSdk(call, result); - break; - case "startSDK": - startSDK(call, result); - break; - case "startSDKwithHandler": - startSDKwithHandler(call, result); - break; - case "logEvent": - logEvent(call, result); - break; - case "setHost": - setHost(call, result); - break; - case "setCurrencyCode": - setCurrencyCode(call, result); - break; - case "enableTCFDataCollection": - enableTCFDataCollection(call, result); - break; - case "setConsentData": - setConsentData(call, result); - break; - case "setConsentDataV2": - setConsentDataV2(call, result); - break; - case "setIsUpdate": - setIsUpdate(call, result); - break; - case "stop": - stop(call, result); - break; - case "updateServerUninstallToken": - updateServerUninstallToken(call, result); - break; - case "setImeiData": - setImeiData(call, result); - break; - case "setAndroidIdData": - setAndroidIdData(call, result); - break; - case "setCustomerUserId": - setCustomerUserId(call, result); - break; - case "setCustomerIdAndLogSession": - setCustomerIdAndLogSession(call, result); - break; - case "waitForCustomerUserId": - waitForCustomerUserId(call, result); - break; - case "setAdditionalData": - setAdditionalData(call, result); - break; - case "setUserEmails": - setUserEmails(call, result); - break; - case "setCollectAndroidId": - setCollectAndroidId(call, result); - break; - case "setCollectIMEI": - setCollectIMEI(call, result); - break; - case "getHostName": - getHostName(result); - break; - case "getHostPrefix": - getHostPrefix(result); - break; - case "setMinTimeBetweenSessions": - setMinTimeBetweenSessions(call, result); - break; - case "validateAndLogInAppAndroidPurchase": - validateAndLogInAppPurchase(call, result); - break; - case "validateAndLogInAppPurchaseV2": - validateAndLogInAppPurchaseV2(call, result); - break; - case "getAppsFlyerUID": - getAppsFlyerUID(result); - break; - case "getSDKVersion": - getSdkVersion(result); - break; - case "setSharingFilter": - setSharingFilter(call, result); - break; - case "setSharingFilterForAllPartners": - setSharingFilterForAllPartners(result); - break; - case "generateInviteLink": - generateInviteLink(call, result); - break; - case "setAppInviteOneLinkID": - setAppInivteOneLinkID(call, result); - break; - case "logCrossPromotionImpression": - logCrossPromotionImpression(call, result); - break; - case "logCrossPromotionAndOpenStore": - logCrossPromotionAndOpenStore(call, result); - break; - case "setOneLinkCustomDomain": - setOneLinkCustomDomain(call, result); - break; - case "setPushNotification": - setPushNotification(call, result); - break; - case "sendPushNotificationData": - sendPushNotificationData(call, result); - break; - case "enableFacebookDeferredApplinks": - enableFacebookDeferredApplinks(call, result); - break; - case "anonymizeUser": - anonymizeUser(call, result); - break; - case "performOnDeepLinking": - performOnDeepLinking(call, result); - break; - case "setDisableAdvertisingIdentifiers": - setDisableAdvertisingIdentifiers(call, result); - break; - case "setSharingFilterForPartners": - setSharingFilterForPartners(call, result); - break; - case "getOutOfStore": - getOutOfStore(result); - break; - case "setOutOfStore": - setOutOfStore(call, result); - break; - case "setPartnerData": - setPartnerData(call, result); - break; - case "setResolveDeepLinkURLs": - setResolveDeepLinkURLs(call, result); - break; - case "setDisableNetworkData": - setDisableNetworkData(call, result); - break; - case "addPushNotificationDeepLinkPath": - addPushNotificationDeepLinkPath(call, result); - break; - case "logAdRevenue": - logAdRevenue(call, result); - break; - case "disableAppSetId": - disableAppSetId(call, result); - break; - default: - result.notImplemented(); - break; - } - } - - private void performOnDeepLinking(MethodCall call, Result result) { - if (activity != null) { - Intent intent = activity.getIntent(); - if (intent != null) { - AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication); - result.success(null); - } else { - Log.d(AF_PLUGIN_TAG, "performOnDeepLinking: intent is null!"); - result.error("NO_INTENT", "The intent is null", null); - } - } else { - Log.d(AF_PLUGIN_TAG, "performOnDeepLinking: activity is null!"); - result.error("NO_ACTIVITY", "The current activity is null", null); - } - } - - private void anonymizeUser(MethodCall call, Result result) { - boolean shouldAnonymize = (boolean) call.argument("shouldAnonymize"); - AppsFlyerLib.getInstance().anonymizeUser(shouldAnonymize); - result.success(null); // indicate that the method invocation is complete - } - - private void startSDKwithHandler(MethodCall call, final Result result) { - try { - final AppsFlyerLib appsFlyerLib = AppsFlyerLib.getInstance(); - - appsFlyerLib.start(activity, null, new AppsFlyerRequestListener() { - @Override - public void onSuccess() { - uiThreadHandler.post(() -> { - if (mMethodChannel != null) { - mMethodChannel.invokeMethod("onSuccess", null); - } else { - Log.e(AF_PLUGIN_TAG, LogMessages.METHOD_CHANNEL_IS_NULL + " - SDK started successfully but callback `onSuccess` failed"); - } - }); - } - - @Override - public void onError(final int errorCode, final String errorMessage) { - uiThreadHandler.post(() -> { - if (mMethodChannel != null) { - HashMap errorDetails = new HashMap<>(); - errorDetails.put("errorCode", errorCode); - errorDetails.put("errorMessage", errorMessage); - mMethodChannel.invokeMethod("onError", errorDetails); - } else { - Log.e(AF_PLUGIN_TAG, LogMessages.METHOD_CHANNEL_IS_NULL + " - SDK failed to start: " + errorMessage); - } - }); - } - }); - result.success(null); - } catch (Throwable t) { - result.error("UNEXPECTED_ERROR", t.getMessage(), null); - } - } - - /** - * Initiates the AppsFlyer SDK. The AtomicBoolean isResultSubmitted ensures the result is - * only submitted once, preventing the "Reply already submitted" exception in Flutter. - */ - private void startSDK(MethodCall call, final Result result) { - final AppsFlyerLib instance = AppsFlyerLib.getInstance(); - instance.start(activity); - result.success(null); - } - - /** - * Sets the user consent data for tracking. - * @deprecated Use {@link #setConsentDataV2(MethodCall, Result)} instead! - */ - @Deprecated - public void setConsentData(MethodCall call, Result result) { - Map arguments = (Map) call.arguments; - Map consentDict = (Map) arguments.get("consentData"); - - boolean isUserSubjectToGDPR = (boolean) consentDict.get("isUserSubjectToGDPR"); - Boolean hasConsentForDataUsage = (Boolean) consentDict.get("hasConsentForDataUsage"); - Boolean hasConsentForAdsPersonalization = (Boolean) consentDict.get("hasConsentForAdsPersonalization"); - - AppsFlyerConsent consentData; - if (isUserSubjectToGDPR && hasConsentForDataUsage != null && hasConsentForAdsPersonalization != null) { - consentData = AppsFlyerConsent.forGDPRUser(hasConsentForDataUsage, hasConsentForAdsPersonalization); - } else { - consentData = AppsFlyerConsent.forNonGDPRUser(); - } - - AppsFlyerLib.getInstance().setConsentData(consentData); - - - result.success(null); - } - - /** - * Sets the user consent data for tracking with flexible parameters. - */ - public void setConsentDataV2(MethodCall call, Result result) { - try { - AppsFlyerConsent consent = getAppsFlyerConsentFromCall(call); - AppsFlyerLib.getInstance().setConsentData(consent); - result.success(null); - } catch (Exception e) { - Log.e(AF_PLUGIN_TAG, LogMessages.ERROR_WHILE_SETTING_CONSENT + e.getMessage(), e); - result.error("CONSENT_ERROR", LogMessages.ERROR_WHILE_SETTING_CONSENT + e.getMessage(), null); - } - } - - @NonNull - @SuppressWarnings("unchecked") - private AppsFlyerConsent getAppsFlyerConsentFromCall(MethodCall call) { - Map args = (Map) call.arguments; - - // Extract nullable Boolean arguments - Boolean isUserSubjectToGDPR = (Boolean) args.get("isUserSubjectToGDPR"); - Boolean consentForDataUsage = (Boolean) args.get("consentForDataUsage"); - Boolean consentForAdsPersonalization = (Boolean) args.get("consentForAdsPersonalization"); - Boolean hasConsentForAdStorage = (Boolean) args.get("hasConsentForAdStorage"); - - // Create and return AppsFlyerConsent object with the given parameters - return new AppsFlyerConsent(isUserSubjectToGDPR, consentForDataUsage, consentForAdsPersonalization, hasConsentForAdStorage); - } - - private void enableTCFDataCollection(MethodCall call, Result result) { - boolean shouldCollect = (boolean) call.argument("shouldCollect"); - AppsFlyerLib.getInstance().enableTCFDataCollection(shouldCollect); - result.success(null); - } - - private void addPushNotificationDeepLinkPath(MethodCall call, Result result) { - if (call.arguments != null) { - ArrayList depplinkPath = (ArrayList) call.arguments; - String[] depplinkPathArr = depplinkPath.toArray(new String[depplinkPath.size()]); - AppsFlyerLib.getInstance().addPushNotificationDeepLinkPath(depplinkPathArr); - } - result.success(null); - } - - private void setDisableNetworkData(MethodCall call, Result result) { - boolean disableNetworkData = (boolean) call.arguments; - AppsFlyerLib.getInstance().setDisableNetworkData(disableNetworkData); - result.success(null); - } - - private void getOutOfStore(Result result) { - result.success(AppsFlyerLib.getInstance().getOutOfStore(this.mContext)); - } - - private void setOutOfStore(MethodCall call, Result result) { - String sourceName = (String) call.arguments; - if (sourceName != null) { - AppsFlyerLib.getInstance().setOutOfStore(sourceName); - } - result.success(null); - } - - private void setResolveDeepLinkURLs(MethodCall call, Result result) { - ArrayList urls = (ArrayList) call.arguments; - String[] urlsArr = urls.toArray(new String[0]); - AppsFlyerLib.getInstance().setResolveDeepLinkURLs(urlsArr); - - result.success(null); - } - - private void setPartnerData(MethodCall call, Result result) { - String partnerId = (String) call.argument("partnerId"); - HashMap partnerData = (HashMap) call.argument("partnersData"); - if (partnerData != null) { - AppsFlyerLib.getInstance().setPartnerData(partnerId, partnerData); - } - result.success(null); - } - - private void setSharingFilterForPartners(MethodCall call, Result result) { - if (call.arguments != null) { - ArrayList partnersInput = (ArrayList) call.arguments; - String[] partners = partnersInput.toArray(new String[partnersInput.size()]); - AppsFlyerLib.getInstance().setSharingFilterForPartners(partners); - } - result.success(null); - } - - private void setDisableAdvertisingIdentifiers(MethodCall call, Result result) { - isSetDisableAdvertisingIdentifiersEnable = (boolean) call.arguments; - if (isSetDisableAdvertisingIdentifiersEnable) { - AppsFlyerLib.getInstance().setDisableAdvertisingIdentifiers(true); - } else { - AppsFlyerLib.getInstance().setDisableAdvertisingIdentifiers(false); - } - result.success(null); - } - - private void enableFacebookDeferredApplinks(MethodCall call, Result result) { - isFacebookDeferredApplinksEnabled = (boolean) call.argument("isFacebookDeferredApplinksEnabled"); - - if (isFacebookDeferredApplinksEnabled) { - AppsFlyerLib.getInstance().enableFacebookDeferredApplinks(true); - } else { - AppsFlyerLib.getInstance().enableFacebookDeferredApplinks(false); - } - result.success(null); - } - - private void setPushNotification(MethodCall call, Result result) { - AppsFlyerLib.getInstance().sendPushNotificationData(activity); - result.success(null); - } - - private void sendPushNotificationData(MethodCall call, Result result) { - final Map pushPayload = (Map) call.arguments; - String errorMsg = null; - Bundle bundle; - - if (pushPayload == null) { - Log.d(AF_PLUGIN_TAG, "Push payload is null"); - return; - } - - try { - bundle = this.jsonToBundle(new JSONObject(pushPayload)); - } catch (JSONException e) { - Log.d(AF_PLUGIN_TAG, "Can't parse pushPayload to bundle"); - return; - } - - if (activity != null) { - Intent intent = activity.getIntent(); - if (intent != null) { - intent.putExtras(bundle); - activity.setIntent(intent); - AppsFlyerLib.getInstance().sendPushNotificationData(activity); - } else { - errorMsg = "The intent is null. Push payload has not been sent!"; - } - } else { - errorMsg = "The activity is null. Push payload has not been sent!"; - } - - if (errorMsg != null) { - Log.d(AF_PLUGIN_TAG, errorMsg); - return; - } - - result.success(null); - } - - private static Bundle jsonToBundle(JSONObject jsonObject) throws JSONException { - Bundle bundle = new Bundle(); - Iterator iter = jsonObject.keys(); - while (iter.hasNext()) { - String key = (String) iter.next(); - String value = jsonObject.getString(key); - bundle.putString(key, value); - } - return bundle; - } - - private void setOneLinkCustomDomain(MethodCall call, Result result) { - ArrayList brandDomains = (ArrayList) call.arguments; - String[] brandDomainsArray = brandDomains.toArray(new String[brandDomains.size()]); - AppsFlyerLib.getInstance().setOneLinkCustomDomain(brandDomainsArray); - result.success(null); - } - - private void logCrossPromotionAndOpenStore(MethodCall call, Result result) { - String appId = (String) call.argument("appId"); - String campaign = (String) call.argument("campaign"); - Map data = (Map) call.argument("params"); - - if (appId != null && !appId.equals("")) { - CrossPromotionHelper.logAndOpenStore(mContext, appId, campaign, data); - } - result.success(null); - } - - private void logCrossPromotionImpression(MethodCall call, Result result) { - String appId = (String) call.argument("appId"); - String campaign = (String) call.argument("campaign"); - Map data = (Map) call.argument("data"); - - if (appId != null && !appId.equals("")) { - CrossPromotionHelper.logCrossPromoteImpression(mContext, appId, campaign, data); - } - result.success(null); - } - - private void setAppInivteOneLinkID(MethodCall call, Result result) { - String oneLinkId = (String) call.argument("oneLinkID"); - if (oneLinkId == null || oneLinkId.length() == 0) { - result.success(null); - } else { - AppsFlyerLib.getInstance().setAppInviteOneLink(oneLinkId); - if (mCallbacks.containsKey("setAppInviteOneLinkIDCallback")) { - JSONObject obj = buildJsonResponse("success", AF_SUCCESS); - runOnUIThread(obj, "setAppInviteOneLinkIDCallback", AF_SUCCESS); - } - } - } - - private void generateInviteLink(MethodCall call, Result rawResult) { - String channel = (String) call.argument("channel"); - String customerID = (String) call.argument("customerID"); - String campaign = (String) call.argument("campaign"); - String referrerName = (String) call.argument("referrerName"); - String referrerImageUrl = (String) call.argument("referrerImageUrl"); - String baseDeepLink = (String) call.argument("baseDeeplink"); - String brandDomain = (String) call.argument("brandDomain"); - Map customParams = (Map) call.argument("customParams"); - - LinkGenerator linkGenerator = ShareInviteHelper.generateInviteUrl(mContext); - - if (channel != null && !channel.equals("")) { - linkGenerator.setChannel(channel); - } - if (campaign != null && !campaign.equals("")) { - linkGenerator.setCampaign(campaign); - } - if (referrerName != null && !referrerName.equals("")) { - linkGenerator.setReferrerName(referrerName); - } - if (referrerImageUrl != null && !referrerImageUrl.equals("")) { - linkGenerator.setReferrerImageURL(referrerImageUrl); - } - if (customerID != null && !customerID.equals("")) { - linkGenerator.setReferrerCustomerId(customerID); - } - if (baseDeepLink != null && !baseDeepLink.equals("")) { - linkGenerator.setBaseDeeplink(baseDeepLink); - } - if (brandDomain != null && !brandDomain.equals("")) { - linkGenerator.setBrandDomain(brandDomain); - } - if (customParams != null && !customParams.equals("")) { - linkGenerator.addParameters(customParams); - } - LinkGenerator.ResponseListener listener = new LinkGenerator.ResponseListener() { - final JSONObject obj = new JSONObject(); - - @Override - public void onResponse(final String oneLinkUrl) { - if (mCallbacks.containsKey("generateInviteLinkSuccess")) { - try { - obj.put("userInviteURL", oneLinkUrl); - runOnUIThread(obj, "generateInviteLinkSuccess", AF_SUCCESS); - } catch (JSONException e) { - e.printStackTrace(); - } - } - } - - @Override - public void onResponseError(final String error) { - if (mCallbacks.containsKey("generateInviteLinkFailure")) { - try { - obj.put("error", error); - runOnUIThread(error, "generateInviteLinkFailure", AF_FAILURE); - } catch (JSONException e) { - e.printStackTrace(); - } - } - } - }; - - linkGenerator.generateLink(mContext, listener); - - rawResult.success(null); - } - - private void runOnUIThread(final Object data, final String callbackName, final String status) { - uiThreadHandler.post( - new Runnable() { - @Override - public void run() { - if (mCallbackChannel != null) { - Log.d(AF_PLUGIN_TAG, "Calling invokeMethod with: " + data); - JSONObject args = new JSONObject(); - try { - args.put("id", callbackName); - //return data for UDL - if (callbackName.equals(AppsFlyerConstants.AF_UDL_CALLBACK)) { - DeepLinkResult dp = (DeepLinkResult) data; - args.put("deepLinkStatus", dp.getStatus().toString()); - if (dp.getError() != null) { - args.put("deepLinkError", dp.getError().toString()); - } - if (dp.getStatus() == DeepLinkResult.Status.FOUND) { - args.put("deepLinkObj", dp.getDeepLink().getClickEvent()); - } - } else { // return data for conversionData and OAOA - JSONObject dataJSON = (JSONObject) data; - args.put("status", status); - args.put("data", data.toString()); - } - } catch (JSONException e) { - e.printStackTrace(); - } - mCallbackChannel.invokeMethod("callListener", args.toString()); - } else { - Log.e(AF_PLUGIN_TAG, "CallbackChannel is null, cannot invoke method: " + callbackName); - } - } - } - ); - } - - private void setSharingFilterForAllPartners(Result result) { - AppsFlyerLib.getInstance().setSharingFilterForAllPartners(); - result.success(null); - } - - private void setSharingFilter(MethodCall call, Result result) { - AppsFlyerLib.getInstance().setSharingFilter(); - result.success(null); - } - - private void getAppsFlyerUID(Result result) { - result.success(AppsFlyerLib.getInstance().getAppsFlyerUID(this.mContext)); - } - - private void validateAndLogInAppPurchase(MethodCall call, Result result) { - registerValidatorListener(); - String publicKey = (String) call.argument("publicKey"); - String signature = (String) call.argument("signature"); - String purchaseData = (String) call.argument("purchaseData"); - String price = (String) call.argument("price"); - String currency = (String) call.argument("currency"); - Map additionalParameters = (Map) call.argument("additionalParameters"); - AppsFlyerLib.getInstance().validateAndLogInAppPurchase(mContext, publicKey, signature, purchaseData, price, - currency, additionalParameters); - result.success(null); - } - - private void validateAndLogInAppPurchaseV2(MethodCall call, Result result) { - try { - // Get the complete purchase details map - Map purchaseDetailsMap = (Map) call.argument("purchaseDetails"); - Map additionalParameters = (Map) call.argument("additionalParameters"); - - if (purchaseDetailsMap == null) { - result.error("INVALID_ARGUMENTS", "Purchase details cannot be null", null); - return; - } - - if (additionalParameters == null) { - additionalParameters = new HashMap<>(); - } - - // Extract fields from purchase details map - String purchaseTypeString = (String) purchaseDetailsMap.get("purchaseType"); - String purchaseToken = (String) purchaseDetailsMap.get("purchaseToken"); - String productId = (String) purchaseDetailsMap.get("productId"); - - // Validate required fields - if (purchaseTypeString == null || purchaseToken == null || productId == null) { - result.error("INVALID_ARGUMENTS", "Purchase details must contain purchaseType, purchaseToken, and productId", null); - return; - } - - // Map Dart enum values to Android AFPurchaseType enum - AFPurchaseType purchaseType = mapPurchaseType(purchaseTypeString); - if (purchaseType == null) { - result.error("INVALID_PURCHASE_TYPE", "Invalid purchase type: " + purchaseTypeString + ". Expected: 'subscription' or 'one_time_purchase'", null); - return; - } - - // Create AFPurchaseDetails object - AFPurchaseDetails purchaseDetails = new AFPurchaseDetails( - purchaseType, - purchaseToken, - productId - ); - - Log.d(AF_PLUGIN_TAG, "validateAndLogInAppPurchaseV2 called with " + purchaseDetailsMap); - - AppsFlyerLib.getInstance().validateAndLogInAppPurchase( - purchaseDetails, - additionalParameters, - new AppsFlyerInAppPurchaseValidationCallback() { - @Override - public void onInAppPurchaseValidationFinished(@NonNull Map validationFinishedResult) { - Log.d(AF_PLUGIN_TAG, "Purchase validation V2 response arrived"); - - // Convert the result to a format Flutter can understand - Map flutterResult = new HashMap<>(); - for (Map.Entry entry : validationFinishedResult.entrySet()) { - flutterResult.put(entry.getKey(), entry.getValue()); - } - - result.success(flutterResult); - } - - @Override - public void onInAppPurchaseValidationError(@NonNull Map validationErrorResult) { - Log.d(AF_PLUGIN_TAG, "Purchase validation V2 returned error"); - - String errorMessage = "Purchase validation failed"; - if (validationErrorResult.containsKey("error_message")) { - errorMessage = (String) validationErrorResult.get("error_message"); - } - - // Convert error result to Flutter format - Map flutterErrorResult = new HashMap<>(); - for (Map.Entry entry : validationErrorResult.entrySet()) { - flutterErrorResult.put(entry.getKey(), entry.getValue()); - } - - result.error("VALIDATION_ERROR", errorMessage, flutterErrorResult); - } - } - ); - - } catch (Exception e) { - Log.e(AF_PLUGIN_TAG, "Error in validateAndLogInAppPurchaseV2: " + e.getMessage(), e); - result.error("VALIDATION_ERROR", "Purchase validation failed: " + e.getMessage(), null); - } - } - - /** - * Maps Dart enum string to Android AFPurchaseType enum. - * @param purchaseTypeString The string representation from Dart - * @return AFPurchaseType enum or null if invalid - */ - private AFPurchaseType mapPurchaseType(String purchaseTypeString) { - switch (purchaseTypeString) { - case "subscription": - return AFPurchaseType.SUBSCRIPTION; - case "one_time_purchase": - return AFPurchaseType.ONE_TIME_PURCHASE; - default: - return null; - } - } - - private void registerValidatorListener() { - AppsFlyerInAppPurchaseValidatorListener validatorListener = new AppsFlyerInAppPurchaseValidatorListener() { - @Override - public void onValidateInApp() { - if (validatePurchaseCallback) { - runOnUIThread(new JSONObject(), AppsFlyerConstants.AF_VALIDATE_PURCHASE, AF_SUCCESS); - } - } - - - @Override - public void onValidateInAppFailure(String s) { - try { - JSONObject obj = new JSONObject(); - obj.put("error", s); - if (validatePurchaseCallback) { - runOnUIThread(obj, AppsFlyerConstants.AF_VALIDATE_PURCHASE, AF_FAILURE); - } - } catch (JSONException e) { - e.printStackTrace(); - } - } - }; - AppsFlyerLib.getInstance().registerValidatorListener(mContext, validatorListener); - } - - private void setMinTimeBetweenSessions(MethodCall call, Result result) { - int seconds = (int) call.argument("seconds"); - AppsFlyerLib.getInstance().setMinTimeBetweenSessions(seconds); - result.success(null); - } - - private void getHostPrefix(Result result) { - result.success(AppsFlyerLib.getInstance().getHostPrefix()); - } - - private void getSdkVersion(Result result) { - result.success(AppsFlyerLib.getInstance().getSdkVersion()); - } - - private void getHostName(Result result) { - result.success(AppsFlyerLib.getInstance().getHostName()); - } - - private void setCollectIMEI(MethodCall call, Result result) { - boolean isCollect = (boolean) call.argument("isCollect"); - AppsFlyerLib.getInstance().setCollectIMEI(isCollect); - result.success(null); - } - - private void setCollectAndroidId(MethodCall call, Result result) { - boolean isCollect = (boolean) call.argument("isCollect"); - AppsFlyerLib.getInstance().setCollectAndroidID(isCollect); - result.success(null); - } - - private void waitForCustomerUserId(MethodCall call, Result result) { - boolean wait = (boolean) call.argument("wait"); - AppsFlyerLib.getInstance().waitForCustomerUserId(wait); - result.success(null); - } - - private void setAdditionalData(MethodCall call, Result result) { - HashMap customData = (HashMap) call.argument("customData"); - AppsFlyerLib.getInstance().setAdditionalData(customData); - result.success(null); - } - - private void setUserEmails(MethodCall call, Result result) { - List emails = call.argument("emails"); - int cryptTypeInt = call.argument("cryptType"); - - AppsFlyerProperties.EmailsCryptType cryptType = null; - if (cryptTypeInt == 0) { - cryptType = AppsFlyerProperties.EmailsCryptType.NONE; - } else if (cryptTypeInt == 1) { - cryptType = AppsFlyerProperties.EmailsCryptType.SHA256; - } else { - throw new InvalidParameterException("You can use only NONE or SHA256 for EmailsCryptType on android"); - } - - if (emails != null) { - AppsFlyerLib.getInstance().setUserEmails(cryptType, emails.toArray(new String[0])); - } - - result.success(null); - } - - private void setCustomerUserId(MethodCall call, Result result) { - String userId = (String) call.argument("id"); - AppsFlyerLib.getInstance().setCustomerUserId(userId); - result.success(null); - } - - private void setCustomerIdAndLogSession(MethodCall call, Result result) { - String userId = (String) call.argument("id"); - AppsFlyerLib.getInstance().setCustomerIdAndLogSession(userId, mContext); - result.success(null); - } - - private void setAndroidIdData(MethodCall call, Result result) { - String androidId = (String) call.argument("androidId"); - AppsFlyerLib.getInstance().setAndroidIdData(androidId); - result.success(null); - } - - private void setImeiData(MethodCall call, Result result) { - String imei = (String) call.argument("imei"); - AppsFlyerLib.getInstance().setImeiData(imei); - result.success(null); - } - - private void updateServerUninstallToken(MethodCall call, Result result) { - String token = (String) call.argument("token"); - AppsFlyerLib.getInstance().updateServerUninstallToken(mContext, token); - result.success(null); - } - - private void stop(MethodCall call, Result result) { - boolean isStopped = (boolean) call.argument("isStopped"); - AppsFlyerLib.getInstance().stop(isStopped, mContext); - result.success(null); - } - - private void setIsUpdate(MethodCall call, Result result) { - boolean isUpdate = (boolean) call.argument("isUpdate"); - AppsFlyerLib.getInstance().setIsUpdate(isUpdate); - result.success(null); - } - - private void setCurrencyCode(MethodCall call, Result result) { - String currencyCode = (String) call.argument("currencyCode"); - AppsFlyerLib.getInstance().setCurrencyCode(currencyCode); - result.success(null); - } - - private void setHost(MethodCall call, MethodChannel.Result result) { - String hostPrefix = call.argument(AppsFlyerConstants.AF_HOST_PREFIX); - String hostName = call.argument(AppsFlyerConstants.AF_HOST_NAME); - - AppsFlyerLib.getInstance().setHost(hostPrefix, hostName); - } - - private void initSdk(MethodCall call, final MethodChannel.Result result) { - AppsFlyerConversionListener gcdListener = null; - DeepLinkListener udlListener = null; - AppsFlyerLib instance = AppsFlyerLib.getInstance(); - - boolean isManualStartMode = (boolean) call.argument(AppsFlyerConstants.AF_MANUAL_START); - - String afDevKey = (String) call.argument(AppsFlyerConstants.AF_DEV_KEY); - if (afDevKey == null || afDevKey.equals("")) { - Log.e(AF_PLUGIN_TAG, LogMessages.AF_DEV_KEY_IS_EMPTY); - result.error("INIT_ERROR", LogMessages.AF_DEV_KEY_IS_EMPTY, null); - return; - } - - boolean advertiserIdDisabled = (boolean) call.argument(AppsFlyerConstants.DISABLE_ADVERTISING_IDENTIFIER); - if (advertiserIdDisabled) { - instance.setDisableAdvertisingIdentifiers(true); - } - - boolean getGCD = (boolean) call.argument(AppsFlyerConstants.AF_GCD); - if (getGCD) { - gcdListener = afConversionListener; - } - // added Unified deeplink - boolean getUdl = (boolean) call.argument(AppsFlyerConstants.AF_UDL); - if (getUdl) { - instance.subscribeForDeepLink(afDeepLinkListener); - } - - boolean isDebug = (boolean) call.argument(AppsFlyerConstants.AF_IS_DEBUG); - if (isDebug) { - instance.setLogLevel(AFLogger.LogLevel.DEBUG); - instance.setDebugLog(true); - } else { - instance.setDebugLog(false); - } - - PluginInfo pluginInfo = new PluginInfo(Plugin.FLUTTER, AppsFlyerConstants.PLUGIN_VERSION); - instance.setPluginInfo(pluginInfo); - - instance.init(afDevKey, gcdListener, mContext); - - String appInviteOneLink = (String) call.argument(AppsFlyerConstants.AF_APP_INVITE_ONE_LINK); - if (appInviteOneLink != null) { - instance.setAppInviteOneLink(appInviteOneLink); - } - - if (!isManualStartMode) { - instance.start(activity); - } - - if (saveCallbacks) { - saveCallbacks = false; - sendCachedCallbacksToDart(); - } - - result.success("success"); - } - - private void logEvent(MethodCall call, MethodChannel.Result result) { - - AppsFlyerLib instance = AppsFlyerLib.getInstance(); - - final String eventName = call.argument(AppsFlyerConstants.AF_EVENT_NAME); - final Map eventValues = call.argument(AppsFlyerConstants.AF_EVENT_VALUES); - - // Send event data through appsflyer sdk - instance.logEvent(mContext, eventName, eventValues); - - result.success(true); - } - - private void logAdRevenue(MethodCall call, Result result) { - try { - String monetizationNetwork = requireNonNullArgument(call, "monetizationNetwork"); - String currencyIso4217Code = requireNonNullArgument(call, "currencyIso4217Code"); - double revenue = requireNonNullArgument(call, "revenue"); - String mediationNetworkString = requireNonNullArgument(call, "mediationNetwork"); - - MediationNetwork mediationNetwork = MediationNetwork.valueOf(mediationNetworkString.toUpperCase(Locale.ENGLISH)); - - // No null check for additionalParameters since it's acceptable for it to be null (optional data) - Map additionalParameters = call.argument("additionalParameters"); - - AFAdRevenueData adRevenueData = new AFAdRevenueData( - monetizationNetwork, - mediationNetwork, - currencyIso4217Code, - revenue - ); - - AppsFlyerLib.getInstance().logAdRevenue(adRevenueData, additionalParameters); - result.success(true); - - } catch (IllegalArgumentException e) { - // The IllegalArgumentException could come from either requireNonNullArgument or valueOf methods. - result.error("INVALID_ARGUMENT_PROVIDED", e.getMessage(), null); - } catch (Throwable t) { - result.error("UNEXPECTED_ERROR", "[logAdRevenue]: An unexpected error occurred: " + t.getMessage(), null); - Log.e(AF_PLUGIN_TAG, "Unexpected exception occurred: [logAdRevenue]", t); - } - } - - /** - * Utility method to ensure that an argument with the specified name is not null. - * If the argument is null, this method will throw an IllegalArgumentException. - * The calling method can then terminate immediately without further processing. - * - * @param call The MethodCall from Flutter, containing all the arguments. - * @param argumentName The name of the argument expected in the MethodCall. - * @param The type of the argument being checked for nullity. - * @return The argument value if it is not null; throw IllegalArgumentException otherwise. - */ - private T requireNonNullArgument(MethodCall call, String argumentName) throws IllegalArgumentException { - T argument = call.argument(argumentName); - if (argument == null) { - Log.e(AF_PLUGIN_TAG, "Exception occurred when trying to: " + call.method + "->" + argumentName + " must not be null"); - throw new IllegalArgumentException("[" + call.method + "]: " + argumentName + " must not be null"); - } - return argument; - } - - //RD-65582 - private void sendCachedCallbacksToDart() { - if (cachedDeepLinkResult != null) { - afDeepLinkListener.onDeepLinking(cachedDeepLinkResult); - cachedDeepLinkResult = null; - } - if (cachedOnConversionDataSuccess != null) { - afConversionListener.onConversionDataSuccess(cachedOnConversionDataSuccess); - cachedOnConversionDataSuccess = null; - } - if (cachedOnAppOpenAttribution != null) { - afConversionListener.onAppOpenAttribution(cachedOnAppOpenAttribution); - cachedOnAppOpenAttribution = null; - } - if (cachedOnAttributionFailure != null) { - afConversionListener.onAttributionFailure(cachedOnAttributionFailure); - cachedOnAttributionFailure = null; - } - if (cachedOnConversionDataFail != null) { - afConversionListener.onConversionDataFail(cachedOnConversionDataFail); - cachedOnConversionDataFail = null; - } - } - - - private JSONObject buildJsonResponse(Object data, String status) { - JSONObject obj = new JSONObject(); - try { - obj.put("status", status); - obj.put("data", data.toString()); - } catch (JSONException e) { - e.printStackTrace(); - } - return obj; - } - - private Map replaceNullValues(Map map) { - // cant use stream because of older versions of java - Map newMap = new HashMap< - >(); - Iterator it = map.entrySet().iterator(); - while (it.hasNext()) { - Map.Entry pair = (Map.Entry) it.next(); - newMap.put(pair.getKey(), pair.getValue() == null ? JSONObject.NULL : pair.getValue()); - it.remove(); // avoids a ConcurrentModificationException - } - - return newMap; - } - - private void disableAppSetId(MethodCall call, Result result) { - AppsFlyerLib.getInstance().disableAppSetId(); - result.success(null); - } - - @Override - public void onAttachedToEngine(FlutterPluginBinding binding) { - onAttachedToEngine(binding.getApplicationContext(), binding.getBinaryMessenger()); - AppsFlyerPurchaseConnector.INSTANCE.onAttachedToEngine(binding); - } - - @Override - public void onDetachedFromEngine(FlutterPluginBinding binding) { - mMethodChannel.setMethodCallHandler(null); - mMethodChannel = null; - mEventChannel.setStreamHandler(null); - mEventChannel = null; - AppsFlyerPurchaseConnector.INSTANCE.onDetachedFromEngine(binding); - mContext = null; - mApplication = null; - } - - @Override - public void onAttachedToActivity(ActivityPluginBinding binding) { - activity = binding.getActivity(); - mApplication = binding.getActivity().getApplication(); - binding.addOnNewIntentListener(onNewIntentListener); - } - - @Override - public void onDetachedFromActivityForConfigChanges() { - this.activity = null; - } - - @Override - public void onReattachedToActivityForConfigChanges(ActivityPluginBinding binding) { - sendCachedCallbacksToDart(); - binding.addOnNewIntentListener(onNewIntentListener); - activity = binding.getActivity(); - } - - @Override - public void onDetachedFromActivity() { - activity = null; - saveCallbacks = true; - AppsFlyerLib.getInstance().unregisterConversionListener(); - } - -} diff --git a/android/src/main/java/com/appsflyer/appsflyersdk/LogMessages.java b/android/src/main/java/com/appsflyer/appsflyersdk/LogMessages.java deleted file mode 100644 index 2bf5b5e4..00000000 --- a/android/src/main/java/com/appsflyer/appsflyersdk/LogMessages.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.appsflyer.appsflyersdk; - -public final class LogMessages { - - // Prevent the instantiation of this utilities class. - private LogMessages() { - throw new IllegalStateException("LogMessages class should not be instantiated"); - } - - public static final String METHOD_CHANNEL_IS_NULL = "mMethodChannel is null, cannot invoke the callback"; - public static final String ACTIVITY_NOT_ATTACHED_TO_ENGINE = "Activity isn't attached to the flutter engine"; - public static final String ERROR_WHILE_SETTING_CONSENT = "Error while setting consent data: "; - public static final String AF_DEV_KEY_IS_EMPTY = "AppsFlyer dev key is empty"; -} diff --git a/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerConstants.kt b/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerConstants.kt new file mode 100644 index 00000000..d29302ea --- /dev/null +++ b/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerConstants.kt @@ -0,0 +1,37 @@ +package com.appsflyer.appsflyersdk + +internal const val PLUGIN_VERSION = "7.0.1" +internal const val AF_PLUGIN_NAME = "flutter" + +internal const val AF_EVENTS_CHANNEL = "af-events" +internal const val AF_METHOD_CHANNEL = "af-api" + +internal const val RPC_METHOD_INIT = "init" +internal const val RPC_METHOD_SET_PLUGIN_INFO = "setPluginInfo" + +internal const val AF_PLUGIN_TAG = "AppsFlyer_FlutterPlugin" + +internal const val PLUGIN_DETACHED = "PLUGIN_DETACHED" +internal const val RPC_EXECUTOR_UNAVAILABLE_MSG = "RPC executor unavailable" + +// Purchase Connector constants +internal const val ERROR = "error" +internal const val RESULT = "result" +internal const val SANDBOX_KEY = "sandbox" +internal const val CONFIGURE_KEY = "configure" +internal const val VALIDATION_INFO = "validationInfo" +internal const val LOG_IN_APP_KEY = "logInAppPurchase" +internal const val LOG_SUBS_KEY = "logSubscriptionPurchase" + +internal const val AF_PURCHASE_CONNECTOR_CHANNEL = "af-purchase-connector" + +// Purchase Connector listeners +internal const val SUBSCRIPTION_PURCHASE_VALIDATION_RESULT_LISTENER_ON_RESPONSE = "SubscriptionPurchaseValidationResultListener:onResponse" +internal const val SUBSCRIPTION_PURCHASE_VALIDATION_RESULT_LISTENER_ON_FAILURE = "SubscriptionPurchaseValidationResultListener:onFailure" +internal const val IN_APP_VALIDATION_RESULT_LISTENER_ON_RESPONSE = "InAppValidationResultListener:onResponse" +internal const val IN_APP_VALIDATION_RESULT_LISTENER_ON_FAILURE = "InAppValidationResultListener:onFailure" +internal const val DID_RECEIVE_PURCHASE_REVENUE_VALIDATION_INFO = "didReceivePurchaseRevenueValidationInfo" + +// Purchase Connector error messages +internal const val MISSING_CONFIGURATION_EXCEPTION_MSG = "Configuration is missing. Call PurchaseConnector.configure() first." +internal const val RE_CONFIGURE_ERROR_MSG = "PurchaseConnector already configured." diff --git a/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerEventBus.kt b/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerEventBus.kt new file mode 100644 index 00000000..6c8ddcd5 --- /dev/null +++ b/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerEventBus.kt @@ -0,0 +1,113 @@ +package com.appsflyer.appsflyersdk + +import androidx.annotation.VisibleForTesting + +/** + * Upper bound for events buffered while no [AppsFlyerEventSink] is attached. + * + * The buffer is process-scoped and deliberately survives engine teardown, so it needs a bound: + * an app that never re-subscribes would otherwise grow it for the lifetime of the process. A + * session buffers a handful of events, so the cap only acts as a safety valve. + */ +private const val MAX_PENDING_EVENTS = 64 + +/** + * Destination for native event JSON on its way to the Dart `af-events` stream. + * + * [send] returns `false` when this sink can no longer accept events — the bus then keeps the + * event buffered and stops using the sink, so a torn-down engine cannot swallow events. + */ +internal fun interface AppsFlyerEventSink { + fun send(eventJson: String): Boolean +} + +/** + * Process-scoped relay between native SDK events and the Dart `af-events` stream. + * + * Android destroys the Flutter engine when the Activity goes away (back press, for example) and + * builds a new [AppsflyerSdkPlugin] when the app returns, while the native SDK keeps the listener + * registered by the previous `AppsFlyerRpcHandler`: `subscribeForDeepLink`, + * `registerConversionListener` and `registerSessionReadyListener` all overwrite a single + * reference, and the SDK exposes no unsubscribe for deep links. Holding the buffer and the sink + * here rather than on the plugin instance keeps two guarantees across that teardown: + * + * - an event emitted by a listener still bound to a detached engine reaches the live sink; + * - an event emitted while no sink is attached is replayed on the next attach instead of landing + * in the buffer of an unreachable plugin instance (RD-65582). + * + * Delivery is FIFO: events are queued first and flushed in publish order, so a replayed event + * always precedes one published after it. + * + * **Threading**: every entry point is synchronized, so publishing from an SDK callback thread is + * safe. Delivery runs on the caller's thread and `EventChannel.EventSink` may only be used on the + * platform main thread, so callers publish from there — see [AppsflyerSdkPlugin]. + */ +internal object AppsFlyerEventBus { + + private val lock = Any() + private val pendingEvents = ArrayDeque() + + private var sink: AppsFlyerEventSink? = null + + /** Queues [eventJson], then flushes as much of the buffer as the attached sink accepts. */ + fun publish(eventJson: String) { + synchronized(lock) { + pendingEvents.addLast(eventJson) + while (pendingEvents.size > MAX_PENDING_EVENTS) { + pendingEvents.removeFirst() + } + drain() + } + } + + /** + * Makes [sink] the active destination and replays everything buffered so far. + * + * The newest attach wins: when a new engine subscribes before the previous one detaches, the + * newer sink is the reachable one. + */ + fun attach(sink: AppsFlyerEventSink) { + synchronized(lock) { + this.sink = sink + drain() + } + } + + /** + * Drops [sink] if it is still the active one, leaving the buffer intact. + * + * The identity check matters because teardown is not ordered against setup: an engine being + * detached must not unbind the sink a newer engine has already attached. + */ + fun detach(sink: AppsFlyerEventSink) { + synchronized(lock) { + if (this.sink === sink) { + this.sink = null + } + } + } + + private fun drain() { + val target = sink ?: return + while (pendingEvents.isNotEmpty()) { + if (!target.send(pendingEvents.first())) { + // The sink belongs to an engine that can no longer take events. Keep the event + // queued so the next attach replays it. + sink = null + return + } + pendingEvents.removeFirst() + } + } + + @VisibleForTesting + internal fun pendingCount(): Int = synchronized(lock) { pendingEvents.size } + + @VisibleForTesting + internal fun reset() { + synchronized(lock) { + sink = null + pendingEvents.clear() + } + } +} diff --git a/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerRpcBridge.kt b/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerRpcBridge.kt new file mode 100644 index 00000000..3b1ff28a --- /dev/null +++ b/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerRpcBridge.kt @@ -0,0 +1,58 @@ +package com.appsflyer.appsflyersdk + +import androidx.annotation.VisibleForTesting + +import com.appsflyer.pluginbridge.model.RpcResponse + +/** + * Native-facing half of the bridge: turns an RPC request envelope into a response. + * + * Implemented over `AppsFlyerRpcHandler`. Keeping the plugin behind this interface is what lets + * [AppsFlyerRpcBridge] own the handler without every caller depending on how it is built. + */ +internal fun interface AppsFlyerRpcExecutor { + fun execute(requestJson: String): RpcResponse +} + +/** + * Process-scoped owner of the native-facing RPC executor. + * + * `AppsFlyerLib` is a process-wide singleton: once initialized it keeps its configuration and the + * listeners registered through `AppsFlyerRpcHandler` for as long as the process lives. Android + * destroys the Flutter engine well before that (back press, a Flutter fragment leaving an + * add-to-app host) and builds a new [AppsflyerSdkPlugin] when the app returns, so an executor held + * on the plugin instance would be rebuilt against an SDK that is already configured — with no + * memory of the listeners it registered there. + * + * Holding it here keeps one executor per process, so a new engine reattaches to the configured + * bridge instead of deriving it again. What stays engine-scoped is the Dart-facing half: the + * channels and the `EventChannel.EventSink` are only valid for the engine that created them and + * are released in `onDetachedFromEngine`. + * + * This does not carry Dart state across the gap. The application's callbacks lived in the + * destroyed isolate, so it still has to subscribe to the streams and call the `register*Listener` + * APIs again after a new engine attaches; reusing the executor only makes that re-registration + * reuse the existing listeners instead of building new ones. + * + * **Threading**: creation is synchronized, so concurrent engines resolve to the same executor. + */ +internal object AppsFlyerRpcBridge { + + private val lock = Any() + + private var executor: AppsFlyerRpcExecutor? = null + + /** Returns the process-wide executor, creating it with [create] on first use. */ + fun shared(create: () -> AppsFlyerRpcExecutor): AppsFlyerRpcExecutor { + synchronized(lock) { + return executor ?: create().also { executor = it } + } + } + + @VisibleForTesting + internal fun reset() { + synchronized(lock) { + executor = null + } + } +} diff --git a/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt b/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt new file mode 100644 index 00000000..3190cea3 --- /dev/null +++ b/android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt @@ -0,0 +1,459 @@ +package com.appsflyer.appsflyersdk + +import android.app.Activity +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log + +import com.appsflyer.AppsFlyerLib +import com.appsflyer.pluginbridge.handler.AppsFlyerRpcHandler +import com.appsflyer.pluginbridge.model.RpcErrorCodes +import com.appsflyer.pluginbridge.model.RpcResponse +import com.appsflyer.pluginbridge.parser.JsonRpcRequestParser + +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry + +/** + * AppsflyerSdkPlugin (Android) + * + * Bridges Dart's single `executeRpc` method call to [AppsFlyerRpcHandler]. `init` is handled + * specially to set up the plugin and native SDK in order; every other call is forwarded as-is. + * Native SDK callbacks flow back unchanged through `af-events`. + * + * ## State lifetimes + * + * Android destroys the Flutter engine on its own schedule — a back press, or a Flutter screen + * leaving an add-to-app host — while the process, the Activity and `AppsFlyerLib` keep running. + * A new instance of this class is built when the app comes back, so state is split by what that + * boundary invalidates: + * + * - **Engine-scoped**, held here and released in [onDetachedFromEngine]: the channels, the + * `af-events` sink adapter, the blocking-RPC executor, and the Activity/Context references. + * - **Process-scoped**, held in [AppsFlyerRpcBridge] and [AppsFlyerEventBus]: the RPC handler and + * the event buffer. Both outlive this instance on purpose, so a recreated engine reattaches to + * the already configured native bridge and still receives events emitted while no engine was + * attached (RD-65582). + * + * Dart state never survives: the application resubscribes to the streams and calls the + * `register*Listener` APIs again after a new engine attaches. Reusing the handler only makes that + * re-registration reuse the listeners already registered on `AppsFlyerLib`. + * + * ## Two executors + * + * [sharedRpcExecutor] serves every RPC but `init`. It resolves the one process-wide handler, built + * with `applicationContext` so it retains neither an Activity nor an engine. + * + * Fast RPCs (setters, getters, and fire-and-forget calls) run inline on the platform thread. + * Awaited-callback RPCs (`start`, `logEvent`, purchase validation, invite links when + * `awaitResponse` is true) run on [blockingRpcExecutor] so a slow native latch wait does not + * head-of-line block unrelated fast calls on the platform thread. + * + * `init` instead runs on a throwaway executor built around the current Activity + * ([createRpcExecutor] straight from [executeRpcSync]), because + * `AndroidLifecycleManagerImpl.registerLifecycleListener` triggers `onActivityResumed` manually + * when it receives an `Activity` — that is what lets SDK 7 inspect the launch intent of a cold + * start, which is already resumed by the time Dart calls `init()`. That executor is deliberately + * not cached: keeping it would pin the Activity for the lifetime of the process. + */ +open class AppsflyerSdkPlugin : MethodCallHandler, FlutterPlugin, ActivityAware { + + private var blockingRpcExecutor: ExecutorService? = null + + @Volatile + private var isEngineDetached = false + + @Volatile + private var applicationContext: Context? = null + + @Volatile + private var activity: Activity? = null + + private var methodChannel: MethodChannel? = null + private var eventChannel: EventChannel? = null + + // RD-65582: buffering and replay live in AppsFlyerEventBus so they survive engine teardown. + // Only the adapter around this engine's EventSink is held here, so it can be detached again. + private var eventSink: AppsFlyerEventSink? = null + + private val onNewIntentListener = PluginRegistry.NewIntentListener { intent -> + val currentActivity = activity + if (currentActivity != null) { + currentActivity.intent = intent + } + false + } + + private val eventStreamHandler: EventChannel.StreamHandler = + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + if (events == null) { + // Flutter does not do this in practice; without a destination the previous sink is + // no longer usable, so events go back to being buffered. + releaseEventSink() + return + } + val sink = createEventSink(events) + eventSink = sink + AppsFlyerEventBus.attach(sink) + } + + override fun onCancel(arguments: Any?) { + releaseEventSink() + } + } + + private fun createEventSink(events: EventChannel.EventSink): AppsFlyerEventSink = + AppsFlyerEventSink { eventJson -> + try { + events.success(eventJson) + true + } catch (t: Throwable) { + // Reached when the engine behind this sink is already gone. Reporting the refusal + // lets the bus keep the event for the next subscriber instead of losing it. + Log.w(AF_PLUGIN_TAG, "af-events sink refused an event: ${t.message}") + false + } + } + + private fun releaseEventSink() { + eventSink?.let { sink -> AppsFlyerEventBus.detach(sink) } + eventSink = null + } + + private fun onAttachedToEngine(applicationContext: Context, messenger: BinaryMessenger) { + isEngineDetached = false + this.applicationContext = applicationContext + this.blockingRpcExecutor = Executors.newSingleThreadExecutor() + + methodChannel = MethodChannel(messenger, AF_METHOD_CHANNEL) + methodChannel?.setMethodCallHandler(this) + + eventChannel = EventChannel(messenger, AF_EVENTS_CHANNEL) + eventChannel?.setStreamHandler(eventStreamHandler) + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) + AppsFlyerPurchaseConnector.onAttachedToEngine(binding) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + binding.addOnNewIntentListener(onNewIntentListener) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + // Set first so in-flight blocking-RPC completions posted to the main looper drop their + // Flutter Result instead of replying on a torn-down engine (mirrors iOS isEngineDetached). + isEngineDetached = true + // The buffer in AppsFlyerEventBus is intentionally left untouched: events emitted while no + // engine is attached have to survive until the next subscriber replays them. The RPC + // executor in AppsFlyerRpcBridge is left alone for the same reason: it belongs to the + // process-wide native SDK, not to this engine. + methodChannel?.setMethodCallHandler(null) + methodChannel = null + eventChannel?.setStreamHandler(null) + eventChannel = null + releaseEventSink() + AppsFlyerPurchaseConnector.onDetachedFromEngine(binding) + blockingRpcExecutor?.shutdown() + blockingRpcExecutor = null + activity = null + applicationContext = null + } + + override fun onDetachedFromActivityForConfigChanges() { + this.activity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + binding.addOnNewIntentListener(onNewIntentListener) + } + + override fun onDetachedFromActivity() { + activity = null + } + + override fun onMethodCall(call: MethodCall, result: Result) { + if (METHOD_EXECUTE_RPC == call.method) { + executeRpc(call, result) + } else { + result.notImplemented() + } + } + + private fun executeRpc(call: MethodCall, result: Result) { + // Internal transport contract (_invokeRpc): {method: String, params: Map}. Apps must not + // call this channel directly; a malformed envelope is an integration error and is rejected + // by [RpcEnvelopeParser] before dispatch (fail-fast, not UNEXPECTED_ERROR). + val (method, params) = RpcEnvelopeParser.parse(call) + + try { + if (RPC_METHOD_INIT == method) { + initFromRpc(params, result) + } else { + dispatchRpc(method, params, result, null) + } + } catch (t: Throwable) { + Log.e(AF_PLUGIN_TAG, "executeRpc error for '$method': ${t.message}", t) + result.error("UNEXPECTED_ERROR", t.message, null) + } + } + + /** + * The process-wide executor, so a new engine reattaches to the already configured bridge + * instead of building one that has no memory of the listeners registered on the native SDK. + * It always uses [Context.getApplicationContext], which outlives both this engine and the + * Activity. + */ + private fun sharedRpcExecutor(): AppsFlyerRpcExecutor = + AppsFlyerRpcBridge.shared { createRpcExecutor(requireApplicationContext().applicationContext) } + + /** + * SDK 7 replays the cold-start launch intent when init() receives an [Activity] (see + * AndroidLifecycleManagerImpl.registerLifecycleListener), so [RPC_METHOD_INIT] runs on an + * ephemeral executor built around that Activity instead of the shared one. + */ + private fun createRpcExecutor(context: Context): AppsFlyerRpcExecutor { + val handler = AppsFlyerRpcHandler( + context, + rpcEventNotifier, + AppsFlyerLib.getInstance(), + JsonRpcRequestParser() + ) + return AppsFlyerRpcExecutor { requestJson -> handler.execute(requestJson) } + } + + private fun requireApplicationContext(): Context { + return applicationContext + ?: throw IllegalStateException("Plugin is not attached to a Flutter engine") + } + + private fun initFromRpc(params: JSONObject, result: Result) { + val afDevKey = params.optString("devKey", "") + val initContext = activity ?: applicationContext + if (initContext == null) { + result.error("INIT_ERROR", "Plugin is not attached to a Flutter engine", null) + return + } + + runRpc(result, "init failed", "INIT_ERROR") { + // Identify the Flutter integration before init so the plugin name reaches the + // first session. Result ignored: the plugin name is a compile-time constant, so + // this can't fail in practice. + executeRpcSync( + RPC_METHOD_SET_PLUGIN_INFO, + jsonOf( + "plugin", AF_PLUGIN_NAME, + "pluginVersion", PLUGIN_VERSION + ) + ) + + val init = executeRpcSync( + RPC_METHOD_INIT, + jsonOf("devKey", afDevKey), + initContext + ) + if (init is RpcResponse.Error) { + deliverRpcResult(init, result, null) + return@runRpc + } + + deliverRpcResult(RpcResponse.VoidSuccess, result, null) + } + } + + private fun dispatchRpc(method: String, params: JSONObject, result: Result, voidValue: Any?) { + if (isBlockingRpc(method, params)) { + runOnBlockingRpcExecutor( + result, + "dispatchRpc('$method') failed", + "UNEXPECTED_ERROR" + ) { + val response = executeRpcSync(method, params) + uiThreadHandler.post { deliverRpcResult(response, result, voidValue) } + } + return + } + + runRpc(result, "dispatchRpc('$method') failed", "UNEXPECTED_ERROR") { + val response = executeRpcSync(method, params) + deliverRpcResult(response, result, voidValue) + } + } + + /** + * RPCs whose native handler blocks on a callback latch (see AppsFlyerRpcHandler.awaitCallback). + * Everything else runs inline on the platform thread so setters/getters are not queued behind + * a slow awaited call. + */ + private fun isBlockingRpc(method: String, params: JSONObject): Boolean { + return when (method) { + RPC_METHOD_START, RPC_METHOD_LOG_EVENT -> + params.optBoolean(RPC_PARAM_AWAIT_RESPONSE, false) + RPC_METHOD_VALIDATE_AND_LOG_IN_APP_PURCHASE, RPC_METHOD_GENERATE_INVITE_LINK -> + params.optBoolean(RPC_PARAM_AWAIT_RESPONSE, true) + else -> false + } + } + + private inline fun runRpc( + result: Result, + failureLog: String, + failureCode: String, + crossinline block: () -> Unit + ) { + try { + block() + } catch (t: Throwable) { + Log.e(AF_PLUGIN_TAG, "$failureLog: ${t.message}", t) + result.error(failureCode, t.message, null) + } + } + + private inline fun runOnBlockingRpcExecutor( + result: Result, + failureLog: String, + failureCode: String, + crossinline block: () -> Unit + ) { + val executor = blockingRpcExecutor + if (executor == null) { + result.error(PLUGIN_DETACHED, RPC_EXECUTOR_UNAVAILABLE_MSG, null) + return + } + try { + executor.execute { + try { + block() + } catch (t: Throwable) { + Log.e(AF_PLUGIN_TAG, "$failureLog: ${t.message}", t) + uiThreadHandler.post { + if (!isEngineDetached) { + result.error(failureCode, t.message, null) + } + } + } + } + } catch (t: RejectedExecutionException) { + Log.e(AF_PLUGIN_TAG, "$failureLog: executor rejected task: ${t.message}", t) + result.error(PLUGIN_DETACHED, RPC_EXECUTOR_UNAVAILABLE_MSG, null) + } + } + + private fun executeRpcSync( + method: String, + params: JSONObject, + initContext: Context? = null + ): RpcResponse { + return try { + val request = JSONObject() + request.put("method", method) + request.put("params", params) + val executor = if (initContext != null) { + createRpcExecutor(initContext) + } else { + sharedRpcExecutor() + } + executor.execute(request.toString()) + } catch (e: JSONException) { + RpcResponse.Error(RpcErrorCodes.INTERNAL_ERROR, e.message ?: "JSON error") + } + } + + private fun deliverRpcResult(response: RpcResponse, result: Result, voidValue: Any?) { + // Synchronous callers cannot observe a detach — they share the platform thread with + // onDetachedFromEngine. Only awaited RPCs can: shutdown() lets the in-flight task run to + // completion, so its latch can resolve after the engine is gone. Replying then is not fatal + // — Flutter drops the response with a "FlutterJNI was detached" warning — but the warning is + // misleading in customer bug reports, so the result is dropped here instead. + if (isEngineDetached) { + Log.d(AF_PLUGIN_TAG, "Dropping RPC result after engine detach") + return + } + if (response is RpcResponse.Success<*>) { + result.success(response.result) + } else if (response is RpcResponse.VoidSuccess) { + result.success(voidValue) + } else if (response is RpcResponse.Error) { + result.error(response.code.toString(), response.message, null) + } else { + result.success(voidValue) + } + } + + /** Builds a JSONObject from alternating key/value pairs; null values are omitted. */ + private fun jsonOf(vararg keyValues: Any?): JSONObject { + val json = JSONObject() + var i = 0 + while (i + 1 < keyValues.size) { + putQuietly(json, keyValues[i] as String?, toJsonValue(keyValues[i + 1])) + i += 2 + } + return json + } + + private fun putQuietly(json: JSONObject, key: String?, value: Any?) { + if (value == null) { + return + } + try { + json.put(key, value) + } catch (e: JSONException) { + Log.e(AF_PLUGIN_TAG, "Failed to put '$key' into RPC params: ${e.message}") + } + } + + private fun toJsonValue(value: Any?): Any? { + if (value is Map<*, *>) { + return JSONObject(value) + } + if (value is List<*>) { + return JSONArray(value) + } + return value + } + + companion object { + private const val METHOD_EXECUTE_RPC = "executeRpc" + + private const val RPC_METHOD_START = "start" + private const val RPC_METHOD_LOG_EVENT = "logEvent" + private const val RPC_METHOD_VALIDATE_AND_LOG_IN_APP_PURCHASE = "validateAndLogInAppPurchase" + private const val RPC_METHOD_GENERATE_INVITE_LINK = "generateInviteLink" + private const val RPC_PARAM_AWAIT_RESPONSE = "awaitResponse" + + private val uiThreadHandler = Handler(Looper.getMainLooper()) + + /** + * Bridge notifier. Events fire on the SDK's callback thread, so we hop to the main thread + * before touching the Flutter channels. + * + * Deliberately declared on the companion: the native SDK keeps the listener that owns this + * notifier registered after the engine is torn down, and capturing no plugin instance is + * what stops that listener from pinning — and publishing into — a dead plugin. + */ + private val rpcEventNotifier: (String) -> Unit = { eventJson -> + uiThreadHandler.post { AppsFlyerEventBus.publish(eventJson) } + } + } +} diff --git a/android/src/main/kotlin/com/appsflyer/appsflyersdk/RpcEnvelopeParser.kt b/android/src/main/kotlin/com/appsflyer/appsflyersdk/RpcEnvelopeParser.kt new file mode 100644 index 00000000..d98c7764 --- /dev/null +++ b/android/src/main/kotlin/com/appsflyer/appsflyersdk/RpcEnvelopeParser.kt @@ -0,0 +1,32 @@ +package com.appsflyer.appsflyersdk + +import io.flutter.plugin.common.MethodCall +import org.json.JSONObject + +/** + * Parses the internal `executeRpc` transport envelope `{method, params}`. + * + * Malformed envelopes are integration errors inside the plugin bridge, not + * user-facing RPC failures. Parsing is intentionally outside dispatch + * `try/catch` so violations fail fast with [IllegalStateException] instead of + * being converted to `UNEXPECTED_ERROR`. + */ +internal object RpcEnvelopeParser { + private const val VIOLATION_PREFIX = "RPC envelope contract violation: " + + fun parse(call: MethodCall): Pair = parse(call.arguments) + + fun parse(arguments: Any?): Pair { + val envelope = arguments as? Map + ?: envelopeViolation("arguments must be Map") + val method = envelope["method"] as? String + ?: envelopeViolation("method must be String") + val paramsMap = envelope["params"] as? Map<*, *> + ?: envelopeViolation("params must be Map") + return method to JSONObject(paramsMap) + } + + private fun envelopeViolation(detail: String): Nothing { + throw IllegalStateException(VIOLATION_PREFIX + detail) + } +} diff --git a/android/src/test/kotlin/com/appsflyer/appsflyersdk/AppsFlyerEventBusTest.kt b/android/src/test/kotlin/com/appsflyer/appsflyersdk/AppsFlyerEventBusTest.kt new file mode 100644 index 00000000..b4914ae8 --- /dev/null +++ b/android/src/test/kotlin/com/appsflyer/appsflyersdk/AppsFlyerEventBusTest.kt @@ -0,0 +1,437 @@ +package com.appsflyer.appsflyersdk + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Records every delivery attempt and accepts at most [acceptLimit] of them. + * + * An unlimited sink stands in for a live engine; a limited one for an engine that goes away while + * the buffer is being flushed. + */ +private class RecordingSink(private val acceptLimit: Int = Int.MAX_VALUE) : AppsFlyerEventSink { + + val received: MutableList = Collections.synchronizedList(mutableListOf()) + + override fun send(eventJson: String): Boolean { + received += eventJson + return received.size <= acceptLimit + } +} + +class AppsFlyerEventBusTest { + + @Before + fun setUp() = AppsFlyerEventBus.reset() + + @After + fun tearDown() = AppsFlyerEventBus.reset() + + // region delivery to an attached sink + + @Test + fun publish_withAttachedSink_deliversImmediately() { + val sink = RecordingSink() + AppsFlyerEventBus.attach(sink) + + AppsFlyerEventBus.publish(EVENT_A) + + assertEquals(listOf(EVENT_A), sink.received) + assertEquals(0, AppsFlyerEventBus.pendingCount()) + } + + @Test + fun publish_withAttachedSink_preservesPublishOrder() { + val sink = RecordingSink() + AppsFlyerEventBus.attach(sink) + + AppsFlyerEventBus.publish(EVENT_A) + AppsFlyerEventBus.publish(EVENT_B) + AppsFlyerEventBus.publish(EVENT_C) + + assertEquals(listOf(EVENT_A, EVENT_B, EVENT_C), sink.received) + } + + @Test + fun attach_withEmptyBuffer_deliversNothing() { + val sink = RecordingSink() + + AppsFlyerEventBus.attach(sink) + + assertEquals(emptyList(), sink.received) + } + + // endregion + + // region buffering and replay + + @Test + fun publish_withoutSink_buffersInsteadOfDropping() { + AppsFlyerEventBus.publish(EVENT_A) + AppsFlyerEventBus.publish(EVENT_B) + + assertEquals(2, AppsFlyerEventBus.pendingCount()) + } + + @Test + fun attach_afterBufferedPublish_replaysInPublishOrder() { + AppsFlyerEventBus.publish(EVENT_A) + AppsFlyerEventBus.publish(EVENT_B) + val sink = RecordingSink() + + AppsFlyerEventBus.attach(sink) + + assertEquals(listOf(EVENT_A, EVENT_B), sink.received) + assertEquals(0, AppsFlyerEventBus.pendingCount()) + } + + @Test + fun publish_afterReplay_keepsReplayedEventsBeforeLiveOnes() { + AppsFlyerEventBus.publish(EVENT_A) + AppsFlyerEventBus.publish(EVENT_B) + val sink = RecordingSink() + + AppsFlyerEventBus.attach(sink) + AppsFlyerEventBus.publish(EVENT_C) + + assertEquals(listOf(EVENT_A, EVENT_B, EVENT_C), sink.received) + } + + @Test + fun attach_twiceWithoutNewEvents_doesNotRedeliver() { + AppsFlyerEventBus.publish(EVENT_A) + val sink = RecordingSink() + + AppsFlyerEventBus.attach(sink) + AppsFlyerEventBus.attach(sink) + + assertEquals(listOf(EVENT_A), sink.received) + } + + // endregion + + // region engine lifecycle + + @Test + fun publish_afterDetach_isBufferedForTheNextEngine() { + val firstEngineSink = RecordingSink() + AppsFlyerEventBus.attach(firstEngineSink) + AppsFlyerEventBus.detach(firstEngineSink) + + AppsFlyerEventBus.publish(EVENT_A) + + assertEquals(emptyList(), firstEngineSink.received) + assertEquals(1, AppsFlyerEventBus.pendingCount()) + } + + /** The regression this class exists for: an event emitted while no engine is attached. */ + @Test + fun engineRecreation_eventPublishedWhileDetached_reachesTheNewEngine() { + val firstEngineSink = RecordingSink() + AppsFlyerEventBus.attach(firstEngineSink) + AppsFlyerEventBus.detach(firstEngineSink) + AppsFlyerEventBus.publish(EVENT_A) + + val secondEngineSink = RecordingSink() + AppsFlyerEventBus.attach(secondEngineSink) + + assertEquals(listOf(EVENT_A), secondEngineSink.received) + assertEquals(emptyList(), firstEngineSink.received) + } + + @Test + fun attach_whileAnotherSinkIsActive_routesToTheNewestSink() { + val oldSink = RecordingSink() + val newSink = RecordingSink() + AppsFlyerEventBus.attach(oldSink) + + AppsFlyerEventBus.attach(newSink) + AppsFlyerEventBus.publish(EVENT_A) + + assertEquals(listOf(EVENT_A), newSink.received) + assertEquals(emptyList(), oldSink.received) + } + + /** + * Engine teardown is not ordered against engine setup, so a detaching engine must not unbind + * the sink a newer engine already attached. + */ + @Test + fun detach_withStaleSink_keepsTheActiveSinkAttached() { + val staleSink = RecordingSink() + val activeSink = RecordingSink() + AppsFlyerEventBus.attach(staleSink) + AppsFlyerEventBus.attach(activeSink) + + AppsFlyerEventBus.detach(staleSink) + AppsFlyerEventBus.publish(EVENT_A) + + assertEquals(listOf(EVENT_A), activeSink.received) + assertEquals(0, AppsFlyerEventBus.pendingCount()) + } + + @Test + fun detach_calledTwice_isIdempotent() { + val sink = RecordingSink() + AppsFlyerEventBus.attach(sink) + + AppsFlyerEventBus.detach(sink) + AppsFlyerEventBus.detach(sink) + AppsFlyerEventBus.publish(EVENT_A) + + assertEquals(emptyList(), sink.received) + assertEquals(1, AppsFlyerEventBus.pendingCount()) + } + + @Test + fun detach_withNeverAttachedSink_leavesBufferIntact() { + AppsFlyerEventBus.publish(EVENT_A) + + AppsFlyerEventBus.detach(RecordingSink()) + + assertEquals(1, AppsFlyerEventBus.pendingCount()) + } + + // endregion + + // region refusing sinks + + @Test + fun publish_whenSinkRefuses_keepsEventBufferedAndDropsSink() { + val refusingSink = RecordingSink(acceptLimit = 0) + AppsFlyerEventBus.attach(refusingSink) + + AppsFlyerEventBus.publish(EVENT_A) + + assertEquals(listOf(EVENT_A), refusingSink.received) + assertEquals(1, AppsFlyerEventBus.pendingCount()) + } + + @Test + fun publish_afterSinkRefused_doesNotReachThatSinkAgain() { + val refusingSink = RecordingSink(acceptLimit = 0) + AppsFlyerEventBus.attach(refusingSink) + AppsFlyerEventBus.publish(EVENT_A) + + AppsFlyerEventBus.publish(EVENT_B) + + assertEquals(listOf(EVENT_A), refusingSink.received) + assertEquals(2, AppsFlyerEventBus.pendingCount()) + } + + @Test + fun attach_afterSinkRefused_replaysEveryUndeliveredEventInOrder() { + AppsFlyerEventBus.publish(EVENT_A) + AppsFlyerEventBus.publish(EVENT_B) + AppsFlyerEventBus.publish(EVENT_C) + val partialSink = RecordingSink(acceptLimit = 1) + + AppsFlyerEventBus.attach(partialSink) + val recoveredSink = RecordingSink() + AppsFlyerEventBus.attach(recoveredSink) + + assertEquals(listOf(EVENT_A, EVENT_B), partialSink.received) + assertEquals(listOf(EVENT_B, EVENT_C), recoveredSink.received) + assertEquals(0, AppsFlyerEventBus.pendingCount()) + } + + // endregion + + // region buffer bound + + @Test + fun publish_beyondBufferBound_keepsTheMostRecentEvents() { + val overflow = 5 + val published = (0 until MAX_PENDING_EVENTS + overflow).map { index -> "event-$index" } + published.forEach { event -> AppsFlyerEventBus.publish(event) } + val sink = RecordingSink() + + AppsFlyerEventBus.attach(sink) + + assertEquals(MAX_PENDING_EVENTS, sink.received.size) + assertEquals(published.takeLast(MAX_PENDING_EVENTS), sink.received) + } + + @Test + fun publish_atBufferBound_dropsNothing() { + val published = (0 until MAX_PENDING_EVENTS).map { index -> "event-$index" } + published.forEach { event -> AppsFlyerEventBus.publish(event) } + val sink = RecordingSink() + + AppsFlyerEventBus.attach(sink) + + assertEquals(published, sink.received) + } + + // endregion + + // region state hygiene + + @Test + fun reset_clearsBufferAndSink() { + val sink = RecordingSink() + AppsFlyerEventBus.attach(sink) + AppsFlyerEventBus.publish(EVENT_A) + + AppsFlyerEventBus.reset() + AppsFlyerEventBus.publish(EVENT_B) + + assertEquals(listOf(EVENT_A), sink.received) + assertEquals(1, AppsFlyerEventBus.pendingCount()) + } + + // endregion + + // region concurrency + + /** + * The notifier hops to the main thread today, but the bus is reachable from a static notifier + * that any SDK callback thread can drive, so it has to hold under parallel publishing. + */ + @Test + fun publish_fromManyThreads_deliversEveryEventExactlyOnce() { + val sink = RecordingSink() + AppsFlyerEventBus.attach(sink) + + publishConcurrently(threads = THREAD_COUNT, eventsPerThread = EVENTS_PER_THREAD) + + val expectedTotal = THREAD_COUNT * EVENTS_PER_THREAD + assertEquals(expectedTotal, sink.received.size) + assertEquals(expectedTotal, sink.received.toSet().size) + assertEquals(0, AppsFlyerEventBus.pendingCount()) + } + + @Test + fun publish_fromManyThreads_preservesPerThreadOrder() { + val sink = RecordingSink() + AppsFlyerEventBus.attach(sink) + + publishConcurrently(threads = THREAD_COUNT, eventsPerThread = EVENTS_PER_THREAD) + + for (thread in 0 until THREAD_COUNT) { + val ownEvents = sink.received.filter { event -> event.startsWith("t$thread-") } + val expected = (0 until EVENTS_PER_THREAD).map { index -> "t$thread-$index" } + assertEquals(expected, ownEvents) + } + } + + /** + * Publishing while engines attach and detach must not lose or duplicate events. The event count + * stays below the buffer bound so that every event is accounted for. + */ + @Test + fun publish_whileSinksChurn_losesAndDuplicatesNothing() { + val deliveries: MutableList = Collections.synchronizedList(mutableListOf()) + val churnThreads = 4 + val eventsPerThread = 8 + val executor = Executors.newFixedThreadPool(churnThreads * 2) + val startGate = CountDownLatch(1) + + try { + val publishers = (0 until churnThreads).map { thread -> + executor.submit { + startGate.await() + for (index in 0 until eventsPerThread) { + AppsFlyerEventBus.publish("t$thread-$index") + } + } + } + val churners = (0 until churnThreads).map { + executor.submit { + startGate.await() + repeat(eventsPerThread) { + val sink = AppsFlyerEventSink { eventJson -> + deliveries += eventJson + true + } + AppsFlyerEventBus.attach(sink) + AppsFlyerEventBus.detach(sink) + } + } + } + + startGate.countDown() + (publishers + churners).forEach { task -> task.get(TASK_TIMEOUT_SECONDS, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + + val finalSink = RecordingSink() + AppsFlyerEventBus.attach(finalSink) + val allDelivered = deliveries + finalSink.received + val expected = (0 until churnThreads).flatMap { thread -> + (0 until eventsPerThread).map { index -> "t$thread-$index" } + } + + assertEquals(expected.size, allDelivered.size) + assertEquals(expected.toSet(), allDelivered.toSet()) + assertEquals(0, AppsFlyerEventBus.pendingCount()) + } + + @Test + fun attach_concurrentlyWithPublish_endsWithASingleActiveSink() { + val executor = Executors.newFixedThreadPool(2) + val startGate = CountDownLatch(1) + val lastSink = RecordingSink() + + try { + val publisher = executor.submit { + startGate.await() + repeat(EVENTS_PER_THREAD) { index -> AppsFlyerEventBus.publish("p-$index") } + } + val attacher = executor.submit { + startGate.await() + repeat(EVENTS_PER_THREAD) { AppsFlyerEventBus.attach(RecordingSink()) } + } + startGate.countDown() + listOf(publisher, attacher).forEach { task -> task.get(TASK_TIMEOUT_SECONDS, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + + AppsFlyerEventBus.attach(lastSink) + AppsFlyerEventBus.publish(EVENT_A) + + assertTrue(lastSink.received.contains(EVENT_A)) + assertEquals(0, AppsFlyerEventBus.pendingCount()) + } + + private fun publishConcurrently(threads: Int, eventsPerThread: Int) { + val executor = Executors.newFixedThreadPool(threads) + val startGate = CountDownLatch(1) + try { + val tasks = (0 until threads).map { thread -> + executor.submit { + startGate.await() + for (index in 0 until eventsPerThread) { + AppsFlyerEventBus.publish("t$thread-$index") + } + } + } + startGate.countDown() + tasks.forEach { task -> task.get(TASK_TIMEOUT_SECONDS, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + } + + private companion object { + private const val EVENT_A = """{"name":"onDeepLinking","data":{"status":"FOUND"}}""" + private const val EVENT_B = """{"name":"onConversionDataSuccess","data":{}}""" + private const val EVENT_C = """{"name":"onSessionReady"}""" + + // Mirrors MAX_PENDING_EVENTS in AppsFlyerEventBus.kt, which is file-private there. + private const val MAX_PENDING_EVENTS = 64 + + private const val THREAD_COUNT = 8 + private const val EVENTS_PER_THREAD = 50 + private const val TASK_TIMEOUT_SECONDS = 10L + } +} diff --git a/android/src/test/kotlin/com/appsflyer/appsflyersdk/AppsFlyerRpcBridgeTest.kt b/android/src/test/kotlin/com/appsflyer/appsflyersdk/AppsFlyerRpcBridgeTest.kt new file mode 100644 index 00000000..b927d5b2 --- /dev/null +++ b/android/src/test/kotlin/com/appsflyer/appsflyersdk/AppsFlyerRpcBridgeTest.kt @@ -0,0 +1,104 @@ +package com.appsflyer.appsflyersdk + +import com.appsflyer.pluginbridge.model.RpcResponse + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** Stands in for the executor wrapping `AppsFlyerRpcHandler`, which needs a real Android context. */ +private class FakeRpcExecutor : AppsFlyerRpcExecutor { + override fun execute(requestJson: String): RpcResponse = RpcResponse.VoidSuccess +} + +class AppsFlyerRpcBridgeTest { + + private val created = AtomicInteger() + + @Before + fun setUp() = AppsFlyerRpcBridge.reset() + + @After + fun tearDown() = AppsFlyerRpcBridge.reset() + + private fun createExecutor(): AppsFlyerRpcExecutor { + created.incrementAndGet() + return FakeRpcExecutor() + } + + @Test + fun shared_withoutAnExecutor_createsOne() { + val executor = AppsFlyerRpcBridge.shared(::createExecutor) + + assertEquals(1, created.get()) + assertEquals(RpcResponse.VoidSuccess, executor.execute(REQUEST)) + } + + @Test + fun shared_repeatedCalls_reuseTheSameExecutor() { + val first = AppsFlyerRpcBridge.shared(::createExecutor) + val second = AppsFlyerRpcBridge.shared(::createExecutor) + + assertSame(first, second) + assertEquals(1, created.get()) + } + + @Test + fun shared_afterEngineRecreation_reusesTheExecutorOfThePreviousEngine() { + // The plugin instance goes away with its engine; the executor belongs to the process, so + // the engine built after a back press reattaches to the already configured bridge. + val firstEngine = AppsFlyerRpcBridge.shared(::createExecutor) + + val secondEngine = AppsFlyerRpcBridge.shared(::createExecutor) + + assertSame(firstEngine, secondEngine) + assertEquals(1, created.get()) + } + + @Test + fun shared_fromConcurrentEngines_createsExactlyOneExecutor() { + val start = CountDownLatch(1) + val done = CountDownLatch(ENGINES) + val resolved = Collections.synchronizedList(mutableListOf()) + val pool = Executors.newFixedThreadPool(ENGINES) + + repeat(ENGINES) { + pool.execute { + start.await() + resolved += AppsFlyerRpcBridge.shared(::createExecutor) + done.countDown() + } + } + start.countDown() + assertTrue(done.await(5, TimeUnit.SECONDS)) + pool.shutdownNow() + + assertEquals(1, created.get()) + assertEquals(1, resolved.distinct().size) + } + + @Test + fun reset_dropsTheExecutorSoTheNextCallBuildsANewOne() { + val first = AppsFlyerRpcBridge.shared(::createExecutor) + + AppsFlyerRpcBridge.reset() + val second = AppsFlyerRpcBridge.shared(::createExecutor) + + assertNotSame(first, second) + assertEquals(2, created.get()) + } + + private companion object { + const val REQUEST = """{"method":"getSdkVersion","params":{}}""" + const val ENGINES = 8 + } +} diff --git a/android/src/test/kotlin/com/appsflyer/appsflyersdk/RpcEnvelopeParserTest.kt b/android/src/test/kotlin/com/appsflyer/appsflyersdk/RpcEnvelopeParserTest.kt new file mode 100644 index 00000000..56028443 --- /dev/null +++ b/android/src/test/kotlin/com/appsflyer/appsflyersdk/RpcEnvelopeParserTest.kt @@ -0,0 +1,80 @@ +package com.appsflyer.appsflyersdk + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RpcEnvelopeParserTest { + + @Test + fun parse_validEnvelope_returnsMethod() { + val (method, _) = RpcEnvelopeParser.parse( + mapOf( + "method" to "start", + "params" to mapOf("awaitResponse" to true), + ), + ) + + assertEquals("start", method) + } + + @Test + fun parse_emptyParamsMap_succeeds() { + val (method, _) = RpcEnvelopeParser.parse( + mapOf( + "method" to "disableAppSetId", + "params" to emptyMap(), + ), + ) + + assertEquals("disableAppSetId", method) + } + + @Test(expected = IllegalStateException::class) + fun parse_nonMapArguments_throwsContractViolation() { + RpcEnvelopeParser.parse("not-a-map") + } + + @Test + fun parse_nonMapArguments_includesContractPrefix() { + try { + RpcEnvelopeParser.parse(null) + } catch (error: IllegalStateException) { + assertEquals( + "RPC envelope contract violation: arguments must be Map", + error.message, + ) + return + } + throw AssertionError("Expected IllegalStateException") + } + + @Test(expected = IllegalStateException::class) + fun parse_missingMethod_throwsContractViolation() { + RpcEnvelopeParser.parse(mapOf("params" to emptyMap())) + } + + @Test(expected = IllegalStateException::class) + fun parse_nonStringMethod_throwsContractViolation() { + RpcEnvelopeParser.parse( + mapOf( + "method" to 123, + "params" to emptyMap(), + ), + ) + } + + @Test(expected = IllegalStateException::class) + fun parse_missingParams_throwsContractViolation() { + RpcEnvelopeParser.parse(mapOf("method" to "start")) + } + + @Test(expected = IllegalStateException::class) + fun parse_nonMapParams_throwsContractViolation() { + RpcEnvelopeParser.parse( + mapOf( + "method" to "start", + "params" to "not-a-map", + ), + ) + } +} diff --git a/doc/API.md b/doc/API.md deleted file mode 100644 index 4717ae32..00000000 --- a/doc/API.md +++ /dev/null @@ -1,1248 +0,0 @@ -# API - - - -## Types -- [AppsFlyerOptions](#appsflyer-options) -- [AdRevenueData](#AdRevenueData) -- [AFMediationNetwork](#AFMediationNetwork) -- [AFPurchaseDetails](#AFPurchaseDetails) -- [AFPurchaseType](#AFPurchaseType) - -## Methods -- [initSdk](#initSdk) -- [startSDK](#startSDK) -- [onAppOpenAttribution](#onAppOpenAttribution) -- [onInstallConversionData](#onInstallConversionData) -- [onDeepLinking](#onDeepLinking) -- [logEvent](#logEvent) -- [anonymizeUser](#anonymizeUser) -- [setUserEmails](#setUserEmails) -- [setMinTimeBetweenSessions](#setMinTimeBetweenSessions) -- [stop](#stop) -- [setCurrencyCode](#setCurrencyCode) -- [setIsUpdate](#setIsUpdate) -- [enableUninstallTracking](#enableUninstallTracking) -- [setImeiData](#setImeiData) -- [setAndroidIdData](#setAndroidIdData) -- [enableLocationCollection](#enableLocationCollection) -- [setCustomerUserId](#setCustomerUserId) -- [setCustomerIdAndLogSession](#setCustomerIdAndLogSession) -- [waitForCustomerUserId](#waitForCustomerUserId) -- [setAdditionalData](#setAdditionalData) -- [setCollectAndroidId](#setCollectAndroidId) -- [setCollectIMEI](#setCollectIMEI) -- [setHost](#setHost) -- [getHostName](#getHostName) -- [getHostPrefix](#getHostPrefix) -- [updateServerUninstallToken](#updateServerUninstallToken) -- [Validate Purchase](#validatePurchase) -- [validateAndLogInAppPurchaseV2](#validatePurchaseV2) -- [sendPushNotificationData](#sendPushNotificationData) -- [addPushNotificationDeepLinkPath](#addPushNotificationDeepLinkPath) -- [User Invite](#userInvite) -- [enableFacebookDeferredApplinks](#enableFacebookDeferredApplinks) -- [enableTCFDataCollection](#enableTCFDataCollection) -- [setConsentData](#setConsentData) - [DEPRECATED] -- [setConsentDataV2](#setConsentDataV2) -- [disableSKAdNetwork](#disableSKAdNetwork) -- [getAppsFlyerUID](#getAppsFlyerUID) -- [setCurrentDeviceLanguage](#setCurrentDeviceLanguage) -- [setSharingFilterForPartners](#setSharingFilterForPartners) -- [setOneLinkCustomDomain](#setOneLinkCustomDomain) -- [setDisableAdvertisingIdentifiers](#setDisableAdvertisingIdentifiers) -- [setPartnerData](#setPartnerData) -- [setResolveDeepLinkURLs](#setResolveDeepLinkURLs) -- [setOutOfStore](#setOutOfStore) -- [getOutOfStore](#getOutOfStore) -- [setDisableNetworkData](#setDisableNetworkData) -- [disableAppSetId](#disableAppSetId) -- [performOnDeepLinking](#performondeeplinking) -- [logAdRevenue](#logAdRevenue) - Since 6.15.1 - - ---- - -##### **`AppsflyerSdk(Map options)`** - -| parameter | type | description | -| --------- | ----- | ----------------- | -| `appsFlyerOptions` | `Map` | SDK configuration | - -**`options`** - - | -| Setting | Type | Description | -| -------- | -------- | ------------- | -| devKey | String | Your application's [devKey](https://support.appsflyer.com/hc/en-us/articles/207032066-Basic-SDK-integration-guide#retrieving-the-dev-key) provided by AppsFlyer (required) | -| appId | String | Your application's [App ID](https://support.appsflyer.com/hc/en-us/articles/207377436-Adding-a-new-app#available-in-the-app-store-google-play-store-windows-phone-store) (required for iOS only) that you configured in your AppsFlyer dashboard | -| showDebug | bool | Debug mode - set to `true` for testing only, do not release to production with this parameter set to `true`! | -| timeToWaitForATTUserAuthorization | double | Delays the SDK start for x seconds until the user either accepts the consent dialog, declines it, or the timer runs out. | -| appInviteOneLink | String | The [OneLink template ID](https://support.appsflyer.com/hc/en-us/articles/115004480866-User-invite-attribution#parameters) that is used to generate a User Invite, this is not a required field in the `AppsFlyerOptions`, you may choose to set it later via the appropriate API. | -| disableAdvertisingIdentifier| bool | Opt-out of the collection of Advertising Identifiers, which include OAID, AAID, GAID and IDFA. | -| disableCollectASA | bool | Opt-out of the Apple Search Ads attributions. | -| manualStart | bool | Prevents from the SDK from sending the launch request after using appsFlyer.initSdk(...). When using this property, the apps needs to manually trigger the appsFlyer.startSdk() API to report the app launch.| - - - - -_Example:_ - -```dart -import 'package:appsflyer_sdk/appsflyer_sdk.dart'; -//.. - -Map appsFlyerOptions = { "afDevKey": afDevKey, - "afAppId": appId, - "showDebug": true}; - -AppsflyerSdk appsflyerSdk = AppsflyerSdk(appsFlyerOptions); - -``` - -**Or you can use `AppsFlyerOptions` class instead** - -##### **`AppsflyerSdk(Map options)`** - -| parameter | type | description | -| --------- | ------------------ | ----------------- | -| `appsFlyerOptions` | `AppsFlyerOptions` | SDK configuration | - -_Example:_ - -```dart -import 'package:appsflyer_sdk/appsflyer_sdk.dart'; -//.. - -final AppsFlyerOptions options = AppsFlyerOptions(afDevKey: "af dev key", - showDebug: true, - appId: "123456789"); -``` - -Once `AppsflyerSdk` object is created, you can call `initSdk` method. - ---- - -##### **`AdRevenueData`** - -| parameter | type | description | -| --------- | ------------------ | ----------------- | -| `monetizationNetwork` | `String` | | -| `mediationNetwork` | `String` | value must be taken from `AFMediationNetwork` | -| `currencyIso4217Code` | `String` | | -| `revenue` | `double` | | -| `additionalParameters` | `Map?` | | - ---- - -##### **`AFMediationNetwork`** -an enumeration that includes the supported mediation networks by AppsFlyer. - - -| networks | -| -------- | -| ironSource -applovinMax -googleAdMob -fyber -appodeal -admost -topon -tradplus -yandex -chartboost -unity -toponPte -customMediation -directMonetizationNetwork | - ---- - - -##### **`initSdk({bool registerConversionDataCallback, bool registerOnAppOpenAttributionCallback}) async` (Changed in 1.2.2)** - -initialize the SDK, using the options initialized from the constructor| -Return response object with the field `status` - -_Example:_ - -```dart -import 'package:appsflyer_sdk/appsflyer_sdk.dart'; -//.. - -AppsflyerSdk _appsflyerSdk = AppsflyerSdk({...}); - -await _appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: true) -``` - ---- -##### **`startSDK()` (Added in 6.13.0)** -In version 6.13.0 of the appslfyer-flutter-plugin SDK we added the option of splitting between the initialization stage and start stage. All you need to do is add the property manualStart: true to the init object, and later call appsFlyer.startSdk() whenever you decide. If this property is set to false or doesn't exist, the sdk will start after calling appsFlyer.initSdk(...). -```dart -_appsflyerSdk.startSDK(); -``` ---- -#### **`onAppOpenAttribution(Func)` -- Trigger callback when onAppOpenAttribution is activated on the native side - -_Example:_ - -```dart -_appsflyerSdk.onAppOpenAttribution((res) { - print("res: " + res.toString()); - }); -``` - -#### **`onInstallConversionData(Func)` -- Trigger callback when onInstallConversionData is activated on the native side - -_Example:_ - -```dart - _appsflyerSdk.onInstallConversionData((res) { - print("res: " + res.toString()); - }); -``` - -#### **`onDeepLinking(Func)` -- Trigger callback when onDeepLinking is activated on the native side - -_Example:_ - -```dart - _appsflyerSdk.onDeepLinking((res) { - print("res: " + res.toString()); - }); -``` - ---- -##### **`logEvent(String eventName, Map? eventValues)`** - -- These in-app events help you to understand how loyal users discover your app, and attribute them to specific - campaigns/media-sources. Please take the time define the event/s you want to measure to allow you - to send ROI (Return on Investment) and LTV (Lifetime Value). -- The `logEvent` method allows you to send in-app events to AppsFlyer analytics. This method allows you to add events dynamically by adding them directly to the application code. - -| parameter | type | description | -| ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `eventName` | `String` | Use descriptive, action-based names (e.g., "purchase", "add_to_cart", "level_completed"), keep names concise but meaningful, use lowercase with underscores for consistency and avoid special characters and spaces. See the [recommended event list by business](https://support.appsflyer.com/hc/en-us/articles/115005544169-In-app-events-Overview#recommended-events-by-business-vertical). | -| `eventValues` | `Map` | event details | - -_Example:_ - -```dart -Future logEvent(String eventName, Map? eventValues) async { - bool? result; - try { - result = await appsflyerSdk.logEvent(eventName, eventValues); - } on Exception catch (e) {} - print("Result logEvent: $result"); -} -``` - ---- - -## Other functionalities: -** `anonymizeUser(shouldAnonymize)`** - -It is possible to anonymize specific user identifiers within AppsFlyer analytics.
-This complies with both the latest privacy requirements (GDPR, COPPA) and Facebook's data and privacy policies. To anonymize an app user. -| parameter | type | description | -| ---------- |----------|------------------ | -| shouldAnonymize | boolean | True if want Anonymize user Data (default value is false). | - -_Example:_ -```dart -appsFlyerSdk.anonymizeUser(true); -``` ---- -**
`setUserEmails(List emails, [EmailCryptType cryptType])`** - -Set the user emails with the given encryption (`EmailCryptTypeNone, EmailCryptTypeSHA256`). the default encryption is `EmailCryptTypeNone`. - -_Example:_ -```dart -appsFlyerSdk.setUserEmails( - ["a@a.com", "b@b.com"], EmailCryptType.EmailCryptTypeSHA256); -``` ---- -** `void setMinTimeBetweenSessions(int seconds)`** -You can set the minimum time between session (the default is 5 seconds) -```dart -appsFlyerSdk.setMinTimeBetweenSessions(3) -``` ---- -** `void stop(bool isStopped)`** -You can stop sending events to Appsflyer by using this method. - -_Example:_ -```dart -widget.appsFlyerSdk.stop(true); -``` ---- -** `void setCurrencyCode(String currencyCode)`** - -_Example:_ -```dart -appsFlyerSdk.setCurrencyCode("currencyCode"); -``` ---- -** `void setIsUpdate(bool isUpdate)`** - -_Example:_ -```dart -appsFlyerSdk.setIsUpdate(true); -``` ---- -** `void enableUninstallTracking(String senderId)`** - -_Example:_ -```dart -appsFlyerSdk.enableUninstallTracking("senderId"); -``` ---- -** `void setImeiData(String imei)`** - -_Example:_ -```dart -appsFlyerSdk.setImeiData("imei"); -``` ---- -** `void setAndroidIdData(String androidIdData)`** - -_Example:_ -```dart -appsFlyerSdk.setAndroidIdData("androidId"); -``` ---- -** `void enableLocationCollection(bool flag)`** - -**Removed as of v6.8.0** - -_Example:_ -```dart -appsFlyerSdk.enableLocationCollection(true); -``` ---- -** `enableTCFDataCollection(bool shouldCollect)`** - -The `enableTCFDataCollection` method is employed to control the automatic collection of the Transparency and Consent Framework (TCF) data. By setting this flag to `true`, the system is instructed to automatically collect TCF data. Conversely, setting it to `false` prevents such data collection. - -_Example:_ -```dart -appsFlyerSdk.enableTCFDataCollection(true); -``` ---- -** `setConsentData(Map consentData)`** *Deprecated* - -The `AppsflyerConsent` object helps manage user consent settings. By using the setConsentData we able to manually collect the TCF data. You can create an instance for users subject to GDPR or otherwise: - -1. Users subjected to GDPR: - -```dart -var forGdpr = AppsFlyerConsent.forGDPRUser( - hasConsentForDataUsage: true, - hasConsentForAdsPersonalization: true -); -_appsflyerSdk.setConsentData(forGdpr); -``` - -2. Users not subject to GDPR: - -```dart -var nonGdpr = AppsFlyerConsent.nonGDPRUser(); -_appsflyerSdk.setConsentData(nonGdpr); -``` - -The `_appsflyerSdk` handles consent data with `setConsentData` method, where you can pass the desired `AppsflyerConsent` instance. - ---- -To reflect TCF data in the conversion (first launch) payload, it's crucial to configure `enableTCFDataCollection` **or** `setConsentData` between the SDK initialization and start phase. Follow the example provided: - -```dart -// Set AppsFlyerOption - make sure to set manualStart to true -final AppsFlyerOptions options = AppsFlyerOptions( - afDevKey: dotenv.env["DEV_KEY"]!, - appId: dotenv.env["APP_ID"]!, - showDebug: true, - timeToWaitForATTUserAuthorization: 15, - manualStart: true); -_appsflyerSdk = AppsflyerSdk(options); - -// Init the AppsFlyer SDK -_appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: true); - -// Set configurations to the SDK -// Enable TCF Data Collection -_appsflyerSdk.enableTCFDataCollection(true); - -// Set Consent Data -// If user is subject to GDPR -// var forGdpr = AppsFlyerConsent.forGDPRUser(hasConsentForDataUsage: true, hasConsentForAdsPersonalization: true); -// _appsflyerSdk.setConsentData(forGdpr); - -// If user is not subject to GDPR -var nonGdpr = AppsFlyerConsent.nonGDPRUser(); -_appsflyerSdk.setConsentData(nonGdpr); - -// Here we start a session -_appsflyerSdk.startSDK(); -``` - -Following this sequence ensures that the consent configurations take effect before the AppsFlyer SDK starts, providing accurate consent data in the first launch payload. -Note: You need to use either `enableTCFDataCollection` or `setConsentData` if you use both of them our backend will prioritize the provided consent data from `setConsentData`. - ---- -** `setConsentDataV2({bool? isUserSubjectToGDPR, bool? consentForDataUsage, bool? consentForAdsPersonalization, bool? hasConsentForAdStorage})`** - -### Sets user consent preferences for GDPR and ad personalization - -> ⚠️ This method replaces the deprecated `setConsentData` - for a complete guide, see our [DMA compliance documentation](DMA.md). - -Use this method to provide the user's consent settings to the AppsFlyer SDK. All parameters are optional - you only need to include the ones relevant to your use case. - -**Parameters:** - -| Parameter | Type | Description | -|-----------|------|-------------| -| `isUserSubjectToGDPR` | `bool?` | Whether the user is subject to GDPR regulations | -| `consentForDataUsage` | `bool?` | Whether the user consents to data usage by AppsFlyer | -| `consentForAdsPersonalization` | `bool?` | Whether the user consents to personalized advertising | -| `hasConsentForAdStorage` | `bool?` | Whether the user consents to ad storage | - -> 📝 **Note:** Setting a parameter to `null` indicates the user hasn't explicitly provided consent for that option. - -_Example:_ -```dart -appsflyerSdk.setConsentDataV2( - isUserSubjectToGDPR: true, - consentForDataUsage: true, - consentForAdsPersonalization: false, - hasConsentForAdStorage: true, -); -``` ---- -** `void setCustomerUserId(String userId)`** - -[What is customer user id?](https://support.appsflyer.com/hc/en-us/articles/207032016-Customer-User-ID) - -_Example:_ -```dart -appsFlyerSdk.setCustomerUserId("id"); -``` ---- -** `void setCustomerIdAndLogSession(String userId)` Android only!** - -[What is customer user id?](https://support.appsflyer.com/hc/en-us/articles/207032016-Customer-User-ID) - -_Example:_ -```dart -appsFlyerSdk.setCustomerIdAndLogSession("id"); -``` ---- -** `void waitForCustomerUserId(bool wait)` Android only** - -You can set this function to `true` if you don't want to log events without setting customer id first. - -_Example:_ -```dart -appsFlyerSdk.waitForCustomerUserId(true); -appsFlyerSdk.setCustomerIdAndLogSession("id"); -``` ---- -** `void setAdditionalData(Map additionalData)`** - -_Example:_ -```dart -var data = {"key1": "value1", "key2": "value2"}; -appsFlyerSdk.setAdditionalData(data); -``` ---- -** `void setCollectAndroidId(bool isCollect)`** - -_Example:_ -```dart -appsFlyerSdk.setCollectAndroidId(true); -``` ---- -** `void setCollectIMEI(bool isCollect)`** -_NOTE:_ Make sure to add `` in the AndroidManifest and request these permissions in the runtime in order for the SDK to be able to collect IMEI - -_Example:_ -```dart -appsFlyerSdk.setCollectIMEI(false); -``` ---- -** `void setHost(String hostPrefix, String hostName)`** -You can change the default host (appsflyer) by using this function - -_Example:_ -```dart -appsFlyerSdk.setHost("pref", "my-host"); -``` ---- -** `Future getHostName()`** - -_Example:_ -```dart -appsFlyerSdk.getHostName().then((name) { - print("Host name: ${name}"); - }); -``` ---- -** `Future getHostPrefix()`** - -_Example:_ -```dart -appsFlyerSdk.getHostPrefix().then((name) { - print("Host prefix: ${name}"); - }); -``` ---- -** `void updateServerUninstallToken(String token)`** - -_Example:_ -```dart -appsFlyerSdk.updateServerUninstallToken("token"); -``` ---- -** Validate Purchase** - -***Cross-Platform V2 API (Recommended - BETA):*** - -> ⚠️ **BETA Feature**: This API is currently in beta. While it's stable and recommended for new implementations, please test thoroughly in your environment before production use. - -**`Future> validateAndLogInAppPurchaseV2(AFPurchaseDetails purchaseDetails, {Map? additionalParameters})`** - -The new unified purchase validation API that works across both Android and iOS platforms. This is the recommended approach for validating in-app purchases. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `purchaseDetails` | `AFPurchaseDetails` | Purchase details containing type, token, and product ID | -| `additionalParameters` | `Map?` | Optional additional parameters | - -**AFPurchaseDetails:** -| Property | Type | Description | -|----------|------|-------------| -| `purchaseType` | `AFPurchaseType` | Type of purchase (oneTimePurchase or subscription) | -| `purchaseToken` | `String` | Purchase token from the app store | -| `productId` | `String` | Product identifier | - -**AFPurchaseType:** -- `AFPurchaseType.oneTimePurchase` - For one-time in-app purchases -- `AFPurchaseType.subscription` - For subscription purchases - -_Example:_ -```dart -// Create purchase details -AFPurchaseDetails purchaseDetails = AFPurchaseDetails( - purchaseType: AFPurchaseType.oneTimePurchase, - purchaseToken: "your_purchase_token", - productId: "your_product_id", -); - -// Validate purchase -try { - Map result = await appsFlyerSdk.validateAndLogInAppPurchaseV2( - purchaseDetails, - additionalParameters: {"custom_param": "value"} - ); - print("Validation successful: $result"); -} catch (e) { - print("Validation failed: $e"); -} -``` - ---- - -***Legacy APIs:*** - -***Android:*** - -`Future validateAndLogInAppAndroidPurchase( - String publicKey, - String signature, - String purchaseData, - String price, - String currency, - Map? additionalParameters)` - -_Example:_ -```dart -appsFlyerSdk.validateAndLogInAppAndroidPurchase( - "publicKey", - "signature", - "purchaseData", - "price", - "currency", - {"fs": "fs"}); -``` - -***iOS:*** - -**`Future validateAndLogInAppIosPurchase( - String productIdentifier, - String price, - String currency, - String transactionId, - Map additionalParameters)`** - -_Example:_ -```dart -appsFlyerSdk.validateAndLogInAppIosPurchase( - "productIdentifier", - "price", - "currency", - "transactionId", - {"fs": "fs"}); -``` - -***Purchase validation sandbox mode for iOS:*** - -`void useReceiptValidationSandbox(bool isSandboxEnabled)` - -_Example:_ -```dart -appsFlyerSdk.useReceiptValidationSandbox(true); -``` - -***Purchase validation callback*** - -`void onPurchaseValidation(Function callback)` - -_Example:_ -```dart -appsflyerSdk.onPurchaseValidation((res){ - print("res: " + res.toString()); -}); -``` - ---- - -##### **validateAndLogInAppPurchaseV2 (Recommended - BETA)** - -> ⚠️ **BETA Feature**: This API is currently in beta. While it's stable and recommended for new implementations, please test thoroughly in your environment before production use. - -**`Future> validateAndLogInAppPurchaseV2(AFPurchaseDetails purchaseDetails, {Map? additionalParameters})`** - -The unified cross-platform purchase validation API introduced in SDK v6.17.3. This is the recommended approach for validating in-app purchases as it provides a consistent interface across Android and iOS. - -|| parameter | type | description | -|| --------- | ----- | ----------- | -|| `purchaseDetails` | `AFPurchaseDetails` | Purchase details object containing purchase type, token, and product ID | -|| `additionalParameters` | `Map?` | Optional additional parameters to send with the validation request | - -**Returns:** `Future>` - Validation result with detailed response information - -**AFPurchaseDetails Properties:** - -|| property | type | description | -|| -------- | ----- | ----------- | -|| `purchaseType` | `AFPurchaseType` | Type of purchase (`AFPurchaseType.oneTimePurchase` or `AFPurchaseType.subscription`) | -|| `purchaseToken` | `String` | Purchase token obtained from the app store | -|| `productId` | `String` | Product identifier of the purchased item | - -_Example:_ - -```dart -// Create purchase details -AFPurchaseDetails purchaseDetails = AFPurchaseDetails( - purchaseType: AFPurchaseType.subscription, - purchaseToken: "your_purchase_token_from_store", - productId: "premium_subscription_monthly", -); - -// Validate the purchase -try { - Map validationResult = await appsflyerSdk.validateAndLogInAppPurchaseV2( - purchaseDetails, - additionalParameters: { - "app_version": "1.2.0", - "validation_source": "flutter_example" - } - ); - - print("✅ Purchase validation successful!"); - print("Validation result: $validationResult"); - -} catch (e) { - print("❌ Purchase validation failed: $e"); - // Handle validation error -} -``` - -**Key Benefits:** -- **Cross-platform compatibility**: Works on both Android and iOS with the same API -- **Type safety**: Uses structured data classes instead of platform-specific parameters -- **Enhanced error handling**: Provides detailed error information in structured format -- **Future-proof**: Built on AppsFlyer's latest V2 validation infrastructure -- **Automatic routing**: Automatically routes to correct validation endpoints based on purchase type - ---- -## ** `void sendPushNotificationData(Map? userInfo)`** - -Push-notification campaigns are used to create re-engagements with existing users → [Learn more here](https://support.appsflyer.com/hc/en-us/articles/207364076-Measuring-Push-Notification-Re-Engagement-Campaigns) - -### Platform-Specific Requirements - -🟩 **Android:** -The AppsFlyer SDK **requires a valid Activity context** to process the push payload. -**Do NOT call this method from the background isolate** (e.g., `_firebaseMessagingBackgroundHandler`), as the activity is not yet created. -Instead, **delay calling this method** until the Flutter app is fully resumed and the activity is alive. - -🍎 **iOS:** -This method can be safely called at any point during app launch or when receiving a push notification. - ---- - -## Integration Approaches - -AppsFlyer supports two approaches for measuring push notification campaigns: - -### Approach 1: Traditional Attribution Parameters (`af` object) - -Use this approach when your push payload contains a custom `af` object with attribution parameters. - -**Required parameters:** `pid`, `is_retargeting`, `c` - -📦 **Example Push Payload with `af` Object:** -```json -{ - "af": { - "c": "test_campaign", - "is_retargeting": true, - "pid": "push_provider_int" - }, - "aps": { - "alert": "Get 5000 Coins", - "badge": "37", - "sound": "default" - } -} -``` - -**Implementation (Android & iOS):** - -```dart -// 1️⃣ Handle Foreground Messages -FirebaseMessaging.onMessage.listen((RemoteMessage message) { - appsFlyerSdk.sendPushNotificationData(message.data); -}); - -// 2️⃣ Handle Notification Taps (App in Background) -FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { - appsFlyerSdk.sendPushNotificationData(message.data); -}); - -// 3️⃣ Handle App Launch from Push (Terminated State) -// Store payload in background handler, then pass to AppsFlyer when app resumes -Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('pending_af_push', jsonEncode(message.data)); -} - -// In your main() or splash screen after Flutter is initialized: -void handlePendingPush() async { - final prefs = await SharedPreferences.getInstance(); - final json = prefs.getString('pending_af_push'); - if (json != null) { - final payload = jsonDecode(json); - appsFlyerSdk.sendPushNotificationData(payload); - await prefs.remove('pending_af_push'); - } -} -``` - -Call `handlePendingPush()` during app startup (e.g., in your `main()` or inside your splash screen after ensuring Flutter is initialized). - ---- - -### Approach 2: OneLink URL in Push Payload (Recommended) - -Use this approach when your push payload contains a **OneLink URL** for deep linking. This method provides a unified deep linking experience. - -> ⚠️ **Important:** This approach requires calling **two different methods** depending on the platform! - -#### **Step 1: Configure Deep Link Path (BOTH Platforms)** - -Call `addPushNotificationDeepLinkPath` **BEFORE** initializing the SDK to tell AppsFlyer where to find the OneLink URL in your push payload. - -```dart -// Must be called BEFORE initSdk() or startSDK() -appsFlyerSdk.addPushNotificationDeepLinkPath(["deeply", "nested", "deep_link"]); - -// Then initialize the SDK -await appsFlyerSdk.initSdk( - registerOnDeepLinkingCallback: true // Enable deep linking callback -); -``` - -#### **Step 2: Send Push Payload to SDK** - -**🟩 Android:** -On Android, calling `addPushNotificationDeepLinkPath` is **sufficient**. The SDK automatically extracts and processes the OneLink URL. - -**🍎 iOS:** -On iOS, you **MUST also call** `sendPushNotificationData(userInfo)` to pass the push payload to the SDK. The SDK then internally calls `handlePushNotification` to extract and process the OneLink URL. - -📦 **Example Push Payload with OneLink URL:** -```json -{ - "deeply": { - "nested": { - "deep_link": "https://yourapp.onelink.me/ABC/campaign123" - } - }, - "aps": { - "alert": "Check out our new feature!", - "badge": "1", - "sound": "default" - } -} -``` - -**Complete Implementation Example:** - -```dart -// ======================================== -// 1. Configure SDK (in main.dart or app initialization) -// ======================================== -void initializeAppsFlyer() async { - // STEP 1: Configure the deep link path BEFORE starting SDK - appsFlyerSdk.addPushNotificationDeepLinkPath(["deeply", "nested", "deep_link"]); - - // STEP 2: Initialize SDK with deep linking callback - await appsFlyerSdk.initSdk( - registerOnDeepLinkingCallback: true - ); - - // STEP 3: Set up deep linking callback to handle the OneLink URL - appsFlyerSdk.onDeepLinking((DeepLinkResult result) { - if (result.status == Status.FOUND) { - print("Deep link found: ${result.deepLink?.deepLinkValue}"); - // Handle deep link navigation here - } - }); -} - -// ======================================== -// 2. Handle Push Notifications -// ======================================== - -// 🍎 iOS: MUST call sendPushNotificationData -// 🟩 Android: Optional (SDK auto-handles), but recommended for consistency - -// 1️⃣ Foreground Messages -FirebaseMessaging.onMessage.listen((RemoteMessage message) { - // iOS: Required to process OneLink URL - // Android: SDK processes automatically, but calling doesn't hurt - appsFlyerSdk.sendPushNotificationData(message.data); -}); - -// 2️⃣ Background Notification Taps (App in Background) -FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { - // iOS: Required to process OneLink URL - appsFlyerSdk.sendPushNotificationData(message.data); -}); - -// 3️⃣ App Launch from Push (Terminated State) -Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('pending_af_push', jsonEncode(message.data)); -} - -// In main() or splash screen: -void handlePendingPush() async { - final prefs = await SharedPreferences.getInstance(); - final json = prefs.getString('pending_af_push'); - if (json != null) { - final payload = jsonDecode(json); - // iOS: Required to process OneLink URL from terminated state - appsFlyerSdk.sendPushNotificationData(payload); - await prefs.remove('pending_af_push'); - } -} -``` - -#### **Key Differences Between Approaches:** - -|| Traditional `af` Object | OneLink URL (Recommended) | -|---|---|---| -| **Android** | `sendPushNotificationData(data)` | `addPushNotificationDeepLinkPath()` (auto-handles) | -| **iOS** | `sendPushNotificationData(data)` | `addPushNotificationDeepLinkPath()` **+** `sendPushNotificationData(data)` | -| **Deep Linking** | Basic attribution only | Full deep linking with `onDeepLinking` callback | -| **Use Case** | Simple re-engagement | Re-engagement + in-app navigation | - ---- - -### Summary - -- **Traditional approach**: Always call `sendPushNotificationData(payload)` on both platforms -- **OneLink approach (Recommended)**: - - ✅ **Both platforms**: Call `addPushNotificationDeepLinkPath()` before SDK init - - ✅ **iOS only**: Also call `sendPushNotificationData(payload)` when push is received - - ✅ **Both platforms**: Handle deep links in `onDeepLinking` callback - - ---- -## ** `void addPushNotificationDeepLinkPath(List deeplinkPath)`** - -Registers a **custom key path** for resolving deep links inside **custom JSON payloads** in push notifications. - -This is the recommended method of integrating AppsFlyer with push notifications. [Learn more here.](https://support.appsflyer.com/hc/en-us/articles/207364076-Measuring-Push-Notification-Re-Engagement-Campaigns)
-> ⚠️ This method must be called BEFORE the AppsFlyer SDK is started — either before calling appsFlyerSdk.initSdk() (if using default auto-start), or before appsFlyerSdk.startSDK() (if using manual start mode). ⚠️ - - -_Example:_ -```dart -appsFlyerSdk.addPushNotificationDeepLinkPath(["deeply", "nested", "deep_link"]); -``` - -With this configuration, the SDK will extract the URL from the following push payload: - -```json -{ - "deeply": { - "nested": { - "deep_link": "https://yourdeeplink2.onelink.me" - } - } -} -``` - ---- -**
User Invite** - -1. First define the Onelink ID (find it in the AppsFlyer dashboard in the onelink section: - -**`Future setAppInviteOneLinkID(String oneLinkID, Function callback)`** - -2. Set the AppsFlyerInviteLinkParams class to set the query params in the user invite link: - -```dart -class AppsFlyerInviteLinkParams { - final String channel; - final String campaign; - final String referrerName; - final String referrerImageUrl; - final String customerID; - final String baseDeepLink; - final String brandDomain; -} -``` - -3. Call the generateInviteLink API to generate the user invite link. Use the success and error callbacks for handling. - -**`void generateInviteLink(AppsFlyerInviteLinkParams parameters, Function success, Function error)`** - - -_Example:_ -```dart -appsFlyerSdk.setAppInviteOneLinkID('OnelinkID', -(res){ - print("setAppInviteOneLinkID callback: $res"); -}); - -AppsFlyerInviteLinkParams inviteLinkParams = new AppsFlyerInviteLinkParams( - channel: "", - referrerName: "", - baseDeepLink: "", - brandDomain: "", - customerID: "", - referrerImageUrl: "", - campaign: "", - customParams: {"key":"value"} -); - -appsFlyerSdk.generateInviteLink(inviteLinkParams, - (result){ - print(result); - }, - (error){ - print(error); - } -); -``` ---- -** `void enableFacebookDeferredApplinks(bool isEnabled)`** - -Please make sure the relevant Facebook dependecies are added to the project! - -For more information check the following article: -https://support.appsflyer.com/hc/en-us/articles/207033826-Facebook-Ads-setup-guide#advanced-using-facebook-ads-appsflyer-sdks-for-deferred-deep-linking - -_Example:_ -```dart -appsFlyerSdk.enableFacebookDeferredApplinks(true); -``` ---- -** `void disableSKAdNetwork(bool isEnabled)`** - -Use this API in order to disable the SK Ad network (request will be sent but the rules won't be returned). - -_Example:_ -```dart -appsFlyerSdk.disableSKAdNetwork(true); -``` ---- -** `Future getAppsFlyerUID() async`** - -Use this API in order to get the AppsFlyer ID. - -_Example:_ -```dart -appsFlyerSdk.getAppsFlyerUID().then((AppsFlyerId) { - print("AppsFlyer ID: ${AppsFlyerId}"); -}); -``` ---- -** `void setCurrentDeviceLanguage(string language)`** - -Use this API in order to set the language - -_Example:_ -```dart -appsFlyerSdk.setCurrentDeviceLanguage("en"); -``` ---- -** `void setSharingFilterForPartners(List partners)`** - -`setSharingFilter` & `setSharingFilterForAllPartners` APIs were deprecated! - -Use `setSharingFilterForPartners` instead. - -Used by advertisers to exclude specified networks/integrated partners from getting data. [Learn more here](https://support.appsflyer.com/hc/en-us/articles/207032126#additional-apis-exclude-partners-from-getting-data) - -_Example:_ -```dart -appsFlyerSdk.setSharingFilterForPartners([]); // Reset list (default) -appsFlyerSdk.setSharingFilterForPartners(null); // Reset list (default) -appsFlyerSdk.setSharingFilterForPartners(['facebook_int']); // Single partner -appsFlyerSdk.setSharingFilterForPartners(['facebook_int', 'googleadwords_int']); // Multiple partners -appsFlyerSdk.setSharingFilterForPartners(['all']); // All partners -appsFlyerSdk.setSharingFilterForPartners(['googleadwords_int', 'all']); // All partners -``` - ---- -** `void setOneLinkCustomDomain(List brandDomains)`** - -Use this API in order to set branded domains. - -Find more information in the [following article on branded domains](https://support.appsflyer.com/hc/en-us/articles/360002329137-Implementing-Branded-Links). - -_Example:_ -```dart - appsFlyerSdk.setOneLinkCustomDomain(["promotion.greatapp.com","click.greatapp.com","deals.greatapp.com"]); -``` ---- -** `void setDisableAdvertisingIdentifiers(bool isSetDisableAdvertisingIdentifiersEnable)`** - -Manually enable or disable Advertiser ID in Android & IDFA in iOS - -_Example:_ -```dart - appsFlyerSdk.setDisableAdvertisingIdentifiers(true); -``` ---- -** `void setPartnerData(String partnerId, Map partnerData)`** - -Allows sending custom data for partner integration purposes. - -_Example:_ -```dart - Map partnerData = {"puid": "1234", "puid": '5678'}; - appsflyerSdk.setPartnerData("partnerId", partnerData); -``` ---- -** `void setResolveDeepLinkURLs(List urls)`** - -Advertisers can wrap an AppsFlyer OneLink within another Universal Link. This Universal Link will invoke the app but any deep linking data will not propagate to AppsFlyer. - -setResolveDeepLinkURLs enables you to configure the SDK to resolve the wrapped OneLink URLs, so that deep linking can occur correctly. - -_Example:_ -```dart - appsflyerSdk.setResolveDeepLinkURLs(["clickdomain.com", "myclickdomain.com", "anotherclickdomain.com"]); -``` ---- -** `void setOutOfStore(String sourceName)`** - -**Android Only!** - -Specify the alternative app store that the app is downloaded from. - -_Example:_ -```dart - if(Platform.isAndroid){ - appsflyerSdk.setOutOfStore("facebook_int"); - } -``` ---- -** `Future getOutOfStore()`** - -**Android Only!** - -Get the third-party app store referrer value. - -_Example:_ -```dart - if(Platform.isAndroid){ - Future store = appsflyerSdk.getOutOfStore(); - store.then((store) { - print(store); - }); - } -``` ---- -** `void setDisableNetworkData(bool disable)`** - -**Android Only!** - -Use to opt-out of collecting the network operator name (carrier) and sim operator name from the device. - -_Example:_ -```dart - if(Platform.isAndroid){ - appsflyerSdk.setDisableNetworkData(true); - } -``` ---- -** `void disableAppSetId()`** - -**Android Only!** - -Disables AppSet ID collection. Starting with v6.17.0, the SDK can automatically collect the AppSet ID. Use this method to opt-out of AppSet ID collection for privacy compliance. - -_Example:_ -```dart - if(Platform.isAndroid){ - appsflyerSdk.disableAppSetId(); - } -``` ---- - -** `void performOnDeepLinking()`** - -**Android Only!** - -Enables manual triggering of deep link resolution. This method allows apps that are delaying the call to `appsflyerSdk.startSDK()` to resolve deep links before the SDK starts.
-Note:
This API will trigger the `appsflyerSdk.onDeepLink` callback. In the following example, we check if `res.deepLinkStatus` is equal to "FOUND" inside `appsflyerSdk.onDeepLink` callback to extract the deeplink parameters. - -```dart - void afStart() async { - // SDK Options - final AppsFlyerOptions options = AppsFlyerOptions( - afDevKey: dotenv.env["DEV_KEY"]!, - appId: dotenv.env["APP_ID"]!, - showDebug: true, - timeToWaitForATTUserAuthorization: 15, - manualStart: true); - _appsflyerSdk = AppsflyerSdk(options); - - // Init of AppsFlyer SDK - await _appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: true); - - // Conversion data callback - _appsflyerSdk.onInstallConversionData((res) { - print("onInstallConversionData res: " + res.toString()); - setState(() { - _gcd = res; - }); - }); - - // App open attribution callback - _appsflyerSdk.onAppOpenAttribution((res) { - print("onAppOpenAttribution res: " + res.toString()); - setState(() { - _deepLinkData = res; - }); - }); - - // Deep linking callback - _appsflyerSdk.onDeepLinking((DeepLinkResult dp) { - switch (dp.status) { - case Status.FOUND: - print(dp.deepLink?.toString()); - print("deep link value: ${dp.deepLink?.deepLinkValue}"); - break; - case Status.NOT_FOUND: - print("deep link not found"); - break; - case Status.ERROR: - print("deep link error: ${dp.error}"); - break; - case Status.PARSE_ERROR: - print("deep link status parsing error"); - break; - } - print("onDeepLinking res: " + dp.toString()); - setState(() { - _deepLinkData = dp.toJson(); - }); - }); - - if(Platform.isAndroid){ - _appsflyerSdk.performOnDeepLinking(); - } - _appsflyerSdk.startSDK(); - } -``` - ---- - -### **
`void logAdRevenue(AdRevenueData adRevenueData)`** - -The logAdRevenue API is designed to simplify the process of logging ad revenue events to AppsFlyer from your Flutter application. This API tracks revenue generated from advertisements, enriching your monetization analytics. Below you will find instructions on how to use this API correctly, along with detailed descriptions and examples for various input scenarios. - -### **Usage:** -To use the logAdRevenue method, you must: - -1. Prepare an instance of `AdRevenueData` with the required information about the ad revenue event. -1. Call `logAdRevenue` with the `AdRevenueData` instance. - -**AdRevenueData Class** -[AdRevenueData](#AdRevenueData) is a data class representing all the relevant information about an ad revenue event: - -* `monetizationNetwork`: The source network from which the revenue was generated (e.g., AdMob, Unity Ads). -* `mediationNetwork`: The mediation platform managing the ad (use AFMediationNetwork enum for supported networks). -* `currencyIso4217Code`: The ISO 4217 currency code representing the currency of the revenue amount (e.g., "USD", "EUR"). -* `revenue`: The amount of revenue generated from the ad. -* `additionalParameters`: Additional parameters related to the ad revenue event (optional). - - -**AFMediationNetwork Enum** -[AFMediationNetwork](#AFMediationNetwork) is an enumeration that includes the supported mediation networks by AppsFlyer. It's important to use this enum to ensure you provide a valid network identifier to the logAdRevenue API. - -### Example: -```dart -// Instantiate AdRevenueData with the ad revenue details. -AdRevenueData adRevenueData = AdRevenueData( - monetizationNetwork: "GoogleAdMob", // Replace with your actual monetization network. - mediationNetwork: AFMediationNetwork.applovinMax.value, // Use the value from the enum. - currencyIso4217Code: "USD", - revenue: 1.23, - additionalParameters: { - // Optional additional parameters can be added here. This is an example, can be discard if not needed. - 'adUnitId': 'ca-app-pub-XXXX/YYYY', - 'ad_network_click_id': '12345' - } -); - -// Log the ad revenue event. -logAdRevenue(adRevenueData); -``` - -**Additional Points** -* Mediation network input must be from the provided [AFMediationNetwork](#AFMediationNetwork) - enum to ensure proper processing by AppsFlyer. For instance, use `AFMediationNetwork.googleAdMob.value` to denote Google AdMob as the Mediation Network. -* The `additionalParameters` map is optional. Use it to pass any extra information you have regarding the ad revenue event; this information could be useful for more refined analytics. -* Make sure the `currencyIso4217Code` adheres to the appropriate standard. Misconfigured currency code may result in incorrect revenue tracking. \ No newline at end of file diff --git a/doc/AdvancedAPI.md b/doc/AdvancedAPI.md deleted file mode 100644 index 3c624d03..00000000 --- a/doc/AdvancedAPI.md +++ /dev/null @@ -1,326 +0,0 @@ -# 📑 Advanced APIs - -- [Measure App Uninstalls](#uninstall) -- [User invite](#user-invite) -- [In-app purchase validation](#iae) -- [Android Out of Store](#out-of-store) -- [Set plugin for IOS 14](#ios14) - ---- - -## Measure App Uninstalls - -### iOS - -You may update the uninstall token from the native side and from the plugin side, as shown in the methods below, you do not have to implement both of the methods, but only one. -You can read more about iOS Uninstall Measurement in our [knowledge base](https://support.appsflyer.com/hc/en-us/articles/4408933557137) and you can follow our guide for Uninstall measurement on our [DevHub](https://dev.appsflyer.com/hc/docs/uninstall-measurement-ios). - -#### First method - -You can register the uninstall token with AppsFlyer by modifying your `AppDelegate.m` file, add the following function call with your uninstall token inside [didRegisterForRemoteNotificationsWithDeviceToken](https://developer.apple.com/reference/uikit/uiapplicationdelegate). - -**Example:** - -```objective-c -@import AppsFlyerLib; - -... - -- (void)application:(UIApplication ​*)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *​)deviceToken { -// notify AppsFlyerLib - [[AppsFlyerLib shared] registerUninstall:deviceToken]; -} -``` - -#### Second method - -You can register the uninstall token with AppsFlyer by calling the following API with your uninstall token: -```dart -appsFlyerSdk.updateServerUninstallToken("token"); -``` - -> **Note:** When using this method on iOS, the token should be passed as a **hexadecimal string representation** of the device token. The plugin will automatically convert the hex string to the required `NSData` format for the AppsFlyer SDK. -> -> If you're using the [firebase_messaging](https://pub.dev/packages/firebase_messaging) plugin, you can get the APNs token on iOS using `FirebaseMessaging.instance.getAPNSToken()` which returns the token as a hex string, which is the expected format for this method. - -### Android - -It is possible to utilize the [Firebase Messaging Plugin for Flutter](https://pub.dev/packages/firebase_messaging) for everything related to the uninstall token. -You can read more about Android Uninstall Measurement in our [knowledge base](https://support.appsflyer.com/hc/en-us/articles/4408933557137) and you can follow our guide for Uninstall measurement using FCM on our [DevHub](https://dev.appsflyer.com/hc/docs/uninstall-measurement-android). - -On the Flutter side, you can register the uninstall token with AppsFlyer by calling the following API with your uninstall token: -```dart -appsFlyerSdk.updateServerUninstallToken("token"); -``` - -**Example using Firebase Messaging (cross-platform):** -```dart -import 'dart:io' show Platform; -import 'package:firebase_messaging/firebase_messaging.dart'; - -// Update uninstall token for AppsFlyer -void _updateUninstallToken(appsFlyerSdk) { - if (Platform.isAndroid) { - FirebaseMessaging.instance.getToken().then((token) { - if (token != null) { - appsFlyerSdk.updateServerUninstallToken(token); - } - }); - } else if (Platform.isIOS) { - FirebaseMessaging.instance.getAPNSToken().then((token) { - if (token != null) { - appsFlyerSdk.updateServerUninstallToken(token); - } - }); - } -} -``` -**Note:** -- On Android, `getToken()` returns the FCM token. -- On iOS, `getAPNSToken()` returns the APNs token as a hex string, suitable for `updateServerUninstallToken`. -- Replace `appsFlyerSdk` with your instance of `AppsflyerSdk`. - ---- - -## User invite - -A complete list of supported parameters is available [here](https://support.appsflyer.com/hc/en-us/articles/115004480866-User-Invite-Tracking), you can also make use of the `customParams` field to include custom parameters of your choice. - -1. First define the Onelink ID either in the AppsFlyerOptions, or in the setAppInviteOneLinkID API (find it in the AppsFlyer dashboard in the onelink section): - - **`Future setAppInviteOneLinkID(String oneLinkID, Function callback)`** - -2. Utilize the AppsFlyerInviteLinkParams class to set the query params in the user invite link: - -```dart -class AppsFlyerInviteLinkParams { - final String channel; - final String campaign; - final String referrerName; - final String referrerImageUrl; - final String customerID; - final String baseDeepLink; - final String brandDomain; - final Map? customParams; -} -``` - -3. Call the generateInviteLink API to generate the user invite link. Use the success and error callbacks for handling. - -**Full example:** - -```dart -// Setting the OneLinkID -appsFlyerSdk.setAppInviteOneLinkID('OnelinkID', -(res){ - print("setAppInviteOneLinkID callback: $res"); -}); - -// Creating the required parameters of the OneLink -AppsFlyerInviteLinkParams inviteLinkParams = new AppsFlyerInviteLinkParams( - channel: "", - referrerName: "", - baseDeepLink: "", - brandDomain: "", - customerID: "", - referrerImageUrl: "", - campaign: "", - customParams: {"key":"value"} -); - -// Generating the OneLink -appsFlyerSdk.generateInviteLink(inviteLinkParams, - (result){ - print(result); - }, - (error){ - print(error); - } -); -``` - ---- - -### In-app purchase validation -Receipt validation is a secure mechanism whereby the payment platform (e.g. Apple or Google) validates that an in-app purchase indeed occurred as reported.
-Learn more - https://support.appsflyer.com/hc/en-us/articles/207032106-Receipt-validation-for-in-app-purchases
- -**Cross-Platform V2 API (Recommended - SDK v6.17.3+):** - -The unified purchase validation API that works across both Android and iOS platforms: - -```dart -Future> validateAndLogInAppPurchaseV2( - AFPurchaseDetails purchaseDetails, - {Map? additionalParameters}) -``` - -**AFPurchaseDetails class:** -```dart -AFPurchaseDetails( - purchaseType: AFPurchaseType, // oneTimePurchase or subscription - purchaseToken: String, // Purchase token from app store - productId: String, // Product identifier -) -``` - -**Example:** -```dart -// Create purchase details -AFPurchaseDetails purchaseDetails = AFPurchaseDetails( - purchaseType: AFPurchaseType.oneTimePurchase, - purchaseToken: "sample_purchase_token_12345", - productId: "com.example.product", -); - -// Validate purchase (works on both Android and iOS) -try { - Map result = await appsFlyerSdk.validateAndLogInAppPurchaseV2( - purchaseDetails, - additionalParameters: {"custom_param": "value"} - ); - print("Validation successful: $result"); -} on PlatformException catch (e) { - // Handle platform-specific errors with detailed information - print("Validation failed: ${e.message}"); - print("Error code: ${e.code}"); - if (e.details != null) { - // Access detailed error information - final details = e.details as Map; - print("Error details: $details"); - // On iOS, additional fields may include: - // - error_code: The NSError code - // - error_domain: The NSError domain - // - error_user_info: Additional error context - } -} catch (e) { - print("Unexpected error: $e"); -} -``` - -**Benefits of V2 API:** -- ✅ **Cross-platform**: Single API works on both Android and iOS -- ✅ **Type-safe**: Uses structured data classes instead of raw strings -- ✅ **Comprehensive error handling**: Returns structured error information including NSError details on iOS -- ✅ **Enhanced validation**: Uses AppsFlyer's latest validation infrastructure -- ✅ **Future-proof**: Built for AppsFlyer's V2 validation endpoints - ---- - -**Deprecated Platform-Specific APIs:** - -> ⚠️ **Deprecated**: The following platform-specific APIs are deprecated and will be removed in a future version. Please migrate to `validateAndLogInAppPurchaseV2` for cross-platform support. - -**Android (Deprecated):** -```dart -@Deprecated('Use validateAndLogInAppPurchaseV2 instead') -Future validateAndLogInAppAndroidPurchase( - String publicKey, - String signature, - String purchaseData, - String price, - String currency, - Map? additionalParameters) -``` -Example: -```dart -// Deprecated - migrate to validateAndLogInAppPurchaseV2 -appsFlyerSdk.validateAndLogInAppAndroidPurchase( - "publicKey", - "signature", - "purchaseData", - "price", - "currency", - {"fs": "fs"}); -``` - -**iOS (Deprecated):** - -❗Important❗ for iOS - set SandBox to ```true```
-```appsFlyer.useReceiptValidationSandbox(true);``` - -```dart -@Deprecated('Use validateAndLogInAppPurchaseV2 instead') -Future validateAndLogInAppIosPurchase( - String productIdentifier, - String price, - String currency, - String transactionId, - Map additionalParameters) -``` - -Example: -```dart -// Deprecated - migrate to validateAndLogInAppPurchaseV2 -appsFlyerSdk.validateAndLogInAppIosPurchase( - "productIdentifier", - "price", - "currency", - "transactionId", - {"fs": "fs"}); -``` - -**Purchase validation callback:** - -`void onPurchaseValidation(Function callback)` - -Example: -```dart -appsflyerSdk.onPurchaseValidation((res){ - print("res: " + res.toString()); -}); -``` - ---- - -##
Android Out of Store -Please make sure to go over [this guide](https://support.appsflyer.com/hc/en-us/articles/207447023-Attributing-out-of-store-Android-markets-guide) to get a general understanding of how out of store attribution is set up in AppsFlyer, and how to implement it. - ---- - -## Set plugin for IOS 14 - -1. Adding the conset dialog: - -There are 2 ways to add it to your app: - - a. Utilize the following Library: https://pub.dev/packages/app_tracking_transparency - -Or - - b. Add native implementation: - - -- Add `#import ` in your `AppDelegate.m` - -- Add the ATT pop-up for IDFA collection so your `AppDelegate.m` will look like this: - -``` -- (void)applicationDidBecomeActive:(nonnull UIApplication *)application { - if (@available(iOS 14, *)) { - [ATTrackingManager requestTrackingAuthorizationWithCompletionHandler:^(ATTrackingManagerAuthorizationStatus status) { - // native code here - }]; - } -} -``` - -2. Add Privacy - Tracking Usage Description inside your `.plist` file in Xcode. - -``` -NSUserTrackingUsageDescription -This identifier will be used to deliver personalized ads to you. -``` - -3. Optional: Set the `timeToWaitForATTUserAuthorization` property in the `AppsFlyerOptions` to delay the sdk initazliation for a number of `x seconds` until the user accept the consent dialog: - -```dart -AppsFlyerOptions options = AppsFlyerOptions( - afDevKey: DotEnv().env["DEV_KEY"], - appId: DotEnv().env["APP_ID"], - showDebug: true, - timeToWaitForATTUserAuthorization: 30 - ); -``` - -For more info visit our [Full Support guide for iOS 14](https://support.appsflyer.com/hc/en-us/articles/207032066#integration-33-configuring-app-tracking-transparency-att-support). \ No newline at end of file diff --git a/doc/BasicIntegration.md b/doc/BasicIntegration.md deleted file mode 100644 index 9ac20a2c..00000000 --- a/doc/BasicIntegration.md +++ /dev/null @@ -1,103 +0,0 @@ -# 🚀 Basic integration of the SDK - -Initialize the SDK to enable AppsFlyer to detect installations, sessions (app opens) and updates. -`AppsflyerSdk` receives either a Map with the defined parameters or an `AppsFlyerOptions` object. - -```dart -import 'package:appsflyer_sdk/appsflyer_sdk.dart'; - -AppsFlyerOptions appsFlyerOptions = AppsFlyerOptions( - afDevKey: afDevKey, - appId: appId, - showDebug: true, - timeToWaitForATTUserAuthorization: 50, // for iOS 14.5 - appInviteOneLink: oneLinkID, // Optional field - disableAdvertisingIdentifier: false, // Optional field - disableCollectASA: false, //Optional field - manualStart: true, ); // Optional field - -AppsflyerSdk appsflyerSdk = AppsflyerSdk(appsFlyerOptions); -``` - -| Setting | Type | Description | -|-----------------------------------| -------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| devKey | String | Your application's [devKey](https://support.appsflyer.com/hc/en-us/articles/207032066-Basic-SDK-integration-guide#retrieving-the-dev-key) provided by AppsFlyer (required) | -| appId | String | Your application's [App ID](https://support.appsflyer.com/hc/en-us/articles/207377436-Adding-a-new-app#available-in-the-app-store-google-play-store-windows-phone-store) (required for iOS only) that you configured in your AppsFlyer dashboard should be without the 'id' prefix | -| showDebug | bool | Debug mode - set to `true` for testing only, do not release to production with this parameter set to `true`! | -| timeToWaitForATTUserAuthorization | double | Delays the SDK start for x seconds until the user either accepts the consent dialog, declines it, or the timer runs out. | -| appInviteOneLink | String | The [OneLink template ID](https://support.appsflyer.com/hc/en-us/articles/115004480866-User-invite-attribution#parameters) that is used to generate a User Invite, this is not a required field in the `AppsFlyerOptions`, you may choose to set it later via the appropriate API. | -| disableAdvertisingIdentifier | bool | Opt-out of the collection of Advertising Identifiers, which include OAID, AAID, GAID and IDFA. | -| disableCollectASA | bool | Opt-out of the Apple Search Ads attributions. | -| manualStart | bool | Prevents from the SDK from sending the launch request after using appsFlyer.initSdk(...). When using this property, the apps needs to manually trigger the appsFlyer.startSdk() API to report the app launch. | - -The next step is to call `initSdk` which have the optional boolean parameters `registerConversionDataCallback` and the deeplink callbacks: `registerOnAppOpenAttributionCallback` -`registerOnDeepLinkingCallback`. -> These are **all set to false by default**, meaning listeners will only be registered if you explicitly pass true. - -> Please keep in mind that registering the `registerOnDeepLinkingCallback` will override the `registerOnAppOpenAttributionCallback`, as the latter is a Legacy callback used for direct deep-linking, please read more about this in our DeepLinking guide. - -After we call `initSdk` we can use all of AppsFlyer SDK features. -Here’s an example of how to register all three: -```dart -await appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: true -); -``` - -| Setting | Description | -| -------- | ------------- | -| registerConversionDataCallback | Set a listener for the [GCD](https://dev.appsflyer.com/hc/docs/conversion-data) response, it is also the callback used for the [Legacy deferred deeplinking](https://dev.appsflyer.com/hc/docs/android-legacy-apis#deferred-deep-linking) | -| registerOnAppOpenAttributionCallback | Set a listener for the [Legacy direct deeplinking](https://dev.appsflyer.com/hc/docs/android-legacy-apis) response | -| registerOnDeepLinkingCallback | Set a listener for the [UDL](https://dev.appsflyer.com/hc/docs/unified-deep-linking-udl) response | - -### startSdk -`startSDK({RequestSuccessListener? onSuccess, RequestErrorListener? onError})` -Version 6.13.0+ of the AppsFlyer Flutter plugin introduces the option to manually start the SDK.
-To utilise this feature, set the property `manualStart: true` within the initialization configuration.
-Once the `manualStart` option is activated, you can call `appsFlyer.startSdk()` at your discretion. If the `manualStart` property is omitted or set to false, the SDK will start immediately after calling `appsFlyer.initSdk(...)`. - -`onSuccess`: An optional callback that is triggered after a successful initialization of the SDK. -`onError`: An optional callback that is fired in case of an error during SDK initialization, providing an error code and an error message. - -```dart - // SDK Options - final AppsFlyerOptions options = AppsFlyerOptions( - afDevKey: "", - appId: "", - showDebug: true, - timeToWaitForATTUserAuthorization: 15, - manualStart: true); - _appsflyerSdk = AppsflyerSdk(options); - - // Initialization of the AppsFlyer SDK - _appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: true); - - // Starting the SDK with optional success and error callbacks - _appsflyerSdk.startSDK( - onSuccess: () { - showMessage("AppsFlyer SDK initialized successfully."); - }, - onError: (int errorCode, String errorMessage) { - showMessage("Error initializing AppsFlyer SDK: Code $errorCode - $errorMessage"); - }, - ); -``` - -Use the `onSuccess` callback to perform actions after successful SDK initialization, and the `onError` callback to handle initialization errors.
-Here's an example from the demo app. - -```dart -_appsflyerSdk.startSDK( - onSuccess: () { - showMessage("AppsFlyer SDK initialized successfully."); - }, - onError: (int errorCode, String errorMessage) { - showMessage("Error initializing AppsFlyer SDK: Code $errorCode - $errorMessage"); - }, -); -``` \ No newline at end of file diff --git a/doc/DMA.md b/doc/DMA.md deleted file mode 100644 index b5c56e1e..00000000 --- a/doc/DMA.md +++ /dev/null @@ -1,205 +0,0 @@ -# Set Consent For DMA Compliance - -Following the DMA regulations that were set by the European Commission, Google (and potentially other SRNs in the future) require to send them the user's consent data in order to interact with them during the attribution process. In our latest plugin update (6.16.2), we've introduced two new public APIs, enhancing our support for user consent and data collection preferences in line with evolving digital market regulations. -There are two alternative ways for gathering consent data: - -- Through a Consent Management Platform (CMP): If the app uses a CMP that complies with the Transparency and Consent Framework (TCF) v2.2 protocol, the SDK can automatically retrieve the consent details. - -**OR** - -- Through a dedicated SDK API: Developers can pass Google's required consent data directly to the SDK using a specific API designed for this purpose. - -## Use CMP to collect consent data - -A CMP compatible with TCF v2.2 collects DMA consent data and stores it in NSUserDefaults (iOS) and SharedPreferences (Android). To enable the SDK to access this data and include it with every event, follow these steps: - -1. Call `appsflyerSdk.enableTCFDataCollection(true)` -2. Initialize the SDK in manual start mode by setting `manualStart: true` in the `AppsFlyerOptions` when creating the AppsflyerSdk instance. -3. Use the CMP to decide if you need the consent dialog in the current session to acquire the consent data. If you need the consent dialog move to step 4, otherwise move to step 5. -4. Get confirmation from the CMP that the user has made their consent decision and the data is available in NSUserDefaults/SharedPreferences. -5. Call `appsflyerSdk.startSDK()` - -```dart -// Initialize AppsFlyerOptions with manualStart: true -final AppsFlyerOptions options = AppsFlyerOptions( - afDevKey: 'your_dev_key', - appId: '1234567890', // Required for iOS only - showDebug: true, - manualStart: true // <--- Manual Start -); - -// Create the AppsflyerSdk instance -AppsflyerSdk appsflyerSdk = AppsflyerSdk(options); - -// Initialize the SDK -appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: true -); - -// CMP pseudocode procedure -if (cmpManager.hasConsent()) { - appsflyerSdk.startSDK(); -} else { - cmpManager.presentConsentDialogToUser() - .then((_) => appsflyerSdk.startSDK()); -} -``` - -## Manually collect consent data - - -### setConsentData is now **deprecated**. use [setConsentDataV2](#setconsentdatav2-recommended-api-for-manual-consent-collection---since-6162) - - -If your app does not use a CMP compatible with TCF v2.2, use the SDK API detailed below to provide the consent data directly to the SDK, distinguishing between cases when GDPR applies or not. - -### When GDPR applies to the user - -If GDPR applies to the user, perform the following: - -1. Given that GDPR is applicable to the user, determine whether the consent data is already stored for this session. - 1. If there is no consent data stored, show the consent dialog to capture the user consent decision. - 2. If there is consent data stored continue to the next step. -2. To transfer the consent data to the SDK create an AppsFlyerConsent object using `forGDPRUser` method that accepts the following parameters:
- `hasConsentForDataUsage: boolean` - Indicates whether the user has consented to use their data for advertising purposes.
- `hasConsentForAdsPersonalization: boolean` - Indicates whether the user has consented to use their data for personalized advertising. -3. Call `appsflyerSdk.setConsentData(consentData)` with the AppsFlyerConsent object. -4. Initialize the SDK using `appsflyerSdk.initSdk()`. - -```dart -// If the user is subject to GDPR - collect the consent data -// or retrieve it from the storage -// ... - -// Set the consent data to the SDK: -var gdprConsent = AppsFlyerConsent.forGDPRUser( - hasConsentForDataUsage: true, - hasConsentForAdsPersonalization: false -); - -appsflyerSdk.setConsentData(gdprConsent); - -// Initialize AppsFlyerOptions -final AppsFlyerOptions options = AppsFlyerOptions( - afDevKey: 'your_dev_key', - appId: '1234567890', // Required for iOS only - showDebug: true -); - -// Create the AppsflyerSdk instance -AppsflyerSdk appsflyerSdk = AppsflyerSdk(options); - -// Initialize the SDK -appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: true -); -``` - -### When GDPR does not apply to the user - -If GDPR doesn't apply to the user perform the following: - -1. Create an AppsFlyerConsent object using `nonGDPRUser` method that doesn't accept any parameters. -2. Call `appsflyerSdk.setConsentData(consentData)` with the AppsFlyerConsent object. -3. Initialize the SDK using `appsflyerSdk.initSdk()`. - -```dart -// If the user is not subject to GDPR: -var nonGdprUserConsentData = AppsFlyerConsent.nonGDPRUser(); - -appsflyerSdk.setConsentData(nonGdprUserConsentData); - -// Initialize AppsFlyerOptions -final AppsFlyerOptions options = AppsFlyerOptions( - afDevKey: 'your_dev_key', - appId: '1234567890', // Required for iOS only - showDebug: true -); - -// Create the AppsflyerSdk instance -AppsflyerSdk appsflyerSdk = AppsflyerSdk(options); - -// Initialize the SDK -appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: true -); -``` - -## setConsentDataV2 (Recommended API for Manual Consent Collection) - since 6.16.2 - -🚀 **Why Use setConsentDataV2?**
-The setConsentDataV2 API is the new and improved way to manually provide user consent data to the AppsFlyer SDK. - -It replaces the now deprecated setConsentData method, offering several improvements:
-✅ **Simpler and More Intuitive:** Accepts named parameters, making it easier to manage.
-✅ **Includes an Additional Consent Parameter:** Now supports hasConsentForAdStorage to give users more granular control over their data.
-✅ **Enhanced Clarity**: Allows nullable boolean values, indicating when users have not provided consent instead of forcing defaults.
-✅ **Future-Proof:** Designed to be aligned with evolving privacy regulations and best practices.
- -If your app previously used setConsentData, it is highly recommended to migrate to setConsentDataV2 for a more flexible and robust solution. - -📌 **API Reference** - -```dart -void setConsentDataV2({ - bool? isUserSubjectToGDPR, - bool? consentForDataUsage, - bool? consentForAdsPersonalization, - bool? hasConsentForAdStorage -}) -``` - -### Parameters - -| Parameter | Type | Description | -| -------- | -------- | -------- | -| isUserSubjectToGDPR | bool? | Indicates if the user is subject to GDPR regulations. | -| consentForDataUsage | bool? | Determines if the user consents to data usage. | -| consentForAdsPersonalization | bool? | Determines if the user consents to personalized ads. | -| hasConsentForAdStorage | bool? | **(New!)** Determines if the user consents to storing ad-related data.| - -- If a parameter is `null`, it means the user has **not explicitly provided consent** for that option. -- These values should be collected from the user via an appropriate **UI or consent prompt** before calling this method. - -📌 **Example Usage** - -```dart -// Initialize AppsFlyerOptions with manualStart: true -final AppsFlyerOptions options = AppsFlyerOptions( - afDevKey: 'your_dev_key', - appId: '1234567890', // Required for iOS only - showDebug: true, - manualStart: true -); - -// Create the AppsflyerSdk instance -AppsflyerSdk appsflyerSdk = AppsflyerSdk(options); - -// Set consent data BEFORE initializing the SDK -appsflyerSdk.setConsentDataV2( - isUserSubjectToGDPR: true, - consentForDataUsage: true, - consentForAdsPersonalization: false, - hasConsentForAdStorage: null // User has not explicitly provided consent -); - -// Initialize the SDK -appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: true -); - -// Start the SDK -appsflyerSdk.startSDK(); -``` - -📌 **Notes**
-• You should call this method **before initializing the AppsFlyer SDK** if possible, or at least before `startSDK()` when using manual initialization.
-• Ensure you collect consent **legally and transparently** from the user before passing these values. diff --git a/doc/DeepLink.md b/doc/DeepLink.md deleted file mode 100644 index 28f9a990..00000000 --- a/doc/DeepLink.md +++ /dev/null @@ -1,324 +0,0 @@ -# Deep linking - -> ⚠️ **IMPORTANT: Flutter 3.27+ Breaking Change** -> -> Starting from Flutter 3.27, the default value for Flutter's deep linking option has changed from `false` to `true`. This means Flutter's built-in deep linking is now enabled by default, which can conflict with third-party deep linking plugins like AppsFlyer. -> -> **If you're using Flutter 3.27 or higher, you MUST disable Flutter's built-in deep linking** by adding the following configurations: -> -> **Android** - Add to your `AndroidManifest.xml` inside the `` tag: -> -> ```xml -> -> ``` -> -> **iOS** - Add to your `Info.plist` file: -> -> ```xml -> FlutterDeepLinkingEnabled -> -> ``` -> -> For more details, see the [official Flutter documentation](https://docs.flutter.dev/release/breaking-changes/deep-links-flag-change). - -Deep Linking vs Deferred Deep Linking: - -A deep link is a special URL that routes to a specific spot, whether that's on a website or in an app. A "mobile deep link" then, is a link that contains all the information needed to take a user directly into an app or a particular location within an app instead of just launching the app's home page. - -If the app is installed on the user's device - the deep link routes them to the correct location in the app. But what if the app isn't installed? This is where Deferred Deep Linking is used. When the app isn't installed, clicking on the link routes the user to the store to download the app. Deferred Deep linking defer or delay the deep linking process until after the app has been downloaded, and ensures that after they install, the user gets to the right location in the app. - -[Android and iOS set-up](#setup) - -![alt text](https://massets.appsflyer.com/wp-content/uploads/2018/03/21101417/app-installed-Recovered.png "") - - -####
The 3 Deep Linking Types: -Since users may or may not have the mobile app installed, there are 2 types of deep linking (Deferred + Direct DeepLinking Legacy APIs or Unified Deep Linking): - -1. Deferred Deep Linking - Legacy API, serving personalized content to new or former users, directly after the installation. -2. Direct Deep Linking - Legacy API, directly serving personalized content to existing users, which already have the mobile app installed. -3. Unified Deep Linking - Starting from v6.1.3, the new Unified Deep Linking API is available to handle deeplinking logic. - -In general, you should utilize either **both** of the legacy methods for deep linking, or only the Unified Deep Linking. - -For more info please check out the [OneLink™ Deep Linking Guide](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-Deep-Linking-Guide#Intro). - ---- - -### 1. Deferred Deep Linking (Get Conversion Data) - -Check out the deferred deeplinking guide from the AppFlyer knowledge base [here](https://support.appsflyer.com/hc/en-us/articles/207032096-Accessing-AppsFlyer-Attribution-Conversion-Data-from-the-SDK-Deferred-Deeplinking-#Introduction). - -Code sample to handle the `onInstallConversionData`: - -```dart -appsflyerSdk.onInstallConversionData((res){ - print("res: " + res.toString()); -}); -``` - -**Note:** The code implementation for `onInstallConversionData` must be made **prior to the initialization** code of the SDK. - ---- - -### 2. Direct Deeplinking - -When a deeplink is clicked on the device the AppsFlyer SDK will return the resolved link in the [onAppOpenAttribution](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-Deep-Linking-Guide#deep-linking-data-the-onappopenattribution-method-) method. - -Code sample to handle `OnAppOpenAttribution`: - -```dart -appsflyerSdk.onAppOpenAttribution((res){ - print("res: " + res.toString()); -}); -``` - -**Note:** The code implementation for `onAppOpenAttribution` must be made **prior to the initialization** code of the SDK. - ---- - -### 3. Unified deep linking - -> 📘 **UDL privacy protection** -> -> For new users, the UDL method only returns parameters relevant to deferred deep linking: `deep_link_value` and `deep_link_sub1` to `deep_link_sub10`. If you try to get any other parameters (`media_source`, `campaign`, `af_sub1-5`, etc.), they return `null`. - -The flow works as follows: - -1. User clicks the OneLink short URL. -2. The iOS Universal Links/ Android App Links (for deep linking) or the deferred deep link, triggers the SDK. -3. The SDK triggers the didResolveDeepLink method, and passes the deep link result object to the user. -4. The onDeepLinking method uses the deep link result object that includes the deep_link_value and other parameters to create the personalized experience for the users, which is the main goal of OneLink. - -> Check out the Unified Deep Linking docs for [Android](https://dev.appsflyer.com/docs/android-unified-deep-linking) and [iOS](https://dev.appsflyer.com/docs/ios-unified-deep-linking). - -Considerations: - -* Requires AppsFlyer Android SDK V6.1.3 or later. -* Does not support SRN campaigns. -* Does not provide af_dp in the API response. -* `onAppOpenAttribution` will not be called. All code should migrate to `onDeepLinking`. - -**Note:** The code implementation for `onDeepLinking` must be made **prior to the initialization** code of the SDK. - -Code sample to handle `onDeepLinking`: - -```dart - appsflyerSdk.onDeepLinking((DeepLinkResult dp) { - switch (dp.status) { - case Status.FOUND: - print(dp.deepLink?.toString()); - print("deep link value: ${dp.deepLink?.deepLinkValue}"); - break; - case Status.NOT_FOUND: - print("deep link not found"); - break; - case Status.ERROR: - print("deep link error: ${dp.error}"); - break; - case Status.PARSE_ERROR: - print("deep link status parsing error"); - break; - } - } -``` - -From version v6.4.0 a Unified deeplinking class was addded. You may use the following class to handle the deeplink: - -```dart -class DeepLink { - - DeepLink(this._clickEvent); - final Map _clickEvent; - Map get clickEvent => _clickEvent; - String? get deepLinkValue => _clickEvent["deep_link_value"] as String; - String? get matchType => _clickEvent["match_type"] as String; - String? get clickHttpReferrer => _clickEvent["click_http_referrer"] as String; - String? get mediaSource => _clickEvent["media_source"] as String; - String? get campaign => _clickEvent["campaign"] as String; - String? get campaignId => _clickEvent["campaign_id"] as String; - String? get afSub1 => _clickEvent["af_sub1"] as String; - String? get afSub2 => _clickEvent["af_sub2"] as String; - String? get afSub3 => _clickEvent["af_sub3"] as String; - String? get afSub4 => _clickEvent["af_sub4"] as String; - String? get afSub5 => _clickEvent["af_sub5"] as String; - bool get isDeferred => _clickEvent["is_deferred"] as bool; - - @override - String toString() { - return 'DeepLink: ${jsonEncode(_clickEvent)}'; - } - String? getStringValue(String key) { - return _clickEvent[key] as String; - } -} -``` - ---- - -# Set-up - -### Android Deeplink Setup - -#### URI Scheme -In your app’s manifest add the following intent-filter to your relevant activity: -```xml - - - - - - -``` - ---- - -#### App Links -For more on App Links check out the guide [here](https://support.appsflyer.com/hc/en-us/articles/115005314223-Deep-Linking-Users-with-Android-App-Links#what-are-android-app-links). - -In your app's manifest add the following intent-filter to your relevant activity: -```xml - - - - - - - -``` - ---- - -#### onNewIntent - -**❗Setting the intent this way is not required from v6.4.0 and above❗** -**❗If you are using a plugin version higher or equal to v6.4.0, ignore this section❗** - -**NOTE:** On Android, AppsFlyer SDK inspects the activity intent object during onResume(). Because of that, for each activity that may be configured or launched with any [non-standard launch mode](https://developer.android.com/guide/topics/manifest/activity-element#lmode) please make sure to add the following code to `MainActivity.java` in `android/app/src/main/java/com...` - -Java example: -```java - @Override - public void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - } -``` - -Kotlin example: -``` - override fun onNewIntent(intent : Intent){ - super.onNewIntent(intent) - setIntent(intent) - } -``` - -✏️✏️ - -### iOS Deeplink Setup - -For more on Universal Links check out the guide [here](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-Deep-Linking-Guide#setups-universal-links). - -Essentially, the Universal Links method links between an iOS mobile app and an associate website/domain, such as AppsFlyer’s OneLink domain (xxx.onelink.me). To do so, it is required to: - -1. Configure OneLink sub-domain and link to the mobile app (by hosting the ‘apple-app-site-association’ file - AppsFlyer takes care of this part in the onelink setup on your dashboard) -2. Configure the mobile app to register approved domains: - -```xml - - - - - com.apple.developer.associated-domains - - applinks:test.onelink.me - - - -``` - -#### URI Scheme - -Add your URI Scheme in the project's settings under "General" -> "URL Types" -> Add a new "URI Scheme". - -**❗Adding the following URI Scheme code is not required from v6.4.0 and above❗** -**❗If you are using a plugin version higher or equal to v6.4.0, ignore the rest of this section❗** - -Add the following to your `AppDelegate`: - -Objective-C example: - -``` - - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString*)sourceApplication annotation:(id)annotation { - // Only for AppsFlyer SDK version 6.2.0 and above - [[AppsFlyerAttribution shared] handleOpenUrl:url sourceApplication:sourceApplication annotation:annotation]; - - // Only for AppsFlyer SDK version 6.1.0 and below - [[AppsFlyerLib shared] handleOpenURL:url sourceApplication:sourceApplication withAnnotation:annotation]; - return YES; - } - - // Reports app open from deep link for iOS 10 - - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *) options { - - // Only for AppsFlyer SDK version 6.2.0 and above - [[AppsFlyerAttribution shared] handleOpenUrl:url options:options]; - - // Only for AppsFlyer SDK version 6.1.0 and below - [[AppsFlyerLib shared] handleOpenUrl:url options:options]; - return YES; - } -``` - -Swift example: - -```swift - override func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { - AppsFlyerAttribution.shared()!.handleOpenUrl(url, sourceApplication: sourceApplication, annotation: annotation); - return true - } - - override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { - AppsFlyerAttribution.shared()!.handleOpenUrl(url, options: options) - return true - } -``` - -For more information on URI-Schemes check out the guide [here](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-deep-linking-guide#setups-uri-scheme-for-ios-8-and-below). - ---- - -#### Universal Links - -**❗Adding the following Universal Links code is not required from v6.4.0 and above❗** -**❗If you are using a plugin version higher or equal to v6.4.0, ignore the rest of this section❗** - -Objective-C example: - - ``` - // Reports app open from a Universal Link for iOS 9 or above - - (BOOL) application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray> *restorableObjects))restorationHandler { - // AppsFlyer SDK version 6.2.0 and above - [[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:restorationHandler]; - - // AppsFlyer SDK version 6.1.0 and below - [[AppsFlyerLib shared] continueUserActivity:userActivity restorationHandler:restorationHandler]; - return YES; - } - ``` - -Swift example: - -```swift - private func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool { - AppsFlyerAttribution.shared()!.continueUserActivity(userActivity, restorationHandler: nil) - return true - } - - override func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - AppsFlyerAttribution.shared()!.continueUserActivity(userActivity, restorationHandler: nil) - return true - } -``` \ No newline at end of file diff --git a/doc/Guides.md b/doc/Guides.md deleted file mode 100644 index 750124c1..00000000 --- a/doc/Guides.md +++ /dev/null @@ -1,541 +0,0 @@ -# Flutter AppsFlyer Plugin Guides - - - -## Table of content - -- [Init SDK](#init-sdk) -- [Android out of store](#out-of-store) -- [Deep Linking](#deeplinking) - - [Deferred Deep Linking (Get Conversion Data)](#deferred-deep-linking) - - [Direct Deep Linking](#direct-deep-linking) - - [Unified deep linking](#Unified-deep-linking) - - [Android Deeplink Setup](#android-deeplinks) - - [iOS Deeplink Setup](#iosdeeplinks) - - [Example in swift](#Example-swift) -- [Set plugin for IOS 14](#ios14) -- [Setting strict mode (app for kids)](#strictMode) -- [Uninstall feature](#uninstall) - ---- - -## Init SDK - -To start using AppsFlyer you first need to create an instance of `AppsflyerSdk` before using any other of our sdk functionalities. - -`AppsflyerSdk` receives a map or `AppsFlyerOptions` object. This is how you can configure our `AppsflyerSdk` instance and connect it to your AppsFlyer account. - -*Example (using map):* -```dart -import 'package:appsflyer_sdk/appsflyer_sdk.dart'; -//.. - -AppsFlyerOptions appsFlyerOptions = { "afDevKey": afDevKey, - "afAppId": appId, - "isDebug": true}; - -AppsflyerSdk appsflyerSdk = AppsflyerSdk(appsFlyerOptions); -``` - -The next step is to call `initSdk` which have the optional boolean parameters `registerConversionDataCallback` and the deeplink callbacks: `registerOnAppOpenAttributionCallback` -`registerOnDeepLinkingCallback` -All callbacks are set to false as default. - -After we call `initSdk` we can use all of AppsFlyer SDK features. - -```dart -appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: true -); -``` - ---- - -## Android Out of store -Please make sure to go over [this guide](https://support.appsflyer.com/hc/en-us/articles/207447023-Attributing-out-of-store-Android-markets-guide) to get general understanding of how out of store attribution is set up in AppsFlyer. If the store you distribute the app through supports install referrer matching or requires the referrer in the postback, make sure to add the following to the AndroidManifest.xml: -```xml - -... - - - - - - -``` - ---- - -## Deep Linking - -> ⚠️ **IMPORTANT: Flutter 3.27+ Breaking Change** -> -> Starting from Flutter 3.27, the default value for Flutter's deep linking option has changed from `false` to `true`. This means Flutter's built-in deep linking is now enabled by default, which can conflict with third-party deep linking plugins like AppsFlyer. -> -> **If you're using Flutter 3.27 or higher, you MUST disable Flutter's built-in deep linking** by adding the following configurations: -> -> **Android** - Add to your `AndroidManifest.xml` inside the `` tag: -> -> ```xml -> -> ``` -> -> **iOS** - Add to your `Info.plist` file: -> -> ```xml -> FlutterDeepLinkingEnabled -> -> ``` -> -> For more details, see the [official Flutter documentation](https://docs.flutter.dev/release/breaking-changes/deep-links-flag-change). - - - - -#### The 3 Deep Linking Types: -Since users may or may not have the mobile app installed, there are 2 types of deep linking: - -1. Deferred Deep Linking - Serving personalized content to new or former users, directly after the installation. -2. Direct Deep Linking - Directly serving personalized content to existing users, which already have the mobile app installed. -3. Unified deep linking - Unified deep linking sends new and existing users to a specific in-app activity as soon as the app is opened. - -For more info please check out the [OneLink™ Deep Linking Guide](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-Deep-Linking-Guide#Intro). - -### 1. Deferred Deep Linking (Get Conversion Data) -In order to use the unified deep link you need to send the `registerConversionDataCallback: true` flag inside the object that sent to the sdk. - -======= -Handle the Deferred deeplink in the following callback: -```dart -appsflyerSdk.onInstallConversionData((res){ - print("res: " + res.toString()); -}); -``` - -Check out the deferred deeplinkg guide from the AppFlyer knowledge base [here](https://support.appsflyer.com/hc/en-us/articles/207032096-Accessing-AppsFlyer-Attribution-Conversion-Data-from-the-SDK-Deferred-Deeplinking-#Introduction) - -### 2. Direct Deeplinking -In order to use the unified deep link you need to send the `registerOnAppOpenAttributionCallback: true` flag inside the object that sent to the sdk. - -Handle the Direct deeplink in the following callback: - -```dart -appsflyerSdk.onAppOpenAttribution((res){ - print("res: " + res.toString()); -}); -``` - -When a deeplink is clicked on the device the AppsFlyer SDK will return the link in the [onAppOpenAttribution](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-Deep-Linking-Guide#deep-linking-data-the-onappopenattribution-method-) method. - -### 3. Unified deep linking -In order to use the unified deep link you need to send the `registerOnDeepLinkingCallback: true` flag inside the object that sent to the sdk. -**NOTE:** when sending this flag, the sdk will ignore `onAppOpenAttribution`! - -**Breaking changes!** - -From version v6.4.0 a Unified deeplinking class was addded. You can use the following class to handle the deeplink: - -```dart -class DeepLink{ - - DeepLink(this._clickEvent); - final Map _clickEvent; - Map get clickEvent => _clickEvent; - String? get deepLinkValue => _clickEvent["deep_link_value"] as String; - String? get matchType => _clickEvent["match_type"] as String; - String? get clickHttpReferrer => _clickEvent["click_http_referrer"] as String; - String? get mediaSource => _clickEvent["media_source"] as String; - String? get campaign => _clickEvent["campaign"] as String; - String? get campaignId => _clickEvent["campaign_id"] as String; - String? get afSub1 => _clickEvent["af_sub1"] as String; - String? get afSub2 => _clickEvent["af_sub2"] as String; - String? get afSub3 => _clickEvent["af_sub3"] as String; - String? get afSub4 => _clickEvent["af_sub4"] as String; - String? get afSub5 => _clickEvent["af_sub5"] as String; - bool get isDeferred => _clickEvent["is_deferred"] as bool; - - @override - String toString() { - return 'DeepLink: ${jsonEncode(_clickEvent)}'; - } - String? getStringValue(String key) { - return _clickEvent[key] as String; - } -} -``` - -Example of handling both the Direct & the deferred deeplink in the following callback: - -```dart - _appsflyerSdk?.onDeepLinking((DeepLinkResult dp) { - switch (dp.status) { - case Status.FOUND: - print(dp.deepLink?.toString()); - print("deep link value: ${dp.deepLink?.deepLinkValue}"); - break; - case Status.NOT_FOUND: - print("deep link not found"); - break; - case Status.ERROR: - print("deep link error: ${dp.error}"); - break; - case Status.PARSE_ERROR: - print("deep link status parsing error"); - break; - } - } -``` - -For more information about this api, please check [OneLink Guide Here](https://dev.appsflyer.com/docs/android-unified-deep-linking) - -### Android Deeplink Setup - - - -#### URI Scheme -In your app’s manifest add the following intent-filter to your relevant activity: -```xml - - - - - - -``` - -**❗Not needed from v6.4.0 and above** - -**NOTE:** On Android, AppsFlyer SDK inspects activity intent object during onResume(). Because of that, for each activity that may be configured or launched with any [non-standard launch mode](https://developer.android.com/guide/topics/manifest/activity-element#lmode) the following code was added to `MainActivity.java` in `android/app/src/main/java/com...` - -Java: - -```java - @Override - public void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - } -``` - -Kotlin: - -``` - override fun onNewIntent(intent : Intent){ - super.onNewIntent(intent) - setIntent(intent) - } -``` - - -#### App Links - -In your app’s manifest add the following intent-filter to your relevant activity: - -```xml - - - - - - - -``` - -For more on App Links check out the guide [here](https://support.appsflyer.com/hc/en-us/articles/115005314223-Deep-Linking-Users-with-Android-App-Links#what-are-android-app-links). - - -### iOS Deeplink Setup - -**❗Not needed from v6.4.0 and above** - - -In order for the callback to be called: - -1. Import AppsFlyer SDK: - -Objective C: - - a. For AppsFlyer SDK V6.2.0 and above add: - - ```#import "AppsflyerSdkPlugin.h"``` - - b. For AppsFlyer SDK V6.1.0 and below add: - - ```#import ``` - -Swift: - -Add ```import AppsFlyerLib``` in the `AppDelegate.swift` file. - -Add in the `Runner-Bridging-Header.h` one of the following lines: - - a. For AppsFlyer SDK V6.2.0 and above add: - - ```#import `` - - b. For AppsFlyer SDK V6.1.0 and below add: - - ```#import ``` - -2. Set-up the following AppsFlyer API: - -### URI Scheme - - -Objective-C: - -``` - - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString*)sourceApplication annotation:(id)annotation { - // AppsFlyer SDK version 6.2.0 and above - [[AppsFlyerAttribution shared] handleOpenUrl:url sourceApplication:sourceApplication annotation:annotation]; - - // AppsFlyer SDK version 6.1.0 and below - [[AppsFlyerLib shared] handleOpenURL:url sourceApplication:sourceApplication withAnnotation:annotation]; - return YES; - } - - // Reports app open from deep link for iOS 10 - - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *) options { - - // AppsFlyer SDK version 6.2.0 and above - [[AppsFlyerAttribution shared] handleOpenUrl:url options:options]; - - // AppsFlyer SDK version 6.1.0 and below - [[AppsFlyerLib shared] handleOpenUrl:url options:options]; - return YES; - } -``` - -Swift: - -```swift - override func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { - AppsFlyerAttribution.shared()!.handleOpenUrl(url, sourceApplication: sourceApplication, annotation: annotation); - return true - } - - override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { - AppsFlyerAttribution.shared()!.handleOpenUrl(url, options: options) - return true - } -``` - -For more on URI-schemes check out the guide [here](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-deep-linking-guide#setups-uri-scheme-for-ios-8-and-below) - - -### Universal Links - -**❗Not needed from v6.4.0 and above** - -Objective-C: - - ``` - // Reports app open from a Universal Link for iOS 9 or above - - (BOOL) application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray> *restorableObjects))restorationHandler { - // AppsFlyer SDK version 6.2.0 and above - [[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:restorationHandler]; - - // AppsFlyer SDK version 6.1.0 and below - [[AppsFlyerLib shared] continueUserActivity:userActivity restorationHandler:restorationHandler]; - return YES; - } - ``` - -Swift: - -```swift - private func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool { - AppsFlyerAttribution.shared()!.continueUserActivity(userActivity, restorationHandler: nil) - return true - } - - override func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - AppsFlyerAttribution.shared()!.continueUserActivity(userActivity, restorationHandler: nil) - return true - } -``` - - -###Example in swift:### - - - -`Runner-Bridging-Header.h` - -``` -#import "GeneratedPluginRegistrant.h" -#import -``` - - -`AppDelegate.swift` - -```swift -import UIKit -import Flutter -import AppsFlyerLib - -@UIApplicationMain -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - // Open URI-scheme for iOS 9 and above - override func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { - NSLog("AppsFlyer [deep link]: Open URI-scheme for iOS 9 and above") - AppsFlyerAttribution.shared()!.handleOpenUrl(url, sourceApplication: sourceApplication, annotation: annotation); - return true - } - - // Reports app open from deep link for iOS 10 or later - override func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - NSLog("AppsFlyer [deep link]: continue userActivity") - AppsFlyerAttribution.shared()!.continueUserActivity(userActivity, restorationHandler:nil ) - return true - } - - override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { - NSLog("AppsFlyer [deep link]: Open URI-scheme options") - - AppsFlyerAttribution.shared()!.handleOpenUrl(url, options: options) - return true - } -} -``` - - -More on Universal Links: -Essentially, the Universal Links method links between an iOS mobile app and an associate website/domain, such as AppsFlyer’s OneLink domain (xxx.onelink.me). To do so, it is required to: - -1. Get your SHA256 fingerprint: - - a. [Creating A Keystore](https://flutter.dev/docs/deployment/android#create-a-keystore) (you'll eventually need to do this to release on the Play Store) - - b. [Generate Fingerprint](https://developers.google.com/android/guides/client-auth) -2. Configure OneLink sub-domain and link to mobile app in the AppsFlyer onelink setup on your dashboard, add the fingerprint there (AppsFlyer takes care of hosting the ‘apple-app-site-association’ file) -3. Configure the mobile app to register approved domains: - - ```xml - - - - - com.apple.developer.associated-domains - - applinks:test.onelink.me - - - - ``` - -For more on Universal Links check out the guide [here](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-Deep-Linking-Guide#setups-universal-links). - - -## Set plugin for IOS 14 - -1. Adding the conset dialog: - -There are 2 ways to add it to your app: - - a. Add the following Library: https://pub.dev/packages/app_tracking_transparency - -Or - - b. Add native implementation: - - -- Add `#import ` in your `AppDelegate.m` - -- Add the ATT pop-up for IDFA collection so your `AppDelegate.m` will look like this: - -``` -- (void)applicationDidBecomeActive:(nonnull UIApplication *)application { - if (@available(iOS 14, *)) { - [ATTrackingManager requestTrackingAuthorizationWithCompletionHandler:^(ATTrackingManagerAuthorizationStatus status) { - // native code here - }]; - } -} -``` - - - -2. Add Privacy - Tracking Usage Description inside your `.plist` file in Xcode. - -``` -NSUserTrackingUsageDescription -This identifier will be used to deliver personalized ads to you. -``` - -3. Optional: Set the `timeToWaitForATTUserAuthorization` property in the `AppsFlyerOptions` to delay the sdk initazliation for a number of `x seconds` until the user accept the consent dialog: - -```dart -AppsFlyerOptions options = AppsFlyerOptions( - afDevKey: DotEnv().env["DEV_KEY"], - appId: DotEnv().env["APP_ID"], - showDebug: true, - timeToWaitForATTUserAuthorization: 30 - ); -``` - -For more info visit our Full Support guide for iOS 14: - -https://support.appsflyer.com/hc/en-us/articles/207032066#integration-33-configuring-app-tracking-transparency-att-support - ---- - -## 👨‍👩‍👧‍👦 Strict mode for App-kids - -Starting from version **6.2.4-nullsafety.5** iOS SDK comes in two variants: **Strict** mode and **Regular** mode. - -Please read more: https://support.appsflyer.com/hc/en-us/articles/207032066#integration-strict-mode-sdk - -***Change to Strict mode*** - -After you [installed](#installation) the AppsFlyer plugin: - -1. Go to the `$HOME/.pub-cache/hosted/pub.dartlang.org/appsflyer_sdk-/ios` folder -2. Open `appsflyer_sdk.podspec`, add `/Strict` to the `s.ios.dependency` as follow: - -`s.ios.dependency 'AppsFlyerFramework', '6.x.x'` To >> `s.ios.dependency 'AppsFlyerFramework/Strict', '6.x.x'` -and save - -3. Go to `ios` folder of your current project and Run `pod update` - -***Change to Regular mode*** - -1. Go to the `$HOME/.pub-cache/hosted/pub.dartlang.org/appsflyer_sdk-/ios` folder: -2. Open `appsflyer_sdk.podspec` and remove `/strict`: - -`s.ios.dependency 'AppsFlyerFramework/Strict', '6.x.x'` To >> `s.ios.dependency 'AppsFlyerFramework', '6.x.x'` -and save - -3. Go to `ios` folder of your current project and Run `pod update` - ---- - -## Uninstall Feature - -Android: - -1. Add Firebase messaging to your flutter app. You can use the Offical Firebase messagin package by Google: -https://pub.dev/packages/firebase_messaging -2. Follow the native guide on implementing the Uninstall feature both on the Firebase plaform and the app: -https://support.appsflyer.com/hc/en-us/articles/360017822118-Integrate-Android-uninstall-measurement-into-an-app - -iOS: - -1. Follow the native iOS guide: - -https://support.appsflyer.com/hc/en-us/articles/360017822178-Integrate-iOS-uninstall-measurement-into-an-app- - ---- diff --git a/doc/InAppEvents.md b/doc/InAppEvents.md deleted file mode 100644 index 4ed88add..00000000 --- a/doc/InAppEvents.md +++ /dev/null @@ -1,30 +0,0 @@ -# In-App events - -In-App Events provide insight on what is happening in your app. It is recommended to take the time and define the events you want to measure to allow you to measure ROI (Return on Investment) and LTV (Lifetime Value). - -Recording in-app events is performed by calling logEvent with event name and value parameters. See In-App Events documentation for more details. - -**Note:** An In-App Event name must be no longer than 45 characters. Events names with more than 45 characters do not appear in the dashboard, but only in the raw Data, Pull and Push APIs. -Find more info about recording events [here](https://dev.appsflyer.com/hc/docs/in-app-events-sdk). - ---- - -## logEvent - -** `logEvent(String eventName, Map? eventValues)`** - -| parameter | type | description | -| ----------- |----------|------------------------------------------ | -| eventName | String | The event name, it is presented in your dashboard. | -| eventValues | Map | The event values that are sent with the event. | - -**Example:** -```dart -Future logEvent(String eventName, Map? eventValues) async { - bool? result; - try { - result = await appsflyerSdk.logEvent(eventName, eventValues); - } on Exception catch (e) {} - print("Result logEvent: $result"); -} -``` \ No newline at end of file diff --git a/doc/Installation.md b/doc/Installation.md deleted file mode 100644 index 289fab96..00000000 --- a/doc/Installation.md +++ /dev/null @@ -1,49 +0,0 @@ -# Adding appsflyer-flutter-plugin to your project - -## Installation - -Open the terminal of your chosen IDE and run the following: - -``` -flutter pub add appsflyer_sdk -``` - -This will download the AppsFlyer flutter plugin to your project, you may observe the changes in your `pubspec.yaml` file. - ---- -## iOS: Swift Package Manager (SPM) support - -Starting with v6.18.0, the plugin's **Core** integration supports Swift Package Manager on iOS, alongside continued full CocoaPods support. If your app has SPM enabled (the default on Flutter 3.44+, or via `flutter config --enable-swift-package-manager` on Flutter 3.24+), no extra setup is needed — Flutter's tooling picks up the plugin's `Package.swift` automatically. - -**If you use Purchase Connector, do not enable SPM for this plugin.** [Purchase Connector](PurchaseConnector.md) requires CocoaPods for the entire plugin (Core included) — it cannot currently be combined with SPM, pending resolution of an upstream Flutter limitation ([flutter/flutter#161182](https://github.com/flutter/flutter/issues/161182)). SPM is recommended only for apps that don't use Purchase Connector at all; if you don't, keep CocoaPods and the `$AppsFlyerPurchaseConnector` Podfile flag as documented in [PurchaseConnector.md](PurchaseConnector.md). - ---- -## Huawei Referrer -Huawei Referrer is supported in SDK v6.14.0 and above. -Due to changes in the Huawei AppGallery store, previous versions of the AppsFlyer SDK are not able to fetch the referrer from the store. [Learn more](https://dev.appsflyer.com/hc/docs/install-android-sdk#huawei-install-referrer). ---- - -## 👨‍👩‍👧‍👦 Strict mode for Kids Apps - -Starting from version **6.2.4-nullsafety.5**, the iOS SDK comes in two variants: **Strict** mode and **Regular** mode. -Please read more: https://support.appsflyer.com/hc/en-us/articles/207032066#integration-strict-mode-sdk - -***Change to Strict mode*** - -After you installed the AppsFlyer plugin: -1. Go to the `$HOME/.pub-cache/hosted/pub.dartlang.org/appsflyer_sdk-/ios` folder -2. Open `appsflyer_sdk.podspec`, add `/Strict` to the `s.ios.dependency` as follow: -`s.ios.dependency 'AppsFlyerFramework', '6.x.x'` to `s.ios.dependency 'AppsFlyerFramework/Strict', '6.x.x'` -and save. - -3. Go to the `ios` folder of your current project and run `pod update`. - -***Change to Regular mode*** - -After you installed the AppsFlyer plugin: -1. Go to the `$HOME/.pub-cache/hosted/pub.dartlang.org/appsflyer_sdk-/ios` folder: -2. Open `appsflyer_sdk.podspec` and remove `/Strict`: -change `s.ios.dependency 'AppsFlyerFramework/Strict', '6.x.x'` to `s.ios.dependency 'AppsFlyerFramework', '6.x.x'` -and save. - -3. Go to the `ios` folder of your current project and run `pod update`. \ No newline at end of file diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 00000000..059dec49 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,25 @@ +# AppsFlyer Flutter plugin — documentation + +Welcome. These guides follow the integration journey, from adding the plugin to going +live and troubleshooting. Follow them in order for a first integration, or jump to the +topic you need. + +## Integration journey + +1. **[Installation](installation-guide.md)** — add the package and configure native (iOS/Android) dependencies. +2. **[Getting started](getting-started.md)** — obtain the SDK singleton, call `init`, use the SDK 7 session-ready start model, and configure iOS 14 / ATT. +3. **[Deep linking](deep-linking.md)** — OneLink / Unified Deep Linking, deferred & direct links, and Android/iOS link setup. +4. **[In-app events & ad revenue](in-app-events.md)** — log custom events and ad-revenue events. + +## Optional features + +- **[Advanced features](advanced-features.md)** — uninstall measurement, user invites, purchase validation, and Android out-of-store attribution. +- **[Privacy, identity, consent & DMA](consent-dma.md)** — configure anonymization, + SDK opt-out, hashed PII, and DMA/GDPR consent. +- **[Purchase Connector](purchase-connector.md)** — automatic IAP / subscription revenue validation (iOS: CocoaPods only). + +## Reference & operations + +- **[API reference](api-reference.md)** — the complete public API, including per-method Android/iOS availability. +- **[Testing & troubleshooting](testing-and-troubleshooting.md)** — verify your integration and resolve common issues. +- **[Migrating v6 → v7](migration-guide.md)** — breaking changes, removed APIs, and upgrade steps. diff --git a/doc/Testing.md b/doc/Testing.md deleted file mode 100644 index 231b63d2..00000000 --- a/doc/Testing.md +++ /dev/null @@ -1,68 +0,0 @@ -# Testing - -More info about testing the SDK for marketers [here](https://support.appsflyer.com/hc/en-us/articles/360001559405-Test-mobile-SDK-integration-with-the-app#introduction). - -- [Testing for iOS](#iOS) -- [Testing for Android](#Android) - -Before testing the SDK, you need to enable the debug mode so the SDK will produce the full logs. -To enable it, set the appsFlyer options object with `showDebug` as `true`, and then initialize the SDK: - -```dart -AppsFlyerOptions appsFlyerOptions = AppsFlyerOptions( - afDevKey: afDevKey, - appId: appId, - showDebug: true); - -AppsflyerSdk appsflyerSdk = AppsflyerSdk(appsFlyerOptions); - -appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: false -); -``` - ---- - -## Testing for iOS - -Open your iOS project with XCode (`appName.xcworkspace`) and run it. In the logs section or in the console app, you will see logs related to AppsFlyer start with `[AppsFlyerSDK]`.
-Search for the launch event that looks like this: - -``` -<~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~~+~> -<~+~ SEND Start: https://launches.appsflyer.com/api/v6.4/iosevent?app_id=7xXxXxX1&buildnumber=6.4.4 -<~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~~+~> -{ launch event payload } // Just an example of a JSON. you will see the full payload -``` -and also: -``` -Result: { - data = {length = 64, bytes = 0x7b226f6c 5f696422 3a224476 5769222c ... 696e6b2e 6d65227d }; - dataStr = "{\"oxXxXxd\":\"DXxXxi\",\"oXxXer\":ss,\"olXxXxain\":\"xXxXxXx\"}"; - retries = 2; - statusCode = 200; // ~~> success! - taskIdentifier = 4; -} -``` - -For more iOS integration tests, see [Here](https://dev.appsflyer.com/hc/docs/testing-ios) - ---- - -##
Testing for Android - -Open your Android project with Android Studio (`android` folder) and run it. In the logcat, you will see logs related to AppsFlyer start with `I/AppsFlyer_x.x.x`.
-Search for the launch event that looks like this: - -``` -I/AppsFlyer_6.4.3: url: https://launches.appsflyer.com/api/v6.4/androidevent?app_id=com.aXxXxt.rxXxXxt&buildnumber=6.4.3 -I/AppsFlyer_6.4.3: data: { launch event payload } // Just an example of a JSON. you will see the full payload -``` -and also: -``` -I/AppsFlyer_6.4.3: response code: 200 // ~~> success! -``` - -For more Android integration tests, see [Here](https://dev.appsflyer.com/hc/docs/testing-android) \ No newline at end of file diff --git a/doc/advanced-features.md b/doc/advanced-features.md new file mode 100644 index 00000000..e8c0bb8c --- /dev/null +++ b/doc/advanced-features.md @@ -0,0 +1,305 @@ +# 📑 Advanced features + +Optional features you add on top of the [core setup](getting-started.md). Adopt only the +ones your app needs. + +> **Audience:** teams adding uninstall measurement, user invites, purchase validation, or +> out-of-store attribution. For the full method list see the [API reference](api-reference.md). + +- [Measure App Uninstalls](#uninstall) +- [User invite](#user-invite) +- [In-app purchase validation](#iae) +- [Android Out of Store](#out-of-store) + +> **iOS 14 / ATT setup moved.** App Tracking Transparency configuration now lives in +> [Getting started → iOS 14 & App Tracking Transparency](getting-started.md#ios-14--app-tracking-transparency). + +--- + +##
Measure App Uninstalls + +Flutter exposes one cross-platform API: +`updateServerUninstallToken(String token)`. Pass an FCM registration token on +Android or a hexadecimal APNs device token on iOS. + +### iOS + +You may update the uninstall token from the native side and from the plugin side, as shown in the methods below, you do not have to implement both of the methods, but only one. +You can read more about iOS Uninstall Measurement in our [knowledge base](https://support.appsflyer.com/hc/en-us/articles/4408933557137) and you can follow our guide for Uninstall measurement on our [DevHub](https://dev.appsflyer.com/hc/docs/uninstall-measurement-ios). + +#### First method + +You can register the uninstall token with AppsFlyer by modifying your `AppDelegate.m` file, add the following function call with your uninstall token inside [didRegisterForRemoteNotificationsWithDeviceToken](https://developer.apple.com/reference/uikit/uiapplicationdelegate). + +**Example:** + +```objective-c +@import AppsFlyerLib; + +... + +- (void)application:(UIApplication *)application + didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { + // Notify AppsFlyerLib. + [[AppsFlyerLib shared] registerUninstall:deviceToken]; +} +``` + +#### Second method + +You can register the uninstall token with AppsFlyer by calling the following API with your uninstall token: +```dart +await appsFlyerSdk.updateServerUninstallToken("0123456789abcdef"); +``` + +> **Note:** When using this method on iOS, the token should be passed as a **hexadecimal string representation** of the device token. The plugin will automatically convert the hex string to the required `NSData` format for the AppsFlyer SDK. +> +> If you're using the [firebase_messaging](https://pub.dev/packages/firebase_messaging) plugin, you can get the APNs token on iOS using `FirebaseMessaging.instance.getAPNSToken()` which returns the token as a hex string, which is the expected format for this method. + +### Android + +It is possible to utilize the [Firebase Messaging Plugin for Flutter](https://pub.dev/packages/firebase_messaging) for everything related to the uninstall token. +You can read more about Android Uninstall Measurement in our [knowledge base](https://support.appsflyer.com/hc/en-us/articles/4408933557137) and you can follow our guide for Uninstall measurement using FCM on our [DevHub](https://dev.appsflyer.com/hc/docs/uninstall-measurement-android). + +On the Flutter side, you can register the uninstall token with AppsFlyer by calling the following API with your uninstall token: +```dart +await appsFlyerSdk.updateServerUninstallToken("fcm-registration-token"); +``` + +**Example using Firebase Messaging (cross-platform):** +```dart +import 'dart:io' show Platform; +import 'package:firebase_messaging/firebase_messaging.dart'; + +// Update uninstall token for AppsFlyer. +Future _updateUninstallToken(AppsFlyerSdk appsFlyerSdk) async { + if (Platform.isAndroid) { + final token = await FirebaseMessaging.instance.getToken(); + if (token != null) { + await appsFlyerSdk.updateServerUninstallToken(token); + } + } else if (Platform.isIOS) { + final token = await FirebaseMessaging.instance.getAPNSToken(); + if (token != null) { + await appsFlyerSdk.updateServerUninstallToken(token); + } + } +} +``` +**Note:** +- On Android, `getToken()` returns the FCM token. +- On iOS, `getAPNSToken()` returns the APNs token as a hex string, suitable for `updateServerUninstallToken`. +- Replace `appsFlyerSdk` with your `AppsFlyerSdk.instance` reference. + +--- + +## User invite + +A complete list of supported parameters is available [here](https://support.appsflyer.com/hc/en-us/articles/115004480866-User-Invite-Tracking), you can also make use of the `userParams` field to include custom parameters of your choice. + +1. First define the OneLink ID with `setAppInviteOneLink` (find it in the AppsFlyer dashboard in the OneLink section): + + **`Future setAppInviteOneLink(String oneLinkId)`** + +2. Utilize the AppsFlyerInviteLinkParams class to set the query params in the user invite link: + +```dart +class AppsFlyerInviteLinkParams { + final String? channel; + final String? campaign; + final String? referrerName; + final String? referrerImageUrl; + final String? referrerCustomerId; + final String? baseDeepLink; + final String? brandDomain; + final Map? userParams; +} +``` + +3. Call `generateInviteLink` to generate the user invite link. The returned + Future completes with the generated URL or throws `AppsFlyerException`. + `awaitResponse` defaults to `true`. On Android, `false` returns the + synchronously generated long link. On iOS, link generation always waits for + the asynchronous result. + +4. Pass the generated URL to your app's share flow. After the user shares the + invite, call `logInvite` with the same channel to log the `af_invite` event: + + **`Future logInvite(String channel, [Map? eventParameters])`** + + Do not call `logInvite` when the link is only generated; call it for the + actual share action. + +**Full example:** + +```dart +// Setting the OneLink ID +await appsFlyerSdk.setAppInviteOneLink('OnelinkID'); + +// Creating the required parameters of the OneLink +const AppsFlyerInviteLinkParams inviteLinkParams = AppsFlyerInviteLinkParams( + channel: "whatsapp", + campaign: "summer_sale", + userParams: {"key":"value"} +); + +// Generating the OneLink +try { + final url = await appsFlyerSdk.generateInviteLink( + parameters: inviteLinkParams, + awaitResponse: true, + ); + + // Pass url to your app's share UI. + // After the user completes the share action: + await appsFlyerSdk.logInvite( + "whatsapp", + {"campaign": "summer_sale"}, + ); +} on AppsFlyerException catch (error) { + print(error); +} +``` + +--- + +### In-app purchase validation +Receipt validation is a secure mechanism whereby the payment platform (e.g. Apple or Google) validates that an in-app purchase indeed occurred as reported.
+Learn more - https://support.appsflyer.com/hc/en-us/articles/207032106-Receipt-validation-for-in-app-purchases
+ +**Cross-platform API:** + +The unified purchase validation API that works across both Android and iOS platforms: + +```dart +Future> validateAndLogInAppPurchase( + AFPurchaseDetails purchase, { + Map? additionalParameters, + bool awaitResponse = true, +}) +``` + +`awaitResponse` defaults to `true`. On Android, `false` starts validation +without waiting for a result and returns an empty map. On iOS, validation +always waits for completion. + +**AFPurchaseDetails interface and platform implementations:** +```dart +AFAndroidPurchaseDetails( + purchaseType: AFPurchaseType, // oneTimePurchase or subscription + purchaseToken: String, // Purchase token from app store + productId: String, // Product identifier +) + +AFIOSPurchaseDetails( + purchaseType: AFPurchaseType, // oneTimePurchase or subscription + transactionId: String, // App Store transaction identifier + productId: String, // Product identifier +) +``` + +Passing an Android purchase-details object on iOS, or an iOS purchase-details +object on Android, throws `ArgumentError` before the native validation call. + +**Example:** +```dart +import 'dart:io' show Platform; + +final AFPurchaseDetails purchaseDetails = Platform.isAndroid + ? const AFAndroidPurchaseDetails( + purchaseType: AFPurchaseType.oneTimePurchase, + purchaseToken: "sample_purchase_token_12345", + productId: "com.example.product", + ) + : const AFIOSPurchaseDetails( + purchaseType: AFPurchaseType.oneTimePurchase, + transactionId: "sample_transaction_id", + productId: "com.example.product", + ); + +// Validate purchase (works on both Android and iOS) +try { + Map result = await appsFlyerSdk.validateAndLogInAppPurchase( + purchaseDetails, + additionalParameters: {"custom_param": "value"}, + awaitResponse: true, + ); + print("Validation successful: $result"); +} on AppsFlyerException catch (error) { + print("Validation failed: $error"); +} on ArgumentError catch (error) { + print("Invalid purchase details: $error"); +} +``` + +**Benefits:** +- ✅ **Cross-platform**: Single API works on both Android and iOS +- ✅ **Type-safe**: Uses structured data classes instead of raw strings +- ✅ **Comprehensive error handling**: `AppsFlyerException` provides an optional numeric `code` and `message` for failed SDK calls +- ✅ **Enhanced validation**: Uses AppsFlyer's latest validation infrastructure +- ✅ **Future-proof**: Built for AppsFlyer's V2 validation endpoints + +--- + +**iOS sandbox mode:** + +For testing iOS purchase validation against the App Store sandbox, enable sandbox mode before validating: + +```dart +await appsFlyerSdk.setUseReceiptValidationSandbox(true); +``` + +For the uninstall-measurement flow, +`setUseUninstallSandbox(true)` is the sandbox companion. + +--- + +##
Android Out of Store + +Use out-of-store attribution when you distribute the Android app through a store other +than Google Play. Read AppsFlyer's +[out-of-store attribution guide](https://support.appsflyer.com/hc/en-us/articles/207447023-Attributing-out-of-store-Android-markets-guide). + +### Google Play Install Referrer + +Play Install Referrer is collected via Google's Install Referrer library. The native SDK +declares this dependency as `compileOnly`, and the Flutter plugin supplies the required +runtime dependency transitively: + +```gradle +dependencies { + implementation 'com.android.installreferrer:installreferrer:2.2' +} +``` + +No app-level Gradle change is required for AppsFlyer. Add the dependency to your app +module only if your application code imports and uses the Install Referrer API directly. + +Upgrade-specific receiver removal instructions are documented in +[doc/migration-guide.md](migration-guide.md). + +### Alternative stores (Samsung, Xiaomi, Huawei) + +If you publish to Samsung Galaxy Store, Xiaomi GetApps, or Huawei AppGallery, add the +optional store referrer Gradle dependencies from the native migration guide +([§11 — optional store referrer libraries](https://dev.appsflyer.com/hc/docs/migrate-android-sdk-to-v7#11-add-optional-store-referrer-libraries)). +No extra Flutter plugin setup is required beyond those dependencies. + +### Runtime configuration + +Set the alternative store label at runtime with `setOutOfStore` (Android only). The value +is runtime-only, so re-apply it on every cold start. See the +[API reference](api-reference.md#setOutOfStore): + +```dart +if (Platform.isAndroid) { + await appsflyerSdk.setOutOfStore("facebook_int"); +} +``` + +--- + +## iOS 14 & App Tracking Transparency + +App Tracking Transparency (ATT) setup has moved to +[Getting started → iOS 14 & App Tracking Transparency](getting-started.md#ios-14--app-tracking-transparency). diff --git a/doc/api-reference.md b/doc/api-reference.md new file mode 100644 index 00000000..129c0d4b --- /dev/null +++ b/doc/api-reference.md @@ -0,0 +1,1741 @@ +# API + + + +## Types +- [AppsFlyerSdk](#appsflyer-options) +- [AFMediationNetwork](#AFMediationNetwork) +- [AFLogLevel](#AFLogLevel) +- [AFPurchaseDetails](#AFPurchaseDetails) +- [AFAndroidPurchaseDetails](#AFAndroidPurchaseDetails) +- [AFIOSPurchaseDetails](#AFIOSPurchaseDetails) +- [AFPurchaseType](#AFPurchaseType) +- [AppsFlyerInviteLinkParams](#AppsFlyerInviteLinkParams) +- [DeepLinkResult](#DeepLinkResult) +- [DeepLinkStatus](#DeepLinkStatus) +- [DeepLinkFailure](#DeepLinkFailure) +- [DeepLink](#DeepLink) +- [AppsFlyerException](#AppsFlyerException) + +## Methods +- [init](#init) +- [enableDebug](#enableDebug) +- [setLogLevel](#setLogLevel) +- [start](#start) +- [registerConversionListener](#registerConversionListener) +- [unregisterConversionListener](#unregisterConversionListener) +- [registerDeepLinkListener](#registerDeepLinkListener) +- [unregisterDeeplinkListener](#unregisterDeeplinkListener) +- [registerSessionReadyListener](#registerSessionReadyListener) +- [unregisterSessionReadyListener](#unregisterSessionReadyListener) +- [isSessionReady](#isSessionReady) +- [logEvent](#logEvent) +- [logLocation](#logLocation) +- [logSession](#logSession) +- [anonymizeUser](#anonymizeUser) +- [setMinTimeBetweenSessions](#setMinTimeBetweenSessions) +- [stop](#stop) +- [setCurrencyCode](#setCurrencyCode) +- [setIsUpdate](#setIsUpdate) +- [setCustomerUserId](#setCustomerUserId) +- [setAdditionalData](#setAdditionalData) +- [setCollectAndroidID](#setCollectAndroidID) +- [setHost](#setHost) +- [getHostName](#getHostName) +- [getHostPrefix](#getHostPrefix) +- [updateServerUninstallToken](#updateServerUninstallToken) +- [Validate Purchase](#validatePurchase) +- [validateAndLogInAppPurchase](#validateAndLogInAppPurchase) +- [setUseReceiptValidationSandbox](#setUseReceiptValidationSandbox) +- [setUseUninstallSandbox](#setUseUninstallSandbox) +- [sendPushNotificationData](#sendPushNotificationData) +- [handlePushNotification](#handlePushNotification) +- [addPushNotificationDeepLinkPath](#addPushNotificationDeepLinkPath) +- [User Invite](#userInvite) +- [setAppInviteOneLink](#setAppInviteOneLink) +- [generateInviteLink](#generateInviteLink) +- [enableFacebookDeferredApplinks](#enableFacebookDeferredApplinks) +- [setFacebookDeferredAppLink](#setFacebookDeferredAppLink) +- [enableTCFDataCollection](#enableTCFDataCollection) +- [setConsentData](#setConsentData) +- [setDisableSKAdNetwork](#setDisableSKAdNetwork) +- [setDisableAppleAdsAttribution](#setDisableAppleAdsAttribution) +- [setDisableIDFVCollection](#setDisableIDFVCollection) +- [setShouldCollectDeviceName](#setShouldCollectDeviceName) +- [isStopped](#isStopped) +- [getAppsFlyerUID](#getAppsFlyerUID) +- [isPreInstalledApp](#isPreInstalledApp) +- [getAttributionId](#getAttributionId) +- [setCurrentDeviceLanguage](#setCurrentDeviceLanguage) +- [setInstallId](#setInstallId) +- [setPreinstallAttribution](#setPreinstallAttribution) +- [setAppId](#setAppId) +- [setSharingFilterForPartners](#setSharingFilterForPartners) +- [setOneLinkCustomDomain](#setOneLinkCustomDomain) +- [setDisableAdvertisingIdentifiers](#setDisableAdvertisingIdentifiers) +- [setDisableCollectASA](#setDisableCollectASA) +- [setPartnerData](#setPartnerData) +- [setResolveDeepLinkURLs](#setResolveDeepLinkURLs) +- [setOutOfStore](#setOutOfStore) +- [getOutOfStore](#getOutOfStore) +- [setDisableNetworkData](#setDisableNetworkData) +- [disableAppSetId](#disableAppSetId) +- [performDeepLinking](#performDeepLinking) +- [appendParametersToDeepLinkingURL](#appendParametersToDeepLinkingURL) +- [setDeepLinkTimeout](#setDeepLinkTimeout) +- [Hashed PII setters](#setUserEmail) + - [setUserPhone](#setUserPhone) + - [setUserFirstName](#setUserFirstName) + - [setUserLastName](#setUserLastName) + - [setUserFbLoginId](#setUserFbLoginId) + - [clearUserPii](#clearUserPii) +- [logInvite](#logInvite) +- [Cross promotion](#crossPromotion) +- [logCrossPromoteImpression](#logCrossPromoteImpression) +- [logAndOpenStore](#logAndOpenStore) +- [logAdRevenue](#logAdRevenue) +- [getSdkVersion](#getSdkVersion) +- [pluginVersion](#pluginVersion) + + +--- + +## Platform-only APIs + +Some methods below are marked _(Android only)_ or _(iOS only)_ because only one +native SDK implements them. + +Calling one on the other platform throws an +[`AppsFlyerException`](#AppsFlyerException). The plugin does not keep its own +list of which platform supports what — every call is forwarded and the native +RPC layer answers, so the API surface stays correct as the native SDKs change. +The exception's `code` therefore comes from the native layer and currently +differs between them: Android reports `422`, iOS reports `404`. Match on the +platform-only marker in this document rather than on the code. + +Guard these calls with `Platform.isAndroid` / `Platform.isIOS`, or let the +exception propagate if the call is not essential on that platform. + +--- + +## Multi-engine hosts + + + +The native AppsFlyer SDK is **process-scoped**. The Flutter plugin keeps one +native RPC handler, one `af-events` transport subscription, and one native +listener reference per event type for the whole process — the same contract as +the native SDKs themselves. + +Each `FlutterEngine` loads its own plugin instance and Dart isolate. +`AppsFlyerSdk.instance` is a singleton within that isolate only. + +**Sequential engine lifecycle** (one engine destroyed, another created later) is +supported: re-run `register*Listener()` after the new engine attaches. Native +configuration survives; you are reconnecting Dart callbacks to the existing +bridge. See [Getting started → Add-to-app and multiple Flutter engines](getting-started.md#multi-engine). + +**Concurrent multi-engine hosts** (two or more engines alive at once — add-to-app +with overlapping Flutter routes, `FlutterEngineGroup`, multi-scene) are **not +supported** for event delivery: + +- The engine whose `af-events` subscription attached **most recently** is the only + one that receives conversion, deep-link, and session-ready callbacks. +- The **last** `register*Listener()` call from any engine wins at the native SDK + and replaces earlier registrations. +- `init()` and `start()` must be driven from **one primary engine**; all engines + share the same native SDK instance. + +Integrate AppsFlyer from a single Flutter entry point. Do not call `init()` or +register listeners from secondary engines that may run in parallel. + +--- + +##### **`AppsFlyerSdk.instance`** + +`AppsFlyerSdk` is the cross-platform SDK entry point. Use its shared +`instance`; configuration is exposed through explicit methods. + +_Example:_ + +```dart +import 'package:appsflyer_sdk/appsflyer_sdk.dart'; + +final AppsFlyerSdk appsflyerSdk = AppsFlyerSdk.instance; +``` + +Once the instance is obtained, call `init`. + +--- + +##### **`AFMediationNetwork`** +an enumeration that includes the supported mediation networks by AppsFlyer. + +| networks | +| -------- | +| ironSource | +| applovinMax | +| googleAdMob | +| fyber | +| appodeal | +| admost | +| topon | +| tradplus | +| yandex | +| chartboost | +| unity | +| toponPte | +| customMediation | +| directMonetizationNetwork | + +--- + +##### **`AFLogLevel`** + +An enumeration of the Android SDK logging levels: `none`, `error`, `warning`, +`info`, `debug`, and `verbose`. + +--- + +##### **`DeepLinkResult`** + +Contains a `DeepLinkStatus`, optional `DeepLink`, and optional +`DeepLinkFailure`. Android supplies a stable failure type; iOS supplies a +message. + +##### **`DeepLinkStatus`** + +The deep-link resolution status: `found`, `notFound`, `error`, or `unknown`. + +##### **`DeepLinkFailure`** + +Contains an optional Android error `type` or optional iOS error `message`. + +##### **`DeepLink`** + +Provides the full `clickEvent` map, `getStringValue(String key)`, and typed +getters for common Unified Deep Linking values such as `deepLinkValue`, +`mediaSource`, `campaign`, `campaignId`, `afSub1` through `afSub5`, and +`isDeferred`. `isDeferred` is reliable on Android; on iOS the native SDK does +not forward an `is_deferred` flag on the click event, so it always returns +`null` there. + +##### **`AppsFlyerException`** + +Contains an optional numeric error code and message. Native failures can use +HTTP-style codes (`400`, `422`, `500`, …). When the platform supplies a +non-numeric code, `code` is `null` and [message] carries the failure text. +Plugin transport failures such as `SERIALIZATION_ERROR` and `RPC_PARSE_ERROR` use +non-numeric codes, so [code] is `null`. On iOS, `SERIALIZATION_ERROR` is raised +when an RPC payload cannot be encoded as JSON (for example non-finite `double` +values in `eventValues`). Android does not use that code today — the same +payload may surface as `UNEXPECTED_ERROR` or a numeric RPC error instead. + +Calling a platform-only API on the wrong platform throws +[`AppsFlyerException`](#AppsFlyerException). The plugin forwards every call to +the native RPC layer; the exception's `code` comes from the native layer and +currently differs between platforms: Android reports `422`, iOS reports `404`. +Match on the platform-only marker in this document rather than on the code. +See [Platform-only APIs](#platform-only-apis). + +--- + + + +##### **`Future init({required String devKey, String? appId})`** + +Initializes the native SDK without sending a session. `appId` is the Apple App +ID required by the native iOS SDK. It is optional and is not sent to the native SDK on Android. + +Invalid `devKey` or `appId` values are validated by the native RPC layer and +reported as `AppsFlyerException` (typically code `422`) when the RPC rejects +the request. + +_Example:_ + +```dart +import 'package:appsflyer_sdk/appsflyer_sdk.dart'; +//.. + +final appsflyerSdk = AppsFlyerSdk.instance; +await appsflyerSdk.init( + devKey: '', + appId: '', +); +``` + +Register conversion, deep-link, and session-ready listeners explicitly with +their corresponding registration methods. + +--- +** `Future enableDebug(bool enabled)`** + +Enables or disables native SDK debug logging. Enable it only for development +and troubleshooting. May be called before [`init`](#init); call before +[`start`](#start) so the first session uses the selected setting. + +```dart +await appsflyerSdk.enableDebug(true); +``` + +--- +** `Future setLogLevel(AFLogLevel logLevel)`** — **Android only** + +Sets the Android SDK logging level. On iOS the call throws an +`AppsFlyerException` — see [Platform-only APIs](#platform-only-apis). Use +[`enableDebug`](#enableDebug) for a cross-platform debug toggle. + +```dart +await appsflyerSdk.setLogLevel(AFLogLevel.debug); +``` + +--- +** `Future registerConversionListener({required OnConversionDataSuccess onSuccess, OnConversionDataFailure? onFailure})`** + +Registers the native conversion-data listener and the callbacks that receive its +results. + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `onSuccess` | `void Function(Map data)` | Required. Receives the conversion data (GCD). | +| `onFailure` | `void Function(Map error)?` | Optional. Receives conversion-data retrieval failures. | + + + + + +```dart +await appsflyerSdk.registerConversionListener( + onSuccess: (data) { + print("conversion data: $data"); + }, + onFailure: (error) { + print("conversion data failed: $error"); + }, +); +``` + +The plugin holds **one callback per event** and replaces it when +`registerConversionListener()` is called again, matching the native SDKs. There +is no stream to subscribe to, so one native event can never be delivered to two +places in your app. + +`onFailure` is independent of the registration result: this call can succeed +while the native SDK still fails to retrieve conversion data. The failure payload +shape differs by platform — Android reports `{"error": String}` with no error +code; iOS reports `{"error": String, "code": int}`. Android also exposes +[`unregisterConversionListener()`](#unregisterConversionListener) to remove its +native listener; iOS has no corresponding unregister operation. + +--- + +** `Future unregisterConversionListener()`** — **Android only** + +Unregisters the native Android conversion-data listener and drops the callbacks +passed to `registerConversionListener()`. Call `registerConversionListener()` +again to resume receiving conversion-data events. On iOS the Dart callbacks are +dropped and the call then throws an `AppsFlyerException` — see +[Platform-only APIs](#platform-only-apis). + +```dart +await appsflyerSdk.unregisterConversionListener(); +``` + +--- +** `Future registerDeepLinkListener(OnDeepLinkReceived onDeepLink)`** + +Registers the native Unified Deep Linking listener and the callback that receives +its results. + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `onDeepLink` | `void Function(DeepLinkResult result)` | Required. Receives every resolved deep link, deferred or direct. | + + + + +```dart +await appsflyerSdk.registerDeepLinkListener((result) { + print("result: $result"); +}); +``` + +Call this **before** [`init`](#init). On Android, `init()` hands the launch +intent to the native SDK, which decides once per install whether to send the +deferred deep-link resolution request; registering afterwards means that request +is never sent for that install, and the skipped state persists across launches. +Direct links are unaffected. Registration before `init()` is supported on both +platforms. + +Calling this again replaces the callback. + +--- +** `Future unregisterDeeplinkListener()`** — **Android only** + +Requests that Android stop forwarding Unified Deep Linking events and drops the +callback passed to `registerDeepLinkListener()`. In the +current Android integration, subsequent events may still be delivered; do not +rely on this method to disable deep-link handling. On iOS the Dart callback is +dropped and the call then throws an `AppsFlyerException` — see +[Platform-only APIs](#platform-only-apis). + +```dart +await appsflyerSdk.unregisterDeeplinkListener(); +``` + +--- + +##### **`Future start({bool awaitResponse = false})`** +In SDK 7, `init(...)` only initializes the SDK; it does not send a session +(Launch). Call `start()` to report one. Unlike SDK 6, there is no `manualStart` +option — initialization never triggers a session automatically. + +When `awaitResponse` is `true`, the Future completes when the native request +succeeds and throws `AppsFlyerException` when it fails. A timeout does not +cancel the native request, which may still succeed later. + +When `awaitResponse` is `false` (default), the Future completes when the +native SDK accepts the request. Delivery success or failure is not reported. + +| parameter | type | description | +| --------------- | ------ | ----------- | +| `awaitResponse` | `bool` | Optional. Defaults to `false`. When `true`, wait for the native request callback. When `false`, return when the native SDK accepts the request. | + +**`start()` must be called once per foreground cycle.** The native SDK resets its "started" state every time the app is backgrounded, so a single `start()` at launch reports only the first session — subsequent foregrounds send nothing. Register the session-ready listener, whose callback fires once per foreground cycle when the native SDK's session-readiness conditions are satisfied: +```dart +// Recommended SDK 7 pattern: start on every session-ready signal. +await appsflyerSdk.registerSessionReadyListener(() async { + await appsflyerSdk.start(); +}); +``` +Gate the first session by deferring the `start()` call inside the callback. + +--- + +** `Future getAttributionId()`** — returns the Facebook (Katana) attribution ID the SDK reads from the installed Facebook app's on-device content provider (also attached to attribution payloads automatically). Most apps never need it directly; exposed for parity with the native SDK. **Android only** — calling it on iOS throws `AppsFlyerException` when the native RPC layer reports the method as unavailable. + +_Example:_ +```dart +appsFlyerSdk.getAttributionId().then((id) { + print("Facebook attribution ID: $id"); +}); +``` + +--- +##### **`Future logEvent(String eventName, {Map? eventValues, bool awaitResponse = false})`** + +- These in-app events help you to understand how loyal users discover your app, and attribute them to specific + campaigns/media-sources. Please take the time define the event/s you want to measure to allow you + to send ROI (Return on Investment) and LTV (Lifetime Value). +- The `logEvent` method allows you to send in-app events to AppsFlyer analytics. This method allows you to add events dynamically by adding them directly to the application code. +- Result reporting mirrors [`start`](#start); both accept `awaitResponse` and default to fire-and-forget acceptance by the native SDK. + +| parameter | type | description | +| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `eventName` | `String` | Use descriptive, action-based names (e.g., "purchase", "add_to_cart", "level_completed"), keep names concise but meaningful, use lowercase with underscores for consistency and avoid special characters and spaces. See the [recommended event list by business](https://support.appsflyer.com/hc/en-us/articles/115005544169-In-app-events-Overview#recommended-events-by-business-vertical). | +| `eventValues` | `Map?` | Optional named event details. Values must be JSON-serializable. Non-finite numbers (`double.nan`, `double.infinity`) are rejected on **iOS** with `AppsFlyerException` (`SERIALIZATION_ERROR`, `code` is `null`). On **Android** the same payload may fail with a different code (`UNEXPECTED_ERROR` or a numeric RPC error). Validate with `value.isFinite` before sending. | +| `awaitResponse` | `bool` | Optional named parameter. Defaults to `false`. When `true`, wait for the native request callback. When `false`, return when the native SDK accepts the request. | + +_Example:_ + +```dart +try { + await appsflyerSdk.logEvent( + eventName, + eventValues: eventValues, + awaitResponse: true, + ); + print("logEvent success"); +} on AppsFlyerException catch (error) { + print("logEvent error: $error"); +} + +// Fire-and-forget: +await appsflyerSdk.logEvent( + eventName, + eventValues: eventValues, + awaitResponse: false, +); +``` + +--- + +##### **`Future logLocation({required double latitude, required double longitude})`** + +Manually logs the device location for the current user. The Future completes +when the native SDK accepts the fire-and-forget call. Supported on Android +and iOS. `latitude` must be between -90 and 90, and `longitude` must be between +-180 and 180. + +```dart +await appsflyerSdk.logLocation( + latitude: 32.0853, + longitude: 34.7818, +); +``` + +--- + +##### **`Future logSession()`** + +Manually logs a session on Android. For typical Flutter apps, call +[`start`](#start) from the +[`registerSessionReadyListener`](#registerSessionReadyListener) callback instead. +**Android only**; on iOS the call throws an `AppsFlyerException` — see +[Platform-only APIs](#platform-only-apis). + +```dart +await appsflyerSdk.logSession(); +``` + +--- + +## SDK 7 APIs + +### Session readiness (SDK 7 session model) + +In SDK 7 the plugin initializes on [`init`](#init); a session is sent when +the app calls [`start`](#start). Because the native SDK requires `start()` once +per foreground cycle, register the session-ready listener and call `start()` from +its callback. + +** `Future registerSessionReadyListener(OnSessionReady onReady)`** + +Enables the native readiness event and registers its callback. + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `onReady` | `void Function()` | Required. Called **once per foreground cycle** when the native session-readiness conditions are satisfied. | + + + +These conditions can include bounded launch deep-link processing. Call `start()` +from the callback so every foreground reports a session. + +```dart +await appsflyerSdk.registerSessionReadyListener(() async { + await appsflyerSdk.start(); +}); +``` + +Calling this again replaces the callback, so `start()` is never issued twice for +one readiness event. + +Platform support: Android ✓ · iOS ✓. + +** `Future unregisterSessionReadyListener()`** — removes the native readiness listener and drops its callback (Android ✓ · iOS ✓). + +** `Future isSessionReady()`** — returns whether all +session-readiness conditions are currently satisfied. Supported on both platforms. + +```dart +await appsFlyerSdk.registerSessionReadyListener(() => print("session ready")); +final ready = await appsFlyerSdk.isSessionReady(); +``` + +### Setter persistence (SDK 7) + +The runtime/session configuration setters listed below are not a substitute for +persistent application configuration. Re-apply them when the application +process starts. Individual APIs can have different storage behavior; for +example, Android persists the custom value supplied through `setInstallId`. + +Re-apply your configuration setters on **every cold start, before +[`start`](#start)**, so they attach to that launch event: + +```dart +// Runs on every cold start (e.g. from initState), before the first start(). +await appsFlyerSdk.setCustomerUserId("user-42"); +await appsFlyerSdk.setCurrencyCode("EUR"); +await appsFlyerSdk.setAdditionalData({"tenant": "eu"}); +await appsFlyerSdk.setConsentData( + isUserSubjectToGDPR: false, +); +``` + +Within a running process the values persist across background→foreground, so you +only re-apply them **once per cold start** — not on every `start()`. Setters +that follow this rule include [`setCustomerUserId`](#setCustomerUserId), +[`setCurrencyCode`](#setCurrencyCode), [`setAdditionalData`](#setAdditionalData), +[`setConsentData`](#setConsentData), [`anonymizeUser`](#anonymizeUser), +[`setSharingFilterForPartners`](#setSharingFilterForPartners), +[`setHost`](#setHost), and the [hashed-PII](#setUserEmail) setters. + +### Hashed PII + +The email, phone, first-name, and last-name setters normalize and hash (SHA-256) +their values on-device before sending them to AppsFlyer. The Facebook +App-Scoped ID is numeric and is not hashed. These APIs are supported on Android +and iOS. + +| API | Description | +| --- | --- | +| `setUserEmail(String email)` | Hash the user's email | +| `setUserPhone(String countryCode, String phoneNumber)` | Hash the user's phone number | +| `setUserFirstName(String firstName)` | Hash the user's first name | +| `setUserLastName(String lastName)` | Hash the user's last name | +| `setUserFbLoginId(int fbLoginId)` | Set the numeric Facebook App-Scoped ID | +| `clearUserPii()` | Clear all previously set PII (hashed email/phone/name fields + the fb login id) | + +```dart +await appsFlyerSdk.setUserEmail("a@a.com"); +await appsFlyerSdk.setUserPhone("1", "5551234567"); +await appsFlyerSdk.clearUserPii(); +``` + +--- + +## Other functionalities: +** `Future anonymizeUser(bool shouldAnonymize)`** + +It is possible to anonymize specific user identifiers within AppsFlyer analytics.
+This complies with both the latest privacy requirements (GDPR, COPPA) and Facebook's data and privacy policies. To anonymize an app user. +| parameter | type | description | +| ---------- |----------|------------------ | +| shouldAnonymize | boolean | True if want Anonymize user Data (default value is false). | + +_Example:_ +```dart +await appsFlyerSdk.anonymizeUser(true); +``` +--- +**
`Future setMinTimeBetweenSessions(int seconds)`** +You can set the minimum time between session (the default is 5 seconds) +```dart +await appsFlyerSdk.setMinTimeBetweenSessions(3); +``` +--- +** `Future stop(bool shouldStop)`** +You can stop sending events to Appsflyer by using this method. + +_Example:_ +```dart +await widget.appsFlyerSdk.stop(true); +``` +--- +** `Future isStopped()`** — **Android only** + +Returns whether the SDK is currently stopped (see `stop`). On iOS it logs a +warning and returns `false`. + +_Example:_ +```dart +final stopped = await appsFlyerSdk.isStopped(); +``` +--- +** `Future setCurrencyCode(String currencyCode)`** + +_Example:_ +```dart +await appsFlyerSdk.setCurrencyCode("USD"); +``` +--- +** `Future setIsUpdate(bool isUpdate)`** — **Android only** + +_Example:_ +```dart +await appsFlyerSdk.setIsUpdate(true); +``` +--- +** `Future enableTCFDataCollection(bool shouldCollect)`** + +The `enableTCFDataCollection` method is employed to control the automatic collection of the Transparency and Consent Framework (TCF) data. By setting this flag to `true`, the system is instructed to automatically collect TCF data. Conversely, setting it to `false` prevents such data collection. + +_Example:_ +```dart +await appsFlyerSdk.enableTCFDataCollection(true); +``` +--- + +** `Future setConsentData({required bool isUserSubjectToGDPR, bool? hasConsentForDataUsage, bool? hasConsentForAdsPersonalization, bool? hasConsentForAdStorage})`** + +### Sets user consent preferences for GDPR and ad personalization + +The named parameters provide manual GDPR and DMA consent data. For a complete workflow, +see the [DMA compliance documentation](consent-dma.md). + +1. Users subjected to GDPR: + +```dart +await appsflyerSdk.setConsentData( + isUserSubjectToGDPR: true, + hasConsentForDataUsage: true, + hasConsentForAdsPersonalization: true, + hasConsentForAdStorage: true, +); +``` + +2. Users not subject to GDPR: + +```dart +await appsflyerSdk.setConsentData( + isUserSubjectToGDPR: false, +); +``` + +When GDPR applies, supply `hasConsentForDataUsage` and +`hasConsentForAdsPersonalization` before the first `start()`. The iOS native +RPC layer rejects incomplete consent as `AppsFlyerException`; Android +forwards the payload as supplied. The optional `hasConsentForAdStorage` value +represents whether the user consented to ad-related storage. + +To reflect consent in the conversion payload, configure either +`enableTCFDataCollection` or `setConsentData` after initialization and +before the first `start()`: + +```dart +final appsflyerSdk = AppsFlyerSdk.instance; +await appsflyerSdk.init( + devKey: '', + appId: '', +); + +await appsflyerSdk.setConsentData( + isUserSubjectToGDPR: true, + hasConsentForDataUsage: true, + hasConsentForAdsPersonalization: true, +); + +await appsflyerSdk.registerSessionReadyListener(() async { + await appsflyerSdk.start(); +}); +``` + +If both TCF collection and explicit consent are used, AppsFlyer backend +prioritizes the explicit data supplied through `setConsentData`. + +--- +** `Future setCustomerUserId(String customerId)`** + +[What is customer user id?](https://support.appsflyer.com/hc/en-us/articles/207032016-Customer-User-ID) + +_Example:_ +```dart +await appsFlyerSdk.setCustomerUserId("id"); +``` +--- +** `Future setAdditionalData(Map customData)`** + +`customData` must be non-null; pass an empty map to clear the data. + +_Example:_ +```dart +var data = {"key1": "value1", "key2": "value2"}; +await appsFlyerSdk.setAdditionalData(data); +``` +--- + +** `Future setCollectAndroidID(bool isCollect)`** — **Android only** + +_Example:_ +```dart +await appsFlyerSdk.setCollectAndroidID(true); +``` +--- +** `Future setHost(String hostPrefixName, String hostName)`** + +Changes the default AppsFlyer host. Use this method only when instructed by +AppsFlyer Support. + +- **Android:** `hostName` must be non-empty. `hostPrefixName` may be empty. +- **iOS:** both `hostPrefixName` and `hostName` must be non-empty. + +Invalid arguments are reported as `AppsFlyerException`. + +_Example:_ +```dart +await appsFlyerSdk.setHost("pref", "my-host"); +``` +--- +** `Future getHostName()`** — **Android only** + +_Example:_ +```dart +appsFlyerSdk.getHostName().then((name) { + print("Host name: ${name}"); + }); +``` +--- +** `Future getHostPrefix()`** — **Android only** + +_Example:_ +```dart +appsFlyerSdk.getHostPrefix().then((name) { + print("Host prefix: ${name}"); + }); +``` +--- +** `Future updateServerUninstallToken(String token)`** + +Registers a token for uninstall measurement. Pass an FCM registration token on +Android or a hexadecimal APNs device token on iOS. + +Token format differs per platform: on **Android** pass the FCM/GCM registration +token as-is; on **iOS** pass the APNs device token **hex-encoded** as an +even-length string (a non-hex string is rejected natively). On iOS, +`getAPNSToken()` already returns the token in hex form. + +_Example:_ +```dart +await appsFlyerSdk.updateServerUninstallToken(token); +``` +--- +** Validate Purchase** + +***Cross-platform API:*** + +** `Future> validateAndLogInAppPurchase(AFPurchaseDetails purchase, {Map? additionalParameters, bool awaitResponse = true})`** + +The unified purchase validation API works across Android and iOS. Use the +purchase-details implementation for the current platform. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `purchase` | `AFPurchaseDetails` | An `AFAndroidPurchaseDetails` or `AFIOSPurchaseDetails` instance | +| `additionalParameters` | `Map?` | Optional additional parameters | +| `awaitResponse` | `bool` | Optional. Defaults to `true`. Android honors this value; iOS always waits for completion. | + +With `awaitResponse: true`, the Future waits for the native validation result. +On Android, `awaitResponse: false` starts validation without a result callback +and the Future completes with an empty map. On iOS, validation always waits for +completion and returns its result. + + +**AFPurchaseDetails interface:** +| Property | Type | Description | +|----------|------|-------------| +| `purchaseType` | `AFPurchaseType` | Type of purchase (oneTimePurchase or subscription) | +| `productId` | `String` | Product identifier | + + +**AFAndroidPurchaseDetails:** +| Property | Type | Description | +|----------|------|-------------| +| `purchaseType` | `AFPurchaseType` | Type of Google Play purchase | +| `purchaseToken` | `String` | Google Play purchase token | +| `productId` | `String` | Product identifier | + + +**AFIOSPurchaseDetails:** +| Property | Type | Description | +|----------|------|-------------| +| `purchaseType` | `AFPurchaseType` | Type of App Store purchase | +| `transactionId` | `String` | App Store transaction ID | +| `productId` | `String` | Product identifier | + + +**AFPurchaseType:** +- `AFPurchaseType.oneTimePurchase` - For one-time in-app purchases +- `AFPurchaseType.subscription` - For subscription purchases + +_Example:_ +```dart +final AFPurchaseDetails purchaseDetails = Platform.isAndroid + ? const AFAndroidPurchaseDetails( + purchaseType: AFPurchaseType.oneTimePurchase, + purchaseToken: "your_purchase_token", + productId: "your_product_id", + ) + : const AFIOSPurchaseDetails( + purchaseType: AFPurchaseType.oneTimePurchase, + transactionId: "your_transaction_id", + productId: "your_product_id", + ); + +// Validate purchase +try { + Map result = await appsFlyerSdk.validateAndLogInAppPurchase( + purchaseDetails, + additionalParameters: {"custom_param": "value"}, + awaitResponse: true, + ); + print("Validation successful: $result"); +} on AppsFlyerException catch (error) { + print("Validation failed: $error"); +} on ArgumentError catch (error) { + // A purchase-details object was used on the wrong platform. + print("Invalid purchase details: $error"); +} +``` + +**Key Benefits:** +- **Cross-platform compatibility**: Works on both Android and iOS with the same API +- **Type safety**: Uses structured data classes instead of platform-specific parameters +- **Enhanced error handling**: Provides detailed error information in structured format (including `NSError` details on iOS) +- **Future-proof**: Built on AppsFlyer's latest V2 validation infrastructure +- **Platform mapping**: Each purchase-details implementation uses the + corresponding Android or iOS purchase-validation model + +--- + +***Purchase validation sandbox mode for iOS:*** + + +`Future setUseReceiptValidationSandbox(bool sandbox)` — **iOS only** + +Enables sandbox mode for App Store receipt validation. + +_Example:_ +```dart +await appsFlyerSdk.setUseReceiptValidationSandbox(true); +``` + + +`Future setUseUninstallSandbox(bool sandbox)` — **iOS only** + +Enables sandbox mode for uninstall-measurement validation (companion of `setUseReceiptValidationSandbox`). + +_Example:_ +```dart +await appsFlyerSdk.setUseUninstallSandbox(true); +``` + +--- + + +##### **validateAndLogInAppPurchase** + +See [Validate Purchase](#validatePurchase) above for the full `validateAndLogInAppPurchase` reference — signature, `AFPurchaseDetails` / `AFPurchaseType`, example, key benefits, and the iOS sandbox toggles. This anchor is kept for existing links. + +--- +## ** `Future sendPushNotificationData({required String campaign, required String pid, bool isRetargeting = false, Map? additionalParameters})`** _(Android only)_ + +Push-notification campaigns are used to create re-engagements with existing users → [Learn more here](https://support.appsflyer.com/hc/en-us/articles/207364076-Measuring-Push-Notification-Re-Engagement-Campaigns) + +The Android API maps directly to the native `AFPushData` fields. +Calling it triggers a new Android Launch request even when the SDK already sent +a Launch in the current session. + +## ** `Future handlePushNotification(Map pushPayload)`** _(iOS only)_ + +Passes the push-notification payload to the iOS SDK. Preserve the AppsFlyer +custom-data structure from the notification; native integrations should pass +the complete APNs `userInfo` dictionary. + +### Platform-Specific Requirements + +🟩 **Android:** +Call `sendPushNotificationData` with `campaign` and `pid`, plus optional +`isRetargeting` and `additionalParameters` values. + +🍎 **iOS:** +Call `handlePushNotification` with the complete notification payload. + +When using `firebase_messaging`, `RemoteMessage.data` contains only the custom +data fields, not the complete APNs `userInfo` dictionary. Ensure that every +AppsFlyer attribution field is present in that custom data. If your provider +encodes a nested object as a JSON string, decode it before reading its fields. + +--- + +## Integration Approaches + +AppsFlyer supports two approaches for measuring push notification campaigns: + +### Approach 1: Traditional Attribution Parameters (`af` object) + +Use this approach when your push payload contains a custom `af` object with attribution parameters. + +**Required parameters:** `pid`, `is_retargeting`, `c` + +📦 **Example Push Payload with `af` Object:** +```json +{ + "af": { + "c": "test_campaign", + "is_retargeting": true, + "pid": "push_provider_int" + }, + "aps": { + "alert": "Get 5000 Coins", + "badge": "37", + "sound": "default" +} +} +``` + +**Implementation:** + +```dart +Future passPushToAppsFlyer(Map data) async { + if (Platform.isAndroid) { + final rawAf = data['af']; + final decodedAf = rawAf is String ? jsonDecode(rawAf) : rawAf; + final af = Map.from(decodedAf as Map); + await appsFlyerSdk.sendPushNotificationData( + campaign: af['c'] as String, + pid: af['pid'] as String, + isRetargeting: af['is_retargeting'] == true, + ); + } else if (Platform.isIOS) { + await appsFlyerSdk.handlePushNotification(data); + } +} + +// 1️⃣ Handle Foreground Messages +FirebaseMessaging.onMessage.listen((RemoteMessage message) async { + await passPushToAppsFlyer(message.data); +}); + +// 2️⃣ Handle Notification Taps (App in Background) +FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) async { + await passPushToAppsFlyer(message.data); +}); + +// 3️⃣ Handle App Launch from Push (Terminated State) +Future handleInitialPush() async { + final message = await FirebaseMessaging.instance.getInitialMessage(); + if (message != null) { + await passPushToAppsFlyer(message.data); + } +} +``` + +Call `handleInitialPush()` once during app startup, after Flutter is initialized +and the AppsFlyer SDK setup has completed. `getInitialMessage()` returns the +notification that opened an app from the terminated state. A Firebase +background-message handler is a separate flow and is not required to handle a +notification tap that launches the app. + +--- + +### Approach 2: OneLink URL in Push Payload (Recommended) + +Use this approach when your push payload contains a **OneLink URL** for deep linking. This method provides a unified deep linking experience. + +> ⚠️ **Important:** This approach requires calling **two different methods** depending on the platform! + +#### **Step 1: Configure Deep Link Path (BOTH Platforms)** + +Call `addPushNotificationDeepLinkPath` before `init()` to tell AppsFlyer where +to find the OneLink URL in your push payload. + +```dart +await appsFlyerSdk.addPushNotificationDeepLinkPath( + ["deeply", "nested", "deep_link"], +); +await appsFlyerSdk.registerDeepLinkListener((result) { + // Handle deep-link navigation here. +}); +await appsFlyerSdk.init( + devKey: '', + appId: '', +); +``` + +Push configuration does not replace the SDK 7 session lifecycle. Complete the +normal [session-ready → `start()` setup](getting-started.md#5-register-the-remaining-listeners) +so the initial Launch and every later foreground session are reported. + +#### **Step 2: Send Push Payload to SDK** + +**🟩 Android:** +On Android, calling `addPushNotificationDeepLinkPath` is **sufficient**. The SDK automatically extracts and processes the OneLink URL. + +**🍎 iOS:** +On iOS, you **MUST also call** `handlePushNotification(pushPayload)` to pass the push payload to the SDK so it can extract and process the OneLink URL. + +📦 **Example Push Payload with OneLink URL:** +```json +{ + "deeply": { + "nested": { + "deep_link": "https://yourapp.onelink.me/ABC/campaign123" + } + }, + "aps": { + "alert": "Check out our new feature!", + "badge": "1", + "sound": "default" + } +} +``` + +**Complete Implementation Example:** + +```dart +// ======================================== +// 1. Configure SDK (in main.dart or app initialization) +// ======================================== +Future initializeAppsFlyer() async { + // STEP 1: Configure the deep-link path before init(). + await appsFlyerSdk.addPushNotificationDeepLinkPath( + ["deeply", "nested", "deep_link"], + ); + + // STEP 2: Enable native deep-link events and handle them. Also before init(). + await appsFlyerSdk.registerDeepLinkListener((DeepLinkResult result) { + if (result.status == DeepLinkStatus.found) { + print("Deep link found: ${result.deepLink?.deepLinkValue}"); + // Handle deep-link navigation here. + } + }); + + // STEP 3: Initialize the SDK. + await appsFlyerSdk.init( + devKey: '', + appId: '', + ); + + // STEP 4: Register last because this can invoke the callback immediately. + // Required: report a session on every foreground cycle. + await appsFlyerSdk.registerSessionReadyListener(() async { + try { + await appsFlyerSdk.start(awaitResponse: true); + print("AppsFlyer session reported."); + } on AppsFlyerException catch (error) { + print("AppsFlyer start error: $error"); + } + }); +} + +// ======================================== +// 2. Handle Push Notifications +// ======================================== + +// 🍎 iOS: MUST call handlePushNotification +// 🟩 Android: addPushNotificationDeepLinkPath is sufficient + +// 1️⃣ Foreground Messages +FirebaseMessaging.onMessage.listen((RemoteMessage message) async { + // iOS: Required to process OneLink URL + if (Platform.isIOS) { + await appsFlyerSdk.handlePushNotification(message.data); + } +}); + +// 2️⃣ Background Notification Taps (App in Background) +FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) async { + // iOS: Required to process OneLink URL + if (Platform.isIOS) { + await appsFlyerSdk.handlePushNotification(message.data); + } +}); + +// 3️⃣ App Launch from Push (Terminated State) +Future handleInitialPush() async { + final message = await FirebaseMessaging.instance.getInitialMessage(); + if (message != null) { + // iOS: Required to process OneLink URL from terminated state + if (Platform.isIOS) { + await appsFlyerSdk.handlePushNotification(message.data); + } + } +} +``` + +Call `handleInitialPush()` once after `initializeAppsFlyer()` completes. On +Android, `addPushNotificationDeepLinkPath()` handles the configured OneLink path; +the explicit terminated-state forwarding above is required only on iOS. + +#### **Key Differences Between Approaches:** + +|| Traditional `af` Object | OneLink URL (Recommended) | +|---|---|---| +| **Android** | `sendPushNotificationData(...)` | `addPushNotificationDeepLinkPath()` (auto-handles) | +| **iOS** | `handlePushNotification(pushPayload)` | `addPushNotificationDeepLinkPath()` **+** `handlePushNotification(pushPayload)` | +| **Deep Linking** | Basic attribution only | Full deep linking with the `registerDeepLinkListener` callback | +| **Use Case** | Simple re-engagement | Re-engagement + in-app navigation | + +--- + +### Summary + +- **Traditional approach**: Call Android `sendPushNotificationData(...)` or iOS `handlePushNotification(pushPayload)` +- **OneLink approach (Recommended)**: + - ✅ **Both platforms**: Call `addPushNotificationDeepLinkPath()` and + `registerDeepLinkListener()` before SDK init + - ✅ **iOS only**: Also call `handlePushNotification(pushPayload)` when push is received + - ✅ **Both platforms**: Handle deep links in the `registerDeepLinkListener` callback + + +--- +## ** `Future addPushNotificationDeepLinkPath(List deepLinkPath)`** + +Registers a **custom key path** for resolving deep links inside **custom JSON payloads** in push notifications. + +This is the recommended method of integrating AppsFlyer with push notifications. [Learn more here.](https://support.appsflyer.com/hc/en-us/articles/207364076-Measuring-Push-Notification-Re-Engagement-Campaigns)
+> ⚠️ Call this method before `init()`. `deepLinkPath` must not be empty. ⚠️ + + +_Example:_ +```dart +await appsFlyerSdk.addPushNotificationDeepLinkPath( + ["deeply", "nested", "deep_link"], +); +``` + +With this configuration, the SDK will extract the URL from the following push payload: + +```json +{ + "deeply": { + "nested": { + "deep_link": "https://yourdeeplink2.onelink.me" + } + } +} +``` + +--- +**
User Invite** + +1. First define the Onelink ID (find it in the AppsFlyer dashboard in the onelink section: + +** `Future setAppInviteOneLink(String oneLinkId)`** + +2. Set the `AppsFlyerInviteLinkParams` class to set the query params in the user invite link: + + + +```dart +class AppsFlyerInviteLinkParams { + final String? channel; + final String? campaign; + final String? referrerName; + final String? referrerImageUrl; + final String? referrerCustomerId; + final String? baseDeepLink; + final String? brandDomain; + final Map? userParams; +} +``` + +3. Call the generateInviteLink API to generate the user invite link. + +** `Future generateInviteLink({AppsFlyerInviteLinkParams? parameters, bool awaitResponse = true})`** + +The Future completes with the generated URL. Native generation failures are +reported as `AppsFlyerException`. On Android, `awaitResponse: true` waits for +asynchronous short-link generation and `false` returns the synchronously +generated long link. On iOS, link generation always waits for the asynchronous +result. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `parameters` | `AppsFlyerInviteLinkParams?` | Optional OneLink generation parameters | +| `awaitResponse` | `bool` | Optional. Defaults to `true`. Android honors this value; iOS always waits for completion. | + +_Example:_ +```dart +await appsFlyerSdk.setAppInviteOneLink('OnelinkID'); + +const AppsFlyerInviteLinkParams inviteLinkParams = AppsFlyerInviteLinkParams( + channel: "", + referrerName: "", + baseDeepLink: "", + brandDomain: "", + referrerCustomerId: "", + referrerImageUrl: "", + campaign: "", + userParams: {"key":"value"} +); + +final url = await appsFlyerSdk.generateInviteLink( + parameters: inviteLinkParams, + awaitResponse: true, +); +print(url); +``` + +4. Log the `af_invite` event when the user actually shares the invite: + +** `Future logInvite(String channel, [Map? eventParameters])`** + +Logs the `af_invite` in-app event so AppsFlyer can attribute the invite and any +downstream installs to the referring user. The returned `Future` completes +after the native SDK accepts the logging call. The native API does not +provide a network-completion callback. Supported on Android and iOS. + +_Example:_ +```dart +await appsFlyerSdk.logInvite("facebook", {"referrerId": "user-123"}); +``` +--- +** Cross promotion** + +** `Future logCrossPromoteImpression(String appId, {String campaign = '', Map? userParams})`** + +Records an impression for a promoted app: + +```dart +await appsFlyerSdk.logCrossPromoteImpression( + "promoted.app.id", + campaign: "summer", + userParams: {"source": "banner"}, +); +``` + +** `Future logAndOpenStore(String promotedAppId, {String campaign = '', Map? userParams})`** + +Records the promotion and asks the native SDK to open the promoted app's store +page: + +```dart +await appsFlyerSdk.logAndOpenStore( + "promoted.app.id", + campaign: "summer", + userParams: {"source": "banner"}, +); +``` + +Both APIs are supported on Android and iOS. + +--- +** `Future enableFacebookDeferredApplinks(bool isEnabled)`** + +Please make sure the relevant Facebook dependecies are added to the project! +Call this method before `init()`. + +For more information check the following article: +https://support.appsflyer.com/hc/en-us/articles/207033826-Facebook-Ads-setup-guide#advanced-using-facebook-ads-appsflyer-sdks-for-deferred-deep-linking + +_Example:_ +```dart +await appsFlyerSdk.enableFacebookDeferredApplinks(true); +``` +--- +** `Future setFacebookDeferredAppLink(String? url)`** _(iOS only)_ + +Manually sets — or, with `null`, clears — the Facebook deferred app-link URL. +On Android the call throws an `AppsFlyerException` — see +[Platform-only APIs](#platform-only-apis). + +Use this only when you already hold the deferred link and want to skip the Facebook SDK lookup; otherwise prefer `enableFacebookDeferredApplinks(true)`. + +_Example:_ +```dart +await appsFlyerSdk.setFacebookDeferredAppLink( + "https://myapp.onelink.me/abc123", +); +``` +--- + +** `Future setDisableSKAdNetwork(bool disable)`** — **iOS only** + +Use this API in order to disable the SK Ad network (request will be sent but +the rules won't be returned). On Android the call throws an `AppsFlyerException` +— see [Platform-only APIs](#platform-only-apis). + +_Example:_ +```dart +await appsFlyerSdk.setDisableSKAdNetwork(true); +``` +--- + +** `Future setDisableAppleAdsAttribution(bool disable)`** — **iOS only** + +Disables Apple Ads (Apple Search Ads) attribution via the AdServices framework +— pass `true` to stop the SDK from calling +`AAAttribution.attributionToken` (iOS 14.3+). On Android the call throws an +`AppsFlyerException` — see [Platform-only APIs](#platform-only-apis). + +_Example:_ +```dart +if (Platform.isIOS) { + await appsFlyerSdk.setDisableAppleAdsAttribution(true); +} +``` +--- + +** `Future setDisableIDFVCollection(bool disable)`** — **iOS only** + +Disables collection of the IDFV (Identifier for Vendor) — pass `true` to stop +the SDK from collecting it. Set it before `start()`. On Android the call throws +an `AppsFlyerException` — see [Platform-only APIs](#platform-only-apis). + +_Example:_ +```dart +if (Platform.isIOS) { + await appsFlyerSdk.setDisableIDFVCollection(true); +} +``` +--- +** `Future setShouldCollectDeviceName(bool collect)`** — **iOS only** + +Enables collection of the device name (e.g. `"John's iPhone"`). This is an +**opt-in** — collection is **off by default** and the device name is personal +data (PII), so only enable it if your privacy policy covers it. Pass `true` to +start collecting it. On Android the call throws an `AppsFlyerException` — see +[Platform-only APIs](#platform-only-apis). + +_Example:_ +```dart +if (Platform.isIOS) { + await appsFlyerSdk.setShouldCollectDeviceName(true); +} +``` +--- +** `Future getAppsFlyerUID()`** + +Use this API in order to get the AppsFlyer ID. + +_Example:_ +```dart +appsFlyerSdk.getAppsFlyerUID().then((AppsFlyerId) { + print("AppsFlyer ID: ${AppsFlyerId}"); +}); +``` +--- +** `Future getSdkVersion()`** + +Returns the native AppsFlyer SDK version. + +```dart +final sdkVersion = await appsFlyerSdk.getSdkVersion(); +``` + +--- +** `String get pluginVersion`** + +Returns the Flutter plugin version without invoking a native method. + +```dart +print(appsFlyerSdk.pluginVersion); +``` + +--- +** `Future isPreInstalledApp()`** — **Android only** + +Returns whether the app install was a device preinstall (OEM/manufacturer). On +iOS the native RPC layer reports the method as unavailable and the call throws +`AppsFlyerException`. See also `setPreinstallAttribution`. + +_Example:_ +```dart +final bool preinstalled = await appsFlyerSdk.isPreInstalledApp(); +``` +--- +** `Future setCurrentDeviceLanguage(String language)`** — **iOS only** + +Use this API in order to set the language + +_Example:_ +```dart +await appsFlyerSdk.setCurrentDeviceLanguage("en"); +``` +--- +** `Future setInstallId(String installId)`** + +Sets a unique install id for the app installation, letting you correlate the AppsFlyer install with an id you generate yourself (e.g. for server-side reconciliation). Supported on both platforms, but the call order and setup requirements differ: + +- **iOS**: call this *before* `init()` (before the dev key is set). Requires `AppsFlyerAllowCustomInstallId` set to `YES` in `Info.plist`. +- **Android**: call this *after* `init()`. Requires the `` flag `APPSFLYER_ALLOW_CUSTOM_INSTALL_ID` set to `true` in `AndroidManifest.xml`. + +On both platforms, the call is silently ignored — no error is returned — if the corresponding manifest/plist flag is missing. + +_Example:_ +```dart +await appsFlyerSdk.setInstallId("install-123"); +``` +--- +** `Future setPreinstallAttribution(String mediaSource, {String campaign = '', String siteId = ''})`** + +Attributes the install to a device preinstall (OEM / manufacturer) deal, declaring that the app shipped preinstalled and attributing the install to the given `mediaSource`, `campaign`, and `siteId`. Call it **before** `start()`. + +**Android only** — the iOS SDK does not provide this programmatic +preinstall-attribution API. On iOS the call throws an `AppsFlyerException` — see +[Platform-only APIs](#platform-only-apis). + +_Example:_ +```dart +await appsFlyerSdk.setPreinstallAttribution( + "media_source", + campaign: "campaign", + siteId: "site_id", +); +``` +--- +** `Future setAppId(String appId)`** + +Overrides the app ID reported to AppsFlyer. Call it **before** `start()`. The +Android SDK rejects an empty `appId`, and the returned Future throws +`AppsFlyerException`. + +**Android only** — on iOS the app ID is provided through `init()` and the +iOS SDK has no `setAppId`, so the call throws an `AppsFlyerException` — see +[Platform-only APIs](#platform-only-apis). + +_Example:_ +```dart +await appsFlyerSdk.setAppId("com.example.app"); +``` +--- +** `Future setSharingFilterForPartners(List? partners)`** + +Used by advertisers to exclude specified networks/integrated partners from getting data. [Learn more here](https://support.appsflyer.com/hc/en-us/articles/207032126#additional-apis-exclude-partners-from-getting-data) + +_Example:_ +```dart +await appsFlyerSdk.setSharingFilterForPartners(['facebook_int']); +await appsFlyerSdk.setSharingFilterForPartners( + ['facebook_int', 'googleadwords_int'], +); +``` + +Passing `null` or an empty list clears the filter; the plugin normalizes +both to the same native request, so the two are interchangeable. On Android +the current RPC bridge rejects a clear request with `AppsFlyerException` +until the native RPC validation is fixed; the call is forwarded rather than +silently ignored. + +--- +** `Future setOneLinkCustomDomain(List domains)`** + +Use this API in order to set branded domains. `domains` must not be empty. + +Find more information in the [following article on branded domains](https://support.appsflyer.com/hc/en-us/articles/360002329137-Implementing-Branded-Links). + +_Example:_ +```dart +await appsFlyerSdk.setOneLinkCustomDomain( + ["promotion.greatapp.com", "click.greatapp.com", "deals.greatapp.com"], +); +``` +--- +** `Future setDisableAdvertisingIdentifiers(bool disable)`** + +Disables collection of advertising identifiers (GAID / IDFA / OAID). Pass `true` to **disable** collection (enabled by default). + +_Example:_ +```dart +await appsFlyerSdk.setDisableAdvertisingIdentifiers(true); +``` +--- +** `Future setDisableCollectASA(bool disable)`** — **iOS only** + +Controls collection of Apple Search Ads attribution data. On Android the call +throws an `AppsFlyerException` — see [Platform-only APIs](#platform-only-apis). + +```dart +if (Platform.isIOS) { + await appsFlyerSdk.setDisableCollectASA(true); +} +``` + +--- +** `Future setPartnerData(String partnerId, Map data)`** + +Allows sending custom data for partner integration purposes. + +_Example:_ +```dart +final partnerData = {"puid": "1234", "region": "eu"}; +await appsFlyerSdk.setPartnerData("partnerId", partnerData); +``` +--- +** `Future setResolveDeepLinkURLs(List urls)`** + +Advertisers can wrap an AppsFlyer OneLink within another Universal Link. This Universal Link will invoke the app but any deep linking data will not propagate to AppsFlyer. + +setResolveDeepLinkURLs enables you to configure the SDK to resolve the wrapped OneLink URLs, so that deep linking can occur correctly. + +`urls` must not be empty. + +_Example:_ +```dart +await appsFlyerSdk.setResolveDeepLinkURLs( + ["clickdomain.com", "myclickdomain.com", "anotherclickdomain.com"], +); +``` +--- +** `Future setOutOfStore(String sourceName)`** + +**Android Only!** + +Specify the alternative app store that the app is downloaded from (out-of-store +attribution). Re-apply on every cold start — SDK 7 does not persist setter values. + +This API does **not** register manifest receivers. See +[Advanced features — Android Out of Store](advanced-features.md#out-of-store). + +_Example:_ +```dart + if (Platform.isAndroid) { + await appsFlyerSdk.setOutOfStore("amazon"); + } +``` +--- +** `Future getOutOfStore()`** + +**Android Only!** + +Gets the configured alternative app-store value. If no runtime value was set, +the Android SDK may return the `AF_STORE` manifest value. + +_Example:_ +```dart + if (Platform.isAndroid) { + final store = await appsFlyerSdk.getOutOfStore(); + print(store); + } +``` +--- +** `Future setDisableNetworkData(bool isDisable)`** + +**Android Only!** + +Use to opt-out of collecting the network operator name (carrier) and sim operator name from the device. + +_Example:_ +```dart + if (Platform.isAndroid) { + await appsFlyerSdk.setDisableNetworkData(true); + } +``` +--- +** `Future disableAppSetId()`** + +**Android Only!** + +Disables the native Android SDK's automatic AppSet ID collection. Use this +method to opt out of AppSet ID collection for privacy compliance. + +_Example:_ +```dart + if (Platform.isAndroid) { + await appsFlyerSdk.disableAppSetId(); + } +``` +--- + +** `Future performDeepLinking(String url, {bool shouldTriggerSession = false})`** + +Manually triggers deep link resolution for a given `url` (full URL, OneLink, or intent-data string). Use it to resolve a deep link before the SDK starts (e.g. when delaying `start()`), or for links that don't arrive through the standard intent / Universal Link flow (e.g. Firebase Messaging). + +The resolved link is delivered to the +[`registerDeepLinkListener`](#registerDeepLinkListener) callback on both +platforms. `shouldTriggerSession` defaults to `false`, so a +bare `performDeepLinking(url)` resolves the link without an extra Launch and +behaves identically on Android and iOS. The flag is Android-only: pass `true` to +also enqueue a Launch for re-engagement; on iOS it has no effect because the +link is always resolved without an extra managed session. + +```dart +Future configureAppsFlyer() async { + final appsflyerSdk = AppsFlyerSdk.instance; + + await appsflyerSdk.registerDeepLinkListener((DeepLinkResult result) { + switch (result.status) { + case DeepLinkStatus.found: + print(result.deepLink); + print("deep link value: ${result.deepLink?.deepLinkValue}"); + break; + case DeepLinkStatus.notFound: + print("deep link not found"); + break; + case DeepLinkStatus.error: + print("deep link error: ${result.error}"); + break; + case DeepLinkStatus.unknown: + print("unknown deep link status"); + break; + } + }); + + await appsflyerSdk.init( + devKey: '', + appId: '', + ); + + await appsflyerSdk.registerConversionListener( + onSuccess: (data) { + print("conversion data: $data"); + }, + ); + + // Resolve a deep link manually. + await appsflyerSdk.performDeepLinking( + "https://yourapp.onelink.me/abc123", + ); +} +``` + +--- + +** `Future appendParametersToDeepLinkingURL(String contains, Map parameters)`** + +Appends `parameters` to any deep-link URL that contains the `contains` substring, before the SDK resolves / attributes it. Useful for enriching wrapped OneLinks with extra query parameters. Implemented on both Android and iOS. + +Pass a non-empty `contains` and at least one entry in `parameters`. An empty +`contains` is invalid on both platforms, and an empty `parameters` map is +invalid on iOS. + +```dart +await appsFlyerSdk.appendParametersToDeepLinkingURL( + "deeplink", + {"deep_link_sub1": "cat123", "deep_link_value": "shoes"}, +); +``` + +--- + +** `Future setDeepLinkTimeout(int timeout)`** + +Sets the deep-link resolution timeout, in **milliseconds**. Configure it before +`init()`. Use a positive value for a cross-platform configuration. The default +when unset differs by platform: **3000 ms on Android, 60000 ms on iOS**. + +```dart + await appsFlyerSdk.setDeepLinkTimeout(3000); +``` + +--- + +### ** `Future logAdRevenue({required String monetizationNetwork, required AFMediationNetwork mediationNetwork, required String currencyIso4217Code, required double revenue, Map? additionalParameters})`** + +The logAdRevenue API is designed to simplify the process of logging ad revenue events to AppsFlyer from your Flutter application. This API tracks revenue generated from advertisements, enriching your monetization analytics. Below you will find instructions on how to use this API correctly, along with detailed descriptions and examples for various input scenarios. + +### **Usage:** +To use the logAdRevenue method, you must: + +1. Prepare the required information about the ad revenue event. +1. Pass the values to `logAdRevenue`. + + +**Parameters** +The method accepts the following ad revenue values: + +* `monetizationNetwork`: The source network from which the revenue was generated (e.g., AdMob, Unity Ads). +* `mediationNetwork`: The mediation platform managing the ad (use AFMediationNetwork enum for supported networks). +* `currencyIso4217Code`: The ISO 4217 currency code representing the currency of the revenue amount (e.g., "USD", "EUR"). +* `revenue`: The amount of revenue generated from the ad. +* `additionalParameters`: Additional parameters related to the ad revenue event (optional). + + +**AFMediationNetwork Enum** +[AFMediationNetwork](#AFMediationNetwork) is an enumeration that includes the supported mediation networks by AppsFlyer. It's important to use this enum to ensure you provide a valid network identifier to the logAdRevenue API. + +> **Note (behavior):** The returned Future completes after the plugin validates +> the request and invokes the native logging API. Validation and native call +> failures are surfaced as `AppsFlyerException`. The native API has no delivery +> callback, so completion does not confirm that the event was uploaded. +> +> **Cross-platform note:** The plugin maps +> `AFMediationNetwork.customMediation` and +> `AFMediationNetwork.directMonetizationNetwork` to the correct platform value +> automatically. All public enum values work on both platforms; no caller +> action is needed. + +### Example: +```dart +// Log the ad revenue event. +await appsFlyerSdk.logAdRevenue( + monetizationNetwork: "GoogleAdMob", // Replace with your actual monetization network. + mediationNetwork: AFMediationNetwork.applovinMax, // Use the value from the enum. + currencyIso4217Code: "USD", + revenue: 1.23, + additionalParameters: { + // Optional additional parameters can be added here. This is an example, can be discard if not needed. + 'adUnitId': 'ca-app-pub-XXXX/YYYY', + 'ad_network_click_id': '12345' + } +); +``` + +**Additional Points** +* Mediation network input must be from the provided [AFMediationNetwork](#AFMediationNetwork) + enum to ensure proper processing by AppsFlyer. For instance, use + `AFMediationNetwork.googleAdMob` to denote Google AdMob as the Mediation Network. +* The `additionalParameters` map is optional. Use it to pass any extra information you have regarding the ad revenue event; this information could be useful for more refined analytics. +* Make sure the `currencyIso4217Code` adheres to the appropriate standard. Misconfigured currency code may result in incorrect revenue tracking. diff --git a/doc/consent-dma.md b/doc/consent-dma.md new file mode 100644 index 00000000..eb863523 --- /dev/null +++ b/doc/consent-dma.md @@ -0,0 +1,218 @@ +# Privacy, identity, consent & DMA compliance + +> **Audience:** apps configuring user identity, privacy controls, or DMA/GDPR +> consent. Requires the [core setup](getting-started.md). + +## Privacy and identity workflow + +Apply privacy and identity settings that must affect the first Launch after +`init()` and **before** registering the session-ready listener. Registration can +invoke the session-ready callback immediately and trigger `start()`. + +| Goal | API | When to use it | +| --- | --- | --- | +| Anonymize attribution data for the current user | `anonymizeUser(true)` | Call before the first `start()`. Call `anonymizeUser(false)` to stop anonymizing future data. | +| Stop all SDK activity and communication | `stop(true)` | Use for a complete SDK opt-out. Do not call `start()` while stopped. Call `stop(false)` to resume, then continue with the normal session-ready → `start()` flow. | +| Disable advertising-identifier collection | `setDisableAdvertisingIdentifiers(true)` | Call before the first `start()` when your privacy choice requires GAID, IDFA, and OAID collection to be disabled. Pass `false` to enable collection again. | +| Set user PII for network sharing | `setUserEmail`, `setUserPhone`, `setUserFirstName`, `setUserLastName`, `setUserFbLoginId` | Set only the values your app is allowed to share. Email, phone, and name values are hashed by the native SDK; the Facebook App-Scoped ID is not hashed. | +| Remove previously set PII | `clearUserPii()` | Call on logout, before switching accounts, or when the app should no longer retain values set through the `setUser*` APIs. It does not clear the Customer User ID, consent, or anonymization state. | + +For example: + +```dart +await appsflyerSdk.init( + devKey: 'your_dev_key', + appId: '1234567890', +); + +// Apply the current user's privacy and identity choices before the first start(). +await appsflyerSdk.setDisableAdvertisingIdentifiers(true); +await appsflyerSdk.anonymizeUser(true); + +// Register last because the callback can run immediately. +await appsflyerSdk.registerSessionReadyListener(() async { + await appsflyerSdk.start(); +}); +``` + +`anonymizeUser(true)` does not stop the SDK; it changes how the user's data is +reported. `stop(true)` stops SDK activity entirely. Re-apply the privacy and +identity configuration your app needs on every cold start, as described in +[Getting started](getting-started.md#4-configure-the-first-launch). + +The following sections explain how to provide DMA/GDPR consent through a CMP or +the manual consent API. + +Following the DMA regulations that were set by the European Commission, Google and Amazon require consent data in order to use it during the attribution process. The SDK 7 plugin supports both TCF-based collection and explicit consent data, enhancing support for user consent and data collection preferences in line with evolving digital market regulations. +There are two alternative ways for gathering consent data: + +- Through a Consent Management Platform (CMP): If the app uses a CMP that complies with the Transparency and Consent Framework (TCF) v2.2 or v2.3 protocol, the SDK can automatically retrieve the consent details. + +**OR** + +- Through a dedicated SDK API: Developers can pass Google's required consent data directly to the SDK using a specific API designed for this purpose. + +## Use CMP to collect consent data + +A CMP compatible with TCF v2.2 or v2.3 collects DMA consent data and stores it in NSUserDefaults (iOS) and SharedPreferences (Android). To enable the SDK to access this data and include it with every event, follow these steps: + +1. Initialize the SDK with `appsflyerSdk.init(...)`. +2. Call `appsflyerSdk.enableTCFDataCollection(true)`. +3. Use the CMP to decide if you need the consent dialog in the current session to acquire the consent data. If you need the consent dialog move to step 4, otherwise move to step 5. +4. Get confirmation from the CMP that the user has made their consent decision and the data is available in NSUserDefaults/SharedPreferences. +5. Register the session-ready listener and call `appsflyerSdk.start()` from its + callback. + +```dart +final appsflyerSdk = AppsFlyerSdk.instance; + +await appsflyerSdk.init( + devKey: 'your_dev_key', + appId: '1234567890', +); +await appsflyerSdk.enableTCFDataCollection(true); + +// CMP pseudocode procedure +if (cmpManager.hasConsent()) { + await appsflyerSdk.registerSessionReadyListener(() async { + await appsflyerSdk.start(); + }); +} else { + await cmpManager.presentConsentDialogToUser(); + await appsflyerSdk.registerSessionReadyListener(() async { + await appsflyerSdk.start(); + }); +} +``` + +## Manually collect consent data + + +### Use [setConsentData](#setconsentdata-recommended-api-for-manual-consent-collection) + + +If your app does not use a CMP compatible with TCF v2.2 or v2.3, use the SDK API detailed below to provide the consent data directly to the SDK, distinguishing between cases when GDPR applies or not. + +### When GDPR applies to the user + +If GDPR applies to the user, perform the following: + +1. Given that GDPR is applicable to the user, determine whether the consent data is already stored for this session. + 1. If there is no consent data stored, show the consent dialog to capture the user consent decision. + 2. If there is consent data stored continue to the next step. +2. Prepare the required consent values:
+ `hasConsentForDataUsage: bool` - Indicates whether the user has consented to use their data for advertising purposes.
+ `hasConsentForAdsPersonalization: bool` - Indicates whether the user has consented to use their data for personalized advertising.
+ `hasConsentForAdStorage: bool?` - (Optional) Indicates whether the user consents to storing ad-related data. +3. Initialize the SDK using `appsflyerSdk.init(...)`. +4. Call `appsflyerSdk.setConsentData(...)` before registering the session-ready listener. +5. Register the session-ready listener and call `appsflyerSdk.start()` from its + callback. + +```dart +// If the user is subject to GDPR - collect the consent data +// or retrieve it from the storage +// ... + +final appsflyerSdk = AppsFlyerSdk.instance; +await appsflyerSdk.init( + devKey: 'your_dev_key', + appId: '1234567890', +); + +await appsflyerSdk.setConsentData( + isUserSubjectToGDPR: true, + hasConsentForDataUsage: true, + hasConsentForAdsPersonalization: false, +); + +await appsflyerSdk.registerSessionReadyListener(() async { + await appsflyerSdk.start(); +}); +``` + +### When GDPR does not apply to the user + +If GDPR doesn't apply to the user perform the following: + +1. Initialize the SDK using `appsflyerSdk.init(...)`. +2. Call `setConsentData` with `isUserSubjectToGDPR: false`. Omit the + GDPR-specific consent values. +3. Register the session-ready listener and call `appsflyerSdk.start()` from its + callback. + +```dart +final appsflyerSdk = AppsFlyerSdk.instance; +await appsflyerSdk.init( + devKey: 'your_dev_key', + appId: '1234567890', +); + +await appsflyerSdk.setConsentData( + isUserSubjectToGDPR: false, +); + +await appsflyerSdk.registerSessionReadyListener(() async { + await appsflyerSdk.start(); +}); +``` + + + +## setConsentData (Recommended API for Manual Consent Collection) + +🚀 **Why Use setConsentData?**
+The `setConsentData` API provides structured consent data to the AppsFlyer SDK. + +It uses named parameters to distinguish GDPR and non-GDPR users:
+✅ **Simple and Intuitive:** Uses clear parameter names for each consent choice.
+✅ **Includes an Additional Consent Parameter:** Now supports hasConsentForAdStorage to give users more granular control over their data.
+✅ **Enhanced Clarity**: Allows nullable boolean values, indicating when users have not provided consent instead of forcing defaults.
+✅ **Future-Proof:** Designed to be aligned with evolving privacy regulations and best practices.
+ +📌 **API Reference** + +```dart +Future setConsentData({ + required bool isUserSubjectToGDPR, + bool? hasConsentForDataUsage, + bool? hasConsentForAdsPersonalization, + bool? hasConsentForAdStorage, +}) +``` + +### Parameters + +| Parameter | Type | Description | +| -------- | -------- | -------- | +| isUserSubjectToGDPR | bool (required) | Indicates if the user is subject to GDPR regulations. | +| hasConsentForDataUsage | bool? | Determines if the user consents to data usage. Supply when `isUserSubjectToGDPR` is `true`. | +| hasConsentForAdsPersonalization | bool? | Determines if the user consents to personalized ads. Supply when `isUserSubjectToGDPR` is `true`. | +| hasConsentForAdStorage | bool? | Determines if the user consents to storing ad-related data. Optional. | + +- When `isUserSubjectToGDPR` is `true`, supply both usage and ads-personalization values before the first `start()`. The iOS native RPC layer validates them; the plugin forwards the payload without Dart-side checks. +- When `isUserSubjectToGDPR` is `false`, omit the GDPR-specific values. +- For an `hasConsentForAdStorage` value of `null`, the user has **not explicitly provided consent** for that option. +- These values should be collected from the user via an appropriate **UI or consent prompt** before calling this method. + +📌 **Example Usage** + +```dart +final appsflyerSdk = AppsFlyerSdk.instance; +await appsflyerSdk.init( + devKey: 'your_dev_key', + appId: '1234567890', +); + +await appsflyerSdk.setConsentData( + isUserSubjectToGDPR: true, + hasConsentForDataUsage: true, + hasConsentForAdsPersonalization: false, + hasConsentForAdStorage: null, +); +``` + +📌 **Notes**
+• Call this method after `init()` and before `start()`.
+• Provide the current consent data on every app start. The values are not persisted across sessions.
+• Ensure you collect consent **legally and transparently** from the user before passing these values. diff --git a/doc/deep-linking.md b/doc/deep-linking.md new file mode 100644 index 00000000..26df8513 --- /dev/null +++ b/doc/deep-linking.md @@ -0,0 +1,268 @@ +# Deep linking + +> **Audience:** apps routing users to in-app content via OneLink. Complete +> [Getting started](getting-started.md) first. API details: +> [`registerDeepLinkListener`](api-reference.md#registerDeepLinkListener). +> Push deep links: +> [`addPushNotificationDeepLinkPath`](api-reference.md#addPushNotificationDeepLinkPath), +> Android [`sendPushNotificationData`](api-reference.md#sendPushNotificationData), +> iOS [`handlePushNotification`](api-reference.md#handlePushNotification). + +> ⚠️ **IMPORTANT: Flutter 3.27+ breaking change** +> +> From Flutter 3.27, built-in Flutter deep linking defaults to **enabled** and +> can conflict with AppsFlyer. **Disable it** when you use this plugin: +> +> **Android** — inside the main `` in `AndroidManifest.xml`: +> +> ```xml +> +> ``` +> +> **iOS** — in `Info.plist`: +> +> ```xml +> FlutterDeepLinkingEnabled +> +> ``` +> +> See the [Flutter breaking change](https://docs.flutter.dev/release/breaking-changes/deep-links-flag-change). + +## Overview + +A **deep link** routes a user to a specific place in your app. If the app is not +installed, **deferred deep linking** routes the user to the store first and +delivers the in-app destination after install. + +The plugin uses **Unified Deep Linking (UDL)** for both direct and deferred deep +links. Results are delivered to the callback you pass to +`registerDeepLinkListener()`. + +Read the [OneLink™ Deep Linking Guide](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-Deep-Linking-Guide#Intro) for dashboard and link configuration. + +![Deep linking flow](https://massets.appsflyer.com/wp-content/uploads/2018/03/21101417/app-installed-Recovered.png) + +--- + +## Platform setup + +Configure native link handling before you wire up Dart listeners. The plugin +forwards incoming URLs and Universal Links to the AppsFlyer SDK; your app must +declare the schemes and domains the OS should open. + +### Android + +#### URI scheme + +Add an intent filter on the activity that should receive the link: + +```xml + + + + + + +``` + +#### App Links + +For App Links, see AppsFlyer's +[Android App Links guide](https://support.appsflyer.com/hc/en-us/articles/115005314223-Deep-Linking-Users-with-Android-App-Links#what-are-android-app-links). + +```xml + + + + + + +``` + +#### Warm starts (`onNewIntent`) + +The plugin updates the attached activity with each new intent. The Android SDK +resolves warm-start deep links through its activity-lifecycle hook after +`registerDeepLinkListener()` has registered the native listener. No custom +`MainActivity.onNewIntent` implementation is required. + +### iOS + +#### Universal Links + +See AppsFlyer's +[Universal Links setup](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-Deep-Linking-Guide#setups-universal-links). + +1. Configure your OneLink sub-domain in the AppsFlyer dashboard (AppsFlyer hosts the `apple-app-site-association` file). +2. Enable **Associated Domains** and add approved domains to `Runner.entitlements`: + +```xml + + + + + com.apple.developer.associated-domains + + applinks:test.onelink.me + + + +``` + +The plugin forwards Universal Link callbacks from `UIApplicationDelegate` and, +when available, from `UISceneDelegate` (Flutter 3.41+). No AppsFlyer-specific +`AppDelegate` forwarding code is required. + +#### URI scheme + +Add your URI scheme under Xcode **General → URL Types**. + +The plugin forwards URL-scheme callbacks the same way. See AppsFlyer's +[URI scheme guide](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-deep-linking-guide#setups-uri-scheme-for-ios-8-and-below). + +--- + +## Flutter integration + +1. Complete [Getting started](getting-started.md) — `init()`, listener + registration, and `start()` from the session-ready callback. +2. Call `registerDeepLinkListener(onDeepLink)` **before** `init()`, passing the + callback that handles the result. + +> ⚠️ **Register before `init()`.** On Android, `init()` hands the launch intent +> to the native SDK, which then decides — once per install — whether to send the +> deferred deep-link resolution request. With no listener registered at that +> point, that request is never sent, and the decision is persisted: later +> launches do not retry it. Registering after `init()` therefore breaks deferred +> deep linking on Android even though direct links keep working. When testing a +> fix, reinstall the app (or clear its data); the skipped state survives a plain +> app restart. + +### Unified Deep Linking (recommended) + +UDL handles **direct** links (app already open or cold start) and **deferred** +links (after install) through the same `registerDeepLinkListener` callback. + +**Flow:** + +1. User clicks a OneLink short URL. +2. Android App Links / iOS Universal Links (or the deferred install path) open the app. +3. The native SDK resolves the link and delivers a result to the plugin. +4. Your callback receives a `DeepLinkResult` with `deep_link_value` and other available fields. + +> 📘 **UDL privacy (new users):** UDL returns only deferred deep-linking +> parameters (`deep_link_value`, `deep_link_sub1`–`deep_link_sub10`). Other +> fields such as `media_source`, `campaign`, and `af_sub1`–`af_sub5` may be +> `null` for new users. + +**Considerations:** + +- Uses the AppsFlyer SDK 7 Unified Deep Linking implementation. +- `af_dp` is not returned in the API response. + +Platform references: [Android UDL](https://dev.appsflyer.com/docs/android-unified-deep-linking), [iOS UDL](https://dev.appsflyer.com/docs/ios-unified-deep-linking). + +```dart +await appsflyerSdk.registerDeepLinkListener((DeepLinkResult result) { + switch (result.status) { + case DeepLinkStatus.found: + print(result.deepLink); + print('deep link value: ${result.deepLink?.deepLinkValue}'); + break; + case DeepLinkStatus.notFound: + print('deep link not found'); + break; + case DeepLinkStatus.error: + print('deep link error: ${result.error}'); + break; + case DeepLinkStatus.unknown: + print('unknown deep link status'); + break; + } +}); +``` + +`DeepLinkResult` exposes a `DeepLink` model: + +```dart +class DeepLink { + final Map _clickEvent; + + const DeepLink(this._clickEvent); + + Map get clickEvent => _clickEvent; + + String? getStringValue(String key) => _clickEvent[key]?.toString(); + + String? get deepLinkValue => getStringValue('deep_link_value'); + String? get matchType => getStringValue('match_type'); + String? get clickHttpReferrer => getStringValue('click_http_referrer'); + String? get mediaSource => getStringValue('media_source'); + String? get campaign => getStringValue('campaign'); + String? get campaignId => getStringValue('campaign_id'); + String? get afSub1 => getStringValue('af_sub1'); + String? get afSub2 => getStringValue('af_sub2'); + String? get afSub3 => getStringValue('af_sub3'); + String? get afSub4 => getStringValue('af_sub4'); + String? get afSub5 => getStringValue('af_sub5'); + + bool? get isDeferred { + final value = _clickEvent['is_deferred']; + if (value is bool) { + return value; + } + if (value?.toString().toLowerCase() == 'true') { + return true; + } + if (value?.toString().toLowerCase() == 'false') { + return false; + } + return null; + } + @override + String toString() { + return 'DeepLink: ${jsonEncode(_clickEvent)}'; + } +} +``` + +> **Platform behavior:** `DeepLink.isDeferred` is reliable on Android. On iOS +> it is always `null` because the native click event does not include an +> `is_deferred` value. Do not use `isDeferred` alone to control cross-platform +> navigation. Handle a found result using `deepLinkValue` and the other +> available deep-link parameters instead. + +### Direct deep linking + +When the app is already installed, a URL scheme, App Link, or Universal Link +opens the app and the resolved destination is delivered to the +`registerDeepLinkListener` callback. +Use the UDL listener above — no separate direct-link API is required. + +### Deferred deep linking + +When the app is not installed, the link first sends the user to the app store. +After installation and the first app open, the resolved destination is +delivered to the `registerDeepLinkListener` callback. Use the same UDL listener described +above; no separate deferred-link listener is required. + +This is the path that depends on registration order: register the listener +before `init()`, otherwise Android never sends the resolution request for that +install. + +--- + +## Quick reference + +| Topic | Section | +| --- | --- | +| Disable Flutter 3.27+ default deep linking | Top of this page | +| Android / iOS manifest and entitlements | [Platform setup](#setup) | +| UDL listener and `DeepLink` model | [Unified Deep Linking](#unified-deeplinking) | +| Direct deep linking | [Direct deep linking](#handle-deeplinking) | +| Deferred deep linking | [Deferred deep linking](#deferred-deep-linking) | diff --git a/doc/getting-started.md b/doc/getting-started.md new file mode 100644 index 00000000..1f2d98b3 --- /dev/null +++ b/doc/getting-started.md @@ -0,0 +1,370 @@ +# 🚀 Getting started + +This guide takes you through the required setup: creating the SDK instance, initializing +it, and starting sessions with the SDK 7 session-ready model. + +> **Audience:** every app integrating the plugin. Complete [Installation](installation-guide.md) first. + +## Prerequisites + +- The plugin added to your app — see [Installation](installation-guide.md). +- Your AppsFlyer **Dev Key** (from the AppsFlyer dashboard) and, for iOS, your **App ID**. +- **iOS:** minimum deployment target `13.0`. For ATT/IDFA, see [iOS 14 & App Tracking Transparency](#ios-14--app-tracking-transparency) below. +- **Android:** review the `AD_ID` permission note in the [README](../README.md#ad_id-permission-for-android). + +## Startup sequence + +Set up the SDK once on every cold start, in this order: + +1. Get the shared SDK instance. +2. Register the deep-link listener, if your app uses deep linking. +3. Initialize the native SDK. +4. Apply configuration that must be included in the first Launch. +5. Register the remaining native listeners your app needs, each with its callback. +6. Call `start()` from the session-ready callback. + +`registerDeepLinkListener()` is the one listener that must be registered +**before** `init(...)`; the others are registered after. See +[Register the deep-link listener before init](#deep-link-listener-before-init). + +The following sections explain each step. + +## 1. Create the SDK instance + +Use the shared `AppsFlyerSdk.instance`. SDK configuration is applied through +explicit methods instead of a configuration object. + +```dart +import 'package:appsflyer_sdk/appsflyer_sdk.dart'; + +final AppsFlyerSdk appsflyerSdk = AppsFlyerSdk.instance; +``` + +## 2. Register the deep-link listener + +`Future registerDeepLinkListener(OnDeepLinkReceived onDeepLink)` + +If your app handles deep links, register this listener **before** `init(...)`: + +```dart +await appsflyerSdk.registerDeepLinkListener((result) { + print('Deep-link result: $result'); +}); +``` + +On Android, `init(...)` hands the launch intent to the native SDK, and that is +when the SDK decides whether to resolve a deferred deep link. The decision is +made once per install: with no listener registered at that moment, the deferred +resolution request is never sent — not even on a later launch. Registering +before `init(...)` is supported on both platforms and is also correct for direct +links. + +Skip this step if your app does not use deep linking. For the full guide, +including the `DeepLinkResult` payload, see [Deep linking](deep-linking.md). + +## 3. Initialize the SDK + +`Future init({required String devKey, String? appId})` + +Call `init(...)` once during app setup. It initializes the native SDK but does +not send a session (Launch) or enable optional listeners. + +```dart +// Optional. Enable only while testing. +await appsflyerSdk.enableDebug(true); + +await appsflyerSdk.init( + devKey: '', + appId: '', +); +``` + +| Parameter | Required | Description | +|---|---|---| +| `devKey` | Android and iOS | Your application's [Dev Key](https://support.appsflyer.com/hc/en-us/articles/207032066-Basic-SDK-integration-guide#retrieving-the-dev-key). | +| `appId` | iOS only | Your application's [Apple App ID](https://support.appsflyer.com/hc/en-us/articles/207377436-Adding-a-new-app#available-in-the-app-store-google-play-store-windows-phone-store). It is optional and is not sent to the native SDK on Android. | + +`enableDebug(...)` can be called before `init(...)`. Disable debug logging +before releasing the app to production. + +## 4. Configure the first Launch + +After `init(...)`, apply any runtime configuration that must be included in the +first Launch. Do this before registering the session-ready listener because the +listener can emit immediately and trigger `start()`. + +For example: + +```dart +await appsflyerSdk.setCustomerUserId(''); +``` + +In SDK 7, values set with `setCustomerUserId`, `setCurrencyCode`, +`setAdditionalData`, `setConsentData`, and `anonymizeUser` are kept in memory +only. Re-apply the values your app needs on every cold start. They remain +available when the app moves between the background and foreground in the same +process. + +Other methods can have different timing and persistence requirements. For +example, `setInstallId()` has platform-specific requirements. Check the +[API reference](api-reference.md#setInstallId) for the method you use. + +Other common optional settings include: + +| Method | Description | +|---|---| +| `setAppInviteOneLink(String oneLinkId)` | Sets the [OneLink template ID](https://support.appsflyer.com/hc/en-us/articles/115004480866-User-invite-attribution#parameters) used to generate user-invite links. | +| `setDisableAdvertisingIdentifiers(bool disable)` | Opts out of collecting advertising identifiers, including OAID, AAID, GAID, and IDFA. | +| `setDisableCollectASA(bool disable)` | Opts out of Apple Search Ads attribution collection. This method is available only on iOS. | + +## 5. Register the remaining listeners + +Each listener takes its callback as an argument, so registering the listener and +handling its event are the same step. Register these after `init(...)`, register +only the optional listeners your app uses, and register the session-ready +listener last: + +```dart +// Optional: conversion data (GCD). +await appsflyerSdk.registerConversionListener( + onSuccess: (data) { + print('Conversion data: $data'); + }, + onFailure: (error) { + print('Conversion data error: $error'); + }, +); + +// Required. Register last because the callback can be invoked immediately. +await appsflyerSdk.registerSessionReadyListener(() async { + try { + await appsflyerSdk.start(awaitResponse: true); + print('AppsFlyer session reported.'); + } on AppsFlyerException catch (error) { + print('AppsFlyer start error: $error'); + } +}); +``` + +| Registration method | Event | +|---|---| +| `registerConversionListener(onSuccess:, onFailure:)` | Enables [GCD](https://dev.appsflyer.com/hc/docs/conversion-data) success and failure events. | +| `registerDeepLinkListener(onDeepLink)` | Enables [UDL](https://dev.appsflyer.com/hc/docs/unified-deep-linking-udl) results. Register it before `init(...)` — see [step 2](#deep-link-listener-before-init). | +| `registerSessionReadyListener(onReady)` | Enables one session-ready event per foreground cycle. | + +The plugin keeps **one callback per event** and replaces it when you register +again, matching the native SDKs. There is no stream to subscribe to, so a single +native event can never reach two handlers in your app — for example, `start()` +cannot be issued twice for one session-ready event. + +### Unregister and re-register explicitly + +Registration is native state, and the plugin never infers that a listener has +gone stale — your app decides when delivery should stop and start again: + +```dart +@override +void dispose() { + // Stop native delivery when this part of the app no longer consumes it. + appsflyerSdk.unregisterSessionReadyListener(); + appsflyerSdk.unregisterConversionListener(); + super.dispose(); +} +``` + +| Unregister method | Availability | +|---|---| +| `unregisterConversionListener()` | Android only | +| `unregisterDeeplinkListener()` | Android only, and a soft unsubscribe — the native SDK keeps its listener, so events are dropped rather than never delivered | +| `unregisterSessionReadyListener()` | Android and iOS | + +Where the unregister call reaches the native SDK it also drops the callback you +passed at registration. On iOS, `unregisterConversionListener()` and +`unregisterDeeplinkListener()` drop your callback and then throw +`AppsFlyerException`, because the iOS SDK has no matching native call — guard +them with `Platform.isAndroid` or catch the exception. + +On Android the native listener also outlives the Flutter engine, which is +destroyed on its own schedule (a back press, or a Flutter screen leaving an +add-to-app host) while the process keeps running. Your Dart callbacks do not +survive that, so after a new engine attaches, call the `register*Listener()` +methods again (steps 2 and 5) — the same sequence as a cold start. Re-registering +is +cheap: it reconnects to the already configured native bridge instead of building +a new one. + + +## Add-to-app and multiple Flutter engines + +The AppsFlyer **native SDK is process-scoped** — one `AppsFlyerLib` instance per +app process. The Flutter plugin mirrors that on the native side: one RPC handler, +one `af-events` delivery path, and one native listener slot per event type +(`registerConversionListener`, `registerDeepLinkListener`, and so on all replace +the previous registration). + +Each `FlutterEngine` gets its own plugin instance and Dart isolate. +`AppsFlyerSdk.instance` is a singleton **within that isolate**, not across +engines. + +### One live engine at a time (typical add-to-app) + +When the user leaves a Flutter screen and the engine is destroyed, register your +listeners again after a new engine attaches — the same sequence as steps 2 and 5 +above. Native configuration survives; re-registering reconnects your Dart callbacks +to the existing native bridge. + +### Multiple engines alive at once (unsupported) + +If two or more Flutter engines coexist in the same process — add-to-app with +overlapping routes, `FlutterEngineGroup` warm-up, or multi-scene hosts — **only +one engine receives native events**: + +| Layer | Behavior | +|---|---| +| `af-events` delivery | The engine whose EventChannel subscription attached **most recently** wins. Older engines do not receive conversion, deep-link, or session-ready callbacks even if Dart listeners are still registered. | +| Native `register*Listener()` | The **last** registration from any engine overwrites the native SDK's single listener reference for that event type. | +| `init()` / `start()` | All engines share the same native SDK. Call `init()`, register listeners, and drive `start()` from **one primary engine** only. | + +Do not integrate AppsFlyer from secondary Flutter modules. If your host app uses +add-to-app, pick one engine (usually the main Flutter entry point) for the full +startup sequence in this guide. + +See also [API reference → Multi-engine hosts](api-reference.md#multi-engine-hosts). + + + +## 6. Start sessions + +`Future start({bool awaitResponse = false})` + +`init(...)` and `registerSessionReadyListener(...)` do not report a session. +`start()` sends the session (Launch). Call it from the session-ready callback +registered in step 5. + +The session-ready callback runs once per foreground cycle, after launch deep-link +processing finishes or times out. This includes the initial launch and every +background-to-foreground transition. Calling `start()` only once during app +setup reports the first session but misses later foreground sessions. + +If the first session must wait for consent, a Customer User ID, ATT +authorization, or another app condition, complete that work inside the +session-ready callback before calling `start()`. + +### Choose when the `Future` completes + +- `start()` uses `awaitResponse: false` by default. Its `Future` completes when + the native SDK accepts the fire-and-forget call; this does not confirm request + delivery. +- `start(awaitResponse: true)` waits for the native request callback. Native + errors and timeouts are reported as `AppsFlyerException`. A timeout does not + cancel the native request, which may still succeed later. + + +### Conversion-data timing + +`registerConversionListener(...)` only enables the callbacks. The conversion-data +request is sent after `start()` reports the Launch, and its result is delivered +to `onSuccess` or `onFailure`. + +The `Future` returned by `start()` is not the conversion-data result. Always use +the conversion-data callbacks to receive GCD data. + +Conversion data also provides an extended deferred deep-linking path for cases +where UDL does not return a deferred link, such as some SRN campaigns or legacy +links. If both listeners are enabled, handle a deferred link only once to avoid +routing the user twice. + +### Complete startup example + +```dart +import 'package:appsflyer_sdk/appsflyer_sdk.dart'; + +final AppsFlyerSdk appsflyerSdk = AppsFlyerSdk.instance; + +Future configureAppsFlyer() async { + await appsflyerSdk.enableDebug(true); // Testing only. + + // Register before init() so Android can resolve a deferred deep link. + await appsflyerSdk.registerDeepLinkListener((result) { + print('Deep-link result: $result'); + }); + + await appsflyerSdk.init( + devKey: '', + appId: '', + ); + + // Apply values that must be included in the first Launch. + await appsflyerSdk.setCustomerUserId(''); + + await appsflyerSdk.registerConversionListener( + onSuccess: (data) { + print('Conversion data: $data'); + }, + onFailure: (error) { + print('Conversion data error: $error'); + }, + ); + + // Register last. The callback can run immediately and trigger start(). + await appsflyerSdk.registerSessionReadyListener(() async { + try { + await appsflyerSdk.start(awaitResponse: true); + print('AppsFlyer session reported.'); + } on AppsFlyerException catch (error) { + print('AppsFlyer start error: $error'); + } + }); +} +``` + +--- + +## iOS 14 & App Tracking Transparency + +On iOS, to attribute installs that rely on the IDFA you must present the App Tracking +Transparency (ATT) prompt and give the user time to respond before the first session is +sent. + +**1. Add ATT handling.** The recommended Flutter integration is the +[`app_tracking_transparency`](https://pub.dev/packages/app_tracking_transparency) +package. Request authorization from the session-ready callback shown in step 5 so +the same Dart flow works with both the legacy application lifecycle and the +UIScene lifecycle. + +**2. Add the usage-description key** to your `Info.plist`: + +```xml +NSUserTrackingUsageDescription +This identifier will be used to deliver personalized ads to you. +``` + +**3. Delay the first session** until the ATT request completes. The plugin does +not expose `waitForATTUserAuthorization`. In step 5, register the following +session-ready callback instead of the basic one: + +```dart +var attHandled = false; + +await appsflyerSdk.registerSessionReadyListener(() async { + if (!attHandled) { + await AppTrackingTransparency.requestTrackingAuthorization(); + attHandled = true; + } + await appsflyerSdk.start(); +}); +``` + +> **Native lifecycle note:** Flutter 3.41 and later use UIScene by default for +> iOS apps. If you implement the ATT request in native code, a UIScene app must +> request it from `sceneDidBecomeActive` in its `SceneDelegate`. Do not rely only +> on `applicationDidBecomeActive` in `AppDelegate`, because that callback may not +> receive UI lifecycle events after UIScene migration. Use the AppDelegate +> callback only for a legacy app that has not adopted UIScene. See Flutter's +> [UIScene adoption guide](https://docs.flutter.dev/release/breaking-changes/uiscenedelegate). + +As in the standard flow, call `registerSessionReadyListener(...)` only after +`init(...)` and any configuration that must be included in the first Launch. + +For the full iOS 14 guide, see AppsFlyer's +[ATT support article](https://support.appsflyer.com/hc/en-us/articles/207032066#integration-33-configuring-app-tracking-transparency-att-support). diff --git a/doc/in-app-events.md b/doc/in-app-events.md new file mode 100644 index 00000000..874dcac9 --- /dev/null +++ b/doc/in-app-events.md @@ -0,0 +1,85 @@ +# In-app events & ad revenue + +In-App Events provide insight on what is happening in your app. It is recommended to take the time and define the events you want to measure to allow you to measure ROI (Return on Investment) and LTV (Lifetime Value). + +> **Audience:** apps sending custom in-app events or ad-revenue events. Requires the [core setup](getting-started.md). + +Recording in-app events is performed by calling logEvent with event name and value parameters. See In-App Events documentation for more details. + +**Note:** An In-App Event name must not be empty. Custom event names should be no longer than 100 characters. +Find more info about recording events [here](https://dev.appsflyer.com/hc/docs/in-app-events-sdk). + +--- + +## logEvent + +** `Future logEvent(String eventName, {Map? eventValues, bool awaitResponse = false})`** + +| parameter | type | description | +| ----------- |----------|------------------------------------------ | +| eventName | String | The event name, it is presented in your dashboard. | +| eventValues | `Map?` | Optional named event parameters sent with the event. | +| awaitResponse | `bool` | Optional named parameter. Defaults to `false`. When `true`, wait for the native request callback. When `false`, return after the native SDK accepts the call. | + +When `awaitResponse` is `true`, the Future completes after the native +request callback succeeds. Request failures and timeouts are reported as +`AppsFlyerException`. See the [API reference](api-reference.md#logEvent) for +details. +With the default `false`, it completes when the native SDK accepts the +fire-and-forget call and does not report the native delivery result. + +**Example:** +```dart +try { + await appsflyerSdk.logEvent( + "purchase", + eventValues: {"af_revenue": 1.99, "af_currency": "USD"}, + awaitResponse: true, + ); + print("logEvent success"); +} on AppsFlyerException catch (error) { + print("logEvent error: $error"); +} +``` + +--- + +## Ad revenue + +Log ad-revenue events with `logAdRevenue`. Always take the `mediationNetwork` +value from the `AFMediationNetwork` enum. The returned Future completes after +the plugin validates the request and invokes the native logging API. Validation +and native call failures are reported as `AppsFlyerException`. The native API +has no delivery callback, so completion does not confirm that the event was +uploaded. + +```dart +Future logAdRevenue({ + required String monetizationNetwork, + required AFMediationNetwork mediationNetwork, + required String currencyIso4217Code, + required double revenue, + Map? additionalParameters, +}) +``` + +| parameter | type | description | +| --------- | ---- | ----------- | +| monetizationNetwork | String | The monetization network that generated the revenue. | +| mediationNetwork | `AFMediationNetwork` | The mediation platform managing the ad. | +| currencyIso4217Code | String | A three-letter ISO 4217 currency code, such as `USD`. | +| revenue | double | The ad-revenue amount. | +| additionalParameters | `Map?` | Optional additional values for the ad-revenue event. | + +```dart +await appsflyerSdk.logAdRevenue( + monetizationNetwork: "GoogleAdMob", + mediationNetwork: AFMediationNetwork.applovinMax, + currencyIso4217Code: "USD", + revenue: 1.23, + additionalParameters: {"adUnitId": "ca-app-pub-XXXX/YYYY"}, +); +``` + +See the [API reference](api-reference.md#logAdRevenue) for the full +`AFMediationNetwork` reference and the cross-platform mediation-network note. diff --git a/doc/installation-guide.md b/doc/installation-guide.md new file mode 100644 index 00000000..708f0e7e --- /dev/null +++ b/doc/installation-guide.md @@ -0,0 +1,63 @@ +# Installation + +Add the plugin to your app and configure the native (iOS/Android) dependencies. This is +step 1 — continue with [Getting started](getting-started.md) once the package is added. + +## Add the package + +Open the terminal of your chosen IDE and run the following: + +``` +flutter pub add appsflyer_sdk +``` + +This will download the AppsFlyer flutter plugin to your project, you may observe the changes in your `pubspec.yaml` file. + +The plugin requires: + +- Flutter `3.24.0` or later; +- Dart `3.5.0` or later; +- Android API 21 or later; +- iOS 13.0 or later. + +--- +## iOS: Swift Package Manager (SPM) support + +Starting with v6.18.0, the plugin's **Core** integration supports Swift Package Manager on iOS, alongside continued full CocoaPods support. If your app has SPM enabled (the default on Flutter 3.44+, or via `flutter config --enable-swift-package-manager` on Flutter 3.24+), no extra setup is needed — Flutter's tooling picks up the plugin's `Package.swift` automatically. + +**If you use Purchase Connector, do not enable SPM for this plugin.** [Purchase Connector](purchase-connector.md) requires CocoaPods for the entire plugin (Core included) — it cannot currently be combined with SPM, pending resolution of an upstream Flutter limitation ([flutter/flutter#161182](https://github.com/flutter/flutter/issues/161182)). Apps that do not use Purchase Connector can use SPM. Apps that use Purchase Connector must keep CocoaPods and set the `$AppsFlyerPurchaseConnector` Podfile flag as documented in [purchase-connector.md](purchase-connector.md). + +--- +## Android: Google Play Install Referrer (SDK 7) + +Plugin `7.x` uses AppsFlyer Android SDK 7, which collects Play Install Referrer via +Google's Install Referrer library — **not** legacy `INSTALL_REFERRER` broadcast receivers. + +The plugin already declares the required dependency and includes it transitively +in the application runtime: + +```gradle +implementation 'com.android.installreferrer:installreferrer:2.2' +``` + +No app-level Gradle change is required for AppsFlyer. Add the dependency to your +app module only if your application code imports and uses the Install Referrer +API directly. + +For Samsung Galaxy Store, Xiaomi GetApps, or Huawei AppGallery, see +[Advanced features — Alternative stores](advanced-features.md#alternative-stores-samsung-xiaomi-huawei). + +Upgrade-specific removal of legacy receiver declarations is documented in +[doc/migration-guide.md](migration-guide.md). + +--- + +## 👨‍👩‍👧‍👦 Strict mode for Kids Apps + +The iOS SDK ships in two variants: **Strict** mode and **Regular** mode. +Please read more: https://support.appsflyer.com/hc/en-us/articles/207032066#integration-strict-mode-sdk + +> **⚠️ SDK 7 note:** The Flutter plugin does not currently expose Strict mode +> as a public configuration option. Swift Package Manager uses the Regular SDK +> variant. If your Kids App requires Strict mode, use CocoaPods and contact +> AppsFlyer Support for the supported plugin configuration. diff --git a/doc/migration-guide.md b/doc/migration-guide.md new file mode 100644 index 00000000..ccfc902d --- /dev/null +++ b/doc/migration-guide.md @@ -0,0 +1,404 @@ +# Migrating the AppsFlyer Flutter plugin from v6 to v7 + + + +This release is a major API cleanup. It replaces callback flags, callback slots, +and SDK-6 names with an explicit SDK-7 lifecycle, typed callbacks, correlated +`Future` results, and platform-aware models. + +Plugin `7.0.1` migrates to **AppsFlyer SDK 7** (Android and iOS `7.0.1`). This +is a major release with intentional breaking changes. Purchase Connector +dependency changes are described later in this guide. + +The minimum supported toolchain is Flutter `3.24.0` and Dart `3.5.0` (and +earlier than Dart `4.0.0`). Android requires API 21 or later, and iOS requires +version 13.0 or later. + +This guide is scoped to the **Flutter plugin**. For the underlying native behavior, read +the official SDK 7 migration guides — they are the source of truth for what changed: + +- Android: +- iOS: + +--- + +## API Removal Rule + +> **Preserve SDK 7 behavior — not SDK 6 APIs.** + +If a public API has been **removed from the native AppsFlyer SDK 7, it is also removed from +the Flutter plugin.** The plugin does not preserve or emulate APIs that no longer exist in +the native SDK unless there is a strong technical or business justification. + +Because this is a major version migration (SDK 6 → SDK 7), breaking API changes are expected +and acceptable when they align the plugin with the native SDKs. Obsolete SDK 6 APIs are **not** +kept for backward compatibility. Instead, for every migration the plugin: + +- **Removes** APIs that were removed from the native SDK. +- **Redesigns** the Flutter API to follow the new SDK 7 architecture. +- **Documents** every removed or changed API in this guide and in the [CHANGELOG](../CHANGELOG.md). +- **Explains** the replacement API or the new SDK 7 workflow, when one exists. + +The plugin stays a thin, platform-consistent abstraction over the native Android and iOS +SDKs rather than maintaining legacy concepts that no longer exist in SDK 7. + +--- + +## Session model and lifecycle + +The biggest behavioral change in SDK 7 is that **you control when a session (Launch) is +sent**. `init()` only initializes the SDK; nothing is reported until you call `start()`, +and `start()` must be called **once per foreground cycle** (the native SDK resets its +"started" flag on every background). + +| v6 API or limitation | v7 replacement | +| --- | --- | +| `AppsflyerSdk` | `AppsFlyerSdk` | +| Map-based constructor and `AppsFlyerOptions` | `AppsFlyerSdk.instance` | +| `initSdk(...)` | `init(devKey:, appId:)`; `appId` is required on iOS and optional on Android | +| `void startSDK({onSuccess, onError})` | `Future start({bool awaitResponse = false})`; await the Future and catch `AppsFlyerException` when requesting the native result | +| `registerConversionDataCallback` init flag | `registerConversionListener(onSuccess:, onFailure:)` | +| `registerOnDeepLinkingCallback` init flag | `registerDeepLinkListener(onDeepLink)` | +| No session-readiness public API | `registerSessionReadyListener(onReady)`, `unregisterSessionReadyListener()`, and `isSessionReady()` | + +Use `AppsFlyerSdk.instance`, register the deep-link listener if your app handles +deep links, initialize it with the developer key and, on iOS, the Apple app ID, +apply runtime configuration through explicit setters, register the remaining +native listeners you need, and call `start()` from the session-ready callback: + +```dart +final appsflyer = AppsFlyerSdk.instance; + +await appsflyer.enableDebug(true); +// Before init(): Android skips deferred deep-link resolution, permanently for +// that install, when no listener is registered while init() runs. +await appsflyer.registerDeepLinkListener((result) { /* route the user */ }); +await appsflyer.init( + devKey: '', + appId: '', +); +await appsflyer.registerSessionReadyListener(() async { + await appsflyer.start(); +}); +``` + +Gate the first session (consent, Customer User ID, ATT) by deferring the `start()` call +inside the callback. Apply any configuration setters (e.g. +`setCustomerUserId`, `setCurrencyCode`, `setConsentData`) **before** `start()`. + +> **Setter values are runtime-only on both platforms.** SDK 7 aligns Android with iOS: +> setter values are no longer persisted to disk and do not survive a process restart. +> Re-apply them on every cold start, before `start()`. + +--- + +## Events, callbacks, and errors + +| Removed API | Replacement | +| --- | --- | +| `onInstallConversionData(callback)` | `registerConversionListener(onSuccess:, onFailure:)`; Android also exposes `unregisterConversionListener()` | +| `onDeepLinking(callback)` | `registerDeepLinkListener(onDeepLink)` | +| global invite-link callbacks | `await generateInviteLink(...)` | +| request success/error callbacks | `await` and catch `AppsFlyerException` | +| `Status` | `DeepLinkStatus` | +| `Error` enum | `DeepLinkFailure` with Android `type` or iOS `message` | + +--- + +## Removed APIs and their replacements + +| Removed Flutter API (v6) | Why | SDK 7 replacement / action | +| --- | --- | --- | +| Map constructor, `AppsFlyerOptions`, and `manualStart` | Configuration-object lifecycle removed | `AppsFlyerSdk.instance`, `init(...)`, then `start()` | +| `onAppOpenAttribution`, `registerOnAppOpenAttributionCallback` | OAOA removed from both native SDKs | `registerDeepLinkListener(onDeepLink)` | +| `performOnDeepLinking()` | Removed on Android (§5a), replaced on iOS | `performDeepLinking(url, {shouldTriggerSession})` | +| `validateAndLogInAppAndroidPurchase` (V1) | Native V1 purchase validation removed | `validateAndLogInAppPurchase` with `AFAndroidPurchaseDetails` | +| `validateAndLogInAppIosPurchase` (V1) | Native 6-param purchase validation removed (iOS §6) | `validateAndLogInAppPurchase` with `AFIOSPurchaseDetails` | +| `onPurchaseValidation` | Legacy validation callback removed | `validateAndLogInAppPurchase` with `awaitResponse` | +| `setPushNotification(bool)` | Removed from both native SDKs | Android: `sendPushNotificationData(...)`; iOS: `handlePushNotification(pushPayload)` | +| `enableUninstallTracking(String)` | Legacy device-token flow removed | `updateServerUninstallToken(String)` | +| `setCollectIMEI(bool)` | Removed from native SDK 7 (§9) | IMEI auto-collection removed; no replacement | +| `waitForCustomerUserId(bool)` | Removed from native SDK 7 (§9) | Call `setCustomerUserId()` before `start()` | +| `setCustomerIdAndLogSession(String)` | Removed from native SDK 7 (§9) | `setCustomerUserId()` then `start()` | +| `setSharingFilter`, `setSharingFilterForAllPartners` | Removed from both native SDKs | `setSharingFilterForPartners(["all"])` | +| `AppsFlyerOptions.timeToWaitForATTUserAuthorization` | Removed from native SDK 7 | Control the timing of `start()` in application code | +| `enableLocationCollection(bool)` | Removed in plugin `6.8.0` | No replacement | +| Callback-slot helpers | Replaced by explicit listener registration | `registerConversionListener(onSuccess:, onFailure:)` and `registerDeepLinkListener(onDeepLink)`, each taking a typed callback | + +`subscribeForDeepLink(listener, timeout)` was an Android native SDK overload, +not a public Flutter v6 API. Its SDK 7 Flutter equivalent is to call +`setDeepLinkTimeout(timeout)`, then `registerDeepLinkListener(onDeepLink)` — +both before `init()`. + +The following are not simulated in Dart: use Unified Deep Linking, control the +timing of `start()` in application code, and use only capabilities exposed by +the Flutter plugin's public API. + +### APIs present in native SDK 7 but removed from the plugin + +Some APIs still exist in the native SDKs but are not available through the +Flutter plugin. Rather than provide methods that silently do nothing, the +plugin removes them from its public API: + +| Removed Flutter API (v6) | Why | SDK 7 replacement / action | +| --- | --- | --- | +| `setUserEmails(List, EmailCryptType)` | Not available in the Flutter plugin | Hashed `setUserEmail(String)` | +| `setImeiData(String)` | Not available in the Flutter plugin | No Flutter SDK 7 replacement | +| `setAndroidIdData(String)` | Not available in the Flutter plugin | No Flutter SDK 7 replacement | + +> The `EmailCryptType` enum was removed together with `setUserEmails`. + +--- + +## Renames and signature changes + +| Removed or changed | Replacement | +| --- | --- | +| `AppsFlyerOptions.showDebug` | `enableDebug(bool)` | +| `AppsFlyerOptions.afDevKey` | `init(devKey: ..., appId: ...)` | +| `AppsFlyerOptions.appInviteOneLink` | `setAppInviteOneLink(...)` | +| `AppsFlyerOptions.disableAdvertisingIdentifier` | `setDisableAdvertisingIdentifiers(...)` | +| `AppsFlyerOptions.disableCollectASA` | `setDisableCollectASA(...)` (iOS only) | +| `setAppInviteOneLinkID(String oneLinkID, Function callback)` | `setAppInviteOneLink(String oneLinkId)`; the required callback was removed | +| `performOnDeepLinking()` | `performDeepLinking(String url, {bool shouldTriggerSession = false})` | +| `setSharingFilter`, `setSharingFilterForAllPartners`, and `setSharingFilterForPartners(List partners)` | `setSharingFilterForPartners(List? partners)` | +| `setCustomerUserId(String id)` | `setCustomerUserId(String customerId)` | +| `setHost(String hostPrefix, String hostName)` | `setHost(String hostPrefixName, String hostName)` | +| `setPartnerData(String partnerId, Map partnerData)` | `setPartnerData(String partnerId, Map data)` | +| `stop(bool isStopped)` | `stop(bool shouldStop)` | +| `addPushNotificationDeepLinkPath(List deeplinkPath)` | `addPushNotificationDeepLinkPath(List deepLinkPath)` | +| `setOneLinkCustomDomain(List brandDomains)` | `setOneLinkCustomDomain(List domains)` | +| `Future getSDKVersion()` | `Future getSdkVersion()` | +| `String getVersionNumber()` | `String get pluginVersion` | +| `Future logEvent(String eventName, Map? eventValues)` | `Future logEvent(String eventName, {Map? eventValues, bool awaitResponse = false})` | +| `setAdditionalData(Map? customData)` | `setAdditionalData(Map customData)`; pass an empty map to clear the data | +| `setDisableAdvertisingIdentifiers(bool isEnabled)` | `setDisableAdvertisingIdentifiers(bool disable)` | +| `validateAndLogInAppPurchaseV2` | `validateAndLogInAppPurchase` | +| Concrete Android-shaped `AFPurchaseDetails(purchaseType:, purchaseToken:, productId:)` | `AFAndroidPurchaseDetails(...)` or `AFIOSPurchaseDetails(...)`, both implementing `AFPurchaseDetails` | +| string ad-mediation value | `AFMediationNetwork` | +| `logAdRevenue(AdRevenueData)` | `logAdRevenue(monetizationNetwork: ..., mediationNetwork: ..., currencyIso4217Code: ..., revenue: ..., additionalParameters: ...)` | +| `setConsentDataV2(...)` or `setConsentData(AppsFlyerConsent)` | `setConsentData(isUserSubjectToGDPR: ..., hasConsentForDataUsage: ..., hasConsentForAdsPersonalization: ..., hasConsentForAdStorage: ...)` | +| `setCollectAndroidId(bool isCollect)` | `setCollectAndroidID(bool isCollect)` | +| `setDisableNetworkData(bool disable)` | `setDisableNetworkData(bool isDisable)` | +| `disableSKAdNetwork(bool isEnabled)` | `setDisableSKAdNetwork(bool disable)` | +| `useReceiptValidationSandbox(bool isSandboxEnabled)` | `setUseReceiptValidationSandbox(bool sandbox)` | +| `enableUninstallTracking(String senderId)` | `updateServerUninstallToken(String token)` on both platforms | +| callback-based `generateInviteLink(...)` | `await generateInviteLink(parameters: ..., awaitResponse: ...)` returning `String` | +| `AppsFlyerInviteLinkParams.customerID` | `AppsFlyerInviteLinkParams.referrerCustomerId` | +| `Map? AppsFlyerInviteLinkParams.customParams` | `Map? AppsFlyerInviteLinkParams.userParams` | +| `logCrossPromotionImpression(appId, campaign, data)` | `logCrossPromoteImpression(appId, campaign: ..., userParams: ...)` | +| `logCrossPromotionAndOpenStore(appId, campaign, params)` | `logAndOpenStore(appId, campaign: ..., userParams: ...)` | +| cross-platform `sendPushNotificationData(Map? userInfo)` | Android `sendPushNotificationData(campaign: ..., pid: ..., isRetargeting: ..., additionalParameters: ...)` or iOS `handlePushNotification(Map pushPayload)` | + +--- + +## Added in SDK 7 + +The following APIs did not exist in the Flutter `6.18.1` public API. + +Session model and deep linking: + +| API | Purpose | +| --- | --- | +| `registerSessionReadyListener`, `unregisterSessionReadyListener`, `isSessionReady` | SDK 7 session-ready model | +| `performDeepLinking(url, {shouldTriggerSession})` | Manual deep link resolution | +| `setDeepLinkTimeout(timeout)` | Deep link resolution timeout | +| `appendParametersToDeepLinkingURL(contains, parameters)` | Enrich matching deep-link URLs before resolution (Android + iOS) | +| `setFacebookDeferredAppLink(url)` | Manually set/clear the Facebook deferred app-link URL (**iOS only**) | + +User identity & PII: + +| API | Purpose | +| --- | --- | +| `setUserEmail`, `setUserPhone`, `setUserFirstName`, `setUserLastName` | The native SDK normalizes and hashes these values with SHA-256 | +| `setUserFbLoginId` | Set the numeric Facebook App-Scoped ID without hashing | +| `clearUserPii` | Clear the email, phone, first name, last name, and Facebook Login ID values set through these APIs | + +New parity APIs exposed in the plugin (already present in the native SDK 7): + +| API | Purpose | Platforms | +| --- | --- | --- | +| `logInvite(channel, [eventParameters])` | Log the `af_invite` in-app event | Android + iOS | +| `logLocation(latitude: ..., longitude: ...)` | Manually log the device location | Android + iOS | +| `logSession()` | Manually log a session on Android | Android only; use `start()` for typical Flutter apps | +| `setLogLevel(level)` | Set native log verbosity | Android only | +| `setInstallId(installId)` | Correlate the install with your own id | Android + iOS | +| `isStopped()` | Query whether the SDK is stopped | Android only | +| `isPreInstalledApp()` | Query whether the install was an OEM preinstall | Android only | +| `getAttributionId()` | Read the Facebook (Katana) attribution id | Android only | +| `setPreinstallAttribution(mediaSource, {campaign, siteId})` | Attribute an OEM preinstall | Android only | +| `setAppId(appId)` | Override the reported app id | Android only | +| `unregisterConversionListener()` | Unregister the conversion-data listener | Android only | +| `unregisterDeeplinkListener()` | Unregister the deep-link listener | Android only | +| `setDisableAppleAdsAttribution(disable)` | Disable Apple Ads (ASA) attribution via AdServices | iOS only | +| `setDisableIDFVCollection(disable)` | Disable IDFV collection | iOS only | +| `setShouldCollectDeviceName(collect)` | Opt in to device-name collection | iOS only | +| `setUseUninstallSandbox(sandbox)` | Sandbox mode for uninstall-measurement validation | iOS only | + +`setInstallId()` has platform-specific ordering and opt-in requirements. On +iOS, call it before `init()` and set `AppsFlyerAllowCustomInstallId` to `YES` in +`Info.plist`. On Android, call it after `init()` and set +`APPSFLYER_ALLOW_CUSTOM_INSTALL_ID` to `true` as `` in +`AndroidManifest.xml`. Without the corresponding flag, the native SDK silently +ignores the call. + +--- + +## Future completion and error behavior + +All SDK setters now return `Future`. For a fire-and-forget operation, a +completed `Future` means the native SDK accepted the call; it does not confirm +network delivery. + +`start({awaitResponse})`, `logEvent(..., {awaitResponse})`, +`validateAndLogInAppPurchase(..., awaitResponse: ...)`, and +`generateInviteLink(..., awaitResponse: ...)` let the app choose whether to +wait for a native result where supported. `awaitResponse` defaults to `false` +for `start` and `logEvent`, and to `true` for purchase validation and invite-link +generation. On iOS, purchase validation and invite-link generation always wait +for their result. + +--- + +## Purchase Connector dependency changes + +When Purchase Connector is enabled, plugin `7.0.1` resolves Android Purchase +Connector `2.2.0` and iOS Purchase Connector `7.0.1`. + +Android Purchase Connector `2.2.0` supports Google Play Billing Library `8.x`. +The Flutter plugin does not add the Billing Library itself, so your app or IAP +plugin must provide a Billing Library `8.x` dependency and use Billing +8-compatible APIs. + +iOS Purchase Connector is available through CocoaPods only. Apps that do not +use Purchase Connector can use Swift Package Manager for the Core integration; +apps that use Purchase Connector must use CocoaPods for both Core and Purchase +Connector. See [Purchase Connector](purchase-connector.md) for setup details. + +--- + +## Android: remove legacy install-referrer receivers + +SDK 7 removed `SingleInstallBroadcastReceiver` and `MultipleInstallBroadcastReceiver`. +Remove any matching `` entries from `android/app/src/main/AndroidManifest.xml` +(together with their `com.android.vending.INSTALL_REFERRER` intent filters). Leaving them +breaks manifest merge at **build time**. The Flutter plugin already declares +Google Play Install Referrer `2.2`, so Flutter apps do not need to add that +dependency manually. + +Native reference: [Migrate Android SDK to V7 — §8](https://dev.appsflyer.com/hc/docs/migrate-android-sdk-to-v7#8-remove-legacy-broadcast-receivers). +For Samsung / Xiaomi / Huawei store referrers, see §11 of the same guide. + +> Plugin v6 docs recommended adding `SingleInstallBroadcastReceiver` for some out-of-store +> markets. That guidance is **obsolete** for SDK 7 — use the Install Referrer library, +> optional store-referrer artifacts, and `setOutOfStore`. See +> [Advanced features — Android Out of Store](advanced-features.md#out-of-store). + +--- + +## iOS: Objective-C public headers removed + +Plugin **6.18.x** exported four Objective-C headers from the Core CocoaPods +subspec (`public_header_files`): + +- `AppsflyerSdkPlugin.h` +- `AppsFlyerAttribution.h` +- `AppsFlyerStreamHandler.h` +- `FlutterAppDelegate+AppsFlyerStreamHandler.h` + +Plugin **7.x** rewrites the Core bridge in **Swift only**. The Core subspec +ships Swift sources and declares **no** `public_header_files`. The +`af-events` stream handler and the old `FlutterAppDelegate` category are gone; +deep-link entry points live in `AppsflyerSdkPlugin.swift` and +`AppsFlyerAttribution.swift`. + +### Who is affected + +| Host app type | Action | +| --- | --- | +| Standard Flutter app (Dart-only `AppDelegate`, no manual AppsFlyer `#import`) | **No change.** Flutter registers the plugin; URL / Universal Link / UIScene callbacks are wired automatically. | +| Add-to-app or custom native `AppDelegate` / `SceneDelegate` that `#import`ed the v6 headers to forward `openURL` / `continueUserActivity` manually | **Update imports** — see below. | + +If your host code still contains: + +```objc +#import +``` + +the project **will not compile** after upgrading to plugin 7. + +### Replacement + +1. **Prefer removing manual forwarding.** The plugin registers as an + application and (when supported) scene delegate. Register listeners from + Dart (`registerDeepLinkListener` before `init()`, then `init()` / + `start()` as documented above) and let the plugin forward OS callbacks. + +2. **If native code must call into the attribution singleton**, import the + generated Swift compatibility header instead of the deleted `.h` files: + + ```objc + #import + ``` + + Then use the pinned Objective-C selectors exposed from Swift, for example + `[[AppsFlyerAttribution shared] handleOpenUrl:url options:options]` and + `continueUserActivity:`. Bridge readiness is opened internally when Dart + calls `init()` — there is no public `markBridgeReady` on the Objective-C + surface. + +3. **Purchase Connector** — when the `$AppsFlyerPurchaseConnector` Podfile + flag is enabled, the Purchase Connector subspec still publishes its own + `.h` files. That does **not** restore the removed Core headers above. + +--- + +## Platform behavior + +Calling a platform-only method outside its supported mobile platform throws an +`AppsFlyerException`. The plugin does not decide this itself — the call is +forwarded and the native RPC layer reports that it has no such method — so the +`code` differs by platform: Android reports `422`, iOS reports `404`. Identify +platform-only APIs by the _(Android only)_ / _(iOS only)_ marker in the +[API reference](api-reference.md#platform-only-apis), not by the code. + +**This is a behavior change.** In plugin v6 most of these calls were a logged +no-op off-platform, so cross-platform code could call them unconditionally. Any +such call site now needs a guard: + +```dart +if (Platform.isAndroid) { + await appsflyerSdk.setCollectAndroidID(true); +} +``` + +To find affected call sites, search your code for the APIs marked +_(Android only)_ or _(iOS only)_ in the API reference; the v6 log line +`AppsFlyer: ignored` no longer exists. + +APIs invoked on unsupported Flutter targets such as web or desktop throw +`MissingPluginException`. Other native failures on Android and iOS are also +surfaced as `AppsFlyerException` with an optional numeric `code` and `message`. + +- Android purchase validation requires a Play purchase token. +- iOS purchase validation requires an App Store transaction ID. +- Clearing partner-sharing filters (`setSharingFilterForPartners(null)` or + `[]`) works on iOS. On Android the call is forwarded to the native RPC + layer and currently surfaces as `AppsFlyerException` until the Android RPC + validation is fixed — it is not silently ignored. +- Passing an empty partner list is normalized to `null` before the RPC call. +- On Android, `unregisterDeeplinkListener()` does not reliably stop subsequent + deep-link events. Do not rely on it to disable deep-link handling. +- `setInstallId` has different native ordering rules: iOS configures it before + initialization and requires `AppsFlyerAllowCustomInstallId=YES` in + `Info.plist`, while Android requires an initialized SDK and + `APPSFLYER_ALLOW_CUSTOM_INSTALL_ID=true` in the application manifest. +- iOS uninstall tokens must be even-length hexadecimal APNs token strings. + +See the [API reference](api-reference.md) for per-method platform availability +(**Android only** / **iOS only** markers on each API). + +--- + +See the full, version-by-version history in the [CHANGELOG](../CHANGELOG.md) and the complete +method reference in [API reference](api-reference.md). diff --git a/doc/PurchaseConnector.md b/doc/purchase-connector.md similarity index 67% rename from doc/PurchaseConnector.md rename to doc/purchase-connector.md index 9cab7572..fe6a9fb8 100644 --- a/doc/PurchaseConnector.md +++ b/doc/purchase-connector.md @@ -1,6 +1,15 @@ -# Flutter Purchase Connector +# Purchase Connector + +> **Audience:** apps measuring in-app purchase / subscription revenue. Requires the [core setup](getting-started.md). **iOS: CocoaPods only — not compatible with Swift Package Manager.** + **At a glance:** Automatically validate and measure revenue from in-app purchases and auto-renewable subscriptions to get the full picture of your customers' life cycles and accurate ROAS measurements. + +> Purchase Connector requires an ROI360 subscription. When Purchase Connector +> reports purchase revenue, do not also send the same purchase through +> `validateAndLogInAppPurchase` or an in-app event containing revenue, because +> doing so can result in duplicate revenue reporting. + For more information please check the following pages: * [ROI360 in-app purchase (IAP) and subscription revenue measurement](https://support.appsflyer.com/hc/en-us/articles/7459048170769-ROI360-in-app-purchase-IAP-and-subscription-revenue-measurement?query=purchase) * [Android Purchase Connector](https://dev.appsflyer.com/hc/docs/purchase-connector-android) @@ -16,8 +25,8 @@ support@appsflyer.com * [Important Note](#important-note) * [Adding The Connector To Your Project](#install-connector) - - [How to Opt-In](#install-connector) - - [What Happens if You Use Dart Files Without Opting In?](#install-connector) + - [How to Opt-In](#how-to-opt-in) + - [What Happens if You Use Dart Files Without Opting In?](#what-happens-if-you-use-dart-files-without-opting-in) * [Basic Integration Of The Connector](#basic-integration) - [Create PurchaseConnector Instance](#create-instance) - [Start Observing Transactions](#start) @@ -45,23 +54,24 @@ support@appsflyer.com ## ⚠️ ⚠️ Important Note ⚠️ ⚠️ -> **🚨 BREAKING CHANGE**: Starting with Purchase Connector version 2.2.0, the module now uses **Google Play Billing Library 8.x.x**. While Gradle will automatically resolve to version 8.x.x in your final APK, **we strongly recommend that your app also upgrades to Billing Library 8.x.x or higher** to ensure API compatibility. -> -> **Why this matters:** -> - If your app code still uses **older Billing Library APIs** (e.g., `querySkuDetailsAsync()` from versions 5-7), these APIs were **removed in version 8** and **will cause runtime crashes** (`NoSuchMethodError`). -> - **Version 8 introduced new APIs** like `queryProductDetailsAsync()` that replace the deprecated methods. -> - **Recommendation**: Update your app's billing integration to use Billing Library 8.x.x APIs to prevent runtime issues. +Plugin `7.0.1` resolves Android Purchase Connector `2.2.0` and iOS Purchase +Connector `7.0.1` when the feature is enabled. Android Purchase Connector +`2.2.0` supports Google Play Billing Library `8.x`. The Flutter plugin does +not add the Billing Library itself, so your app or IAP plugin must provide a +Billing Library `8.x` dependency and use Billing 8-compatible APIs. The Purchase Connector feature of the AppsFlyer SDK depends on specific libraries provided by Google and Apple for managing in-app purchases: -- For Android, it depends on the [Google Play Billing Library](https://developer.android.com/google/play/billing/integrate) (Minimum required version: 8.x.x and higher). -- For iOS, it depends on [StoreKit](https://developer.apple.com/documentation/storekit). (Supported versions are StoreKit V1 + V2) - -However, these dependencies aren't actively included with the SDK. This means that the responsibility of managing these dependencies and including the necessary libraries in your project falls on you as the consumer of the SDK. +- For Android, it depends on the [Google Play Billing Library](https://developer.android.com/google/play/billing/integrate) `8.x`. +- For iOS, it observes transactions from the system [StoreKit](https://developer.apple.com/documentation/storekit) framework. StoreKit 1 and StoreKit 2 are supported. -If you're implementing in-app purchases in your app, you'll need to ensure that the Google Play Billing Library (for Android) or StoreKit (for iOS) are included in your project. You can include these libraries manually in your native code, or you can use a third-party Flutter plugin, such as the [`in_app_purchase`](https://pub.dev/packages/in_app_purchase) plugin. +The Purchase Connector observes purchases; it does not implement your app's +purchase flow. Provide that flow in native application code or with a Flutter +plugin such as [`in_app_purchase`](https://pub.dev/packages/in_app_purchase). -Remember to appropriately manage these dependencies when implementing the Purchase Validation feature in your app. Failing to include the necessary libraries might result in failures when attempting to conduct in-app purchases or validate purchases. +Remember to appropriately manage these dependencies when implementing Purchase +Connector. Failing to provide the required purchase framework can prevent the +app from conducting or validating purchases. ## Adding The Connector To Your Project @@ -71,34 +81,34 @@ The Purchase Connector feature in AppsFlyer SDK Flutter Plugin is an optional en To opt-in and include this feature in your app, you need to set specific properties based on your platform: -For **iOS**, in your Podfile located within the `iOS` folder of your Flutter project, set `$AppsFlyerPurchaseConnector` to `true`. +For **iOS**, in your Podfile located within the `ios` folder of your Flutter project, set `$AppsFlyerPurchaseConnector` to `true`. ```ruby $AppsFlyerPurchaseConnector = true ``` -For **Android**, in your `gradle.properties` file located within the `Android` folder of your Flutter project,, set `appsflyer.enable_purchase_connector` to `true`. +For **Android**, in your `gradle.properties` file located within the `android` folder of your Flutter project, set `appsflyer.enable_purchase_connector` to `true`. ```groovy appsflyer.enable_purchase_connector=true ``` -Once you set these properties, the Purchase Validation feature will be integrated into your project and you can utilize its functionality in your app. +Once you set these properties and rebuild the app, Purchase Connector will be integrated into your project and you can utilize its functionality in your app. -> ⚠️ **iOS + Swift Package Manager**: Purchase Connector requires **CocoaPods for the entire plugin** — there is no Swift Package Manager path for it, and it cannot currently be combined with Swift Package Manager for the Core integration either. This is a temporary limitation pending an upstream Flutter fix ([flutter/flutter#161182](https://github.com/flutter/flutter/issues/161182)). **If your app uses Purchase Connector, do not enable Swift Package Manager for this plugin — keep your `Podfile` and use CocoaPods for both Core and Purchase Connector.** If you enable SPM anyway, calling any Purchase Connector API will silently fail with a `MissingPluginException` — see the next section. SPM is only recommended for apps that don't use Purchase Connector at all (see [Installation.md](Installation.md#ios-swift-package-manager-spm-support)). +> ⚠️ **iOS + Swift Package Manager**: Purchase Connector requires **CocoaPods for the entire plugin** — there is no Swift Package Manager path for it, and it cannot currently be combined with Swift Package Manager for the Core integration either. This is a temporary limitation pending an upstream Flutter fix ([flutter/flutter#161182](https://github.com/flutter/flutter/issues/161182)). **If your app uses Purchase Connector, do not enable Swift Package Manager for this plugin — keep your `Podfile` and use CocoaPods for both Core and Purchase Connector.** If you enable SPM anyway, calling any Purchase Connector API throws `MissingPluginException` — see the next section. SPM is only recommended for apps that don't use Purchase Connector at all (see [installation-guide.md](installation-guide.md#ios-swift-package-manager-spm-support)). ### What Happens if You Use Dart Files Without Opting In? -The Dart files for the Purchase Validation feature are always included in the plugin. If you try to use these Dart APIs without opting into the feature, the APIs will not have effect because the corresponding native code necessary for them to function will not be included in your project. +The Dart files for Purchase Connector are always included in the plugin. If you try to use these Dart APIs without opting into the feature, the corresponding native code is not included and calling any Purchase Connector API throws `MissingPluginException`. -In such cases, you'll likely experience errors or exceptions when trying to use functionalities provided by the Purchase Validation feature. To avoid these issues, ensure that you opt-in to the feature if you intend to use any related APIs. +In such cases, you'll experience errors when invoking native Purchase Connector operations. To avoid these issues, ensure that you opt in to the feature if you intend to use it. ## Basic Integration Of The Connector ### Create PurchaseConnector Instance The `PurchaseConnector` requires a configuration object of type `PurchaseConnectorConfiguration` at instantiation time. This configuration object governs how the `PurchaseConnector` behaves in your application. -To properly set up the configuration object, you must specify certain parameters: +The configuration object has the following optional parameters: - `logSubscriptions`: If set to `true`, the connector logs all subscription events. - `logInApps`: If set to `true`, the connector logs all in-app purchase events. - `sandbox`: If set to `true`, transactions are tested in a sandbox environment. Be sure to set this to `false` in production. -- `storeKitVersion`: (iOS only) Specifies which StoreKit version to use. Defaults to `StoreKitVersion.storeKit1` if not specified. +- `storeKitVersion`: (iOS only) Specifies which StoreKit version to use. Defaults to `StoreKitVersion.SK1` if not specified. Here's an example usage: @@ -109,7 +119,7 @@ void main() { logSubscriptions: true, // Enables logging of subscription events logInApps: true, // Enables logging of in-app purchase events sandbox: true, // Enables testing in a sandbox environment - storeKitVersion: StoreKitVersion.storeKit1, // iOS only: StoreKit version (defaults to storeKit1) + storeKitVersion: StoreKitVersion.SK1, // iOS only: StoreKit version (defaults to SK1) ), ); @@ -129,7 +139,7 @@ void main() { logSubscriptions: true, logInApps: true, sandbox: true, - storeKitVersion: StoreKitVersion.storeKit1, // Default StoreKit version + storeKitVersion: StoreKitVersion.SK1, // Default StoreKit version ), ); @@ -140,7 +150,7 @@ void main() { logSubscriptions: false, logInApps: false, sandbox: false, - storeKitVersion: StoreKitVersion.storeKit2, // This will be ignored + storeKitVersion: StoreKitVersion.SK2, // This will be ignored ), ); @@ -153,10 +163,10 @@ Thus, always ensure that the initial configuration fully suits your requirements Remember to set `sandbox` to `false` before releasing your app to production. If the production purchase event is sent in sandbox mode, your event won't be validated properly by AppsFlyer. ### Start Observing Transactions -Start the SDK instance to observe transactions.
+Start Purchase Connector to observe transactions.
**⚠️ Please Note** -> This should be called right after calling the `AppsflyerSdk` [start](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/blob/master/doc/BasicIntegration.md#startsdk). +> This should be called right after calling `AppsFlyerSdk` [start](getting-started.md#start). > Calling `startObservingTransactions` activates a listener that automatically observes new billing transactions. This includes new and existing subscriptions and new in app purchases. > The best practice is to activate the listener as early as possible. ```dart @@ -165,14 +175,14 @@ Start the SDK instance to observe transactions.
``` ###
Stop Observing Transactions -Stop the SDK instance from observing transactions.
+Stop Purchase Connector from observing transactions.
**⚠️ Please Note** > This should be called if you would like to stop the Connector from listening to billing transactions. This removes the listener and stops observing new transactions. > An example for using this API is if the app wishes to stop sending data to AppsFlyer due to changes in the user's consent (opt-out from data sharing). Otherwise, there is no reason to call this method. > If you do decide to use it, it should be called right before calling the Android SDK's [`stop`](https://dev.appsflyer.com/hc/docs/android-sdk-reference-appsflyerlib#stop) API ```dart - // start + // stop afPurchaseClient.stopObservingTransactions(); ``` @@ -201,8 +211,8 @@ The Purchase Connector supports both StoreKit 1 and StoreKit 2 on iOS. You can c ###
Available StoreKit Versions -- **`StoreKitVersion.storeKit1`** (Default) - Uses the original StoreKit framework -- **`StoreKitVersion.storeKit2`** - Uses the modern StoreKit 2 framework (iOS 15.0+) +- **`StoreKitVersion.SK1`** (Default) - Uses the original StoreKit framework +- **`StoreKitVersion.SK2`** - Uses the modern StoreKit 2 framework (iOS 15.0+) ### Configuration Examples @@ -225,7 +235,7 @@ final afPurchaseClient = PurchaseConnector( logSubscriptions: true, logInApps: true, sandbox: true, - storeKitVersion: StoreKitVersion.storeKit1, // Explicitly set to StoreKit 1 + storeKitVersion: StoreKitVersion.SK1, // Explicitly set to StoreKit 1 ), ); ``` @@ -237,7 +247,7 @@ final afPurchaseClient = PurchaseConnector( logSubscriptions: true, logInApps: true, sandbox: true, - storeKitVersion: StoreKitVersion.storeKit2, // Use modern StoreKit 2 + storeKitVersion: StoreKitVersion.SK2, // Use modern StoreKit 2 ), ); ``` @@ -257,34 +267,33 @@ final afPurchaseClient = PurchaseConnector( **Example with Error Handling:** ```dart -try { - final afPurchaseClient = PurchaseConnector( - config: PurchaseConnectorConfiguration( - logSubscriptions: true, - logInApps: true, - sandbox: true, - storeKitVersion: StoreKitVersion.storeKit2, - ), - ); - - // Start observing transactions - afPurchaseClient.startObservingTransactions(); - - print("Purchase Connector initialized with StoreKit 2"); -} catch (e) { - print("Failed to initialize Purchase Connector: $e"); - // Consider fallback to StoreKit 1 or handle error appropriately -} +final afPurchaseClient = PurchaseConnector( + config: PurchaseConnectorConfiguration( + logSubscriptions: true, + logInApps: true, + sandbox: true, + storeKitVersion: StoreKitVersion.SK2, + ), +); + +// Start observing transactions +afPurchaseClient.startObservingTransactions(); ``` -> 📝 **Note**: If you don't specify `storeKitVersion`, the connector defaults to `StoreKitVersion.storeKit1` for maximum compatibility. Only use StoreKit 2 if your app's minimum iOS version is 15.0 or higher, or if you've implemented proper fallback handling. +> 📝 **Note**: If you don't specify `storeKitVersion`, the connector defaults +> to `StoreKitVersion.SK1`. When `StoreKitVersion.SK2` is selected, the +> connector uses StoreKit 2 on iOS 15.0 and later and automatically falls back +> to StoreKit 1 on iOS 13 and 14. The app does not need to implement this +> fallback. ## Register Validation Results Listeners You can register listeners to get the validation results once getting a response from AppsFlyer servers to let you know if the purchase was validated successfully.
###
Cross-Platform Considerations -The AppsFlyer SDK Flutter plugin acts as a bridge between your Flutter app and the underlying native SDKs provided by AppsFlyer. It's crucial to understand that the native infrastructure of iOS and Android is quite different, and so is the AppsFlyer SDK built on top of them. These differences are reflected in how you would handle callbacks separately for each platform. +The Flutter plugin provides a common API, but purchase callbacks differ between +iOS and Android. Handle the callback model for the platform your app is running +on. In the iOS environment, there is a single callback method `didReceivePurchaseRevenueValidationInfo` to handle both subscriptions and in-app purchases. You set this callback using `setDidReceivePurchaseRevenueValidationInfo`. @@ -347,21 +356,21 @@ To test purchases in an iOS environment on a real device with a TestFlight sandb **StoreKit Version Considerations for Testing:** - **StoreKit 1**: Works on all iOS versions, well-established testing procedures -- **StoreKit 2**: Requires iOS 15.0+, provides enhanced testing capabilities and more detailed transaction information +- **StoreKit 2**: Used on iOS 15.0+; older supported iOS versions automatically fall back to StoreKit 1 ```dart // Example configuration for testing with StoreKit 2 final purchaseConnector = PurchaseConnector( config: PurchaseConnectorConfiguration( sandbox: true, // Enable sandbox for testing - storeKitVersion: StoreKitVersion.storeKit2, + storeKitVersion: StoreKitVersion.SK2, logSubscriptions: true, logInApps: true, ), ); ``` -> *IMPORTANT NOTE: Before releasing your app to production please be sure to set `sandbox` to `false`. If a production purchase event is sent in sandbox mode, your event will not be validated properly! * +> *IMPORTANT NOTE: Before releasing your app to production please be sure to set `sandbox` to `false`. If a production purchase event is sent in sandbox mode, your event will not be validated properly!* ### Dart Usage for Android and iOS @@ -374,17 +383,17 @@ final purchaseConnector = PurchaseConnector( sandbox: true, logSubscriptions: true, logInApps: true, - // storeKitVersion defaults to StoreKitVersion.storeKit1 + // storeKitVersion defaults to StoreKitVersion.SK1 ) ); -// Testing in a sandbox environment with StoreKit 2 (iOS 15.0+) +// Prefer StoreKit 2 in the sandbox (used on iOS 15.0+) final purchaseConnectorSK2 = PurchaseConnector( config: PurchaseConnectorConfiguration( sandbox: true, logSubscriptions: true, logInApps: true, - storeKitVersion: StoreKitVersion.storeKit2, // Enhanced testing capabilities + storeKitVersion: StoreKitVersion.SK2, // Enhanced testing capabilities ) ); ``` @@ -401,58 +410,65 @@ Add following keep rules to your `proguard-rules.pro` file: -keep class com.appsflyer.** { *; } -keep class kotlin.jvm.internal.Intrinsics{ *; } -keep class kotlin.collections.**{ *; } +-keep class kotlin.Result$Companion { *; } ``` ## Full Code Example ```dart -PurchaseConnectorConfiguration config = PurchaseConnectorConfiguration( - logSubscriptions: true, - logInApps: true, +import 'dart:convert'; + +import 'package:appsflyer_sdk/appsflyer_sdk.dart'; +import 'package:flutter/foundation.dart'; + +void configurePurchaseConnector() { + final config = PurchaseConnectorConfiguration( + logSubscriptions: true, + logInApps: true, sandbox: false, - storeKitVersion: StoreKitVersion.storeKit2 // Use StoreKit 2 on iOS (requires iOS 15.0+) -); -final afPurchaseClient = PurchaseConnector(config: config); - -// set listeners for Android -afPurchaseClient.setSubscriptionValidationResultListener( - (Map? result) { - // handle subscription validation result for Android - result?.entries.forEach((element) { - debugPrint( - "Subscription Validation Result\n\t Token: ${element.key}\n\tresult: ${jsonEncode(element.value.toJson())}"); - }); -}, (String result, JVMThrowable? error) { - // handle subscription validation error for Android - var errMsg = error != null ? jsonEncode(error.toJson()) : null; - debugPrint( - "Subscription Validation Result\n\t result: $result\n\terror: $errMsg"); -}); - -afPurchaseClient.setInAppValidationResultListener( - (Map? result) { - // handle in-app validation result for Android - result?.entries.forEach((element) { - debugPrint( - "In App Validation Result\n\t Token: ${element.key}\n\tresult: ${jsonEncode(element.value.toJson())}"); - }); -}, (String result, JVMThrowable? error) { - // handle in-app validation error for Android - var errMsg = error != null ? jsonEncode(error.toJson()) : null; - debugPrint( - "In App Validation Result\n\t result: $result\n\terror: $errMsg"); -}); - -// set listener for iOS -afPurchaseClient - .setDidReceivePurchaseRevenueValidationInfo((validationInfo, error) { - var validationInfoMsg = - validationInfo != null ? jsonEncode(validationInfo) : null; - var errMsg = error != null ? jsonEncode(error.toJson()) : null; - debugPrint( - "iOS Validation Result\n\t validationInfo: $validationInfoMsg\n\terror: $errMsg"); - // handle subscription and in-app validation result and errors for iOS -}); - -// start -afPurchaseClient.startObservingTransactions(); -``` \ No newline at end of file + storeKitVersion: StoreKitVersion.SK2, // Uses SK2 on iOS 15+; SK1 on iOS 13-14 + ); + final afPurchaseClient = PurchaseConnector(config: config); + + // Set listeners for Android. + afPurchaseClient.setSubscriptionValidationResultListener( + (Map? result) { + // Handle subscription validation result for Android. + result?.entries.forEach((element) { + debugPrint( + "Subscription Validation Result\n\t Token: ${element.key}\n\tresult: ${jsonEncode(element.value.toJson())}"); + }); + }, (String result, JVMThrowable? error) { + // Handle subscription validation error for Android. + final errMsg = error != null ? jsonEncode(error.toJson()) : null; + debugPrint( + "Subscription Validation Result\n\t result: $result\n\terror: $errMsg"); + }); + + afPurchaseClient.setInAppValidationResultListener( + (Map? result) { + // Handle in-app validation result for Android. + result?.entries.forEach((element) { + debugPrint( + "In App Validation Result\n\t Token: ${element.key}\n\tresult: ${jsonEncode(element.value.toJson())}"); + }); + }, (String result, JVMThrowable? error) { + // Handle in-app validation error for Android. + final errMsg = error != null ? jsonEncode(error.toJson()) : null; + debugPrint( + "In App Validation Result\n\t result: $result\n\terror: $errMsg"); + }); + + // Set listener for iOS. + afPurchaseClient + .setDidReceivePurchaseRevenueValidationInfo((validationInfo, error) { + final validationInfoMsg = + validationInfo != null ? jsonEncode(validationInfo) : null; + final errMsg = error != null ? jsonEncode(error.toJson()) : null; + debugPrint( + "iOS Validation Result\n\t validationInfo: $validationInfoMsg\n\terror: $errMsg"); + // Handle subscription and in-app validation results and errors for iOS. + }); + + afPurchaseClient.startObservingTransactions(); +} +``` diff --git a/doc/testing-and-troubleshooting.md b/doc/testing-and-troubleshooting.md new file mode 100644 index 00000000..94a12e11 --- /dev/null +++ b/doc/testing-and-troubleshooting.md @@ -0,0 +1,141 @@ +# Testing & troubleshooting + +More info about testing the SDK for marketers [here](https://support.appsflyer.com/hc/en-us/articles/360001559405-Test-mobile-SDK-integration-with-the-app#introduction). + +- [Testing for iOS](#iOS) +- [Testing for Android](#Android) +- [Troubleshooting](#troubleshooting) + +Before testing the SDK, enable debug mode so the SDK produces full logs. +Call `enableDebug(true)` before `init()` and `start()`: + +```dart +final appsflyerSdk = AppsFlyerSdk.instance; +await appsflyerSdk.enableDebug(true); +await appsflyerSdk.init( + devKey: afDevKey, + appId: appId, +); +``` + +--- + +## Testing for iOS + +Open your iOS project with XCode (`appName.xcworkspace`) and run it. In the logs section or in the console app, you will see logs related to AppsFlyer start with `[AppsFlyerSDK]`.
+Search for the launch event that looks like this: + +``` +<~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~~+~> +<~+~ SEND Start: https://launches.appsflyer.com/api/v7.0/iosevent?app_id=7xXxXxX1&buildnumber=7.0.1 +<~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~~+~> +{ launch event payload } // Just an example of a JSON. you will see the full payload +``` +and also: +``` +Result: { + data = {length = 64, bytes = 0x7b226f6c 5f696422 3a224476 5769222c ... 696e6b2e 6d65227d }; + dataStr = "{\"oxXxXxd\":\"DXxXxi\",\"oXxXer\":ss,\"olXxXxain\":\"xXxXxXx\"}"; + retries = 2; + statusCode = 200; // ~~> success! + taskIdentifier = 4; +} +``` + +For more iOS integration tests, see [Here](https://dev.appsflyer.com/hc/docs/testing-ios) + +--- + +##
Testing for Android + +Open your Android project with Android Studio (`android` folder) and run it. In the logcat, you will see logs related to AppsFlyer start with `I/AppsFlyer_x.x.x`.
+Search for the launch event that looks like this: + +``` +I/AppsFlyer_7.0.1: url: https://launches.appsflyer.com/api/v7.0/androidevent?app_id=com.aXxXxt.rxXxXxt&buildnumber=7.0.1 +I/AppsFlyer_7.0.1: data: { launch event payload } // Just an example of a JSON. you will see the full payload +``` +and also: +``` +I/AppsFlyer_7.0.1: response code: 200 // ~~> success! +``` + +For more Android integration tests, see [Here](https://dev.appsflyer.com/hc/docs/testing-android) + +--- + +##
Troubleshooting + +### No launch/session is sent ("SDK session not started") + +In SDK 7, `init()` only initializes the SDK — it does **not** send a session. +`start()` must be called **once per foreground cycle**. Register the native +session-ready listener and call `start()` from its callback so every foreground +(including background→foreground) reports a session: + +```dart +final appsflyerSdk = AppsFlyerSdk.instance; + +await appsflyerSdk.init( + devKey: afDevKey, + appId: appId, +); +await appsflyerSdk.registerSessionReadyListener(() async { + await appsflyerSdk.start(); +}); +``` + +See [Getting started → start](getting-started.md#start). + +### Setter values are lost after a cold start + +SDK 7 setters (`setCustomerUserId`, `setCurrencyCode`, `setConsentData`, …) are +runtime-only. Re-apply them on every cold start **before** `start()` — see the +setter-persistence note in [Getting started](getting-started.md#start). + +### Deferred deep link never arrives on Android (direct links work) + +Android decides whether to send the deferred deep-link resolution request while +`init()` processes the launch intent, and skips it when no listener is registered +at that moment. Call `registerDeepLinkListener()` **before** `init()` — see +[Getting started → step 2](getting-started.md#deep-link-listener-before-init). + +The skip is persisted per install, so restarting the app does not retry it: after +fixing the order, reinstall the app or clear its data before testing again. In +`adb logcat`, a sent request logs `[DDL] Preparing request 1` followed by an +`[HTTP Client] POST` to a `dlsdk` URL. + +### Deep links stop working on Flutter 3.27+ + +Flutter 3.27 enables its built-in deep linking by default, which intercepts AppsFlyer +OneLinks. Disable it via `flutter_deeplinking_enabled=false` (Android) and +`FlutterDeepLinkingEnabled=false` (iOS) — see the breaking-change note at the top of +[Deep linking](deep-linking.md). + +### Events missing in an add-to-app or multi-engine host + +The native SDK and plugin `af-events` transport are **process-scoped**. When two or +more Flutter engines are alive at once, only the engine whose EventChannel +subscription attached **most recently** receives conversion, deep-link, and +session-ready callbacks. The last `register*Listener()` from any engine also wins +at the native layer. + +Integrate AppsFlyer from **one primary engine** — call `init()`, register +listeners, and drive `start()` only there. Do not initialize from secondary Flutter +modules that may run in parallel. + +When an engine is destroyed and recreated sequentially (typical add-to-app back +navigation), re-register listeners after the new engine attaches — see +[Getting started → Add-to-app and multiple Flutter engines](getting-started.md#multi-engine). + +### `MissingPluginException` when calling a Purchase Connector API + +Purchase Connector has no Swift Package Manager path. If you enabled SPM, calling +any Purchase Connector API throws `MissingPluginException`. Use CocoaPods for the +whole plugin — see [Purchase Connector](purchase-connector.md). + +### iOS installs not attributed to IDFA + +The ATT prompt must be presented and completed before the app allows the first +`start()` call. The Flutter plugin does not provide an ATT-wait API. See +[Getting started → iOS 14 & ATT](getting-started.md#ios-14--app-tracking-transparency). diff --git a/example/README.md b/example/README.md index 297d9665..af488820 100644 --- a/example/README.md +++ b/example/README.md @@ -1,12 +1,23 @@ # appsflyer_sdk_example -This plugin has a demo project bundled with it. To give it a try , clone this repo and from root a.e. `flutter_appsflyer_sdk` execute the following: +This plugin includes a demo project. To run it, clone the +`appsflyer-flutter-plugin` repository and execute the following from the +repository root: -```bash -$ flutter packages get -$ cd example/ -$ flutter run +Create `example/.env` with your AppsFlyer credentials: + +```dotenv +DEV_KEY=YOUR_DEV_KEY +APP_ID=YOUR_IOS_APP_ID +``` +`APP_ID` is required for iOS. For an Android-only run, its value may be empty, +but the key must still be present because the example loads it from `.env`. + +```bash +cd example +flutter pub get +flutter run ``` ![demo printscreen](assets/demo_example.png?raw=true) diff --git a/example/android/gradle.properties b/example/android/gradle.properties index d74c11a1..9191e58c 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index df97d72b..ca025c83 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 99a54c6d..e4e674d6 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.8.1' apply false - id "org.jetbrains.kotlin.android" version "2.0.0" apply false + id "com.android.application" version '8.11.1' apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false } include ":app" \ No newline at end of file diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf76..391a902b 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/example/ios/Podfile b/example/ios/Podfile index a4220ff8..a2405536 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '13.0' +platform :ios, '15.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -28,14 +28,21 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup target 'Runner' do - use_frameworks! + # AppsFlyerFramework ships a static AppsFlyerLib.xcframework; static linkage lets it (and the + # AppsFlyerRPC source pod) coexist with use_frameworks! without the "statically linked binaries" + # transitive-dependency error. + use_frameworks! :linkage => :static use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) # Google Ads On-Device Conversion SDK for testing AppsFlyer integration pod 'GoogleAdsOnDeviceConversion', '~> 3.2.0' - + + # SDK 7 RPC migration: AppsFlyerRPC (the native AFRPCRequestHandler bridge) and its transitive + # AppsFlyerFramework 7.0.1 dependency are resolved from CocoaPods via the appsflyer_sdk plugin + # podspec. No local `:path` or CDN `:podspec` overrides are needed now that both pods are published. + target 'RunnerTests' do inherit! :search_paths end @@ -45,7 +52,7 @@ post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' end end end diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index b194b797..9824ba6c 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -200,7 +200,6 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, D2F636E2006BF2310FF8EF27 /* [CP] Copy Pods Resources */, - CBA4E0AD93E266A8CFF44591 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -331,23 +330,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - CBA4E0AD93E266A8CFF44591 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; D2F636E2006BF2310FF8EF27 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -480,7 +462,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -610,7 +592,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -661,7 +643,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index bfbd7948..a7c12ca6 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -32,10 +32,9 @@ import Flutter return nil }() if let url = url { - // Delay long enough for the Flutter engine to spin up, MainPage to - // initState, and AppsflyerSdk.initSdk + startSDK to complete on the - // Dart side. The test plan's wait_after_trigger_sec is 12s, leaving - // ample margin. + // Delay long enough for the Flutter engine to spin up and for the + // example's AppsFlyer initialization and start lifecycle to complete. + // The test plan's wait_after_trigger_sec is 12s, leaving ample margin. DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self, weak application] in guard let self = self, let application = application else { return } _ = self.application(application, open: url, options: [:]) diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift index 86a7c3b1..a7249f0c 100644 --- a/example/ios/RunnerTests/RunnerTests.swift +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,47 @@ -import Flutter -import UIKit import XCTest -class RunnerTests: XCTestCase { +/// Pins the Foundation behavior the iOS plugin relies on instead of an Objective-C `@try`/`@catch` +/// boundary around RPC dispatch. +/// +/// `JSONSerialization.data(withJSONObject:)` raises `NSInvalidArgumentException` — not a catchable +/// Swift error — for non-finite numbers, and Dart can send them (`logEvent` with `double.nan`). +/// `AFRPCBridge`-bound payloads reach Foundation only through `jsonString(from:)`, which calls +/// `isValidJSONObject` first, so these are rejected as a `SERIALIZATION_ERROR` before any write is +/// attempted. If a future OS stops rejecting them up front, this test fails and the exception +/// boundary has to come back. +class RPCPayloadSerializationTests: XCTestCase { - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + /// Mirrors `jsonString(from:)` in `AppsflyerSdkPlugin.swift`. + private func jsonString(from object: Any) -> String? { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: []) else { + return nil + } + return String(data: data, encoding: .utf8) } + private func envelope(value: Any) -> [String: Any] { + return ["method": "logEvent", "params": ["eventValues": ["revenue": value]]] + } + + func testNonFiniteNumbersAreRejectedBeforeSerialization() { + let nonFinite: [String: Any] = [ + "Double.nan": Double.nan, + "Double.infinity": Double.infinity, + "-Double.infinity": -Double.infinity, + "Float.nan": Float.nan, + "NSDecimalNumber.notANumber": NSDecimalNumber.notANumber + ] + for (name, value) in nonFinite { + let object = envelope(value: value) + XCTAssertFalse(JSONSerialization.isValidJSONObject(object), + "\(name) must be rejected before data(withJSONObject:) is reached") + XCTAssertNil(jsonString(from: object), "\(name) must serialize to nil, not crash") + } + } + + func testFiniteNumbersStillSerialize() { + XCTAssertNotNil(jsonString(from: envelope(value: 12.5))) + XCTAssertNotNil(jsonString(from: envelope(value: 0))) + } } diff --git a/example/lib/home_container.dart b/example/lib/home_container.dart index e3e53761..90daae59 100644 --- a/example/lib/home_container.dart +++ b/example/lib/home_container.dart @@ -13,13 +13,13 @@ class HomeContainer extends StatefulWidget { // ignore: prefer_const_constructors_in_immutables HomeContainer({ - Key? key, + super.key, required this.onData, required this.deepLinkData, required this.logEvent, required this.logAdRevenueEvent, required this.validatePurchase, - }) : super(key: key); + }); @override State createState() => _HomeContainerState(); diff --git a/example/lib/home_container_streams.dart b/example/lib/home_container_streams.dart deleted file mode 100644 index ccfc962e..00000000 --- a/example/lib/home_container_streams.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'app_constants.dart'; -import 'text_border.dart'; -import 'utils.dart'; - -class HomeContainerStreams extends StatefulWidget { - final Stream onData; - final Stream onAttribution; - final Future Function(String, Map) logEvent; - - // ignore: prefer_const_constructors_in_immutables - HomeContainerStreams({ - Key? key, - required this.onData, - required this.onAttribution, - required this.logEvent, - }) : super(key: key); - - @override - State createState() => _HomeContainerStreamsState(); -} - -class _HomeContainerStreamsState extends State { - final String eventName = "Custom Event"; - - final Map eventValues = { - "af_content_id": "id123", - "af_currency": "USD", - "af_revenue": "2" - }; - - String _logEventResponse = - "Event status will be shown here once it's triggered."; - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - child: Container( - padding: const EdgeInsets.all(AppConstants.containerPadding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(20.0), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.blueGrey, width: 0.5), - borderRadius: BorderRadius.circular(5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "APPSFLYER SDK", - style: TextStyle( - fontSize: 18, - color: Colors.black, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: AppConstants.topPadding), - StreamBuilder( - stream: widget.onData.asBroadcastStream(), - builder: (BuildContext context, - AsyncSnapshot snapshot) { - return TextBorder( - controller: TextEditingController( - text: snapshot.hasData - ? Utils.formatJson(snapshot.data) - : "Waiting for conversion data..."), - labelText: "CONVERSION DATA", - ); - }, - ), - const SizedBox(height: 12.0), - StreamBuilder( - stream: widget.onAttribution.asBroadcastStream(), - builder: (BuildContext context, - AsyncSnapshot snapshot) { - return TextBorder( - controller: TextEditingController( - text: snapshot.hasData - ? Utils.formatJson(snapshot.data) - : "Waiting for attribution data..."), - labelText: "ATTRIBUTION DATA", - ); - }), - ], - )), - const SizedBox(height: 12.0), - Container( - padding: const EdgeInsets.all(20.0), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - border: Border.all(color: Colors.grey, width: 0.5), - ), - child: Column(children: [ - const Text( - "EVENT LOGGER", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 12.0), - TextBorder( - controller: TextEditingController( - text: - "Event Name: $eventName\nEvent Values: $eventValues"), - labelText: "EVENT REQUEST", - ), - const SizedBox(height: 12.0), - TextBorder( - labelText: "SERVER RESPONSE", - controller: TextEditingController(text: _logEventResponse), - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: () { - widget.logEvent(eventName, eventValues).then((onValue) { - setState(() { - _logEventResponse = onValue.toString(); - }); - }).catchError((onError) { - setState(() { - _logEventResponse = onError.toString(); - }); - }); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 10), - textStyle: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - child: const Text("Trigger Event"), - ), - ]), - ) - ], - ), - ), - ); - } -} diff --git a/example/lib/main.dart b/example/lib/main.dart index 7e9cd055..241da9d5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -14,7 +14,7 @@ Future main() async { } class MyApp extends StatelessWidget { - const MyApp({Key? key}) : super(key: key); + const MyApp({super.key}); @override Widget build(BuildContext context) { diff --git a/example/lib/main_page.dart b/example/lib/main_page.dart index 83f9d82f..f93f8bdc 100644 --- a/example/lib/main_page.dart +++ b/example/lib/main_page.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'dart:io'; import 'package:appsflyer_sdk/appsflyer_sdk.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -9,24 +9,24 @@ import 'af_qa_logger.dart'; import 'home_container.dart'; class MainPage extends StatefulWidget { - const MainPage({Key? key}) : super(key: key); + const MainPage({super.key}); @override - State createState() { - return MainPageState(); - } + State createState() => MainPageState(); } class MainPageState extends State { - late AppsflyerSdk _appsflyerSdk; - Map _deepLinkData = {}; - Map _gcd = {}; - // Resolves on the first onInstallConversionData callback so the auto-run - // doesn't race ahead and call stop(true) before the install GCD response - // lands. AppsFlyer's SDK substitutes the conversion-data payload with - // "isStopTracking enabled" when the callback fires after stop(true), which - // makes phase_1's is_first_launch=true check flake. + late final AppsFlyerSdk _appsflyerSdk; + + // Unblocks the auto-run after the first install GCD callback. Must complete + // before stop(true) so phase_1's is_first_launch check is not corrupted. final Completer _gcdReady = Completer(); + // Unblocks post-start auto APIs after the first session-ready-driven start + // callback (success or error) in this cold-start auto-run. + final Completer _firstStartDone = Completer(); + + Map _deepLinkData = {}; + Map _gcd = {}; @override void initState() { @@ -35,244 +35,293 @@ class MainPageState extends State { } Future afStart() async { - final AppsFlyerOptions options = AppsFlyerOptions( - afDevKey: dotenv.env["DEV_KEY"]!, - appId: dotenv.env["APP_ID"]!, - showDebug: true, - timeToWaitForATTUserAuthorization: 15, - manualStart: true); - _appsflyerSdk = AppsflyerSdk(options); - - _registerCallbacks(); - await _runPreStartAutoApis(); - - await _appsflyerSdk.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: true); - - await _startSdkProgrammatically(); - await _runPostStartAutoApis(); - - if (Platform.isAndroid) { - _appsflyerSdk.performOnDeepLinking(); - } + try { + _appsflyerSdk = AppsFlyerSdk.instance; + await _appsflyerSdk.enableDebug(true); - await _runStandardEvents(); - await _runCustomEvent(); - await _runIdentityCheck(); + await _runPreStartAutoApis(); - try { - // GCD lands after the launch event is acknowledged by AppsFlyer servers. - // On slow CI emulators that round-trip can stretch past 60s. 90s here - // matches the startSDK budget and keeps the rest of the auto-run - // (stop/resume sequence + final marker) deterministic. - await _gcdReady.future.timeout(const Duration(seconds: 90)); - } on TimeoutException { - AfQaLogger.error("onInstallConversionData", "code=-1 msg=gcd_timeout"); - } - await _runStopResumeSequence(); - - if (mounted) setState(() {}); - // Emit a single terminal marker the smoke runner can poll for. Lets the - // runner replace its fixed `wait_after_launch_sec` sleep with an early - // exit, which matters on CI where the SDK's first-launch HTTP round-trip - // can take 60-120s on a no-KVM Linux emulator or a cold macOS sim. - AfQaLogger.autoApis("--- Auto run complete ---"); - } + // Before init(): the Android SDK runs its one-shot deferred deep-link + // resolution while init() replays the launch intent, and skips it when no + // listener is registered yet. + await _registerDeepLinkListener(); - void _registerCallbacks() { - _appsflyerSdk.onInstallConversionData((res) { - AfQaLogger.callback("onInstallConversionData", res); - if (!_gcdReady.isCompleted) _gcdReady.complete(); - if (mounted) setState(() => _gcd = res); - }); + // init() initializes only; start() sends the Launch and must be called + // from the session-ready callback once per foreground cycle. + await _appsflyerSdk.init( + devKey: dotenv.env['DEV_KEY']!, + appId: dotenv.env['APP_ID']!, + ); - _appsflyerSdk.onAppOpenAttribution((res) { - AfQaLogger.callback("onAppOpenAttribution", res); - if (mounted) setState(() => _deepLinkData = res); - }); + await _registerListeners(); + + await _firstStartDone.future; + await _runPostStartAutoApis(); + + await _runStandardEvents(); + await _runCustomEvent(); + await _runIdentityCheck(); + + try { + await _gcdReady.future.timeout(const Duration(seconds: 90)); + } on TimeoutException { + AfQaLogger.error('onInstallConversionData', 'code=-1 msg=gcd_timeout'); + } + await _runStopResumeSequence(); + } catch (error, stackTrace) { + AfQaLogger.error('afStart', '$error\n$stackTrace'); + } finally { + if (mounted) { + setState(() {}); + } + AfQaLogger.autoApis('--- Auto run complete ---'); + } + } - _appsflyerSdk.onDeepLinking((DeepLinkResult dp) { + Future _registerDeepLinkListener() async { + await _appsflyerSdk.registerDeepLinkListener((result) { // Empty payload when the SDK didn't resolve a deep link, so the // smoke runner's pattern check sees a stable `payload={}` shape. - final payload = dp.deepLink == null ? const {} : dp.toJson(); + final payload = result.deepLink == null ? const {} : result.toJson(); AfQaLogger.callback( - "onDeepLinking", - "status=${dp.status}, " - "deepLinkValue=${dp.deepLink?.deepLinkValue}, " - "payload=$payload", + 'onDeepLinking', + 'status=${_deepLinkStatusForQaLog(result.status)}, ' + 'deepLinkValue=${result.deepLink?.deepLinkValue}, ' + 'payload=$payload', ); - if (mounted) setState(() => _deepLinkData = dp.toJson()); + if (mounted) { + setState(() => _deepLinkData = result.toJson()); + } }); } + Future _registerListeners() async { + await _appsflyerSdk.registerConversionListener( + onSuccess: (res) { + AfQaLogger.callback('onInstallConversionData', res); + if (!_gcdReady.isCompleted) { + _gcdReady.complete(); + } + if (mounted) { + setState(() => _gcd = _conversionPayloadForUi(res)); + } + }, + onFailure: (error) { + AfQaLogger.error('onInstallConversionData', error); + if (!_gcdReady.isCompleted) { + _gcdReady.complete(); + } + }, + ); + + // The session-ready callback fires once per foreground cycle after launch + // deep link resolution; issue start() here so every foreground sends a + // session. + await _appsflyerSdk.registerSessionReadyListener(() { + AfQaLogger.callback('onSessionReady', null); + _startSdkForCurrentSession(); + }); + } + + /// Maps the SDK 7 deep-link status to the legacy QA log format. + static String _deepLinkStatusForQaLog(DeepLinkStatus status) { + switch (status) { + case DeepLinkStatus.found: + return 'Status.FOUND'; + case DeepLinkStatus.notFound: + return 'Status.NOT_FOUND'; + case DeepLinkStatus.error: + return 'Status.ERROR'; + case DeepLinkStatus.unknown: + return 'Status.PARSE_ERROR'; + } + } + + /// Extracts the conversion-data map for the example UI (`payload` envelope). + static Map _conversionPayloadForUi( + Map res, + ) { + final payload = res['payload']; + if (payload is Map) { + return Map.from(payload); + } + return res; + } + + @override + void dispose() { + _appsflyerSdk.unregisterSessionReadyListener(); + _appsflyerSdk.unregisterConversionListener(); + super.dispose(); + } + Future _runPreStartAutoApis() async { - _safeCall("setCurrencyCode", () { - _appsflyerSdk.setCurrencyCode("EUR"); - AfQaLogger.result("setCurrencyCode", "EUR"); + // Apply configuration setters before the first start() in this session. + await _safeCall('setCurrencyCode', () async { + await _appsflyerSdk.setCurrencyCode('EUR'); + AfQaLogger.result('setCurrencyCode', 'EUR'); }); - _safeCall("setCustomerUserId", () { - _appsflyerSdk.setCustomerUserId("e2e_user_42"); - AfQaLogger.result("setCustomerUserId", "e2e_user_42"); + await _safeCall('setCustomerUserId', () async { + await _appsflyerSdk.setCustomerUserId('e2e_user_42'); + AfQaLogger.result('setCustomerUserId', 'e2e_user_42'); }); - final Map additionalData = { - "tenant": "qa_eu", - "experiment": "rc_pipeline_v1", + const additionalData = { + 'tenant': 'qa_eu', + 'experiment': 'rc_pipeline_v1', }; - _safeCall("setAdditionalData", () { - _appsflyerSdk.setAdditionalData(additionalData); + await _safeCall('setAdditionalData', () async { + await _appsflyerSdk.setAdditionalData(additionalData); AfQaLogger.log( - "setAdditionalData", "keys=${additionalData.keys.toList()}"); + 'setAdditionalData', + 'keys=${additionalData.keys.toList()}', + ); }); - AfQaLogger.autoApis("--- Pre-start auto APIs complete ---"); + AfQaLogger.autoApis('--- Pre-start auto APIs complete ---'); } - Future _startSdkProgrammatically() async { - final completer = Completer(); - _appsflyerSdk.startSDK( - onSuccess: () { - AfQaLogger.result("startSDK", "SUCCESS"); - if (!completer.isCompleted) completer.complete(); - }, - onError: (int errorCode, String errorMessage) { - AfQaLogger.error("startSDK", "code=$errorCode msg=$errorMessage"); - if (!completer.isCompleted) completer.complete(); - }, - ); + Future _startSdkForCurrentSession() async { try { - // 20s is too tight on the no-KVM Linux emulator: GAID lookups burn ~7s - // and the AppsFlyer CDN-config GET can hang for ~50s before the SDK can - // send the launch event that triggers onSuccess. 90s comfortably covers - // a slow boot and still fails fast on a hung start. - await completer.future.timeout(const Duration(seconds: 90)); - } on TimeoutException { - AfQaLogger.error("startSDK", "code=-1 msg=startSDK_callback_timeout"); + await _appsflyerSdk.start(awaitResponse: true); + AfQaLogger.result('startSDK', 'SUCCESS'); + } on AppsFlyerException catch (error) { + AfQaLogger.error( + 'startSDK', + 'code=${error.code} msg=${error.message}', + ); + } finally { + if (!_firstStartDone.isCompleted) { + _firstStartDone.complete(); + } } } Future _runPostStartAutoApis() async { try { - final v = await _appsflyerSdk.getSDKVersion(); - AfQaLogger.result("getSDKVersion", v); - } catch (e) { - AfQaLogger.error("getSDKVersion", e); + final version = await _appsflyerSdk.getSdkVersion(); + AfQaLogger.result('getSDKVersion', version); + } catch (error) { + AfQaLogger.error('getSDKVersion', error); } try { final uid = await _appsflyerSdk.getAppsFlyerUID(); - AfQaLogger.result("getAppsFlyerUID", uid); - } catch (e) { - AfQaLogger.error("getAppsFlyerUID", e); + AfQaLogger.result('getAppsFlyerUID', uid); + } catch (error) { + AfQaLogger.error('getAppsFlyerUID', error); } - AfQaLogger.autoApis("--- Post-start auto APIs complete ---"); + AfQaLogger.autoApis('--- Post-start auto APIs complete ---'); } Future _runStandardEvents() async { - await _logEvent("af_demo_launch", const {}); + await _logEvent('af_demo_launch', const {}); await _logEvent( - "af_purchase", + 'af_purchase', const { - "af_revenue": 19.99, - "af_currency": "EUR", - "af_content_id": "id_42", + 'af_revenue': 19.99, + 'af_currency': 'EUR', + 'af_content_id': 'id_42', }, - resultTag: "logEvent: af_purchase sent", + resultTag: 'logEvent: af_purchase sent', ); await _logEvent( - "af_content_view", + 'af_content_view', const { - "af_content_id": "id_42", - "af_content_type": "demo", + 'af_content_id': 'id_42', + 'af_content_type': 'demo', }, - resultTag: "logEvent: af_content_view sent", + resultTag: 'logEvent: af_content_view sent', ); } Future _runCustomEvent() async { - await _logEvent("af_qa_custom_purchase", const { - "af_revenue": 42.5, - "af_currency": "EUR", - "metadata": { - "tenant": "qa_eu", - "experiment": "rc_pipeline_v1", - "ab_variant": "B", + await _logEvent('af_qa_custom_purchase', const { + 'af_revenue': 42.5, + 'af_currency': 'EUR', + 'metadata': { + 'tenant': 'qa_eu', + 'experiment': 'rc_pipeline_v1', + 'ab_variant': 'B', }, }); } Future _runIdentityCheck() async { - _safeCall("setCustomerUserId", () { - _appsflyerSdk.setCustomerUserId("e2e_user_42"); - AfQaLogger.result("setCustomerUserId", "e2e_user_42"); + await _safeCall('setCustomerUserId', () async { + await _appsflyerSdk.setCustomerUserId('e2e_user_42'); + AfQaLogger.result('setCustomerUserId', 'e2e_user_42'); }); - _safeCall("setCurrencyCode", () { - _appsflyerSdk.setCurrencyCode("EUR"); - AfQaLogger.result("setCurrencyCode", "EUR"); + await _safeCall('setCurrencyCode', () async { + await _appsflyerSdk.setCurrencyCode('EUR'); + AfQaLogger.result('setCurrencyCode', 'EUR'); }); const additionalData = { - "tenant": "qa_eu", - "experiment": "rc_pipeline_v1", + 'tenant': 'qa_eu', + 'experiment': 'rc_pipeline_v1', }; - _safeCall("setAdditionalData", () { - _appsflyerSdk.setAdditionalData(additionalData); + await _safeCall('setAdditionalData', () async { + await _appsflyerSdk.setAdditionalData(additionalData); AfQaLogger.log( - "setAdditionalData", "keys=${additionalData.keys.toList()}"); + 'setAdditionalData', + 'keys=${additionalData.keys.toList()}', + ); }); - await _logEvent("af_qa_identity_check", const { - "customer_user_id": "e2e_user_42", - "tenant": "qa_eu", - "experiment": "rc_pipeline_v1", + await _logEvent('af_qa_identity_check', const { + 'customer_user_id': 'e2e_user_42', + 'tenant': 'qa_eu', + 'experiment': 'rc_pipeline_v1', }); } Future _runStopResumeSequence() async { - _safeCall("stop", () { - _appsflyerSdk.stop(true); - AfQaLogger.result("stop", true); + await _safeCall('stop', () async { + await _appsflyerSdk.stop(true); + AfQaLogger.result('stop', true); }); - await _logEvent("af_qa_suppressed", const {}); + await _logEvent('af_qa_suppressed', const {}); await Future.delayed(const Duration(seconds: 3)); - _safeCall("stop", () { - _appsflyerSdk.stop(false); - AfQaLogger.result("stop", false); + await _safeCall('stop', () async { + await _appsflyerSdk.stop(false); + AfQaLogger.result('stop', false); }); - await _logEvent("af_qa_resumed", const {}); + await _logEvent('af_qa_resumed', const {}); } - /// Emit `[AF_QA][logEvent] name=... params=...`, call the SDK, then emit - /// `[AF_QA][] result: ...` on success (default tag: - /// `logEvent()`) or the unified `[AF_QA][logEvent] error: ...` on - /// throw — the latter shape is what the smoke runner's `no_log_event_error` - /// absent check greps for, so any logEvent failure surfaces uniformly. + /// Logs the invocation, calls [AppsFlyerSdk.logEvent] (fire-and-forget), and + /// emits a harness `result: true` line for the smoke runner. Server failures + /// are not surfaced unless awaitResponse is enabled here. Future _logEvent( String name, Map params, { String? resultTag, }) async { - AfQaLogger.log("logEvent", "name=$name params=$params"); + AfQaLogger.log('logEvent', 'name=$name params=$params'); try { - final r = await _appsflyerSdk.logEvent(name, params); - AfQaLogger.result(resultTag ?? "logEvent($name)", r); - return r; - } catch (e) { - AfQaLogger.error("logEvent", e); - return null; + await _appsflyerSdk.logEvent( + name, + eventValues: Map.from(params), + ); + } on AppsFlyerException catch (error) { + AfQaLogger.error('logEvent', error); } + AfQaLogger.result(resultTag ?? 'logEvent($name)', true); + return true; } - void _safeCall(String tag, void Function() body) { + Future _safeCall( + String tag, + Future Function() body, + ) async { try { - body(); - } catch (e) { - AfQaLogger.error(tag, e); + await body(); + } catch (error) { + AfQaLogger.error(tag, error); } } @@ -301,61 +350,66 @@ class MainPageState extends State { } Future logEvent(String eventName, Map eventValues) async { - final result = await _logEvent(eventName, eventValues); - print(result == null ? "Failed to log event" : "Event logged"); - return result; + return _logEvent(eventName, eventValues); } void logAdRevenueEvent() { - try { - Map customParams = { - 'ad_platform': 'Admob', - 'ad_currency': 'USD', - }; - - AdRevenueData adRevenueData = AdRevenueData( + () async { + try { + await _appsflyerSdk.logAdRevenue( monetizationNetwork: 'SpongeBob', - mediationNetwork: AFMediationNetwork.applovinMax.value, + mediationNetwork: AFMediationNetwork.applovinMax, currencyIso4217Code: 'USD', revenue: 100.3, - additionalParameters: customParams); - _appsflyerSdk.logAdRevenue(adRevenueData); - AfQaLogger.log("logAdRevenue", - "monetizationNetwork=SpongeBob currency=USD revenue=100.3"); - print("Ad Revenue event logged with no errors"); - } catch (e) { - AfQaLogger.error("logAdRevenue", e); - print("Failed to log event: $e"); - } + additionalParameters: const { + 'ad_platform': 'Admob', + 'ad_currency': 'USD', + }, + ); + AfQaLogger.log( + 'logAdRevenue', + 'monetizationNetwork=SpongeBob currency=USD revenue=100.3', + ); + } on AppsFlyerException catch (error) { + AfQaLogger.error('logAdRevenue', error); + } + }(); } Future?> validatePurchase( - String purchaseToken, String productId) async { + String purchaseToken, + String productId, + ) async { + final purchase = defaultTargetPlatform == TargetPlatform.iOS + ? AFIOSPurchaseDetails( + purchaseType: AFPurchaseType.oneTimePurchase, + productId: productId, + transactionId: purchaseToken, + ) + : AFAndroidPurchaseDetails( + purchaseType: AFPurchaseType.oneTimePurchase, + productId: productId, + purchaseToken: purchaseToken, + ); + try { - final purchaseDetails = AFPurchaseDetails( - purchaseType: AFPurchaseType.oneTimePurchase, - purchaseToken: purchaseToken, - productId: productId, + AfQaLogger.log( + 'validatePurchase', + 'productId=$productId tokenLen=${purchaseToken.length}', ); - - Map additionalParameters = { - 'validation_source': 'flutter_example', - 'app_version': '1.0.0', - }; - - AfQaLogger.log("validatePurchase", - "productId=$productId tokenLen=${purchaseToken.length}"); - final result = await _appsflyerSdk.validateAndLogInAppPurchaseV2( - purchaseDetails, - additionalParameters: additionalParameters, + final result = await _appsflyerSdk.validateAndLogInAppPurchase( + purchase, + additionalParameters: const { + 'validation_source': 'flutter_example', + 'app_version': '1.0.0', + }, + awaitResponse: true, ); - AfQaLogger.result("validatePurchase", result); - print("Purchase validation successful: $result"); - return result as Map?; - } catch (e) { - AfQaLogger.error("validatePurchase", e); - print("Purchase validation failed: $e"); + AfQaLogger.result('validatePurchase', result); + return result; + } on AppsFlyerException catch (error) { + AfQaLogger.error('validatePurchase', error); rethrow; } } diff --git a/example/lib/text_border.dart b/example/lib/text_border.dart index cf0c23c6..bc299121 100644 --- a/example/lib/text_border.dart +++ b/example/lib/text_border.dart @@ -5,9 +5,11 @@ class TextBorder extends StatelessWidget { final TextEditingController controller; final String labelText; - const TextBorder( - {required this.controller, required this.labelText, Key? key}) - : super(key: key); + const TextBorder({ + required this.controller, + required this.labelText, + super.key, + }); @override Widget build(BuildContext context) { diff --git a/example/pubspec.yaml b/example/pubspec.yaml index fc3c5e52..49494585 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -12,7 +12,8 @@ version: 1.0.0+1 publish_to: none environment: - sdk: '>=2.12.0 <4.0.0' + sdk: '>=3.5.0 <4.0.0' + flutter: ">=3.24.0" dependencies: flutter: @@ -36,6 +37,14 @@ dev_dependencies: # The following section is specific to Flutter. flutter: + # The example app is built with CocoaPods (the plugin's podspec pins AppsFlyerRPC 7.0.12 -> + # AppsFlyerFramework 7.0.1). Swift Package Manager is disabled here because this repository's + # folder name (appsflyer-flutter-plugin) differs from the Dart package name (appsflyer_sdk); with + # a local path dependency SwiftPM derives the package identity from the checkout folder name and + # fails to resolve ("unable to override package ... identity ... doesn't match"). The plugin still + # ships a valid Package.swift, so pub.dev consumers can use SwiftPM normally. + config: + enable-swift-package-manager: false # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart deleted file mode 100644 index 3b8c4cab..00000000 --- a/example/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -// import 'package:flutter/material.dart'; -// import 'package:flutter_test/flutter_test.dart'; - -// import 'package:example/main.dart'; - -// void main() { -// testWidgets('Counter increments smoke test', (WidgetTester tester) async { -// // Build our app and trigger a frame. -// await tester.pumpWidget(MyApp()); - -// // Verify that our counter starts at 0. -// expect(find.text('0'), findsOneWidget); -// expect(find.text('1'), findsNothing); - -// // Tap the '+' icon and trigger a frame. -// await tester.tap(find.byIcon(Icons.add)); -// await tester.pump(); - -// // Verify that our counter has incremented. -// expect(find.text('0'), findsNothing); -// expect(find.text('1'), findsOneWidget); -// }); -// } diff --git a/internal-docs/ARCHITECTURE.md b/internal-docs/ARCHITECTURE.md new file mode 100644 index 00000000..a4d917a1 --- /dev/null +++ b/internal-docs/ARCHITECTURE.md @@ -0,0 +1,523 @@ +# AppsFlyer Flutter Plugin — Architecture (Flutter ↔ RPC ↔ Native SDK) + +**Status:** Current implementation +**Last verified:** 2026-08-10 +**Flutter plugin:** 7.0.1 +**Android SDK / RPC:** 7.0.1 +**iOS SDK:** 7.0.1 +**iOS RPC:** 7.0.12 + +This document describes the current implementation of the `appsflyer_sdk` Flutter plugin. Its scope is the cross-platform wrapper, its Flutter channels, the Android and iOS RPC integrations, and the optional Purchase Connector. The native SDKs remain separately versioned systems; this repository adapts their supported RPC capabilities rather than reimplementing attribution, persistence, or networking. + +Historical SDK 6 behavior and upgrade instructions belong in [`doc/migration-guide.md`](../doc/migration-guide.md), not in the current architecture contract. Method signatures and platform availability belong in [`doc/api-reference.md`](../doc/api-reference.md); this document explains boundaries and flows rather than duplicating that reference. + +## 1. Architectural principles + +The Flutter plugin is a thin, typed bridge over the native Android and iOS RPC modules. + +- `AppsFlyerSdk.instance` is the public singleton entry point. +- Dart owns public naming, type safety, per-platform payload adaptation, event models, and error normalization. +- Native SDK validation, persistence, lifecycle state, threading, and network behavior remain native responsibilities. +- Every core Dart-to-native call uses one RPC transport method: `executeRpc` on `af-api`. +- Native asynchronous SDK events use `af-events` and are demultiplexed into one application callback per event, registered through the `register*Listener` APIs. The plugin exposes no public `Stream`. +- Per-call results remain correlated to the originating `MethodChannel` reply; they are not delivered through global callback slots. +- Intentional Android/iOS RPC differences are adapted explicitly instead of being hidden by duplicated business logic. + +Dependencies point inward toward the native capability: + +```text +host Flutter app + → public Dart API and Dart models + → Flutter channel transport + → platform plugin adapter + → native RPC parser/router + → native AppsFlyer SDK +``` + +The callback direction is reversed, but ownership is not: the native SDK emits a callback, the RPC layer creates an event envelope, the platform plugin transports it, and Dart invokes the typed callback registered for that event. Neither native platform imports Dart business logic. The optional Purchase Connector follows a separate channel and does not pass through the core RPC router. + +```mermaid +flowchart LR + App["Flutter application"] --> SDK["AppsFlyerSdk public API"] + SDK -->|"executeRpc {method, params}"| Method["af-api MethodChannel"] + Method --> Android["Android plugin → AppsFlyerRpcHandler"] + Method --> IOS["iOS plugin → AppsFlyerRPCBridge"] + Android --> AndroidSDK["AppsFlyer Android SDK 7"] + IOS --> IOSSDK["AppsFlyer iOS SDK 7"] + AndroidSDK -->|"callback → RPC notifier → plugin"| Events["af-events EventChannel"] + IOSSDK -->|"delegate → RPC emitter → plugin"| Events + Events --> Registry["private listener registry (one callback per event)"] + Registry --> App +``` + +## 2. Public Dart layer + +`lib/src/appsflyer_sdk.dart` contains the public SDK surface. + +### 2.1 Singleton and platform selection + +`AppsFlyerSdk.instance` owns the production `MethodChannel` and `EventChannel`. The `@visibleForTesting` named constructor accepts injected channels and an optional `TargetPlatform`, allowing platform-specific behavior to be tested without changing global Flutter platform state. + +The stored platform is used only for bridge concerns: + +- selecting Android or iOS RPC method names; +- selecting platform-specific parameter shapes; +- short-circuiting APIs unsupported by the current native RPC layer; +- serializing platform-specific models such as purchase details and mediation values. + +A platform-only API is not gated in Dart. Every call routes through `_invokeRpc` regardless of the current platform, and the native RPC layer answers `unknown method` when it does not implement it; that surfaces to the caller as `AppsFlyerException`. Keeping a platform-support table in Dart was rejected deliberately — it duplicates knowledge the RPC contract already owns and goes stale the moment a native SDK adds support, silently blocking a method that now works. The trade-off is that the exception `code` comes from the native layer and is not yet aligned: Android reports `422` (`INVALID_PARAMETERS`, because its parser maps unknown methods through the generic parse-error path) and iOS reports `404`. Aligning Android on `404` is a native RPC change, not a plugin one. Shared APIs behave the same way, except that the package registers native implementations only for Android and iOS, so invoking one on another Flutter target produces `MissingPluginException` rather than `AppsFlyerException`. + +### 2.2 RPC helpers + +All public RPC-backed methods delegate to two helpers: + +```dart +Future _invokeNullableRpc(String method, [Map? params]); +Future _invokeRpc(String method, [Map? params]); +Future _invokeVoidRpc(String method, [Map? params]); +``` + +`_invokeNullableRpc` is the unconstrained primitive for RPC calls whose native +reply may legitimately be absent (`String?`, `Map?`, or `void`). `_invokeRpc` +requires a non-null reply and throws `AppsFlyerException` when the native side +returns nothing, so callers such as `isSessionReady`, `isStopped`, +`getSdkVersion`, and `generateInviteLink` do not need ad-hoc `?? false`, `!`, +or per-method null checks. `_invokeVoidRpc` delegates to +`_invokeNullableRpc`. + +`_invokeRpc` sends this channel payload: + +```json +{ + "method": "", + "params": {} +} +``` + +The Flutter channel method is always `executeRpc`. A native `PlatformException` is converted into public `AppsFlyerException` before reaching plugin consumers. If the native reply decodes to a non-null value whose runtime type does not match the Dart call's expected type argument, `_invokeNullableRpc` throws `AppsFlyerException` instead of surfacing a raw cast error. When `_invokeRpc` receives a null reply it throws `AppsFlyerException` with message ` returned no value`. `AppsFlyerException.code` is populated only when `PlatformException.code` is numeric. Android RPC failures normally use HTTP-style numeric codes (`400`, `404`, `422`, `500`, `503`). iOS protocol errors also preserve numeric RPC codes, but an iOS handler failure without an `errorCode` is exposed with the non-numeric fallback `SDK_ERROR`. Plugin transport failures such as `UNEXPECTED_ERROR`, `SERIALIZATION_ERROR`, and `RPC_PARSE_ERROR` are also non-numeric. All of those cases therefore produce `AppsFlyerException(code: null, ...)` while retaining the message. + +`MissingPluginException` — when no native handler answers the channel — is **not** part of the RPC error contract and is **not** converted to `AppsFlyerException`. It indicates a Flutter integration gap (unsupported platform, or plugin registration failure) and should not occur on a properly integrated Android or iOS build. + +`_invokeVoidRpc` discards successful native response data and exposes `Future`. For native fire-and-forget setters, completion means that the RPC layer accepted the call; it does not invent a native network-completion callback. Dart does not add its own timeout or cancellation layer. + +### 2.3 Public result delivery + +Public APIs use the delivery style supported by the underlying capability: + +| Public behavior | Examples | Transport behavior | +| --- | --- | --- | +| Awaitable request result | `start`, `logEvent`, `validateAndLogInAppPurchase`, `generateInviteLink` | Completes or fails through the originating `MethodChannel` reply | +| Awaitable RPC acceptance | setters, `logAdRevenue`, `logInvite` | Completes after the native RPC accepts the operation | +| Immediate Dart getter | `pluginVersion` | Reads local package metadata; no RPC | +| Registered callback | conversion data, UDL, session readiness | Delivered through `af-events` and dispatched to the single callback passed to the matching `register*Listener` | + +`start`, `logEvent`, `generateInviteLink`, and `validateAndLogInAppPurchase` expose a public `awaitResponse` parameter. It defaults to `false` for `start` and `logEvent`, and to `true` for the two result-producing APIs. The flag is forwarded to both platforms for `start` and `logEvent`, but only Android exposes it for invite generation and purchase validation. + +## 3. Flutter platform channels + +The channel names are identical across Dart, Android, and iOS: + +| Purpose | Channel | Dart | Android | iOS | +| --- | --- | --- | --- | --- | +| Requests and per-call replies | `af-api` | `MethodChannel` | `MethodChannel` | `FlutterMethodChannel` | +| Native SDK events | `af-events` | `EventChannel` | `EventChannel` | `FlutterEventChannel` | +| Optional Purchase Connector | `af-purchase-connector` | `MethodChannel` | included build variant | CocoaPods subspec | + +The core `MethodChannel` remains necessary: an `EventChannel` can deliver native events but cannot provide correlated request/reply calls for initialization, setters, getters, `start`, event logging, purchase validation, or invite-link generation. + +The channel names, `executeRpc` entry point, RPC method strings, parameter keys, JSON envelopes, and native event names are **internal transport contracts**. They are not a second public Flutter API and applications must not call them directly. `AppsFlyerSdk`, its exported models, and its documented callback typedefs are the public compatibility boundary. A transport contract can differ by platform while the public Dart method remains stable; the Dart layer owns that mapping. + +## 4. Forward path: Dart → RPC → native SDK + +### 4.1 Generic request + +```text +Flutter public method + → _invokeRpc / _invokeVoidRpc + → af-api: executeRpc {method, params} + → platform plugin dispatch + → native RPC parser and handler + → native SDK 7 API + → native RPC response + → MethodChannel result + → Dart value or AppsFlyerException +``` + +### 4.2 Android transport + +`AppsflyerSdkPlugin.kt` forwards every method except plugin-orchestrated `init` to `AppsFlyerRpcHandler`. + +- Fast RPCs (setters, getters, and fire-and-forget `start` / `logEvent` / purchase validation / invite generation when `awaitResponse` is `false`) run inline on the platform thread and complete the Flutter `Result` immediately. +- Awaited-callback RPCs (`start`, `logEvent`, `validateAndLogInAppPurchase`, or `generateInviteLink` when `awaitResponse` is `true`) run on a dedicated single-thread `blockingRpcExecutor` so a slow native latch wait does not head-of-line block unrelated fast calls. +- The handler uses `JsonRpcRequestParser` and the typed Android RPC request catalog. +- SDK callbacks required for awaitable RPC operations are converted into the corresponding RPC response. +- Flutter results are delivered on the main thread. +- The RPC handler is process-scoped. `AppsFlyerRpcBridge` holds one handler behind an `AppsFlyerRpcExecutor`, built with `applicationContext` so it retains neither a destroyed `Activity` nor a torn-down engine. Because `AppsFlyerLib` is itself a process-wide singleton that keeps its configuration and registered listeners, reusing the handler means a recreated engine reattaches to a bridge that still matches the native SDK instead of building one with no memory of the listeners registered on it. The `init` RPC alone runs on an ephemeral executor built around the current `Activity` when one is attached, so SDK 7 can replay the cold-start launch intent for deep linking. +- Awaited native callbacks block only the dedicated blocking executor until completion or timeout. Fast calls are not queued behind them. +- The process-scoped `AppsFlyerRpcHandler` can be entered from both the platform thread (fast RPCs) and `blockingRpcExecutor` (awaited RPCs). That overlap is intentional: fast setters/getters must not wait behind a slow `await start()`. Listener bookkeeping inside the handler is not synchronized; if tighter guarantees are needed, they belong in the Android RPC module (`plugin_bridge`), not by serializing every plugin RPC on one executor. +- The `executeRpc` envelope `{method, params}` is parsed by `RpcEnvelopeParser` before dispatch. Both `method` and `params` are required; `params` may be empty but must be a `Map`. Dart's `_invokeNullableRpc` always supplies `params: params ?? {}`. A malformed envelope from anything other than that path is an integration error: Android throws `IllegalStateException` with message prefix `RPC envelope contract violation:` outside the dispatch `try/catch`; iOS calls `preconditionFailure` with the same prefix. Neither path is converted to a user-facing `AppsFlyerException` by design. + +### 4.3 iOS transport + +`AppsflyerSdkPlugin.swift` serializes `{method, params}` and calls `AppsFlyerRPCBridge.executeJson`. + +- `AppsFlyerRPCBridge` is `@MainActor`-isolated. It starts an async task per request; unlike Android, the plugin has no single FIFO executor for unrelated calls. Callers must `await` operations whose ordering matters. +- The plugin's Flutter-facing methods are non-isolated, so it reaches the bridge through `AFRPCBridge` (`AFRPCBridge.swift`), a Swift-only accessor that is the single point of contact in both directions. Outbound call sites — Flutter channel handlers plus `UIApplication`/`UIScene` delegate callbacks — already run on the main thread, so it uses `MainActor.assumeIsolated` to keep each call synchronous while asserting that assumption at runtime; engine detach, the one caller that may run off the main thread, hops through the main queue instead. Inbound events are always enqueued with `DispatchQueue.main.async` (even when already on the main thread) so af-events delivery order matches Android's always-post model; outbound RPC completions are normalized through `onMainActor` so mutations such as `markBridgeReady` do not depend on `AppsFlyerRPCBridge` keeping its own main-actor hop across version bumps. There is no Objective-C in the Core plugin. +- The `executeRpc` envelope `{method, params}` is parsed by `parseEnvelope` before dispatch, matching Android's `RpcEnvelopeParser` contract. Both keys are required; `params` may be empty but must be a `Map`. Dart's `_invokeNullableRpc` always supplies `params: params ?? {}`. A malformed envelope from anything other than that path is an integration error: iOS calls `preconditionFailure` with message prefix `RPC envelope contract violation:`; Android throws `IllegalStateException` with the same prefix. Neither path is converted to a user-facing `AppsFlyerException` by design. Dispatch-time serialization still validates every RPC payload with `JSONSerialization.isValidJSONObject` in `jsonString(from:)` before writing, which rejects the non-finite doubles that would otherwise raise `NSInvalidArgumentException`; such calls fail with a `SERIALIZATION_ERROR` `FlutterError`. `example/ios/RunnerTests` pins that serialization behavior. +- Native completion-handler APIs are invoked on the main queue and bridged into Swift concurrency. RPC state used to gate listeners is held in an actor. +- JSON protocol errors and SDK failures become `FlutterError` values. +- iOS-specific nested result envelopes are unwrapped into the primitive or map shape expected by Dart. Scalar getters with named-key nesting (`getSdkVersion`, `getAppsFlyerUID`, `isSessionReady`, `generateInviteLink`) have explicit cases until the RPC module aligns with Android's flat shape; everything else returns the `data` map when present (void setters correctly get `nil`). +- `logAndOpenStore` is the only non-init public call requiring plugin orchestration because the plugin opens the returned store URL. + +### 4.4 iOS Swift Package Manager (Core) + +Core iOS code is shared between CocoaPods (`ios/appsflyer_sdk.podspec`) and SPM (`ios/appsflyer_sdk/Package.swift`). The SPM manifest includes Flutter's required path dependency on `../FlutterFramework`. That package is **not** checked into the plugin repo — Flutter tooling generates it in the consuming app's ephemeral build output during `flutter pub get` / `flutter build`. Standalone `swift package resolve` against the plugin checkout is therefore expected to fail; the supported integration test is `flutter build ios` with Swift Package Manager enabled in the app. See F-060 for Purchase Connector exclusions and verification details. + +## 5. Reverse path: native SDK → RPC → Dart callbacks + +The native RPC event notifier emits JSON event envelopes. Both platform plugins forward those envelopes through `af-events` without maintaining Dart callback slots. + +Events emitted before Dart attaches an `EventChannel` listener are buffered by the platform plugin and replayed when `onListen` runs. On Android that buffer lives in the process-scoped `AppsFlyerEventBus` rather than on the plugin instance, so it also spans engine teardown: the native SDK keeps the listeners registered through the process-scoped `AppsFlyerRpcHandler`, and routing every event through the bus means those late events reach the next subscriber instead of an unreachable plugin. iOS instead removes its bridge event handler in `detachFromEngineForRegistrar:` — only when the detaching instance still owns the bridge's single handler slot — so it has no equivalent late-delivery path and keeps its engine-scoped buffer. + +Dart holds exactly one `af-events` subscription, owned by `AppsFlyerSdk` itself. It is established lazily on the first `register*Listener` call — not in the constructor — so nothing is read from the native buffers before the application has asked for events. That first attach flushes the entire native buffer, including events for listeners the application registers later in the sequence, so `_AppsFlyerListenerRegistry` holds those until their callback arrives rather than dropping them. The handler catches malformed transport values, logs them with `debugPrint`, and drops them instead of failing the subscription: + +```dart +void _ensureEventsSubscribed() { + _eventSubscription ??= + _eventChannel.receiveBroadcastStream().listen(_handleNativeEvent); +} +``` + +`_AppsFlyerEvent.fromNative` accepts the RPC JSON string and normalizes: + +- `event` → `_AppsFlyerEvent.name` (must be a non-empty string; missing, empty, or non-string values throw `FormatException`); +- `data` → `Map` when `data` is a JSON object, otherwise `{}` (covers Android `onSessionReady` with `data: null`). + +Transport-only envelope fields (`timestamp`, `origin`) are ignored on the Dart side. + +The Android and iOS plugins each keep an in-memory FIFO of event JSON strings while no Dart event sink is attached, then flush it from `onListen`. Both queues are capped at 64 events and drop the oldest on overflow; Android's `AppsFlyerEventBus` is process-scoped, iOS's `pendingEvents` is engine-scoped. Nothing is persisted, so events that occur before plugin registration, after engine teardown, or before the native RPC event handler exists are not recoverable. + +`_AppsFlyerListenerRegistry` (`lib/src/appsflyer_listener_registry.dart`) maps each native event name to the single callback registered for it, replacing that callback on re-registration — the same contract as the native SDKs, which hold one listener reference per event type. + +An event that arrives before its listener has ever been registered is held in a FIFO capped at 64 — the same bound as each native buffer, since the held events are that buffer's replay — and delivered in a microtask when the listener registers. Holding covers the startup window only. Once a listener has been registered, an event arriving while no callback is installed (because the application unregistered) is logged and dropped rather than replayed on re-registration; `off` also discards anything still held for that event name. + +| Registration API | Callback | Native event names | +| --- | --- | --- | +| `registerConversionListener` | `onSuccess` | `onConversionDataSuccess` | +| `registerConversionListener` | `onFailure` | `onConversionDataFail` | +| `registerDeepLinkListener` | `onDeepLink` | `onDeepLinking` or `onDeepLinkReceived` | +| `registerSessionReadyListener` | `onReady` | `onSessionReady` | + +Because the callback is an argument to the registration call, it is always in place before the RPC is dispatched, and no public `Stream` exists for an application to attach additional subscribers to. A single native event therefore cannot fan out to several handlers — `start()` cannot be issued twice for one session-ready event. + +## 6. Initialization and session lifecycle + +### 6.1 Initialization + +The public API is: + +```dart +Future init({required String devKey, String? appId}); +``` + +- Android receives only `devKey`; `appId` is not sent. +- iOS requires a non-empty `appId` and receives both fields. The native RPC layer validates both values. +- Initialization does not register optional conversion, UDL, or session-ready listeners. +- Initialization does not send a Launch. + +Android initialization sequence: + +```text +setPluginInfo(plugin: flutter, pluginVersion) + → init(devKey) + → complete the originating Flutter result on the main thread +``` + +Plugin metadata is attempted before initialization on both platforms so it can label the first session. Its result is intentionally non-fatal and does not abort `init`. + +iOS initialization sequence: + +```text +register RPC event handler + → setPluginInfo(plugin: flutter, pluginVersion) + → initialize(devKey, appId) + → handle pending launch options when present + → mark AppsFlyerAttribution bridge ready + → replay queued iOS URL / Universal Link requests +``` + +### 6.2 Listener registration + +Native listeners are registered explicitly, each with its own ordering relative to `init()`: + +| Flutter API | Android RPC | iOS RPC | Order | +| --- | --- | --- | --- | +| `registerDeepLinkListener(onDeepLink)` | `subscribeForDeepLink` | `registerDeeplinkListener` | Before `init()` | +| `registerConversionListener(onSuccess:, onFailure:)` | `registerConversionListener` | `registerConversionListener` | After `init()` | +| `registerSessionReadyListener(onReady)` | `registerSessionReadyListener` | `registerSessionReadyListener` | After `init()`, last | + +The deep-link listener is the exception because Android's deferred-resolution gate runs inside `init()`: the plugin passes the `Activity` as the init context (§7.1), which makes the native SDK replay the launch intent immediately, and `AFDeepLinkManager` sends the deferred resolution request only if a listener is already attached — a one-shot decision persisted per install. See F-037. + +Android additionally exposes `unregisterConversionListener` and the RPC soft-unsubscribe mapped by `unregisterDeeplinkListener`. Session-ready unregister is supported on both platforms. + +Registration is native state with an explicit contract: the application decides when delivery stops and resumes, and the plugin never infers that a listener has gone stale. Applications call the matching `unregister*Listener()` at the point in their own lifecycle where they no longer consume the events, and register again afterwards. Where that call reaches the native SDK it also clears the Dart callback slot; the iOS no-ops (`unregisterConversionListener`, `unregisterDeeplinkListener`) leave the callback in place, matching their native behavior. Because Android's RPC handler is process-scoped (§4.2), a registration — and an `unregisterDeeplinkListener` soft unsubscribe — outlives the engine that requested it, while the Dart callbacks do not; a recreated engine therefore repeats the registration sequence of a cold start. See [`doc/getting-started.md`](../doc/getting-started.md) for the integrator-facing version of this contract. + +### 6.3 Session start + +The app registers the native listener after initialization and calls `start()` for every foreground-cycle signal delivered to its callback. + +```dart +final appsFlyer = AppsFlyerSdk.instance; + +await appsFlyer.init(devKey: devKey, appId: appId); +await appsFlyer.registerSessionReadyListener(() async { + await appsFlyer.start(); +}); +``` + +`start({awaitResponse})` and `logEvent(..., {awaitResponse})` forward the public flag (default `false`) to both native RPC layers. `true` completes the `Future` on native request success and `false` completes after RPC acceptance. `generateInviteLink(..., awaitResponse: ...)` and `validateAndLogInAppPurchase(..., awaitResponse: ...)` default to `true` and forward the flag to Android RPC. Android returns a synchronous long link for `generateInviteLink(awaitResponse: false)` and an empty validation-result map for `validateAndLogInAppPurchase(awaitResponse: false)`. The current iOS RPC 7.0.12 does not expose the flag for those two APIs and always awaits their callbacks. + +Listener registration can cause readiness or attribution work promptly, so application code must apply consent/identity settings that must affect the first Launch before registering the session-ready listener. The wrapper does not maintain an initialized/started state machine or reject out-of-order calls; it relies on the application to await required sequencing and on native RPC/SDK validation for unsupported states. Most configuration is native runtime state and must be re-applied after a cold process start. + +### 6.4 Callback timeouts + +Timeouts belong to the native RPC layers, not Dart. They bound how long a Flutter request waits; they do not cancel native work, so a late native operation may still complete after Dart has received an error. + +| Awaited operation | Android RPC 7.0.1 | iOS RPC 7.0.12 | +| --- | ---: | ---: | +| `start` | 5 s | 10 s | +| `logEvent` | 5 s | 10 s | +| `validateAndLogInAppPurchase` | 5 s | 30 s | +| `generateInviteLink` | 10 s | 10 s | +| `logAndOpenStore` | no awaited SDK callback in Android RPC | 10 s before the plugin opens the URL | + +These are distinct from `setDeepLinkTimeout`, which configures native deep-link resolution (default 3,000 ms on Android and 60,000 ms on iOS). Android requires a positive value; iOS accepts zero. + +## 7. Deep-link lifecycle forwarding + +### 7.1 Android + +The plugin is `ActivityAware` and registers a `NewIntentListener`. + +- On a warm-start intent, it calls `activity.setIntent(intent)` and returns `false`; it does not invoke `performDeepLinking` itself or claim exclusive handling. +- The SDK 7 lifecycle integration inspects the activity's current intent on resume after `subscribeForDeepLink`, so keeping that intent current enables native UDL resolution. +- There is no plugin-owned Android URL queue. If no activity is attached when the new intent arrives, the plugin does not retain it. +- The `init` RPC uses the active `Activity` when one is attached so the Android SDK can inspect cold-start lifecycle state; application context is the fallback. Every other RPC runs on the process-scoped executor, which always uses application context. + +### 7.2 iOS + +The plugin registers AppDelegate and, when available, UIScene lifecycle delegates. + +- URL-scheme links map to `handleOpenUrl` or `handleOpenURL` according to the native callback shape. +- Universal Links map to `continueUserActivity`. +- launch options are retained until the RPC bridge is initialized; +- `AppsFlyerAttribution` queues early URL/Universal Link requests and replays them after plugin-internal `markBridgeReady(markedBy:)`. Queue state is serialized on the main queue inside the singleton (interim until the RPC lifecycle-callback wrapper absorbs it). Serialization and RPC failures are logged via `os_log`; the host app still learns deep-link outcomes only through `af-events` (F-037). That call records the owning plugin instance so `resetBridgeStateIfOwned(by:)` on engine detach clears `isBridgeReady` / `pendingRequests` without affecting a live second engine. A parameterless `@objc markBridgeReady` is intentionally not exposed: it would open the gate without an owner and break detach cleanup. + +These lifecycle RPC calls are implementation details and are not public Dart methods. + +## 8. Platform-specific API adaptation + +The public layer normalizes only where a reliable mapping exists. + +Examples: + +- `registerDeepLinkListener` selects the different Android and iOS RPC method names. +- `init` omits `appId` on Android. iOS receives both fields; validation is native-side. +- `AFMediationNetwork` maps to the native platform's accepted identifier. +- `AppsFlyerInviteLinkParams.referrerCustomerId` maps to Android `customerId` and iOS `referrerCustomerId`. +- `AFPurchaseDetails` has dedicated Android and iOS implementations because the native RPC request shapes differ. +- `sendPushNotificationData` is Android-only, while `handlePushNotification` is iOS-only. + +Where the native RPC layer has no equivalent, the Flutter API either logs and ignores the call or does not expose the capability at all. Dart does not simulate missing native behavior. + +Important differences that affect design and testing: + +| Concern | Android | iOS | +| --- | --- | --- | +| Core request scheduling | Fast RPCs inline on the platform thread; awaited-callback RPCs on one blocking executor | Independent async tasks through a `@MainActor` bridge | +| Native request catalog | Kotlin sealed requests parsed by `JsonRpcRequestParser` | Swift typed requests parsed by `AFRPCParser` and routed by domain | +| Result shape | Bare `RpcResponse.Success` value or void | JSON response envelope with plugin-side unwrapping | +| UDL subscribe/unsubscribe | `subscribeForDeepLink`; soft unsubscribe drops future callbacks because the SDK has no native unsubscribe | `registerDeeplinkListener`; no public unregister mapping | +| Warm link entry | Current `Activity` intent consumed by SDK lifecycle | AppDelegate/UIScene callbacks explicitly forwarded through RPC | +| iOS app ID | Not used | Required by Dart `init` | +| Purchase Connector opt-in | Gradle source-set flag | CocoaPods subspec; unavailable through SPM | + +## 9. Purchase validation and Purchase Connector + +### 9.1 RPC purchase validation + +`validateAndLogInAppPurchase` is part of the core RPC bridge and accepts the `AFPurchaseDetails` interface. + +- `AFAndroidPurchaseDetails` sends `purchaseType`, `productId`, `purchaseToken`, and `additionalParameters`. +- `AFIOSPurchaseDetails` sends the nested `product` and `transaction` objects expected by the iOS RPC. +- `AppsFlyerSdk.validateAndLogInAppPurchase` appends the public `awaitResponse` value only to the Android payload; the iOS RPC does not expose that field. +- Supplying a model for the wrong current platform throws `ArgumentError` before crossing the channel. + +### 9.2 Purchase Connector + +Purchase Connector is a separate optional native subsystem using `af-purchase-connector` rather than the core RPC channel. + +- Android is enabled through `appsflyer.enable_purchase_connector=true`. +- iOS is enabled through the CocoaPods Purchase Connector subspec. +- iOS Purchase Connector is not available through the plugin's Swift Package Manager integration. +- Dart keeps a separate process singleton. The first factory call requires configuration and sends an unawaited `configure` channel call; later configuration objects are ignored with a log. +- `startObservingTransactions()` and `stopObservingTransactions()` send unawaited calls because their public signatures return `void`. Consequently, native `PlatformException` failures from these calls are not normalized by the core `AppsFlyerException` path. +- Native validation callbacks travel back over the same Purchase Connector `MethodChannel`, not `af-events`. Dart stores callback functions: separate Android subscription/in-app success/failure listeners and one combined iOS validation callback. +- Android marshals connector callbacks to the main looper and serializes maps as JSON strings. iOS dispatches its delegate callback to the main queue and also sends JSON text; Dart accepts either a JSON string or a decoded map. +- On Android, `AppsFlyerPurchaseConnector` keys its `MethodChannel`, `ConnectorWrapper`, and validation listeners per `FlutterPluginBinding`, so add-to-app / multi-engine setups do not share one channel or tear down another engine's connector on detach. +- On iOS, `PurchaseConnectorPlugin` remains a process singleton holding one channel, so the last engine to register owns it. It publishes no instance of its own and therefore receives no detach callback directly: `AppsflyerSdkPlugin.detachFromEngineForRegistrar:` calls `tearDownForEngineDetach(registrar:)`, which stops transaction observation, clears the revenue delegate and the channel, and lets the next engine call `configure` again. The teardown is skipped unless the detaching registrar still owns the channel, so a stale engine cannot stop observation for a live one. + +## 10. Error handling and state boundaries + +- Core RPC `PlatformException` values are converted to `AppsFlyerException`. `MissingPluginException` is left unwrapped — it is outside the RPC contract. +- Platform-only calls are not short-circuited in Dart; they reach the RPC and surface the native `AppsFlyerException` off-platform. Shared calls are not guaranteed to work outside Android/iOS. +- Dart throws `ArgumentError` before transport for purchase details on the wrong platform. Most other input validation, including `init` parameters and `setConsentData` GDPR fields, remains in the typed native RPC request and SDK. +- Android converts parser/validation failures to numeric `RpcResponse.Error` values. Unexpected plugin orchestration failures use plugin error strings such as `UNEXPECTED_ERROR` or `INIT_ERROR`. +- iOS distinguishes protocol errors in the response `error` envelope from handler failures represented by `result.success == false`; the iOS plugin adapter converts both to `FlutterError` and unwraps successful values. +- A malformed native event is logged and dropped by Dart. It never reaches an application callback and does not fail the `af-events` subscription. Conversion-data failure and UDL failure are normal event payloads, not failed MethodChannel requests. +- Android splits teardown by lifetime. The Dart-facing half is engine-scoped: `onDetachedFromEngine` sets `isEngineDetached` first, then clears the channel handlers, detaches this engine's `af-events` sink, and shuts down the blocking-RPC executor. Clearing the method-call handler is what stops new calls — teardown and `onMethodCall` both run on the platform thread, so no RPC can start after detach. Only awaited RPCs outlive teardown, because `shutdown()` lets the in-flight task run to completion: its latch can resolve seconds later, and `deliverRpcResult` then drops the `Flutter Result` rather than replying on a dead channel. Replying would not crash — Flutter discards the response with a `FlutterJNI was detached` warning — but that warning is misleading in customer bug reports. The native-facing half is process-scoped and deliberately survives: `AppsFlyerEventBus` keeps its buffer and `AppsFlyerRpcBridge` keeps the RPC handler, so a recreated engine reattaches to the configured bridge. Dart state does not survive either way — the application calls the `register*Listener` APIs again after a new engine attaches, and reusing the handler only makes that re-registration reuse the existing listeners instead of building new ones. +- iOS registers its RPC event handler during plugin construction and tears it down in `detachFromEngineForRegistrar:` (after `publish:` in `registerWithRegistrar:`), clearing `eventSink`, `pendingEvents`, and the bridge event handler when the `FlutterEngine` is deallocated. `AppsFlyerRPCBridge` holds one handler per process while plugin instances are per engine, so `AFRPCBridge` records the registering instance as the slot's owner and removes the handler only for that owner — a detaching engine cannot silence events for an engine that registered after it and is still alive. This mirrors the `this.sink === sink` guard in `AppsFlyerEventBus.detach`; on both platforms the newest registration owns event delivery. `isEngineDetached` is set first in teardown so any in-flight `executeJson` completion (including the `init` sequence and `logAndOpenStore`) skips `FlutterResult` and `markBridgeReady(markedBy:)` instead of replying on a dead channel or flushing `AppsFlyerAttribution`'s process-scoped queue for a torn-down engine. Teardown runs the whole block on the main queue, hopping when `detachFromEngineForRegistrar:` arrives off it: every other writer of the plugin's state (`onListen`, `onCancel`, `deliverEvent`, and the RPC completions reading `isEngineDetached`) is already main-thread confined, so the hop is what makes that confinement complete and lets the state stay lock-free. It captures the instance strongly, since both the bridge handler slot and `AppsFlyerAttribution`'s queue are keyed on its identity. +- Event callbacks belong to the Flutter application, but the transport subscription belongs to the plugin: Dart keeps one `af-events` listener and one callback slot per event, so the SDK installs no per-callback global state and the application cannot create a second delivery path. + +## 11. Serialization and parameter contracts + +Core calls cross two serialization boundaries: + +```text +Dart values + → Flutter StandardMessageCodec + → Java Map / Objective-C NSDictionary + → native JSON request string + → typed RPC request +``` + +- Public parameter maps should contain JSON-compatible values only: string-keyed maps, lists, strings, booleans, finite numbers, and `null`. Platform channels can carry some additional Flutter codec types, but the following native JSON boundary cannot. +- Dart generally includes explicit `null` values in the `params` map. Android's plugin JSON conversion omits null-valued map entries, while iOS serializes them as JSON null; the typed request on each platform then decides whether the resulting value means “absent,” nullable, or invalid. Do not assume identical wire JSON even when the public Dart semantics are aligned. Clearing the iOS sharing filter is one case where an explicit Dart null is intentionally consumed on iOS. +- Android converts the Flutter map to `JSONObject` and its parser uses typed `opt*` helpers. Missing, omitted-null, or wrong-typed optional values can therefore collapse to parser defaults. +- iOS validates the Objective-C object with `NSJSONSerialization`, then `AFRPCParser` decodes `AnyCodable` into JSON scalar/list/map types and typed request initializers validate required values. The iOS parser rejects request JSON at or above 1 MiB. +- Enums are never sent by ordinal. Dart maps them to stable native strings, including platform-specific `AFMediationNetwork` values and different purchase-type spellings. +- Returned platform maps use dynamic Flutter codec key/value types. Dart converts result and event maps to `Map` at the public boundary. Core result type mismatches can surface as Dart cast/type errors; malformed event values are caught and dropped. +- Native callback events are JSON strings even though core MethodChannel requests begin as maps. Purchase Connector callbacks also currently send JSON strings, while its Dart handler tolerates an already-decoded map. + +Treat `lib/src/appsflyer_sdk.dart` and the platform model serializers as the source of truth for public-to-RPC mapping. Treat Android `RpcRequest`/`JsonRpcRequestParser` and iOS `AFRPCTypedRequests`/`AFRPCParser` as the source of truth for accepted native transport shapes. + +## 12. Privacy and security boundaries + +The plugin exposes controls; the host application remains responsible for collecting consent lawfully and ordering calls so the first session reflects the user's choice. + +- Apply consent, anonymization, identifier-collection, and stop/resume decisions before registering the session-ready listener when they must affect the first Launch. `setConsentData` is native runtime state: reapply it on each cold/process start, while background-to-foreground cycles in the same process retain it. TCF collection reads the platform consent stores through the native SDK when enabled. +- Advertising and device identifier collection is implemented by native SDK controls. The Dart bridge maps the setting but does not read, store, or redact identifiers itself. Platform-specific controls include Android ID/App Set ID/network data and iOS IDFV, ASA/Apple Ads, SKAdNetwork, and device name. +- Email, phone, first name, and last name cross the in-process Flutter channel and RPC boundary as raw strings. SHA-256 normalization/hashing happens inside the native SDK before those values are sent to AppsFlyer. The Facebook App-Scoped ID is explicitly not hashed. `clearUserPii` clears values set through all public `setUser*` PII methods, including that Facebook ID; it does not clear customer ID, consent, anonymization, or stopped state. +- Event values, additional data, partner data, push payloads, purchase details, deep links, and identifiers supplied by the app are passed to native code. The plugin is not a general-purpose sanitizer or secret store. iOS rejects dangerous schemes for `setFacebookDeferredAppLink`; that narrow validation does not apply to every URL-taking API. +- Platform channels and native RPC calls are in-process boundaries, not encrypted inter-process/network protocols. Network transport, native storage, identifier persistence, and server delivery belong to the native AppsFlyer SDKs. +- Debug logging is off unless enabled. Native debug logs can include request/event data useful for integration testing, so applications should not enable them in production or log channel payloads containing sensitive values. The wrapper's normal RPC diagnostics log method/failure information rather than intentionally logging PII values. + +See [`doc/consent-dma.md`](../doc/consent-dma.md) for integration ordering and the public privacy controls. + +## 13. Testing strategy + +Different test levels protect different boundaries: + +| Level | Location | Responsibility | +| --- | --- | --- | +| Dart channel/unit tests | `test/appsflyer_sdk_test.dart` | Public method-to-RPC names, parameter maps, platform-only forwarding, exception normalization, typed event routing, malformed-event behavior | +| Generated-model checks | `lib/appsflyer_sdk.g.dart` plus generator workflow | Purchase Connector JSON model conversion; regenerate after annotated model changes | +| Android RPC tests | native Android SDK/RPC repository | Typed request parsing/validation, handler-to-SDK mapping, callbacks, response/error behavior, timeouts | +| iOS RPC tests | native iOS SDK/RPC repository | Parser/router/domain handlers, state actor, event encoding, SDK timeout races, negative paths | +| Platform adapter tests | `android/src/test/kotlin/com/appsflyer/appsflyersdk/AppsFlyerEventBusTest.kt`, `AppsFlyerRpcBridgeTest.kt`; otherwise no comprehensive suite in this repository | Android event buffering, replay ordering, sink attach/detach across engine recreation, concurrent publishing, and single-executor RPC bridge reuse across engine recreation are covered by JVM unit tests (`./gradlew :appsflyer_sdk:testDebugUnitTest` from `example/android`, run by the Android CI job). Channel registration, engine detach, Android activity/new-intent behavior, iOS AppDelegate/UIScene forwarding, and result unwrapping still require focused native tests or example-app verification | +| Device/integration tests | `example/`, RC scenario scripts, real AppsFlyer dashboard/logs | Plugin registration, native dependency packaging, lifecycle sessions, deep links, attribution callbacks, push/uninstall paths, and network-visible behavior | + +The PR gate is `.github/workflows/lint-test-build.yml`: analyze, format check and `flutter test --coverage` on Linux, then a per-platform release build, with `./gradlew :appsflyer_sdk:testDebugUnitTest` running ahead of the build on the Android job (preceded by `flutter build apk --config-only`, because the Gradle wrapper is gitignored and a fresh checkout has none until the Flutter tool invokes Gradle). The iOS `RunnerTests` suite is not wired into CI: it re-implements the function under test rather than importing the plugin, so it pins Foundation behavior and would cover no plugin code in exchange for a simulator boot on a runner that bills at 10x. None of that loads a real device: it cannot prove lifecycle, packaging or network-visible behavior, and SPM resolution is not covered at all because `Package.swift` depends on the app-generated `FlutterFramework` path. Run the example on a device or emulator for platform changes and follow [`doc/testing-and-troubleshooting.md`](../doc/testing-and-troubleshooting.md). Purchase Connector changes need opt-in builds; iOS Core should be checked through both CocoaPods and SPM where applicable. + +## 14. Adding or changing capabilities + +### 14.1 Public method or core RPC method + +1. Confirm the capability exists in the pinned Android and/or iOS RPC catalog. If it does not, add and release the native RPC capability first; do not reproduce native SDK business logic in Dart. +2. Define the public Dart signature and platform availability in `lib/src/appsflyer_sdk.dart`. Add a small model only when it gives callers type safety or isolates a real platform-shape difference. +3. Map the public call to the exact native method name and parameter keys. Adapt the payload when the two contracts differ; do not silently send an iOS contract to Android or vice versa. Do not add a platform gate when only one side supports the method — forward it and let the RPC reject it. +4. Decide the completion contract: RPC acceptance, awaited native callback, returned value, or asynchronous event. Keep a request result on its originating MethodChannel reply; reserve `af-events` for unsolicited/repeating SDK events. +5. Update iOS result unwrapping when the public API expects data from an iOS nested response. Add plugin orchestration only for cross-layer duties such as initialization ordering or opening a returned URL. +6. Test Dart mappings for both platforms, including nulls/defaults, exceptions, and off-platform behavior. Add or update native RPC parser/handler tests in the owning native repository and run device coverage for lifecycle or packaging changes. +7. Update API, feature, migration, and architecture documentation affected by the change. Do not hand-edit generated `.g.dart` files. + +### 14.2 Callback or event + +1. Register the native SDK delegate/listener in the native RPC layer and define a stable event name plus JSON-compatible data shape. +2. Emit through the RPC notifier/event emitter and keep Flutter-channel access on the platform main thread. +3. Ensure the platform plugin registers the event handler early enough and decide whether its existing buffer is sufficient — Android's `AppsFlyerEventBus` is process-scoped, iOS's is engine-scoped, and both are capped at 64 events. +4. Decode and normalize the event in Dart, then dispatch it through `_AppsFlyerListenerRegistry` to a callback taken as an argument by the matching `register*Listener` API. Do not add a public `Stream`. Document platform payload differences. +5. Test listener gating, event name/payload mapping, malformed input, callback replacement on re-registration, and early-event replay. Add device coverage when the callback depends on application lifecycle. + +### 14.3 Platform-only or Purchase Connector feature + +Keep platform-only behavior visibly gated in Dart and documented as such. Purchase Connector features belong to its separate channel, models, native opt-in sources, and callback mechanism; they should not be added to `af-api` merely to make the channels look uniform. + +## 15. Known constraints and trade-offs + +- Android and iOS are the only registered Flutter targets. Platform-only calls throw `AppsFlyerException` off-platform; calls on other targets can throw `MissingPluginException`. +- Public/native compatibility is checked by tests and review, not generated from a shared cross-platform schema. Android and iOS RPC method names and parameter shapes can drift independently. +- Android runs fast RPCs inline on the platform thread. Only awaited-callback RPCs use a dedicated blocking executor, so a slow validation or invite-link wait does not stall unrelated setters/getters. iOS permits unrelated requests to overlap, so ordering must still be expressed by awaiting calls. +- Native timeout errors do not cancel SDK work. Fire-and-forget completion is acceptance, not network delivery. +- Event buffering is in memory and never persisted, so it does not survive process death. Both platforms cap the buffer at 64 events and drop the oldest on overflow; the buffer is process-scoped on Android and engine-scoped on iOS. Malformed events are dropped. Dart attaches its single `af-events` subscription on the first `register*Listener` call, and that attach flushes the whole native buffer at once: `_AppsFlyerListenerRegistry` holds any replayed event whose listener has not registered yet — capped at 64, the same bound as the native buffers — and delivers it when that listener registers. An event arriving after its listener has been registered and then unregistered is logged and dropped. +- Android deep-link correctness relies on an attached activity and SDK lifecycle inspection of its current intent; there is no plugin URL queue. iOS owns explicit AppDelegate/UIScene forwarding and queues early URL requests in `AppsFlyerAttribution` until initialization. +- Android deep-link unsubscribe is soft: it clears the RPC listener reference, but the native SDK has no unsubscribe API. iOS exposes no conversion/UDL unregister mapping in the current RPC. +- The plugin does not enforce a full lifecycle state machine. Call ordering, cold-start configuration replay, ATT prompting, and consent UI remain application responsibilities. +- iOS Purchase Connector requires CocoaPods and is absent from the SPM product. Its Dart `void` operations do not expose native completion errors through the core exception contract. +- The public dynamic map surfaces cannot provide compile-time guarantees for arbitrary event/additional-data keys or values. Keep payloads JSON-compatible and verify platform-specific mappings. + +## 16. Key files + +| Layer | File | Responsibility | +| --- | --- | --- | +| Public library | `lib/appsflyer_sdk.dart` | Library exports | +| Dart SDK | `lib/src/appsflyer_sdk.dart` | Public API, per-platform payload adaptation, RPC invocation, typed event callbacks | +| Event model | `lib/src/appsflyer_event.dart` | Native event decoding and normalization | +| Listener registry | `lib/src/appsflyer_listener_registry.dart` | Private one-callback-per-event dispatch for `af-events` | +| Errors | `lib/src/appsflyer_exception.dart` | Public SDK exceptions | +| Purchase models | `lib/src/af_purchase_details.dart` | Android/iOS purchase request implementations | +| Invite model | `lib/src/appsflyer_invite_link_params.dart` | Platform-aware invite parameter mapping | +| Android plugin | `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Channels, RPC dispatch, lifecycle forwarding, `af-events` sink adapter | +| Android event relay | `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerEventBus.kt` | Process-scoped event buffering, FIFO replay, and sink attach/detach across engine recreation | +| Android RPC bridge owner | `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerRpcBridge.kt` | Process-scoped `AppsFlyerRpcHandler` behind `AppsFlyerRpcExecutor`, so engine recreation reattaches to the configured native bridge | +| Android dependencies | `android/build.gradle` | SDK/RPC BOM, optional connector source set, Android compatibility | +| iOS plugin | `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Channels, RPC dispatch, lifecycle forwarding, result unwrapping | +| iOS attribution adapter | `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.swift` | Queues and forwards early URL/Universal Link RPC calls | +| iOS RPC bridge access | `ios/appsflyer_sdk/Sources/appsflyer_sdk/AFRPCBridge.swift` | Main-actor-checked access to the `@MainActor`-isolated `AppsFlyerRPCBridge` from the plugin's non-isolated contexts | +| iOS dependencies | `ios/appsflyer_sdk.podspec`, `ios/appsflyer_sdk/Package.swift` | CocoaPods subspecs; Core-only SPM manifest (AppsFlyerFramework + vendored AppsFlyerRPC binaryTarget + ephemeral Flutter-generated `FlutterFramework` path dependency — see §4.4, F-060) | +| Purchase Connector | `lib/src/purchase_connector/`, `android/src/main/include-connector/`, `ios/PurchaseConnector/` | Optional non-core channel, state, models, and native callbacks | +| Dart contract tests | `test/appsflyer_sdk_test.dart` | Public mapping, platform behavior, errors, and event decoding | + +## 17. Sources of truth and related documentation + +Source-of-truth ownership is split by boundary: + +| Contract | Source of truth | +| --- | --- | +| Public Flutter API and public models | Current `lib/appsflyer_sdk.dart` library and `lib/src/` implementation | +| Dart-to-native mapping and public error/event normalization | `lib/src/appsflyer_sdk.dart`, platform serializers, and `lib/src/appsflyer_event.dart` | +| Channel registration, buffering, lifecycle forwarding, orchestration, and iOS result unwrapping | Android/iOS platform plugin source in this repository | +| Android RPC method names, accepted parameters, validation, callbacks, and timeouts | Pinned native Android `RpcRequest`, `JsonRpcRequestParser`, and `AppsFlyerRpcHandler` | +| iOS RPC method names, accepted parameters, validation, callbacks, and timeouts | Pinned native iOS `AFRPCParser`, typed requests, router, and domain handlers | +| Integration guidance | Public guides; secondary to code when they disagree | + +Repository references: + +- [`doc/README.md`](../doc/README.md) — integration-guide index +- [`doc/api-reference.md`](../doc/api-reference.md) — public API and platform availability +- [`doc/getting-started.md`](../doc/getting-started.md) — initialization and session-start integration +- [`doc/deep-linking.md`](../doc/deep-linking.md) — UDL and lifecycle setup +- [`doc/consent-dma.md`](../doc/consent-dma.md) — consent, identifiers, anonymization, and PII +- [`doc/purchase-connector.md`](../doc/purchase-connector.md) — optional connector setup and behavior +- [`doc/testing-and-troubleshooting.md`](../doc/testing-and-troubleshooting.md) — device verification and operational failures +- [`doc/migration-guide.md`](../doc/migration-guide.md) — SDK 6 to SDK 7 changes +- [`internal-docs/features/INDEX.md`](features/INDEX.md) and [`internal-docs/features/DIAGRAM.md`](features/DIAGRAM.md) — feature-level implementation records and dependency diagrams; observe their per-entry verification dates +- [`internal-docs/tech-designs/spm-support.md`](tech-designs/spm-support.md) — historical ADR-like design record; its superseded sections are explicitly marked, so use the current manifest and feature F-060 for current behavior + +No dedicated ADR directory or checked-in machine-readable cross-platform RPC schema exists in this repository. Any external alignment matrix or schema can assist review, but it is not sufficient evidence unless it matches the pinned source revisions above. + +Before declaring a release ready, verify the Dart API, both native RPC catalogs, platform mappings, event shapes, docs, and tests against the same revision. + +## 18. Document maintenance + +Review this document whenever the public Dart surface, channel names, RPC versions/catalogs, native dependency versions, result/event envelopes, initialization or lifecycle behavior, threading/timeouts, platform support, privacy controls, Purchase Connector packaging, or test strategy changes. Also review it during every release-version alignment. Update the `Last verified` date only after checking the Dart wrapper and both pinned native RPC implementations, not for prose-only edits. diff --git a/internal-docs/features/DIAGRAM.md b/internal-docs/features/DIAGRAM.md index 952f7924..73eb353a 100644 --- a/internal-docs/features/DIAGRAM.md +++ b/internal-docs/features/DIAGRAM.md @@ -1,8 +1,10 @@ # AppsFlyer Flutter Plugin — Feature Diagrams -## Section 1 — Runtime Flow +> **Verification status:** All dependency metadata and workflow edges were checked on **2026-08-10** against the current Dart, platform-plugin, native RPC, and relevant native SDK sources. Removed feature tombstones are intentionally absent from runtime diagrams. -Only features with at least one inbound or outbound cross-feature edge are shown. `eventsAndRevenue` and `platformIntegration` have no cross-feature edges in this codebase — every feature in those two categories is a standalone 1:1 native setter, so neither appears below. +## Section 1 — Declared Feature Dependencies + +For this section and the table below, `A --> B` means **feature A declares B in `depends_on`**. These are implementation/workflow prerequisites, not merely related topics. F-037's iOS and Android entry-point dependencies are platform-conditional; a single runtime uses the applicable branch. ```mermaid flowchart TD @@ -10,24 +12,14 @@ flowchart TD F001["F-001
SDK Initialization"]:::sdkCore F002["F-002
SDK Start"]:::sdkCore F011["F-011
TCF/DMA Auto Consent"]:::sdkCore - F015["F-015
Customer User ID"]:::sdkCore - F021["F-021
Delayed Session Start"]:::sdkCore - F034["F-034
Ad ID Collection Disable"]:::sdkCore F048["F-048
Plugin Metadata Reporting"]:::sdkCore - F057["F-057
ASA Opt-out"]:::sdkCore - F058["F-058
ATT Wait Timeout"]:::sdkCore - F059["F-059
Debug Logging Toggle"]:::sdkCore end subgraph purchaseValidation ["purchaseValidation"] - F023["F-023
IAP Validation V1"]:::purchaseValidation - F024["F-024
IAP Validation V2"]:::purchaseValidation - F025["F-025
Receipt Sandbox Toggle"]:::purchaseValidation - F038["F-038
Legacy Validation Callback"]:::purchaseValidation F049["F-049
Purchase Connector Config"]:::purchaseValidation F050["F-050
StoreKit Version Selection"]:::purchaseValidation F051["F-051
Android Validation Listeners"]:::purchaseValidation - F052["F-052
iOS Combined Validation Callback"]:::purchaseValidation + F052["F-052
iOS Validation Callback"]:::purchaseValidation F053["F-053
Google Play Data Models"]:::purchaseValidation F054["F-054
Build-Time Opt-in"]:::purchaseValidation F055["F-055
Missing-Config Guard"]:::purchaseValidation @@ -36,38 +28,22 @@ flowchart TD subgraph deepLinking ["deepLinking"] F014["F-014
Manual Deep-Link Re-trigger"]:::deepLinking F022["F-022
Push Deep-Link Path Config"]:::deepLinking - F031["F-031
Push Notification Data Handling"]:::deepLinking F035["F-035
Conversion Data Callback"]:::deepLinking - F036["F-036
App-Open Attribution Callback"]:::deepLinking F037["F-037
UDL Callback & Models"]:::deepLinking - F039["F-039
Native iOS Deep-Link Entry Points"]:::deepLinking - F040["F-040
Android New-Intent Forwarding"]:::deepLinking + F039["F-039
iOS Deep-Link Entry Points"]:::deepLinking + F040["F-040
Android New-Intent State"]:::deepLinking end subgraph oneLinkAndGrowth ["oneLinkAndGrowth"] F027["F-027
Invite Link Generation"]:::oneLinkAndGrowth F028["F-028
App Invite OneLink ID"]:::oneLinkAndGrowth - F029["F-029
Cross-Promotion Tracking"]:::oneLinkAndGrowth - F056["F-056
App Invite OneLink ID (init-time)"]:::oneLinkAndGrowth end F002 --> F001 F011 --> F001 F011 --> F002 - F021 --> F015 - F035 --> F001 - F036 --> F001 - F037 --> F001 F048 --> F001 - F057 --> F001 - F058 --> F001 - F059 --> F001 - - F023 --> F025 - F023 --> F038 - F024 --> F025 - F049 --> F051 - F049 --> F052 + F049 --> F054 F050 --> F049 F051 --> F049 @@ -78,15 +54,13 @@ flowchart TD F014 --> F037 F022 --> F037 - F031 --> F022 + F035 --> F001 + F035 --> F002 + F037 --> F001 F037 --> F039 F037 --> F040 F027 --> F028 - F027 --> F056 - F028 --> F056 - F029 --> F027 - F056 --> F028 classDef sdkCore fill:#4C6EF5,color:#fff classDef purchaseValidation fill:#F59F00,color:#fff @@ -96,74 +70,55 @@ flowchart TD --- -## Section 2 — Initialization Flow +## Section 2 — First-Launch Workflow -Features that configure, register, gate, or boot other features at startup time. `F-001` (SDK Initialization) is the sole boot entry point — every init-time option and startup-gated registration hangs off it directly. +This diagram is chronological, not a `depends_on` graph. It distinguishes calls that must precede `init()` from runtime configuration and explicit listener registration. Each listener takes its callback as an argument, so the callback is always in place before the native listener is installed and an immediate event cannot be lost. ```mermaid flowchart LR - F001["F-001 · SDK Initialization"]:::sdkCore - F002["F-002 · SDK Start"]:::sdkCore - F034["F-034 · Ad ID Collection Disable"]:::sdkCore - F037["F-037 · UDL Callback & Models"]:::deepLinking - F048["F-048 · Plugin Metadata Reporting"]:::sdkCore - F056["F-056 · App Invite OneLink ID (init-time)"]:::oneLinkAndGrowth - F057["F-057 · ASA Opt-out"]:::sdkCore - F058["F-058 · ATT Wait Timeout"]:::sdkCore - F059["F-059 · Debug Logging Toggle"]:::sdkCore - F011["F-011 · TCF/DMA Auto Consent"]:::sdkCore - - F001 -->|"gates start until manual-start configured"| F002 - F001 -->|"applies init-time disable flag"| F034 - F001 -->|"sets UDL registration flag"| F037 - F001 -->|"reports plugin type/version inline"| F048 - F001 -->|"applies init-time OneLink ID"| F056 - F001 -->|"applies init-time ASA opt-out"| F057 - F001 -->|"applies init-time ATT wait timeout"| F058 - F001 -->|"applies init-time debug flag"| F059 - F002 -->|"deferred start once CMP consent confirmed"| F011 + PRE["Pre-init configuration
F-067 timeout, F-022 push path,
iOS side of F-063"]:::config + F001["F-001 · init()"]:::sdkCore + CONFIG["Apply launch configuration
consent, identity, privacy;
Android side of F-063"]:::config + LISTENERS["Register conversion and/or UDL listeners"]:::listeners + SESSION["Register session-ready listener"]:::listeners + READY["session-ready callback runs
once per foreground cycle"]:::event + F002["F-002 · start()"]:::sdkCore + + PRE --> F001 --> CONFIG --> LISTENERS --> SESSION --> READY --> F002 classDef sdkCore fill:#4C6EF5,color:#fff - classDef deepLinking fill:#E64980,color:#fff - classDef oneLinkAndGrowth fill:#7048E8,color:#fff + classDef config fill:#495057,color:#fff + classDef dart fill:#12B886,color:#fff + classDef listeners fill:#7048E8,color:#fff + classDef event fill:#E64980,color:#fff ``` +Configuration that is native runtime state remains available across background-to-foreground cycles in the same process, but must be reapplied after a cold start. `start()` is still called for every session-ready event. Conversion-data registration alone does not issue a request; the Launch from `start()` triggers that work. + --- ## Section 3 — Dependency Table -| Feature | Depends On | Note | -|---------|-----------|------| -| F-002 | F-001 | SDK session start only makes sense after init/options have been validated and passed to native | -| F-011 | F-001 | TCF auto-consent collection requires manual-start init configuration | -| F-011 | F-002 | TCF auto-consent defers the actual `startSDK()` call until CMP consent is confirmed | -| F-014 | F-037 | Manual deep-link re-trigger forces the native SDK to re-run the same UDL resolution path | -| F-021 | F-015 | iOS routes `setCustomerIdAndLogSession` to the identical native handler as plain `setCustomerUserId` | -| F-022 | F-037 | Push-notification deep-link path config only matters once a payload reaches UDL resolution | -| F-023 | F-025 | iOS validates against the sandbox/production endpoint set by the receipt-validation toggle | -| F-023 | F-038 | V1 validation delivers its async result through the legacy purchase-validation callback | -| F-024 | F-025 | iOS validates against the sandbox/production endpoint set by the receipt-validation toggle | -| F-027 | F-028 | Invite-link generation needs a base OneLink ID configured at runtime | -| F-027 | F-056 | Invite-link generation needs a base OneLink ID configured at init time (whichever wrote last wins) | -| F-028 | F-056 | Both setters write the same native OneLink-ID property — last write wins | -| F-029 | F-027 | iOS cross-promotion reuses the same invite-URL generator helper as invite-link generation | -| F-031 | F-022 | Push notification data handling resolves deep links using the registered JSON key-path | -| F-035 | F-001 | Conversion data delivery is gated by SDK init/start having registered the listener | -| F-036 | F-001 | App-open attribution delivery is gated by SDK init/start having registered the listener | -| F-037 | F-001 | UDL listener/delegate registration is gated by the UDL flag set during init | -| F-037 | F-039 | UDL resolution on iOS is fed by the native URL-scheme/Universal-Link/Scene entry points | -| F-037 | F-040 | UDL resolution on Android is fed by the new-intent forwarding entry point | -| F-048 | F-001 | Plugin metadata is reported inline as part of the native `initSdk` call | -| F-049 | F-051 | Android `configure()` requires the validation-result listener object as a constructor param | -| F-049 | F-052 | iOS `configure()` assigns the purchase-revenue delegate that the combined callback depends on | -| F-049 | F-054 | Purchase Connector only compiles/registers when the build-time opt-in is enabled | -| F-050 | F-049 | StoreKit version is packed into the shared `configure()` payload owned by F-049 | -| F-051 | F-049 | Android validation listeners only receive events once `configure()`/observation has started | -| F-052 | F-049 | iOS combined validation callback relies on the delegate wired during `configure()` | -| F-053 | F-049 | Data models are payload shapes exchanged only through the configured connector | -| F-053 | F-051 | Data models are referenced exclusively from the Android validation-result listener models | -| F-055 | F-049 | Guard exists specifically to catch use of Purchase Connector APIs before `configure()` runs | -| F-056 | F-028 | Both setters write the same native OneLink-ID property — last write wins | -| F-057 | F-001 | ASA opt-out is an init-time option validated/applied inside `initSdk` | -| F-058 | F-001 | ATT wait timeout is an init-time option validated/applied inside `initSdk` | -| F-059 | F-001 | Debug logging is an init-time option validated/applied inside `initSdk` | +| Feature | Depends On | Verified reason | +|---------|------------|-----------------| +| F-002 | F-001 | Native session start requires initialization | +| F-011 | F-001 | Documented automatic-consent setup runs after initialization | +| F-011 | F-002 | The consent workflow gates the first Launch until CMP state is ready | +| F-014 | F-037 | Resolution result is delivered through the UDL callback | +| F-022 | F-037 | Configured push URL is useful to the Flutter app through UDL delivery | +| F-027 | F-028 | Invite generation requires a base OneLink ID | +| F-035 | F-001 | Conversion listener is explicitly registered after initialization | +| F-035 | F-002 | The Launch sent by `start()` triggers conversion-data retrieval | +| F-037 | F-001 | UDL listener is explicitly registered before initialization | +| F-037 | F-039 | iOS URL/Universal Link/UIScene entry points feed native resolution | +| F-037 | F-040 | Android warm-intent state must be current for lifecycle resolution | +| F-048 | F-001 | Plugin metadata is reported inside native init orchestration | +| F-049 | F-054 | Native Purchase Connector exists only in opted-in builds | +| F-050 | F-049 | StoreKit selection is part of the connector configure payload | +| F-051 | F-049 | Android callbacks require the configured connector and observation lifecycle | +| F-052 | F-049 | iOS callback relies on the delegate wired by connector configuration | +| F-053 | F-049 | Models are populated only by configured connector callbacks | +| F-053 | F-051 | The models are the Android listener payload shapes | +| F-055 | F-049 | Guard protects first construction of the connector singleton | + +Optional relationships are intentionally excluded. In particular, F-025 is an optional sandbox switch for F-024; F-031 can attribute a push without F-022; and F-060's exclusion of Purchase Connector is a packaging constraint rather than a runtime dependency. diff --git a/internal-docs/features/F-001-sdk-initialization.md b/internal-docs/features/F-001-sdk-initialization.md index 39a3a68a..6af28646 100644 --- a/internal-docs/features/F-001-sdk-initialization.md +++ b/internal-docs/features/F-001-sdk-initialization.md @@ -1,79 +1,76 @@ --- id: F-001 -name: SDK Initialization & Options Validation +name: SDK Initialization type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -This is the entry point that wires the Flutter app's dev key, app ID and startup flags into the native AppsFlyer SDK. Without it, no other AppsFlyer API works: no attribution, no events, no deep linking. The Dart-side validation (`_validateAFOptions` / `_validateMapOptions`) catches misconfiguration early (missing dev key, malformed iOS numeric App Store ID) via `assert`s, and decides whether the SDK auto-starts or waits for an explicit `startSDK()` call (F-002). It also stamps the plugin's identity (`Plugin.FLUTTER` / `AFSDKPluginFlutter`) onto the native SDK so AppsFlyer's backend can attribute traffic to the Flutter wrapper. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +`AppsFlyerSdk.init` configures the native AppsFlyer SDK 7 instance with the developer key and, on iOS, the Apple App ID. Before initialization, the platform bridge makes a best-effort call that identifies the integration as the Flutter plugin; failure of this reporting step does not abort initialization. Initialization does not register optional native listeners and does not send a session; those operations remain explicit public API calls. --- ## Trigger -Called once by the host app immediately after constructing `AppsflyerSdk(options)`, typically in `main()` before `runApp()`. Runs whenever `initSdk()` is invoked, regardless of whether `AppsFlyerOptions` (typed) or a raw `Map` was passed to the factory constructor. +Intended to be called once during application setup through the shared `AppsFlyerSdk.instance`, and before registering the native conversion, deep-link, or session-ready listeners the app needs. Dart does not enforce a single call. Android accepts no `appId`; iOS requires a non-empty value. --- ## Call Chain +All Dart-to-native traffic uses the `af-api` `MethodChannel`. The public method wraps the platform-specific initialization parameters in the standard `{method, params}` RPC envelope. + ``` -AppsflyerSdk(options) factory [lib/src/appsflyer_sdk.dart] - → AppsflyerSdk.private(...) [lib/src/appsflyer_sdk.dart] -AppsflyerSdk.initSdk({registerConversionDataCallback, registerOnAppOpenAttributionCallback, registerOnDeepLinkingCallback}) - → _validateAFOptions(AppsFlyerOptions) | _validateMapOptions(Map) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("initSdk", validatedOptions) - → Android: AppsflyerSdkPlugin.onMethodCall("initSdk") → initSdk(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().init(afDevKey, gcdListener, mContext) - → instance.start(activity) [only if isManualStartMode == false] - → iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") → initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [AppsFlyerLib shared].appsFlyerDevKey / .appleAppID / .isDebug = ... - → [[AppsFlyerLib shared] start] [only if manualStart == NO] +AppsFlyerSdk.instance.init(devKey: ..., appId: ...) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('init', platform-specific params) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.initFromRpc [android/.../AppsflyerSdkPlugin.kt] + → best-effort setPluginInfo(plugin: flutter, pluginVersion); failure is ignored + → Android RPC init(devKey) + → iOS: AppsflyerSdkPlugin.initFromRpc [ios/.../AppsflyerSdkPlugin.swift] + → best-effort setPluginInfo(plugin: flutter, pluginVersion); failure is ignored + → iOS RPC initialize(devKey, appId) + → handle pending launch options, when present + → mark the attribution bridge ready and flush queued lifecycle requests ``` +Listener registration is intentionally not part of this sequence. The app separately calls `registerConversionListener`, `registerDeepLinkListener`, and/or `registerSessionReadyListener` after initialization. + --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `initSdk`, `_validateAFOptions`, `_validateMapOptions` — validation + MethodChannel dispatch | -| `lib/src/appsflyer_options.dart` | `AppsFlyerOptions` typed config model (devKey, appId, ATT wait time, manualStart, etc.) | -| `lib/src/appsflyer_constants.dart` | String keys shared across Dart/native (`AF_DEV_KEY`, `AF_APP_Id`, `AF_MANUAL_START`, `AF_GCD`, `AF_UDL`, `PLUGIN_VERSION`) | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `initSdk(call, result)` — native Android init, conditional auto-start | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | Native Android mirror of the Dart string keys | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` — native iOS init, conditional auto-start | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define` string keys (`afDevKey`, `afAppId`, `afManualStart`, …) and `kAppsFlyerPluginVersion` | +| `lib/src/appsflyer_sdk.dart` | `AppsFlyerSdk.instance`, `init`, `_invokeVoidRpc`, and `_invokeRpc` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Makes the non-blocking `setPluginInfo` call before the required `init` RPC; dev-key validation is left to the RPC layer so its `422` reaches the caller intact | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Makes the non-blocking `setPluginInfo` call before `initialize`, forwards pending launch options, and marks the attribution bridge ready | --- ## Input / Output | | | |--|--| -| **Input** | `afDevKey` (String, required), `appId` (String, required on iOS — validated against `^\d{8,11}$`), `showDebug` (bool), `manualStart` (bool), `timeToWaitForATTUserAuthorization` (double, iOS only), `disableAdvertisingIdentifier` (bool), `disableCollectASA` (bool, iOS only), `appInviteOneLink` (String?), plus derived flags `GCD`/`UDL` computed from the `registerConversionDataCallback` / `registerOnAppOpenAttributionCallback` / `registerOnDeepLinkingCallback` parameters | -| **Output** | Native SDK instance initialized and, unless `manualStart: true`, started; Android returns `"success"` string to Dart, iOS returns `{"status": "OK"}`. Neither is currently exposed to the caller since `initSdk()`'s returned `Future` is rarely awaited for its value. | +| **Input** | `devKey` (`String`, required by both native RPC layers); `appId` (`String?`, required by the native iOS RPC layer, omitted from the Android RPC request). Dart does not validate either value before transport. | +| **Output** | `Future` that completes after the required initialization operations succeed: Android `init`, or iOS `initialize` plus pending launch-options handling when present. Invalid input is validated by the native RPC layer and surfaced as `AppsFlyerException` when the RPC reports an error. `setPluginInfo` failure is intentionally non-blocking. No session is sent. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check initSdk call` (line 93) constructs `AppsflyerSdk.private(...)` with `mapOptions` and asserts the mocked channel receives `initSdk`. This exercises `_validateMapOptions` end-to-end but does not assert on the resulting validated map's contents, and does not cover `_validateAFOptions` (the typed `AppsFlyerOptions` path) or the iOS App ID regex / ATT-wait-time assertions at all — those run only under `Platform.isIOS`, which the Dart test environment does not satisfy. +`test/appsflyer_sdk_test.dart` verifies the iOS payload, confirms that Android omits `appId`, allows Android initialization without it, forwards invalid `devKey`/`appId` values to the native RPC layer instead of validating them in Dart, and verifies the singleton entry point. --- ## Known Limitations -- Validation uses Dart `assert()`, which is stripped in release/profile builds — a missing `afDevKey` or malformed iOS `appId` will silently pass validation in release mode and only fail (or silently misbehave) once it reaches native code. -- The plugin version string is duplicated in three places and has drifted: Dart `AppsflyerConstants.PLUGIN_VERSION = "6.17.9"` (`lib/src/appsflyer_constants.dart`) vs. Android `AppsFlyerConstants.PLUGIN_VERSION = "6.18.0"` and iOS `kAppsFlyerPluginVersion = "6.18.0"` (matching `pubspec.yaml`'s `6.18.0`). The value reported to AppsFlyer's backend via `PluginInfo`/`setPluginInfoWith:` therefore differs from what `getVersionNumber()` (F-003) returns to the app. -- `disableCollectASA` and `timeToWaitForATTUserAuthorization` are only read/applied on iOS; on Android these options are silently ignored (no assertion or warning). -- Android's `initSdk` calls `result.success("success")` unconditionally at the end, even though `setDisableAdvertisingIdentifiers`, `subscribeForDeepLink`, etc. earlier in the method have no error handling — a native exception before that line surfaces to Flutter only as a generic platform exception, not one of the plugin's own error codes. +- Input validation for `devKey` and `appId` is performed by the native RPC layer. Android rejects an empty `devKey` through `InitRequest` (`422`); iOS rejects a missing or empty `appId` through `AFRPCInitRequest`. Dart forwards the values as supplied and does not validate them before transport. +- Plugin identification is best-effort on both platforms. A `setPluginInfo` failure does not fail `init()`, so successful completion confirms native initialization but not successful plugin-info reporting. +- Initialization alone does not produce conversion, deep-link, session-ready, or Launch events. The relevant native listeners and `start()` must be invoked explicitly. --- ## Dependencies ```mermaid flowchart LR - F001["F-001 · SDK Initialization & Options Validation"]:::sdkCore + F001["F-001 · SDK Initialization"]:::sdkCore classDef sdkCore fill:#4C6EF5,color:#fff ``` diff --git a/internal-docs/features/F-002-sdk-start.md b/internal-docs/features/F-002-sdk-start.md index e4d4b554..cc19903c 100644 --- a/internal-docs/features/F-002-sdk-start.md +++ b/internal-docs/features/F-002-sdk-start.md @@ -1,42 +1,56 @@ --- id: F-002 -name: SDK Start (auto/manual + result handler) +name: SDK Start (session launch) type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: ["F-001"] --- ## Business Purpose -When `manualStart: true` is set on `AppsFlyerOptions` (F-001), the native SDK is initialized but deliberately does **not** begin sending sessions/attribution requests — this lets the host app gate the first network call behind consent collection (see F-011/F-012) or other startup preconditions. `startSDK()` is the trigger that actually opens the session. Without it, apps using manual-start mode would never attribute installs or sessions. The optional `onSuccess`/`onError` handler variant lets the app know definitively whether the first session request succeeded, which matters for CMP/consent flows that need to confirm the SDK is live before proceeding. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +`AppsFlyerSdk.start` asks the native SDK 7 to send a session (Launch). The application is expected to call it from the callback registered with `registerSessionReadyListener`; neither Dart nor the RPC layer checks readiness before forwarding the call. Keeping initialization and session start separate lets the application defer a session for consent, Customer User ID, ATT, or another application condition. --- ## Trigger -Called explicitly by the host app after `initSdk()` when `manualStart: true` was configured. Also implicitly satisfied automatically inside `initSdk`/`initSdkWithCall:` on both platforms when `manualStart` is `false` (the default), meaning most apps never call `startSDK()` directly. +The host app calls `registerSessionReadyListener(onReady)` after `init()` and awaits `start()` for each invocation of that callback. Configuration setters that must affect the Launch are applied before `start()`. --- ## Call Chain +`start` forwards the public `awaitResponse` flag to the native RPC layer without calling `isSessionReady`. Default `false` is fire-and-forget; `true` waits for the native request completion callback. + ``` -AppsflyerSdk.startSDK({onSuccess, onError}) [lib/src/appsflyer_sdk.dart] - → guards on _isSdkStarted (no-op if already started) - → if onSuccess/onError provided: - _methodChannel.setMethodCallHandler(...) // listens for native "onSuccess"/"onError" - _methodChannel.invokeMethod('startSDKwithHandler') - → Android: AppsflyerSdkPlugin.startSDKwithHandler(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().start(activity, null, AppsFlyerRequestListener) - → onSuccess()/onError() → mMethodChannel.invokeMethod("onSuccess"|"onError") - → iOS: AppsflyerSdkPlugin.startSDKwithHandler:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] startWithCompletionHandler:^(...)] - → [_methodChannel invokeMethod:@"onSuccess"|@"onError" ...] - → else: - _methodChannel.invokeMethod('startSDK') - → Android: AppsflyerSdkPlugin.startSDK(call, result) → AppsFlyerLib.getInstance().start(activity) - → iOS: AppsflyerSdkPlugin.startSDK:result: → [[AppsFlyerLib shared] start] +AppsFlyerSdk.registerSessionReadyListener(onReady) + → _ensureEventsSubscribed() + _listeners.on('onSessionReady', …) + (one callback slot, replaced on re-registration) + → RPC 'registerSessionReadyListener' + → native listener retained across foreground cycles + → after configuration and launch deep-link processing complete or time out + → native event 'onSessionReady' → EventChannel('af-events') + → _AppsFlyerListenerRegistry.dispatch → onReady() + +AppsFlyerSdk.isSessionReady() + → RPC 'isSessionReady' → current native readiness boolean; unexpected null throws AppsFlyerException + +AppsFlyerSdk.start({awaitResponse}) [lib/src/appsflyer_sdk.dart] + → no Dart or RPC readiness check; the app is responsible for session-ready ordering + → _invokeVoidRpc('start', {'awaitResponse': awaitResponse}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → awaitResponse true: AppsFlyerLib.start(requestListener), bounded by the bridge's own 5s wait + (awaitCallback / START_TIMEOUT_MILLIS) + → awaitResponse false: AppsFlyerLib.start() with no request listener; RPC returns immediate success + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → awaitResponse true: AppsFlyerLib.startWithCompletionHandler, bounded by the bridge's own 10s wait + (SDKTimeoutHelper.defaultTimeout) + → awaitResponse false: start without waiting for the completion handler + → awaitResponse true: successful per-call reply completes Future; PlatformException → AppsFlyerException + → awaitResponse false: Future completes after native start returns and RPC reports immediate success + +AppsFlyerSdk.unregisterSessionReadyListener() + → RPC 'unregisterSessionReadyListener' → removes the native listener on both platforms ``` --- @@ -44,36 +58,42 @@ AppsflyerSdk.startSDK({onSuccess, onError}) [li ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `startSDK()` — guards double-start via `_isSdkStarted`, chooses handler vs. plain path | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `startSDK`, `startSDKwithHandler` — native start, posts `onSuccess`/`onError` back on the UI thread | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `startSDK:result:`, `startSDKwithHandler:result:` — native start, dispatches completion handler results on main queue | +| `lib/src/appsflyer_sdk.dart` | `registerSessionReadyListener(onReady)`, the `OnSessionReady` typedef, and `start({bool awaitResponse = false})` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Forwards `start` through the Android RPC handler | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Forwards `start` through the iOS RPC bridge | --- ## Input / Output | | | |--|--| -| **Input** | Optional `RequestSuccessListener onSuccess` and `RequestErrorListener onError` Dart callbacks; no other parameters | -| **Output** | No return value from `startSDK()` itself (`void`). If a handler was supplied, the native side invokes `onSuccess` with no arguments, or `onError(int errorCode, String errorMessage)` back through the Dart `MethodChannel.setMethodCallHandler`, after which the handler is torn down (`setMethodCallHandler(null)`) so it fires only once. | +| **Input** | Listener registration/unregistration and readiness query take no arguments. `start` takes `awaitResponse` (`bool`, named, default `false`) — when `true`, wait for the native request callback; when `false`, return after the native fire-and-forget method returns and RPC reports immediate success. | +| **Output** | `registerSessionReadyListener()` and `unregisterSessionReadyListener()` return `Future` after synchronous native registration state changes. `isSessionReady()` returns `Future`; an unexpected native null reply throws `AppsFlyerException` instead of being reported as `false`. The registered `onReady` callback is invoked once per foreground cycle. For `start()`, the default fire-and-forget completion does not prove a Launch was sent; `awaitResponse: true` completes on the native request callback or throws `AppsFlyerException` for native errors/timeouts. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart` covers `initSdk`, `setHost`, `logEvent`, etc., but has no test invoking `instance.startSDK(...)` in either its handler or plain form, and no test for the `_isSdkStarted` double-start guard. +`test/appsflyer_sdk_test.dart` verifies registration/unregistration RPC names, the `isSessionReady` return value, delivery of a payload-free `onSessionReady` event to the registered callback, that re-registering replaces the callback rather than adding a second one (so `start()` cannot be issued twice for one readiness event), that unregistering drops it, the default `start()` payload, and explicit `awaitResponse: true`. Error tests verify shared `PlatformException` to `AppsFlyerException` conversion. Native session-readiness lifecycle tests live in the Android and iOS SDK repositories; no Flutter device test covers a full background-to-foreground cycle. --- ## Known Limitations -- `_isSdkStarted` is set to `true` as a side effect of `initSdk()` whenever `manualStart == false` (auto-start mode). If the host app then also calls `startSDK()` "just in case," the Dart guard silently no-ops it — this is correct behavior but is easy to misread as a bug when debugging why a manually-added `startSDK()` call appears to do nothing. -- If `startSDK()` is called with a handler and the native side never calls back (e.g. process death, or an unexpected exception path), the Dart method handler is never cleared and `_isSdkStarted` remains `true` forever, permanently blocking any future `startSDK()` call for that app session. -- On the `default` branch of the Android `setMethodCallHandler` switch (i.e. an unrecognized method name arrives), the Dart code resets `_isSdkStarted = false`, which would allow a subsequent `startSDK()` call to fire a second native `start()` — this branch is not currently exercised by any real native call and appears to be defensive/dead code. -- iOS's `startSDKwithHandler:` also registers a `UIApplicationDidBecomeActiveNotification` observer (`appDidBecomeActive`) as a side effect of the plain `startSDK:` path but not from within `startSDKwithHandler:` itself — foreground-resume auto-restart behavior differs subtly between the two start paths. +- The app must keep a session-ready listener registered and call `start()` for every invocation of its callback. +- The plugin holds one session-ready callback, replaced on re-registration; there is no public stream, so two parts of an app cannot each trigger `start()` for the same readiness event. A readiness event arriving before `registerSessionReadyListener()` has run is held by `_AppsFlyerListenerRegistry` and replayed when the listener registers. After the listener has been registered once, a readiness event arriving while it is unregistered is logged and dropped instead. +- `isSessionReady()` is a snapshot, not a substitute for the per-cycle callback. The native readiness state resets when the app backgrounds, while the registered listener is retained until explicitly unregistered or the native state is torn down. On Android the listener outlives the Flutter engine, because the `AppsFlyerRpcHandler` holding it is process-scoped (`AppsFlyerRpcBridge`); the Dart callback does not, so a recreated engine must call `registerSessionReadyListener()` again. +- Neither the Flutter layer nor either RPC handler calls `isSessionReady` before forwarding `start`; correct ordering is the application's responsibility. +- Android requires both prior initialization and a registered native session-ready listener. If either is missing, the native SDK logs a warning and returns without sending a Launch. With `awaitResponse: false`, Dart still receives the RPC's immediate success; with `awaitResponse: true`, no native callback arrives and the Android RPC reports its 5-second timeout. Android also ignores repeated `start()` calls within the same native session. +- iOS `start` does not itself enforce session-ready listener registration or readiness. Its public native contract instructs callers to invoke it from the session-ready listener. +- The Flutter layer does not synthesize a session or retry a failed native request. +- When `awaitResponse` is `true`, the RPC bridge bounds its wait for the native completion callback with its own internal timeout (Android: 5s `START_TIMEOUT_MILLIS`; iOS: 10s `SDKTimeoutHelper.defaultTimeout`) — not exposed to or configurable from Dart, and not equal between platforms for the same Dart API. If the native request legitimately takes longer, the bridge reports a timeout `AppsFlyerException` even though the request may still succeed natively afterward; neither bridge reports that later outcome. Error codes are native-bridge-owned and are not identical across platforms. +- Both timeout values live outside this plugin (`appsflyer-android-sdk` plugin_bridge / `appsflyer.sdk.ios` AppsFlyerRPC); aligning or exposing them would require a change in those repositories. +- When `awaitResponse` is `false`, delivery success or failure is not surfaced to Dart. --- ## Dependencies ```mermaid flowchart LR - F002["F-002 · SDK Start"]:::sdkCore -->|"only meaningful when manualStart is set during"| F001["F-001 · SDK Initialization & Options Validation"]:::sdkCore + F002["F-002 · SDK Start"]:::sdkCore -->|"requires initialized SDK"| F001["F-001 · SDK Initialization"]:::sdkCore classDef sdkCore fill:#4C6EF5,color:#fff ``` diff --git a/internal-docs/features/F-003-sdk-plugin-version-retrieval.md b/internal-docs/features/F-003-sdk-plugin-version-retrieval.md index 5b031235..190218b9 100644 --- a/internal-docs/features/F-003-sdk-plugin-version-retrieval.md +++ b/internal-docs/features/F-003-sdk-plugin-version-retrieval.md @@ -4,14 +4,12 @@ name: SDK/Plugin Version Retrieval type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Support and QA need a reliable way to answer "which native AppsFlyer SDK build, and which Flutter plugin build, is actually running in this app?" `getSDKVersion()` surfaces the native SDK's own version string (useful for diagnosing SDK-side bugs against AppsFlyer's release notes), while `getVersionNumber()` surfaces the Flutter plugin wrapper's own version. Without these, bug reports and support tickets would rely on the app's `pubspec.yaml`/podspec pin, which does not confirm what was actually compiled into the running binary. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Support and QA need a reliable way to answer "which native AppsFlyer SDK build, and which Flutter plugin build, is actually running in this app?" `getSdkVersion()` surfaces the native SDK's own version string (for diagnosing SDK-side bugs against AppsFlyer's release notes); the `pluginVersion` getter surfaces the Flutter plugin wrapper's version. Without these, bug reports would rely on the app's `pubspec.yaml`/podspec pin, which does not confirm what was actually compiled into the running binary. --- @@ -21,16 +19,20 @@ Called on demand by host app code — typically diagnostic/support tooling, debu --- ## Call Chain +`getSdkVersion()` is a correlated RPC (`getSdkVersion`); `pluginVersion` is a synchronous Dart getter with no channel call. + ``` -AppsflyerSdk.getSDKVersion() [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("getSDKVersion") - → Android: AppsflyerSdkPlugin.onMethodCall("getSDKVersion") → getSdkVersion(result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().getSdkVersion() - → iOS: AppsflyerSdkPlugin.handleMethodCall("getSDKVersion") → getSDKVersion:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] getSDKVersion] - -AppsflyerSdk.getVersionNumber() [lib/src/appsflyer_sdk.dart] - → returns AppsflyerConstants.PLUGIN_VERSION (pure Dart constant, no channel call) [lib/src/appsflyer_constants.dart] +AppsFlyerSdk.getSdkVersion() [lib/src/appsflyer_sdk.dart] + → _invokeRpc('getSdkVersion') + → MethodChannel('af-api').invokeMethod('executeRpc', {method:'getSdkVersion', params:{}}) + → Android: dispatchRpc → AppsFlyerRpcHandler → AppsFlyerLib.getSdkVersion() [android/.../AppsflyerSdkPlugin.kt] + → iOS: dispatchRpc → AppsFlyerRPCBridge → [AppsFlyerLib shared] ... [ios/.../AppsflyerSdkPlugin.swift] + (iOS unwraps the version from the nested {data:{version}} result) + → unexpected null reply throws AppsFlyerException (` returned no value`) + → PlatformException is converted to AppsFlyerException + +AppsFlyerSdk.pluginVersion [lib/src/appsflyer_sdk.dart] + → returns _AppsFlyerConstants.PLUGIN_VERSION (pure Dart constant, no channel call) [lib/src/appsflyer_constants.dart] ``` --- @@ -38,10 +40,10 @@ AppsflyerSdk.getVersionNumber() [lib/src/a ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `getSDKVersion()` (async, native round-trip), `getVersionNumber()` (sync, local constant) | -| `lib/src/appsflyer_constants.dart` | `PLUGIN_VERSION` constant returned by `getVersionNumber()` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `getSdkVersion(result)` — proxies `AppsFlyerLib.getInstance().getSdkVersion()` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `getSDKVersion:result:` — proxies `[AppsFlyerLib shared] getSDKVersion]` | +| `lib/src/appsflyer_sdk.dart` | `Future getSdkVersion()` (async RPC round-trip), `String get pluginVersion` (sync, local constant) | +| `lib/src/appsflyer_constants.dart` | `_AppsFlyerConstants.PLUGIN_VERSION = "7.0.1"` constant returned by `pluginVersion` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | generic `getSdkVersion` dispatch over `AppsFlyerRpcHandler` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | generic `getSdkVersion` dispatch; `unwrapValueForMethod` extracts `data.version` | --- @@ -49,18 +51,18 @@ AppsflyerSdk.getVersionNumber() [lib/src/a | | | |--|--| | **Input** | None | -| **Output** | `getSDKVersion()` → `Future` — the native AppsFlyer SDK's own version string. `getVersionNumber()` → `String` — the Flutter plugin's hardcoded version constant (synchronous, no native call). | +| **Output** | `getSdkVersion()` → `Future` — the native AppsFlyer SDK's version string. `pluginVersion` → `String` — the Flutter plugin's version constant (`7.0.1`, synchronous). | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check getSDKVersion call` (line 192) asserts the mocked channel receives `getSDKVersion`. No test exists for `getVersionNumber()` (trivial, but untested). +`test/appsflyer_sdk_test.dart` → `'maps getters and native return values'` verifies that `getSdkVersion()` dispatches RPC method `getSdkVersion` with empty params and returns the version string from the mocked native reply. `'pluginVersion exposes the compiled plugin version constant'` asserts that `pluginVersion` returns the compiled `PLUGIN_VERSION` constant (`7.0.1`) without a channel call. --- ## Known Limitations -- `AppsflyerConstants.PLUGIN_VERSION` in Dart (`lib/src/appsflyer_constants.dart`) is hardcoded to `"6.17.9"`, while `pubspec.yaml`'s package version, Android's `AppsFlyerConstants.PLUGIN_VERSION`, and iOS's `kAppsFlyerPluginVersion` are all `"6.18.0"`. `getVersionNumber()` therefore returns a stale value one release behind the actual plugin version and the value the native layer reports upstream to AppsFlyer via `setPluginInfo`/`setPluginInfoWith:` (see F-001). This is a manual-bump constant with no single source of truth or CI check tying it to `pubspec.yaml`. -- `getVersionNumber()` reports the *plugin's* version, not the native SDK's version — the naming similarity to `getSDKVersion()` is a common source of confusion for integrators. +- `_AppsFlyerConstants.PLUGIN_VERSION` is a separate compiled constant rather than a runtime read of `pubspec.yaml`. The RC and production-promotion workflows rewrite the Dart, Android (`AppsFlyerConstants.kt`), and iOS (`kAppsFlyerPluginVersion` in `AppsflyerSdkPlugin.swift`) constants alongside the package version, but no general validation check guarantees that they remain equal when changes are made outside those workflows. +- `pluginVersion` reports the *plugin's* version, not the native SDK's — integrators looking for the native version must use `getSdkVersion()`. --- diff --git a/internal-docs/features/F-004-in-app-event-logging.md b/internal-docs/features/F-004-in-app-event-logging.md index 8cf260b3..0983b41d 100644 --- a/internal-docs/features/F-004-in-app-event-logging.md +++ b/internal-docs/features/F-004-in-app-event-logging.md @@ -4,32 +4,33 @@ name: In-App Event Logging type: eventsAndRevenue platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose AppsFlyer's attribution model can only compute ROI (Return on Investment) and LTV (Lifetime Value) for media sources if the app reports what users actually *do* after install — purchases, tutorial completions, level-ups, subscriptions, etc. `logEvent` is the single funnel through which every custom in-app event (a name plus an arbitrary value map) reaches AppsFlyer's backend and is joined to the installing campaign/media-source. Without it, install attribution would exist in isolation with no downstream engagement or monetization signal, making campaign performance comparison and LTV/ROI reporting impossible. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger -Called by the host app at any point after the SDK is initialized, whenever a business-significant in-app action occurs (e.g. purchase, level completion, tutorial finish, subscription). +The host app awaits `AppsFlyerSdk.instance.logEvent(...)` whenever a business-significant in-app action occurs (purchase, level completion, tutorial finish, subscription). The supported lifecycle is to call it after `init()` and the first `start()`; neither Dart nor the RPC layers enforce that ordering before forwarding the event. --- ## Call Chain +`logEvent` forwards the public `awaitResponse` flag to the native RPC layer. Default `false` is fire-and-forget; `true` waits for the native request completion callback. + ``` -AppsflyerSdk.logEvent(eventName, eventValues) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("logEvent", {'eventName': ..., 'eventValues': ...}) - → Android: AppsflyerSdkPlugin.onMethodCall("logEvent") → logEvent(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().logEvent(mContext, eventName, eventValues) - → result.success(true) - → iOS: AppsflyerSdkPlugin.handleMethodCall("logEvent") → logEventWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] logEvent:eventName withValues:eventValues] - → result(@YES) +AppsFlyerSdk.logEvent(eventName, {eventValues, awaitResponse}) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('logEvent', {eventName, eventValues, awaitResponse}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.logEvent(...) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → AppsFlyerLib logEvent + → awaitResponse true: successful per-call reply completes Future; PlatformException → AppsFlyerException + → awaitResponse false: Future completes after the native fire-and-forget API returns and RPC reports immediate success ``` --- @@ -37,31 +38,35 @@ AppsflyerSdk.logEvent(eventName, eventValues) ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `logEvent(String eventName, Map? eventValues)` — Dart public API, returns `Future` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `logEvent(MethodCall, Result)` — reads `AF_EVENT_NAME`/`AF_EVENT_VALUES` args, forwards to `AppsFlyerLib.getInstance().logEvent(mContext, eventName, eventValues)`, always returns `result.success(true)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `logEventWithCall:result:` — reads `eventName`/`eventValues` (normalizes `NSNull` to `nil`), forwards to `[[AppsFlyerLib shared] logEvent:withValues:]`, always returns `result(@YES)`; comment `//TODO: Add callback handler` marks that no completion callback is wired | -| `doc/InAppEvents.md` | Public integration guide with usage example | +| `lib/src/appsflyer_sdk.dart` | `logEvent(String eventName, {Map? eventValues, bool awaitResponse = false})` — public API over the shared RPC path | +| `lib/src/appsflyer_exception.dart` | `AppsFlyerException.fromPlatformException` — converts the native error reply into a typed Dart exception | +| `android/.../AppsflyerSdkPlugin.kt` | No per-method handler — generic `executeRpc` → `dispatchRpc('logEvent', ...)` forwards the envelope to `AppsFlyerRpcHandler` | +| `ios/.../AppsflyerSdkPlugin.swift` | No per-method handler — generic `executeRpc` → `dispatchRpc` forwards the envelope to `AppsFlyerRPCBridge` | +| `doc/in-app-events.md` | Public integration guide with usage example | --- ## Input / Output | | | |--|--| -| **Input** | `eventName` (String, required — AppsFlyer docs recommend ≤45 chars or the event is dropped from the dashboard but still visible in raw data); `eventValues` (Map, nullable — arbitrary event parameters, e.g. `af_revenue`, `af_content_id`) | -| **Output** | `Future` — on both platforms this resolves to `true` unconditionally once the native SDK call is *dispatched*; it does not reflect whether the event was actually delivered to/accepted by AppsFlyer's backend (no listener/callback is wired on either platform) | +| **Input** | `eventName` (`String`, required and non-empty on both RPC layers; Android additionally enforces a maximum of 255 characters, while iOS has no RPC-level maximum); `eventValues` (`Map?`, optional named — values must survive the Flutter platform codec and the platform plugin's JSON serialization); `awaitResponse` (`bool`, named, default `false` — when `true`, wait for the native request callback; when `false`, return after the native fire-and-forget API returns and RPC reports immediate success). | +| **Output** | `Future`. With the default `awaitResponse: false`, completion does not confirm delivery. With `awaitResponse: true`, completes when the native request succeeds and throws `AppsFlyerException` for native errors or RPC timeouts. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check logEvent call` (line 115) awaits `logEvent("eventName", {"key": "val"})` against a mocked channel and asserts the channel receives the `logEvent` invocation; it only exercises the Dart-to-channel dispatch, not native behavior or the actual return value semantics. +`test/appsflyer_sdk_test.dart`: +- `logEvent is fire-and-forget by default` — asserts the `logEvent` RPC is dispatched with `eventName`, `eventValues`, and `awaitResponse: false`. +- `logEvent can wait for the native request callback` — asserts `awaitResponse: true` is forwarded. +- `PlatformException with a numeric RPC code becomes AppsFlyerException` — drives a failing `logEvent` call with platform code `422` and verifies code `422` and message. --- ## Known Limitations -- **No delivery confirmation on either platform**: both native handlers call the fire-and-forget overload of the AppsFlyer SDK's `logEvent` (no `AppsFlyerRequestListener`/completion block) and immediately return `true`/`@YES`. A caller awaiting `logEvent()` gets no signal about whether the event actually reached AppsFlyer — the returned boolean only reflects "the method call was processed," not "the event was sent successfully." -- iOS explicitly documents this gap in-code: `//TODO: Add callback handler` in `logEventWithCall:result:`. -- No client-side validation of the 45-character event-name limit; events with longer names still get accepted by the plugin and are silently excluded from the AppsFlyer dashboard (only visible via raw data/Pull/Push APIs), per `doc/InAppEvents.md`. -- `eventValues` accepts an untyped `Map`, so type mismatches (e.g. non-JSON-serializable values) are only caught when the native SDK attempts to serialize the payload, not at the Dart call site. +- Dart performs no event-name validation. Both RPC layers reject an empty name with code `422`; Android also rejects names longer than 255 characters, while iOS applies no bridge-level maximum. Any additional backend or dashboard limit is outside the verified plugin/RPC contract. +- `eventValues` accepts `Map`, but not every Dart object is transport-safe. Unsupported values can fail in the Flutter platform-channel codec or the platform plugin's JSON serialization before reaching the native SDK; there is no Dart-side schema validation. +- When `awaitResponse` is `true`, the RPC wait is bounded to 5 seconds on Android and 10 seconds on iOS. A timeout throws `AppsFlyerException` but does not cancel the native request, which may still succeed later without another Dart result. +- When `awaitResponse` is `false`, delivery success or failure is not surfaced to Dart. --- diff --git a/internal-docs/features/F-005-ad-revenue-logging.md b/internal-docs/features/F-005-ad-revenue-logging.md index d3df7818..7da3ac16 100644 --- a/internal-docs/features/F-005-ad-revenue-logging.md +++ b/internal-docs/features/F-005-ad-revenue-logging.md @@ -4,70 +4,85 @@ name: Ad Revenue Logging type: eventsAndRevenue platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Apps that monetize through in-app advertising (rather than, or in addition to, direct purchases) need their ad-impression revenue attributed back to the campaigns/media sources that drove the installs — otherwise ROI/LTV reporting only sees purchase revenue and dramatically understates (or misses entirely) the true value of ad-monetized user cohorts. `logAdRevenue` reports a single ad-revenue event (network, mediation platform, currency, amount, optional extra params) to AppsFlyer so that ad monetization can be joined to install attribution the same way in-app purchase events are (see F-004). Removing it would blind AppsFlyer's dashboards to any revenue generated purely through ad impressions/clicks. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Apps that monetize through in-app advertising (rather than, or in addition to, direct purchases) need their ad-impression revenue attributed back to the campaigns and media sources that drove the installs — otherwise ROI/LTV reporting only sees purchase revenue and dramatically understates (or misses entirely) the true value of ad-monetized user cohorts. `logAdRevenue` reports a single ad-revenue event (monetization network, mediation platform, currency, amount, optional extra parameters) to AppsFlyer so that ad monetization can be joined to install attribution the same way in-app purchase events are (see F-004). Removing it would blind AppsFlyer's dashboards to any revenue generated purely through ad impressions and clicks. --- ## Trigger -Called by the host app whenever a mediation SDK (AdMob, AppLovin MAX, ironSource, Unity, etc.) reports a paid ad impression/click, typically from within that mediation SDK's own revenue-paid callback. +Called after `init()` whenever a mediation SDK (AdMob, AppLovin MAX, ironSource, Unity, etc.) reports a paid ad impression or click, typically from within that mediation SDK's own revenue-paid callback. Dart and the RPC layers do not enforce initialization ordering before forwarding the call. --- ## Call Chain +`logAdRevenue` takes flat, RPC-aligned named parameters. There is no Dart ad-revenue model class; the Dart layer builds the RPC parameter map inline and converts the typed `AFMediationNetwork` value to its platform-specific string. + ``` -AppsflyerSdk.logAdRevenue(AdRevenueData) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("logAdRevenue", adRevenueData.toMap()) - → Android: AppsflyerSdkPlugin.onMethodCall("logAdRevenue") → logAdRevenue(call, result) [android/.../AppsflyerSdkPlugin.java] - → MediationNetwork.valueOf(mediationNetworkString.toUpperCase(Locale.ENGLISH)) - → new AFAdRevenueData(monetizationNetwork, mediationNetwork, currencyIso4217Code, revenue) - → AppsFlyerLib.getInstance().logAdRevenue(adRevenueData, additionalParameters) - → result.success(true) | result.error(...) on invalid/unexpected input - → iOS: AppsflyerSdkPlugin.handleMethodCall("logAdRevenue") → logAdRevenue:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → getEnumValueFromString: maps the Dart enum's string value to AppsFlyerAdRevenueMediationNetworkType - → [[AFAdRevenueData alloc] initWithMonetizationNetwork:mediationNetwork:currencyIso4217Code:eventRevenue:] - → [[AppsFlyerLib shared] logAdRevenue:additionalParameters:] - → (no result(...) call on the success path; result(...) is only invoked on error) +AppsFlyerSdk.logAdRevenue(...) [lib/src/appsflyer_sdk.dart] + → mediationNetwork.rpcValue(isIOS: _isIOS) [lib/src/appsflyer_constants.dart] + → _invokeVoidRpc('logAdRevenue', {monetizationNetwork, mediationNetwork, + currencyIso4217Code, revenue, + additionalParameters}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.executeRpc → dispatchRpc('logAdRevenue', ...) + → AppsFlyerRpcHandler.execute(json) → AppsFlyerLib.logAdRevenue(...) + → iOS: AppsflyerSdkPlugin.executeRpc → dispatchRpc:method:@"logAdRevenue" + → [AppsFlyerRPCBridge shared] executeJson:completion: → AFRPCRequestHandler → SDK + → successful per-call reply completes Future + → PlatformException is converted to AppsFlyerException ``` +`logAdRevenue` does not send an `awaitResponse` parameter, so the Future completes after RPC validation and invocation of the void native logging API. It does not confirm that the native SDK accepted or uploaded the event. + +--- + +## Cross-platform mediation-network quirk +`AFMediationNetwork.rpcValue({required bool isIOS})` returns the canonical RPC string for each case (for example `applovinMax` → `"applovin_max"`). Two cases differ between platforms: the iOS RPC parser strips underscores and expects the short forms, while the Android bridge matches the underscored enum names. + +| `AFMediationNetwork` | `rpcValue(isIOS: false)` | `rpcValue(isIOS: true)` | +|--|--|--| +| `customMediation` | `custom_mediation` | `custom` | +| `directMonetizationNetwork` | `direct_monetization_network` | `directmonetization` | + +Every other value returns the same string on both platforms. + --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `logAdRevenue(AdRevenueData)` — Dart public API, `void`, serializes via `adRevenueData.toMap()` | -| `lib/src/appsflyer_ad_revenue_data.dart` | `AdRevenueData` model: `monetizationNetwork`, `mediationNetwork` (String), `currencyIso4217Code`, `revenue` (double), optional `additionalParameters` | -| `lib/src/appsflyer_constants.dart` | `AFMediationNetwork` enum with a `.value` getter mapping each case (e.g. `applovinMax`) to the exact lowercase/snake_case string (`"applovin_max"`) both native sides expect | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `logAdRevenue(MethodCall, Result)` — validates required args via `requireNonNullArgument`, converts the mediation-network string to the native `MediationNetwork` enum via `.valueOf(...toUpperCase())`, builds `AFAdRevenueData`, calls `AppsFlyerLib.getInstance().logAdRevenue(...)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `logAdRevenue:result:` and `getEnumValueFromString:` — validates required args, maps the mediation-network string to `AppsFlyerAdRevenueMediationNetworkType` via an explicit `NSDictionary` lookup table, builds `AFAdRevenueData`, calls `[[AppsFlyerLib shared] logAdRevenue:additionalParameters:]` | -| `doc/API.md` | `logAdRevenue` / `AdRevenueData` / `AFMediationNetwork` public documentation and usage example | +| `lib/src/appsflyer_sdk.dart` | `logAdRevenue({monetizationNetwork, mediationNetwork, currencyIso4217Code, revenue, additionalParameters})` → `Future`; builds the flat RPC parameter map | +| `lib/src/appsflyer_constants.dart` | `AFMediationNetwork` enum and its `rpcValue({required bool isIOS})` platform mapping | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | No per-method handler — generic `executeRpc` → `dispatchRpc('logAdRevenue', ...)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler — generic `executeRpc` → `dispatchRpc` | +| `doc/api-reference.md` | `logAdRevenue` and `AFMediationNetwork` public documentation | --- ## Input / Output | | | |--|--| -| **Input** | `monetizationNetwork` (String, required — the ad network the impression came from, e.g. "GoogleAdMob"); `mediationNetwork` (String, required — must equal one of `AFMediationNetwork.value`'s outputs, e.g. `"applovin_max"`); `currencyIso4217Code` (String, required); `revenue` (double, required); `additionalParameters` (Map, optional) | -| **Output** | Android: `Future` (unused by the `void` Dart method) resolving `result.success(true)` on success, or `result.error("INVALID_ARGUMENT_PROVIDED", ...)` for a missing/unrecognized field, or `result.error("UNEXPECTED_ERROR", ...)` for any other throwable. iOS: `result(...)` is only ever invoked on the error paths (`FlutterError` with codes such as `NULL_MONETIZATION_NETWORK`, `INVALID_MEDIATION_NETWORK`, `UNEXPECTED_ERROR`); on success the method returns without calling `result` at all. | +| **Input** | `monetizationNetwork` (`String`, required and non-empty in both RPC layers); `mediationNetwork` (`AFMediationNetwork`, required — typed, so an unknown network cannot be passed); `currencyIso4217Code` (`String`, required and non-empty; Android RPC additionally requires exactly three characters, while both native SDKs validate that it is an actual ISO 4217 code); `revenue` (`double`, required, with no Dart/RPC range validation); `additionalParameters` (`Map?`, optional) | +| **Output** | `Future`. RPC validation and bridge errors surface as `AppsFlyerException`. A completed Future means RPC validation succeeded and the void native logging API was invoked. Native SDK validation failures and upload failures are not returned to Dart. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check logAdRevenue call` (line 296) constructs an `AdRevenueData` with `AFMediationNetwork.applovinMax.value`, calls `logAdRevenue`, and asserts the mocked channel receives `logAdRevenue` with `mediationNetwork == 'applovin_max'`. This exercises only the Dart-to-channel dispatch and the enum-to-string mapping; it does not exercise either native handler's mediation-network parsing/validation logic. +`test/appsflyer_sdk_test.dart` — `maps every mediation network to the native RPC string` loops over every `AFMediationNetwork` value on both the Android- and iOS-configured SDK instances and asserts the full `logAdRevenue` RPC parameter map, including the platform-specific `mediationNetwork` strings (`custom_mediation` / `custom`, `direct_monetization_network` / `directmonetization`, and the shared underscored identifiers for the other networks). + +The `PlatformException becomes AppsFlyerException` test covers the shared `_invokeRpc` error conversion that `logAdRevenue` uses; it exercises that path through `logEvent` rather than through `logAdRevenue`. --- ## Known Limitations -- **String-based mediation network mapping is duplicated three times** (Dart `AFMediationNetwork.value`, Android's `MediationNetwork.valueOf(...toUpperCase())`, iOS's hand-written `NSDictionary` in `getEnumValueFromString:`) with no shared source of truth — adding a new mediation network requires updating all three in lockstep, and a mismatch (e.g. a typo in one map) fails silently as an "unsupported network" error at runtime rather than a compile-time error. -- **iOS never resolves the Flutter result on success**: in `logAdRevenue:result:`, the success path calls `[[AppsFlyerLib shared] logAdRevenue:additionalParameters:]` and returns without ever calling `result(...)`. Since the Dart-side `logAdRevenue` is `void` and does not await a result, this is silent to callers today, but it means the platform channel's pending reply is simply never resolved on the happy path — asymmetric with Android, which always calls `result.success(true)`. -- Android's mediation-network parsing uses `.toUpperCase(Locale.ENGLISH)` then `MediationNetwork.valueOf(...)`; any string that doesn't exactly match a native enum constant after upper-casing (e.g. an unexpected value from a future `AFMediationNetwork` addition) throws `IllegalArgumentException`, caught and surfaced as `INVALID_ARGUMENT_PROVIDED` — but only after the Dart caller has already committed to that string via the shared enum, so failures depend on the plugin's native SDK dependency version staying in sync with `AFMediationNetwork`. -- No compile-time guarantee that `AdRevenueData.mediationNetwork` (a plain `String`) was actually built from `AFMediationNetwork.value` — passing an arbitrary string compiles fine and only fails at the native layer. +- The mediation-network string mapping is duplicated between Dart (`AFMediationNetwork.rpcValue`) and the native RPC parsers, with no shared source of truth. A newly supported mediation network requires coordinated updates on both sides, and a mismatch fails as an "unsupported network" at runtime rather than at compile time. +- Future completion confirms only that the RPC request was validated and forwarded to the native logging API. Android silently ignores the call before SDK initialization, and both native SDKs can discard an invalid currency or payload without reporting that rejection through RPC. The native API also exposes no upload callback. +- Android RPC requires a three-character currency string; iOS RPC requires only a non-empty string. Both native SDKs subsequently validate the actual ISO 4217 code, but native rejection is not surfaced to Dart. +- `additionalParameters` values are untyped (`Map`), so an unsupported value can fail in the Flutter platform codec or plugin JSON serialization before reaching the native SDK. --- diff --git a/internal-docs/features/F-006-custom-host-configuration.md b/internal-docs/features/F-006-custom-host-configuration.md index 80a1b86f..4c78ae4a 100644 --- a/internal-docs/features/F-006-custom-host-configuration.md +++ b/internal-docs/features/F-006-custom-host-configuration.md @@ -4,35 +4,36 @@ name: Custom Host Configuration type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Enterprises operating in regulated markets (e.g. China) or behind private network/CDN setups need the AppsFlyer SDK to send its HTTPS traffic to a non-default host. `setHost` lets the integrator redirect the SDK's network calls to a custom domain/prefix; `getHostName`/`getHostPrefix` let the app (or diagnostics tooling) confirm what is currently configured. Without this, apps requiring a custom collection endpoint could not integrate AppsFlyer at all in those environments. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Enterprises operating in regulated markets (for example China) or behind private network/CDN setups need the AppsFlyer SDK to send its HTTPS traffic to a non-default host. `setHost` lets the integrator redirect the SDK's network calls to a custom domain and prefix; `getHostName`/`getHostPrefix` let the app (or diagnostics tooling) read back what is configured on Android. Without this, apps requiring a custom collection endpoint could not integrate AppsFlyer in those environments. Use it only when instructed by AppsFlyer support. --- ## Trigger -Called by the host app before/around SDK start, whenever the default AppsFlyer collection host must be overridden. `getHostName`/`getHostPrefix` are called on demand (e.g. debug screens) to read back the current configuration. +`setHost` is awaited by the host app before `start()`, whenever the default AppsFlyer collection host must be overridden. `getHostName`/`getHostPrefix` are awaited on demand (for example from a debug screen) and only on Android. --- ## Call Chain +All three are generic RPC calls. Dart performs no value validation on `setHost`; `getHostName` and `getHostPrefix` are Android-only at the native RPC layer. Android RPC requires a non-empty `hostName` but permits an empty `hostPrefixName`; iOS RPC requires both values to be non-empty. + ``` -AppsflyerSdk.setHost(hostPrefix, hostName) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setHost", {hostPrefix, hostName}) - → Android: AppsflyerSdkPlugin.onMethodCall("setHost") → setHost(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setHost(hostPrefix, hostName) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setHost") → setHost:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] setHost:hostName withHostPrefix:hostPrefix] - -AppsflyerSdk.getHostName() / getHostPrefix() - → _methodChannel.invokeMethod("getHostName" | "getHostPrefix") - → Android: getHostName(result) / getHostPrefix(result) → AppsFlyerLib.getInstance().getHostName()/getHostPrefix() - → iOS: getHostName:result: / getHostPrefix:result: → [[AppsFlyerLib shared] host] / [[AppsFlyerLib shared] hostPrefix] +AppsFlyerSdk.setHost(String hostPrefixName, String hostName) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setHost', {'hostPrefixName': ..., 'hostName': ...}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler → AppsFlyerLib.setHost(...) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge → [AppsFlyerLib shared] setHost:... + +AppsFlyerSdk.getHostName() / AppsFlyerSdk.getHostPrefix() (Android only) + → _invokeRpc('getHostName' | 'getHostPrefix') + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.getHostName() / getHostPrefix() (returned on the RPC reply) + → iOS: native RPC reports method not found → AppsFlyerException (404) + → PlatformException is converted to AppsFlyerException ``` --- @@ -40,30 +41,29 @@ AppsflyerSdk.getHostName() / getHostPrefix() ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setHost`, `getHostName`, `getHostPrefix` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setHost`, `getHostName`, `getHostPrefix` native handlers | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | `AF_HOST_PREFIX`, `AF_HOST_NAME` argument key constants | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setHost:result:`, `getHostName:result:`, `getHostPrefix:result:` native handlers | +| `lib/src/appsflyer_sdk.dart` | `setHost(String hostPrefixName, String hostName)`, plus Android-only `getHostName()` and `getHostPrefix()` routed through RPC without a Dart guard | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic RPC dispatch for `setHost`, `getHostName`, and `getHostPrefix` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Generic RPC dispatch for `setHost` | --- ## Input / Output | | | |--|--| -| **Input** | `setHost`: `hostPrefix` (String), `hostName` (String). `getHostName`/`getHostPrefix`: none. | -| **Output** | `setHost` → `void` (fire-and-forget). `getHostName()`/`getHostPrefix()` → `Future` reflecting the currently configured values. | +| **Input** | `setHost`: `hostPrefixName` (`String`) and `hostName` (`String`), sent under the RPC param keys `hostPrefixName` and `hostName`. Android RPC requires a non-empty `hostName` and permits an empty `hostPrefixName`; iOS RPC requires both values to be non-empty. `getHostName`/`getHostPrefix`: no parameters (the RPC params map is empty). | +| **Output** | `setHost` → `Future` that completes after RPC validation and the synchronous native setter invocation; it does not confirm that the native SDK accepted or used the host. RPC or bridge failures are exposed as `AppsFlyerException`. `getHostName()`/`getHostPrefix()` → `Future` on Android; on iOS wrong-platform calls throw `AppsFlyerException` when the native RPC layer reports the method as unavailable. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setHost call` (line 121) asserts the channel receives `setHost` with `hostPrefix`/`hostName` arguments; `check getHostPrefix call` (line 220) and `check getHostName call` (line 226) assert the corresponding method names are invoked. No test asserts the actual return value flowing back from a (mocked) native host name/prefix. +`test/appsflyer_sdk_test.dart` → `'maps cross-platform configuration and identity APIs'` verifies that `setHost('prefix', 'example.com')` dispatches RPC method `setHost` with params `{'hostPrefixName': 'prefix', 'hostName': 'example.com'}`. `'maps getters and native return values'` verifies that `getHostName()` and `getHostPrefix()` dispatch their RPC methods with an empty params map and return the mocked native values. `'platform-only getters surface the native method-not-found error'` asserts that both getters throw `AppsFlyerException` with code `404` on iOS. `'PlatformException with a numeric RPC code becomes AppsFlyerException'` covers the shared error-conversion path, although it does not invoke `setHost` specifically. There is no plugin test for empty or whitespace-only host values. --- ## Known Limitations -- **Android bug**: `AppsflyerSdkPlugin.setHost(call, result)` never calls `result.success(...)` or `result.error(...)` — every other handler in the file does. Because Dart's `setHost()` is `void` and does not await the returned `Future`, this is currently harmless to callers, but it means the platform channel's pending reply for that invocation is left unresolved, unlike all other methods in this plugin, and would surface as a bug if a future refactor made `setHost` return/await a value. -- No input validation on `hostPrefix`/`hostName` on either platform — an empty string or malformed host is passed straight to the native SDK, which may fail silently or send traffic nowhere. -- Must be called before the SDK actually establishes its first network connection to take effect; calling it after `startSDK()`/auto-start has already fired a request may be too late — this ordering constraint is not enforced by the Dart or native code. +- `getHostName`/`getHostPrefix` are Android-only at the native RPC layer. On iOS each throws `AppsFlyerException` when the RPC layer reports the method as unavailable. On Android an unexpected native null reply also throws instead of surfacing as `null`. +- Dart does not guard against empty values. Android RPC rejects an empty `hostName` with an RPC error but accepts an empty `hostPrefixName`; iOS RPC rejects either empty value. On Android, a whitespace-only `hostName` passes RPC validation but is silently ignored by the native SDK, so the `Future` can still complete successfully without changing the host. +- Must be called before the SDK establishes its first network connection (before `start()`) to take effect; this ordering is not enforced by the plugin. --- diff --git a/internal-docs/features/F-007-device-id-collection-optout.md b/internal-docs/features/F-007-device-id-collection-optout.md index c09617c3..965f932d 100644 --- a/internal-docs/features/F-007-device-id-collection-optout.md +++ b/internal-docs/features/F-007-device-id-collection-optout.md @@ -1,71 +1,66 @@ --- id: F-007 -name: Device ID Collection Opt-out (IMEI/Android ID) +name: Android ID Collection Opt-out type: sdkCore platform: android status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Google Play policy prohibits apps that bundle Google Play Services from collecting IMEI or Android ID for advertising/attribution purposes; only apps without Play Services are allowed to rely on these identifiers as a fallback. `setCollectIMEI`/`setCollectAndroidId` let a Play-Services-enabled app explicitly opt out of this collection so it stays compliant, while apps without Play Services can leave it enabled as their only device-level identifier fallback. Getting this wrong risks Play Store policy violations and app rejection/removal. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +`setCollectAndroidID` controls whether the Android SDK may collect Android ID as a fallback identifier. Apps must choose the value that matches their distribution context, consent basis, privacy disclosures, and current Google Play policy rather than assuming the identifier is always appropriate. --- ## Trigger -Called by the host app during startup configuration, before or around SDK init, whenever the app needs to explicitly declare its IMEI/Android ID collection posture (typically apps that ship with Google Play Services present). +Called by the host app during startup configuration, before `start()`, when it needs to declare its Android ID collection posture (typically apps shipping with Google Play Services present). Dart and RPC do not enforce this ordering. --- ## Call Chain +`setCollectAndroidID` is a generic RPC with no Dart platform gate (Android ID is an Android-only identifier, so on iOS the native RPC layer answers that it does not implement the method and the call throws `AppsFlyerException`). + ``` -AppsflyerSdk.setCollectIMEI(isCollect) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setCollectIMEI", {'isCollect': isCollect}) - → Android: AppsflyerSdkPlugin.onMethodCall("setCollectIMEI") → setCollectIMEI(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setCollectIMEI(isCollect) - -AppsflyerSdk.setCollectAndroidId(isCollect) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setCollectAndroidId", {'isCollect': isCollect}) - → Android: AppsflyerSdkPlugin.onMethodCall("setCollectAndroidId") → setCollectAndroidId(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setCollectAndroidID(isCollect) +AppsFlyerSdk.setCollectAndroidID(isCollect) [lib/src/appsflyer_sdk.dart] + → off Android: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('setCollectAndroidID', {isCollect}) + → af-api "executeRpc" {method:'setCollectAndroidID', params} + → Android: dispatchRpc → AppsFlyerRpcHandler → AppsFlyerLib.setCollectAndroidID(isCollect) [android/.../AppsflyerSdkPlugin.kt] ``` -No iOS branch exists for either method name in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:` — on iOS these calls fall through to `result(FlutterMethodNotImplemented)`. --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setCollectIMEI(bool)`, `setCollectAndroidId(bool)` — platform-agnostic Dart API surface (no `Platform.isAndroid` guard) | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setCollectIMEI`, `setCollectAndroidId` native handlers | +| `lib/src/appsflyer_sdk.dart` | `setCollectAndroidID(bool)` — dispatched through RPC without a Dart platform check | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | generic `setCollectAndroidID` RPC dispatch over `AppsFlyerRpcHandler` | --- ## Input / Output | | | |--|--| -| **Input** | `isCollect` (bool) — `true` keeps collection enabled (default SDK behavior), `false` opts out. | -| **Output** | `void` — fire-and-forget; no confirmation returned to Dart. | +| **Input** | `isCollect` (`bool`) — `true` enables Android ID collection and `false` opts out. The native SDK's stored opt-in flag defaults to `false`. RPC param key `isCollect`. | +| **Output** | `Future` — on Android, completes after RPC handling and the synchronous native setter invocation; it does not confirm that an Android ID was subsequently collected. RPC or bridge failures are exposed as `AppsFlyerException`. On iOS the call still reaches the channel and throws `AppsFlyerException`, because the iOS RPC layer does not implement the method. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setCollectIMEI call` (line 232) and `check setCollectAndroidId call` (line 238) assert the mocked channel receives the respective method names. Tests run in the Dart test harness only, so they cannot and do not distinguish Android vs. iOS native behavior. +`test/appsflyer_sdk_test.dart` → `'maps every Android-only API'` verifies that `setCollectAndroidID(true)` dispatches RPC method `setCollectAndroidID` with `{'isCollect': true}`. `'platform-only calls are forwarded to the native RPC instead of being swallowed in Dart'` asserts that calling it on iOS still dispatches the RPC, and `'platform-only setters surface the native error'` asserts that the resulting native failure reaches the caller as `AppsFlyerException`. The Flutter tests do not verify whether the native SDK subsequently collects an Android ID. --- ## Known Limitations -- **Android-only**: there is no corresponding native implementation on iOS (concept doesn't apply — IMEI/Android ID are Android-specific identifiers). Calling these methods from a Flutter app running on iOS results in a `MissingPluginException`/`FlutterMethodNotImplemented` at the native layer, since the Dart API has no platform guard and will happily invoke the channel method regardless of `Platform.isIOS`. -- No compile-time or runtime warning in the Dart layer indicates these are Android-only; integrators must consult documentation (or this catalog) to learn that. +- **Android-only** but not Dart-gated: calling it on iOS reaches the native RPC layer, which does not implement the method, so the call throws `AppsFlyerException` instead of quietly doing nothing. +- Calling the API before `start()` is recommended configuration ordering but is not enforced by Dart or RPC. --- ## Dependencies ```mermaid flowchart LR - F007["F-007 · Device ID Collection Opt-out"]:::sdkCore + F007["F-007 · Android ID Collection Opt-out"]:::sdkCore classDef sdkCore fill:#4C6EF5,color:#fff ``` diff --git a/internal-docs/features/F-008-manual-imei-android-id-override.md b/internal-docs/features/F-008-manual-imei-android-id-override.md index 35869e17..1ed69405 100644 --- a/internal-docs/features/F-008-manual-imei-android-id-override.md +++ b/internal-docs/features/F-008-manual-imei-android-id-override.md @@ -3,69 +3,56 @@ id: F-008 name: Manual IMEI/Android ID Override type: sdkCore platform: android -status: active -last_verified: 2026-07-15 +status: removed +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Some apps already collect IMEI/Android ID themselves (e.g. via a legacy device-management SDK) and want AppsFlyer to reuse those values rather than re-reading them independently, or need to supply a value in contexts where the SDK's own read would fail (e.g. restricted permission states). `setImeiData`/`setAndroidIdData` let the host app hand these identifiers to the SDK directly instead of relying on its automatic collection (F-007 governs whether that automatic collection happens at all). +In SDK 6 the plugin exposed `setImeiData(String)` and `setAndroidIdData(String)` so apps that already held IMEI/Android ID values could hand them to the SDK instead of relying on its automatic collection. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +> **Removed in SDK 7.** Both `setImeiData` and `setAndroidIdData` no longer exist in the Flutter plugin. These APIs are **not exposed by the SDK 7 RPC bridges** (`AppsFlyerRpcHandler` / `AppsFlyerRPCBridge`), so the plugin cannot reach them. Per the API Removal Rule, they were removed rather than shipped as silent no-ops. There is no RPC-reachable replacement. See [`doc/migration-guide.md`](/doc/migration-guide.md). --- ## Trigger -Called by the host app during startup configuration when it already holds IMEI/Android ID values it wants to feed to AppsFlyer, in place of the SDK's own device-level collection. +N/A — the APIs have been removed. There is no Dart method, no RPC method, and no native handler. --- ## Call Chain -``` -AppsflyerSdk.setImeiData(imei) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setImeiData", {'imei': imei}) - → Android: AppsflyerSdkPlugin.onMethodCall("setImeiData") → setImeiData(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setImeiData(imei) - -AppsflyerSdk.setAndroidIdData(androidId) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setAndroidIdData", {'androidId': androidId}) - → Android: AppsflyerSdkPlugin.onMethodCall("setAndroidIdData") → setAndroidIdData(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setAndroidIdData(androidId) -``` -No iOS branch exists for either method name in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:`. +N/A — removed. No `setImeiData` / `setAndroidIdData` method exists in `lib/src/appsflyer_sdk.dart`, and neither name is handled by the `executeRpc` dispatch on Android or iOS. --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setImeiData(String)`, `setAndroidIdData(String)` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setImeiData`, `setAndroidIdData` native handlers | +| — | No implementation remains in `lib/src/appsflyer_sdk.dart`. Removal is documented in [`doc/migration-guide.md`](/doc/migration-guide.md) and `CHANGELOG.md`. | --- ## Input / Output | | | |--|--| -| **Input** | `setImeiData`: `imei` (String). `setAndroidIdData`: `androidId` (String). | -| **Output** | `void` — fire-and-forget; no confirmation returned to Dart. | +| **Input** | N/A (removed) | +| **Output** | N/A (removed) | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setImeiData call` (line 272) and `check setAndroidIdData call` (line 278) assert the mocked channel receives the respective method names. No assertion on the argument values actually reaching native code (only channel dispatch is exercised, per the test's mock architecture). +No tests — the APIs no longer exist. `test/appsflyer_sdk_test.dart` contains no references to `setImeiData` / `setAndroidIdData`. --- ## Known Limitations -- **Android-only**: no iOS implementation (IMEI/Android ID are not applicable identifiers on iOS). Same `MissingPluginException`/`FlutterMethodNotImplemented` risk as F-007 if called on iOS, since the Dart API is not platform-guarded. -- No format/length validation of the `imei`/`androidId` strings before they are handed to the native SDK — a malformed value would only surface as a data-quality problem downstream in AppsFlyer's reporting, not as a client-side error. +- No RPC-reachable replacement exists in SDK 7. Apps that previously fed device identifiers manually must rely on the SDK's own (policy-compliant) collection; the Android-ID opt-out is covered by F-007 through `AppsFlyerSdk.instance.setCollectAndroidID(bool)`. --- ## Dependencies ```mermaid flowchart LR - F008["F-008 · Manual IMEI/Android ID Override"]:::sdkCore + F008["F-008 · Manual IMEI/Android ID Override (removed)"]:::sdkCore classDef sdkCore fill:#4C6EF5,color:#fff ``` diff --git a/internal-docs/features/F-009-min-time-between-sessions.md b/internal-docs/features/F-009-min-time-between-sessions.md index 3e3eb6b0..15db816f 100644 --- a/internal-docs/features/F-009-min-time-between-sessions.md +++ b/internal-docs/features/F-009-min-time-between-sessions.md @@ -4,31 +4,32 @@ name: Minimum Time Between Sessions type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -By default AppsFlyer starts a new session whenever the app returns to the foreground after being backgrounded, using the SDK's built-in threshold. Apps with unusual foreground/background patterns (e.g. quick task-switching flows, widget-driven relaunches) can get inflated session counts that distort engagement metrics. `setMinTimeBetweenSessions` lets the app widen (or narrow) that threshold so relaunches within the configured window are folded into the current session instead of counted as a new one, keeping session-based KPIs meaningful. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +By default AppsFlyer starts a new session whenever the app returns to the foreground after being backgrounded, using the SDK's built-in threshold. Apps with unusual foreground/background patterns (quick task-switching, widget-driven relaunches) can get inflated session counts. `setMinTimeBetweenSessions` widens or narrows that threshold so relaunches within the configured window fold into the current session instead of counting as a new one. --- ## Trigger -Called by the host app during startup configuration, before or shortly after SDK init, whenever the default session-splitting threshold needs to be overridden. +Called by the host app during startup configuration, before the first `start()`, when the default session-splitting threshold needs to be overridden. The Flutter API does not require a particular order relative to `init()`. --- ## Call Chain +An ordinary fire-and-forget RPC setter available on both platforms, returning `Future`. + ``` -AppsflyerSdk.setMinTimeBetweenSessions(seconds) [lib/src/appsflyer_sdk.dart] - → assert(seconds >= 0) - → _methodChannel.invokeMethod("setMinTimeBetweenSessions", {'seconds': seconds}) - → Android: AppsflyerSdkPlugin.onMethodCall("setMinTimeBetweenSessions") → setMinTimeBetweenSessions(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setMinTimeBetweenSessions(seconds) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setMinTimeBetweenSessions") → setMinTimeBetweenSessions:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [AppsFlyerLib shared].minTimeBetweenSessions = seconds +AppsFlyerSdk.setMinTimeBetweenSessions(seconds) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setMinTimeBetweenSessions', {'seconds': seconds}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.executeRpc → dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.setMinTimeBetweenSessions(seconds) + → iOS: AppsflyerSdkPlugin.executeRpc → dispatchRpc → AppsFlyerRPCBridge.executeJson + → native minimum-time-between-sessions setter + → PlatformException is converted to AppsFlyerException ``` --- @@ -36,28 +37,28 @@ AppsflyerSdk.setMinTimeBetweenSessions(seconds) [lib/sr ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setMinTimeBetweenSessions(int)` — asserts non-negative seconds, dispatches to channel | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setMinTimeBetweenSessions` native handler | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setMinTimeBetweenSessions:result:` native handler (direct property assignment) | +| `lib/src/appsflyer_sdk.dart` | `setMinTimeBetweenSessions(int seconds)` — dispatches the RPC, returns `Future` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic `executeRpc` → `dispatchRpc` routing to `AppsFlyerRpcHandler` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Generic `executeRpc` → `dispatchRpc` forwarding to `AppsFlyerRPCBridge` | --- ## Input / Output | | | |--|--| -| **Input** | `seconds` (int, must be `>= 0` per Dart `assert`) | -| **Output** | `void` — fire-and-forget; no confirmation returned to Dart. | +| **Input** | `seconds` (`int`) sent under the `seconds` param key. Both native RPC parsers reject negative values. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or request timeout. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setMinTimeBetweenSessions call` (line 214) asserts the mocked channel receives `setMinTimeBetweenSessions`. The negative-seconds `assert` guard is not covered by any test. +`test/appsflyer_sdk_test.dart` — `maps cross-platform configuration and identity APIs` asserts that `setMinTimeBetweenSessions(15)` dispatches RPC method `setMinTimeBetweenSessions` with `{'seconds': 15}`. --- ## Known Limitations -- The `seconds >= 0` guard is a Dart `assert()`, which is stripped in release/profile builds — a negative value passed in a release build reaches native code unchecked, where behavior is whatever the native SDK does with a negative threshold (undocumented in this repo). -- No upper-bound validation — an unreasonably large value (e.g. `Duration` misused as seconds) is not caught client-side. +- Dart performs no range validation, but both native RPC parsers reject negative values before invoking the SDK. +- The native API has no completion callback, so a completed `Future` confirms only that the RPC layer accepted the call. --- diff --git a/internal-docs/features/F-010-currency-code-setting.md b/internal-docs/features/F-010-currency-code-setting.md index 4550b6d7..41d04206 100644 --- a/internal-docs/features/F-010-currency-code-setting.md +++ b/internal-docs/features/F-010-currency-code-setting.md @@ -4,32 +4,32 @@ name: Currency Code Setting type: eventsAndRevenue platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -AppsFlyer's revenue analytics (ROI/LTV dashboards) need a consistent currency to normalize monetary values reported through in-app purchase/revenue events. Apps that sell in a currency other than the SDK's USD default must declare that currency once via `setCurrencyCode`, so every subsequent in-app event's monetary value is interpreted (and converted for reporting) correctly. Without it, revenue figures for non-USD apps would be misreported or misinterpreted at AppsFlyer's default currency assumption, corrupting revenue-based attribution and LTV comparisons across campaigns. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +AppsFlyer's revenue analytics (ROI/LTV dashboards) need a consistent currency to normalize monetary values reported through in-app purchase and revenue events. Apps that sell in a currency other than the SDK's USD default must declare that currency once through `setCurrencyCode`, so every subsequent in-app event's monetary value is interpreted and converted for reporting correctly. Without it, revenue figures for non-USD apps would be misreported at AppsFlyer's default currency assumption, corrupting revenue-based attribution and LTV comparisons across campaigns. --- ## Trigger -Called by the host app once, typically at startup (before or after logging revenue-bearing events), whenever the app's transactions are denominated in a non-default (non-USD) currency. +Called by the host app once, typically after `init()` and before `start()`, whenever the app's transactions are denominated in a non-default (non-USD) currency. --- ## Call Chain +An ordinary fire-and-forget RPC setter available on both platforms, returning `Future`. + ``` -AppsflyerSdk.setCurrencyCode(currencyCode) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setCurrencyCode", {'currencyCode': currencyCode}) - → Android: AppsflyerSdkPlugin.onMethodCall("setCurrencyCode") → setCurrencyCode(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setCurrencyCode(currencyCode) - → result.success(null) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setCurrencyCode") → setCurrencyCode:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] setCurrencyCode:currencyCode] - → result(nil) +AppsFlyerSdk.setCurrencyCode(currencyCode) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setCurrencyCode', {'currencyCode': currencyCode}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.executeRpc → dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.setCurrencyCode(...) [plugin_bridge module] + → iOS: AppsflyerSdkPlugin.executeRpc → dispatchRpc → AppsFlyerRPCBridge.executeJson + → AFRPCRequestHandler → SDK + → PlatformException is converted to AppsFlyerException ``` --- @@ -37,30 +37,31 @@ AppsflyerSdk.setCurrencyCode(currencyCode) ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setCurrencyCode(String currencyCode)` — platform-agnostic Dart API, `void` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setCurrencyCode(MethodCall, Result)` — forwards to `AppsFlyerLib.getInstance().setCurrencyCode(currencyCode)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setCurrencyCode:result:` — forwards to `[[AppsFlyerLib shared] setCurrencyCode:]` | -| `doc/API.md` | Public documentation for `setCurrencyCode` | +| `lib/src/appsflyer_sdk.dart` | `setCurrencyCode(String currencyCode)` — platform-agnostic, returns `Future` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | No per-method handler — generic `executeRpc` → `dispatchRpc` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler — generic `executeRpc` → `dispatchRpc` | +| `doc/api-reference.md` | Public documentation for `setCurrencyCode` | --- ## Input / Output | | | |--|--| -| **Input** | `currencyCode` (String) — expected to be a 3-character ISO 4217 code (default is `"USD"` per the Dart doc comment) | -| **Output** | `void` on the Dart side; both native handlers call `result(nil)`/`result.success(null)` unconditionally — there is no validation or error signal if an invalid/malformed currency code is passed | +| **Input** | `currencyCode` (`String`) — expected to be a three-letter ISO 4217 code; the native default is `"USD"`. Sent under the `currencyCode` param key. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or request timeout. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setCurrencyCode call` (line 129) calls `setCurrencyCode("USD")` and asserts the mocked channel receives `setCurrencyCode` with `currencyCode: 'USD'`; exercises only the Dart-to-channel dispatch, not native validation (since none exists) or actual downstream effect on event currency conversion. +`test/appsflyer_sdk_test.dart` — `maps cross-platform configuration and identity APIs` asserts that `setCurrencyCode('USD')` dispatches RPC method `setCurrencyCode` with `{'currencyCode': 'USD'}`. Only the Dart-to-RPC dispatch is exercised. --- ## Known Limitations -- **No format validation anywhere in the plugin**: neither the Dart API, nor the Android handler, nor the iOS handler check that `currencyCode` is a valid 3-letter ISO 4217 code. Any string (empty, too long, lowercase, non-existent code) is passed straight through to the native SDK; whether the native SDK itself validates or silently ignores an invalid code is outside this plugin's code and undocumented here. -- No API to read back the currently configured currency code — the plugin is write-only for this setting (unlike, e.g., `getHostName`/`getHostPrefix` for `setHost`). -- No enforced ordering relative to `initSdk()`/`startSDK()` or relative to `logEvent`/`logAdRevenue` calls; if called after revenue events have already been logged, prior events may retain the previous (default `"USD"`) currency depending on native SDK behavior, which is not something this plugin layer controls or documents. +- Dart performs no format validation. Android RPC requires a non-empty three-character string; iOS RPC checks only that the value is a string. Neither bridge verifies that it is a real ISO 4217 code, and later SDK-level rejection is not returned to Dart. +- No API exists to read back the currently configured currency code — the plugin is write-only for this setting. +- No ordering is enforced relative to `init()`, `start()`, or revenue-logging calls; applying it after revenue events have been logged may leave prior events at the previous currency, depending on native SDK behavior. +- The native API has no completion callback, so a completed `Future` confirms only that the RPC layer accepted the call. --- diff --git a/internal-docs/features/F-011-tcf-dma-automatic-consent-collection.md b/internal-docs/features/F-011-tcf-dma-automatic-consent-collection.md index 7bf6f923..1b92cf80 100644 --- a/internal-docs/features/F-011-tcf-dma-automatic-consent-collection.md +++ b/internal-docs/features/F-011-tcf-dma-automatic-consent-collection.md @@ -4,30 +4,32 @@ name: TCF/DMA Automatic Consent Collection type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: ["F-001", "F-002"] --- ## Business Purpose -The EU Digital Markets Act (DMA) requires gatekeepers like Google to obtain and forward user consent data before certain attribution/advertising interactions can occur. Rather than forcing every integrator to manually read Consent Management Platform (CMP) state and pass it to AppsFlyer via F-012's API, `enableTCFDataCollection` lets the SDK read TCF v2.2-formatted consent strings directly out of `SharedPreferences` (Android) / `NSUserDefaults` (iOS) — wherever a TCF-compliant CMP already stores them — and attach that consent data to every outgoing event automatically. Without this, apps using a CMP would need to duplicate the CMP's consent state into AppsFlyer's manual consent API themselves. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +The EU Digital Markets Act (DMA) requires gatekeepers like Google to obtain and forward user consent data before certain attribution and advertising interactions can occur. Rather than forcing every integrator to manually read Consent Management Platform (CMP) state and pass it to AppsFlyer through F-012's API, `enableTCFDataCollection` lets the SDK read TCF v2.2-formatted consent strings directly out of `SharedPreferences` (Android) / `NSUserDefaults` (iOS) — wherever a TCF-compliant CMP already stores them — and attach that consent data to every outgoing event automatically. Without this, apps using a CMP would need to duplicate the CMP's consent state into AppsFlyer's manual consent API themselves. --- ## Trigger -Called by the host app once, typically at startup before SDK init. Per `doc/DMA.md`, the documented integration pattern is: (1) call `enableTCFDataCollection(true)`, (2) initialize the SDK with `manualStart: true` (F-001), (3) let the CMP present its consent dialog if needed, (4) once the CMP confirms consent data is stored, call `startSDK()` (F-002) so the first network request already carries the CMP-collected consent. +The host app calls this once per launch. Per `doc/consent-dma.md`, the documented sequence is: (1) `await appsflyerSdk.init(...)`, (2) `await appsflyerSdk.enableTCFDataCollection(true)`, (3) let the CMP present its consent dialog if needed, (4) register the session-ready listener and call `start()` (F-002) once the CMP has stored its consent data, so the first network request already carries the CMP-collected consent. --- ## Call Chain +`enableTCFDataCollection` is an ordinary fire-and-forget RPC setter available on both platforms and returns `Future`. + ``` -AppsflyerSdk.enableTCFDataCollection(shouldCollect) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeListMethod("enableTCFDataCollection", {'shouldCollect': shouldCollect}) - → Android: AppsflyerSdkPlugin.onMethodCall("enableTCFDataCollection") → enableTCFDataCollection(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().enableTCFDataCollection(shouldCollect) - → iOS: AppsflyerSdkPlugin.handleMethodCall("enableTCFDataCollection") → enableTCFDataCollection:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] enableTCFDataCollection:shouldCollect] +AppsFlyerSdk.enableTCFDataCollection(shouldCollect) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('enableTCFDataCollection', {'shouldCollect': shouldCollect}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.executeRpc → dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.getInstance().enableTCFDataCollection(shouldCollect) + → iOS: AppsflyerSdkPlugin.executeRpc → dispatchRpc → AppsFlyerRPCBridge.executeJson + → [[AppsFlyerLib shared] enableTCFDataCollection:shouldCollect] + → PlatformException is converted to AppsFlyerException ``` --- @@ -35,37 +37,37 @@ AppsflyerSdk.enableTCFDataCollection(shouldCollect) [lib/sr ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `enableTCFDataCollection(bool)` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `enableTCFDataCollection` native handler | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `enableTCFDataCollection:result:` native handler | -| `doc/DMA.md` | Integration guide documenting the required manual-start + CMP sequencing | +| `lib/src/appsflyer_sdk.dart` | `enableTCFDataCollection(bool shouldCollect)` returning `Future` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic `executeRpc` → `dispatchRpc` routing to `AppsFlyerRpcHandler` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Generic `executeRpc` → `dispatchRpc` forwarding to `AppsFlyerRPCBridge` | +| `doc/consent-dma.md` | Integration guide documenting the `init()` → `enableTCFDataCollection` → CMP → `start()` sequencing | --- ## Input / Output | | | |--|--| -| **Input** | `shouldCollect` (bool) — `true` enables automatic TCF v2.2 string reads from platform storage. | -| **Output** | `void` — fire-and-forget; no confirmation returned to Dart. Downstream effect is that TCF consent strings are attached to subsequent SDK network requests. | +| **Input** | `shouldCollect` (`bool`) sent under the `shouldCollect` param key. `true` enables automatic TCF v2.2 string reads from platform storage. | +| **Output** | `Future` completes once the RPC layer accepts the fire-and-forget native call; native errors throw `AppsFlyerException`. The downstream effect is that TCF consent strings are attached to subsequent SDK network requests. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check enableTCFDataCollection call` (line 308) asserts the mocked channel receives `enableTCFDataCollection`. No test verifies the documented manual-start/CMP sequencing, and no test exists for the interaction between this flag and `startSDK()`/`initSdk()` timing. +`test/appsflyer_sdk_test.dart` — `maps cross-platform configuration and identity APIs` asserts that `enableTCFDataCollection(true)` dispatches RPC method `enableTCFDataCollection` with `{'shouldCollect': true}`. No test verifies the documented CMP sequencing or the interaction between this flag and `init()`/`start()` timing. --- ## Known Limitations -- Dart's `enableTCFDataCollection` calls `_methodChannel.invokeListMethod(...)` (list-typed channel invocation) even though neither native handler returns a list — Android's handler returns `result.success(null)` and iOS's returns `result(nil)`. This mismatched invocation method works today only because the return value is discarded (`void` method, result not awaited); it is a latent inconsistency versus every other setter in this file, which use plain `invokeMethod`. -- The feature is purely a "read consent from storage" toggle — it does not validate that a TCF-compliant CMP is actually present or that the stored string is well-formed; if no CMP has written TCF data, the SDK simply finds nothing to read, with no error surfaced to the app. -- Correct behavior depends entirely on the app following the documented ordering (manual start → CMP consent → `startSDK()`); calling `enableTCFDataCollection` after the SDK has already auto-started (default `manualStart: false`) may mean the first session/event already went out without consent data attached. +- The feature is purely a "read consent from storage" toggle. It does not validate that a TCF-compliant CMP is present or that the stored string is well-formed; if no CMP has written TCF data, the SDK finds nothing to read and no error is surfaced to the app. +- Correct behavior depends on the app following the documented ordering (`init()` → CMP consent → `start()`). Because SDK 7 requires an explicit `start()`, that ordering is now under the app's control, but nothing in the Dart layer enforces it: calling `enableTCFDataCollection` after `start()` may mean the first session went out without consent data attached. +- The native API has no completion callback, so a completed `Future` confirms only that the RPC layer accepted the call. --- ## Dependencies ```mermaid flowchart LR - F011["F-011 · TCF/DMA Automatic Consent Collection"]:::sdkCore -->|"requires manualStart configured via"| F001["F-001 · SDK Initialization & Options Validation"]:::sdkCore + F011["F-011 · TCF/DMA Automatic Consent Collection"]:::sdkCore -->|"enabled after"| F001["F-001 · SDK Initialization"]:::sdkCore F011 -->|"deferred session start after CMP consent, via"| F002["F-002 · SDK Start"]:::sdkCore classDef sdkCore fill:#4C6EF5,color:#fff ``` diff --git a/internal-docs/features/F-012-manual-gdpr-dma-consent-api.md b/internal-docs/features/F-012-manual-gdpr-dma-consent-api.md index 84c962bf..9827ec58 100644 --- a/internal-docs/features/F-012-manual-gdpr-dma-consent-api.md +++ b/internal-docs/features/F-012-manual-gdpr-dma-consent-api.md @@ -1,83 +1,83 @@ --- id: F-012 -name: Manual GDPR/DMA Consent API (V1 + V2) +name: Manual GDPR/DMA Consent API type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Apps that don't rely on a TCF-compatible CMP (F-011) still need a way to legally record and forward the user's GDPR/DMA consent decisions before AppsFlyer collects or uses their data. `setConsentData`/`setConsentDataV2` are the manual counterpart: the app itself determines whether GDPR applies and what the user consented to, then hands that decision to the SDK explicitly. `setConsentDataV2` is the current, more granular API (adds `hasConsentForAdStorage`, supports nullable "not yet decided" states); `setConsentData` is the deprecated V1 shape kept for backward compatibility. Getting this right is a legal-compliance requirement, not just a UX nicety — incorrect or missing consent forwarding can put the integrating company in violation of GDPR/DMA. +Apps that do not rely on a TCF-compatible CMP (F-011) still need a way to legally record and forward the user's GDPR/DMA consent decisions before AppsFlyer collects or uses their data. `setConsentData` is the manual counterpart: the app itself determines whether GDPR applies and what the user consented to, then hands that decision to the SDK explicitly. Getting this right is a legal-compliance requirement, not a UX nicety — incorrect or missing consent forwarding can put the integrating company in violation of GDPR/DMA. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +SDK 7 replaces the previous two-variant surface (the deprecated `AppsFlyerConsent` object and `setConsentDataV2`) with a single flat method whose named parameters map one-to-one onto the RPC contract. There is no consent model class to construct and no deprecated alternative to choose between. --- ## Trigger -Called by the host app once consent has been captured from the user (via its own consent UI), and per `doc/DMA.md`, ideally called *before* `initSdk()` (or at least before `startSDK()` when using manual-start mode) so the very first SDK network request already carries the correct consent state. +Called by the host app once consent has been captured from the user's own consent UI. Per `doc/consent-dma.md`, call it after `init()` and before the first `start()`, so the launch request carries the correct state. The native SDK retains the value across foreground cycles in the same process, but not across a cold start; supply it once per process launch. --- ## Call Chain +A single flat method dispatches the `setConsentData` RPC on both platforms. Dart forwards the payload as supplied. + ``` -AppsflyerSdk.setConsentData(AppsFlyerConsent consentData) [DEPRECATED] [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod('setConsentData', {'consentData': consentData.toMap()}) - → Android: AppsflyerSdkPlugin.onMethodCall("setConsentData") → setConsentData(call, result) [android/.../AppsflyerSdkPlugin.java] - → new AppsFlyerConsent.forGDPRUser(...) | AppsFlyerConsent.forNonGDPRUser() - → AppsFlyerLib.getInstance().setConsentData(consentData) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setConsentData") → setConsentData:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerConsent alloc] initForGDPRUserWith...] | initWithNonGDPRUser - → [[AppsFlyerLib shared] setConsentData:consentData] - -AppsflyerSdk.setConsentDataV2({isUserSubjectToGDPR, consentForDataUsage, consentForAdsPersonalization, hasConsentForAdStorage}) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod('setConsentDataV2', {...}) - → Android: AppsflyerSdkPlugin.onMethodCall("setConsentDataV2") → setConsentDataV2(call, result) → getAppsFlyerConsentFromCall(call) [android/.../AppsflyerSdkPlugin.java] - → new AppsFlyerConsent(isUserSubjectToGDPR, consentForDataUsage, consentForAdsPersonalization, hasConsentForAdStorage) - → AppsFlyerLib.getInstance().setConsentData(consent) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setConsentDataV2") → setConsentDataV2:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerConsent alloc] initWithIsUserSubjectToGDPR:...hasConsentForAdStorage:...] - → [[AppsFlyerLib shared] setConsentData:consentData] +AppsFlyerSdk.setConsentData({isUserSubjectToGDPR, hasConsentForDataUsage, + hasConsentForAdsPersonalization, hasConsentForAdStorage}) + [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setConsentData', {isUserSubjectToGDPR, hasConsentForDataUsage, + hasConsentForAdsPersonalization, hasConsentForAdStorage}) + → _invokeNullableRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.executeRpc → dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerConsent → AppsFlyerLib.getInstance().setConsentData(consent) + → iOS: AppsflyerSdkPlugin.executeRpc → dispatchRpc → AppsFlyerRPCBridge.executeJson + → AppsFlyerConsent → [[AppsFlyerLib shared] setConsentData:consentData] + → PlatformException is converted to AppsFlyerException ``` +On iOS, `AFRPCSetConsentDataRequest` rejects incomplete GDPR consent as a validation error. Android's `SetConsentDataRequest` currently does not mirror that check; the plugin still forwards the call so native behavior can evolve without another Dart change. + --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_consent.dart` | `AppsFlyerConsent` model — `forGDPRUser`/`nonGDPRUser` factories, `toMap()` (used by deprecated V1 API only) | -| `lib/src/appsflyer_sdk.dart` | `setConsentData` (`@Deprecated`), `setConsentDataV2` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setConsentData` (deprecated), `setConsentDataV2`, `getAppsFlyerConsentFromCall` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setConsentData:result:` (deprecated), `setConsentDataV2:result:` | -| `doc/DMA.md` | Full integration guide for both the CMP-automatic (F-011) and manual (this feature) consent paths | +| `lib/src/appsflyer_sdk.dart` | `setConsentData({required bool isUserSubjectToGDPR, bool? hasConsentForDataUsage, bool? hasConsentForAdsPersonalization, bool? hasConsentForAdStorage})` forwards the flat RPC payload | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic `executeRpc` → `dispatchRpc` routing `setConsentData` to `AppsFlyerRpcHandler` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Generic `executeRpc` → `dispatchRpc` forwarding `setConsentData` to `AppsFlyerRPCBridge` | +| `doc/consent-dma.md` | Integration guide for both the CMP-automatic (F-011) and manual (this feature) consent paths | +| `doc/migration-guide.md` | Maps the removed `setConsentDataV2(...)` / `setConsentData(AppsFlyerConsent)` variants onto the flat `setConsentData(...)` API | --- ## Input / Output | | | |--|--| -| **Input** | V1 (`setConsentData`): `AppsFlyerConsent` object — `isUserSubjectToGDPR` (bool), `hasConsentForDataUsage` (bool), `hasConsentForAdsPersonalization` (bool). V2 (`setConsentDataV2`): four independently-nullable named bools — `isUserSubjectToGDPR`, `consentForDataUsage`, `consentForAdsPersonalization`, `hasConsentForAdStorage` — `null` explicitly means "not yet decided," distinct from `false`. | -| **Output** | `void` for both — fire-and-forget on the Dart side. Android's `setConsentDataV2` wraps the native call in try/catch and returns a `CONSENT_ERROR` platform error to Dart on failure; iOS's V2 handler similarly catches `NSException` and returns a `CONSENT_ERROR` `FlutterError`. The deprecated V1 handlers on both platforms have no error handling. | +| **Input** | `isUserSubjectToGDPR` (required `bool`); `hasConsentForDataUsage`, `hasConsentForAdsPersonalization`, and `hasConsentForAdStorage` (`bool?`, where `null` means "not supplied"). Dart constructs all four keys and forwards them without pre-validation. Android's JSON conversion omits null-valued entries, while iOS transports them as JSON null. Both native request models interpret absent optional values accordingly. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or request timeout. | --- ## Tests -`test/appsflyer_sdk_test.dart` mocks `'setConsentData'` in its channel handler switch (line 56) but has **no test** that actually calls `instance.setConsentData(...)` or asserts on it — the mock case exists without a corresponding `test(...)` block. There is **no test at all**, mocked or otherwise, for `setConsentDataV2`, despite it being the currently recommended API per `doc/DMA.md`. +`test/appsflyer_sdk_test.dart` covers both the mapping and the forward-only behavior: +- `maps cross-platform configuration and identity APIs` asserts that a fully populated call dispatches RPC method `setConsentData` with `{'isUserSubjectToGDPR': true, 'hasConsentForDataUsage': true, 'hasConsentForAdsPersonalization': false, 'hasConsentForAdStorage': true}`. +- `setConsentData forwards incomplete GDPR payloads to native RPC` asserts that `setConsentData(isUserSubjectToGDPR: true)` dispatches the RPC with null consent fields instead of throwing in Dart. --- ## Known Limitations -- `setConsentData` (V1) is `@Deprecated('Use setConsentDataV2 instead')` in Dart, and `doc/DMA.md` explicitly flags it as deprecated, yet it remains fully wired end-to-end on both platforms with no runtime warning or removal timeline. -- V1's `AppsFlyerConsent` model (`lib/src/appsflyer_consent.dart`) forces `hasConsentForDataUsage`/`hasConsentForAdsPersonalization` to non-null booleans, which cannot represent an explicit "user has not yet decided" state — this is precisely the gap V2's nullable parameters were introduced to close. -- `setConsentDataV2` has zero test coverage despite being the actively recommended, DMA-critical API — a regression in its argument marshaling (e.g. a renamed key on one platform) would not be caught by the existing test suite. -- Both consent APIs are order-sensitive relative to `initSdk()`/`startSDK()` (must be called first to affect the initial request), but neither the Dart API nor either native handler enforces or warns about this ordering. +- iOS validates required GDPR fields in its RPC request model; Android does not yet mirror that check in `SetConsentDataRequest`, so incomplete consent can reach the native SDK on Android until the RPC bridge is updated. +- Consent is order-sensitive relative to `start()` (it must be set before the first session to affect the launch request), but neither the Dart API nor either native handler enforces or warns about this ordering. +- "Every app start" means every cold/process start. The native SDK keeps the value for later background-to-foreground sessions in the same process; the Flutter plugin adds no persistence of its own. +- The native API has no completion callback, so a completed `Future` confirms only that the RPC layer accepted the call — not that the consent state reached AppsFlyer's servers. --- ## Dependencies ```mermaid flowchart LR - F012["F-012 · Manual GDPR/DMA Consent API (V1 + V2)"]:::sdkCore + F012["F-012 · Manual GDPR/DMA Consent API"]:::sdkCore classDef sdkCore fill:#4C6EF5,color:#fff ``` diff --git a/internal-docs/features/F-013-user-anonymization.md b/internal-docs/features/F-013-user-anonymization.md index 725b3ec6..e6f5ddba 100644 --- a/internal-docs/features/F-013-user-anonymization.md +++ b/internal-docs/features/F-013-user-anonymization.md @@ -4,30 +4,32 @@ name: User Anonymization (Opt-out logging) type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -When a specific user opts out of tracking (e.g. via an in-app privacy setting, or in response to a "do not track" regulatory requirement), the app needs a way to tell AppsFlyer to stop logging identifiable data for that user without tearing down the whole SDK. `anonymizeUser` flips this per-user opt-out flag on the native SDK. Without it, the only way to honor such a request would be the much blunter `stop()` API, which disables the SDK entirely rather than scoping the opt-out to one user. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +When a specific user opts out of tracking (via an in-app privacy setting or a "do not track" requirement), the app needs to tell AppsFlyer to stop logging identifiable data for that user without tearing down the whole SDK. `anonymizeUser` flips this opt-out flag on the native SDK. Without it, the only alternative would be the much blunter `stop()` (F-017), which disables the SDK entirely. --- ## Trigger -Called by the host app whenever the current user's tracking-opt-out preference changes (e.g. a settings toggle, or an automated privacy-compliance check at login). +The host app awaits `AppsFlyerSdk.instance.anonymizeUser(...)` whenever the current user's tracking-opt-out preference changes — a settings toggle, or a privacy-compliance check at login. Apply the setting before `start()` when the first session must already be anonymized. --- ## Call Chain +`anonymizeUser` is an awaitable RPC setter available on both platforms. + ``` -AppsflyerSdk.anonymizeUser(shouldAnonymize) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("anonymizeUser", {'shouldAnonymize': shouldAnonymize}) - → Android: AppsflyerSdkPlugin.onMethodCall("anonymizeUser") → anonymizeUser(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().anonymizeUser(shouldAnonymize) - → iOS: AppsflyerSdkPlugin.handleMethodCall("anonymizeUser") → anonymizeUser:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [AppsFlyerLib shared].anonymizeUser = shouldAnonymize +AppsFlyerSdk.anonymizeUser(shouldAnonymize) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('anonymizeUser', {'shouldAnonymize': shouldAnonymize}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.anonymizeUser(shouldAnonymize) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → [AppsFlyerLib shared] anonymize flag + → PlatformException is converted to AppsFlyerException ``` --- @@ -35,29 +37,28 @@ AppsflyerSdk.anonymizeUser(shouldAnonymize) [lib/sr ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `anonymizeUser(bool)` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `anonymizeUser` native handler | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `anonymizeUser:result:` native handler (direct property assignment) | +| `lib/src/appsflyer_sdk.dart` | `anonymizeUser(bool shouldAnonymize)` — awaitable RPC setter | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Forwards `anonymizeUser` through the Android RPC handler | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Forwards `anonymizeUser` through the iOS RPC bridge | --- ## Input / Output | | | |--|--| -| **Input** | `shouldAnonymize` (bool) — `true` enables anonymized logging for the current user, `false` restores normal logging. | -| **Output** | `void` — fire-and-forget; no confirmation returned to Dart. | +| **Input** | `shouldAnonymize` (`bool`) — `true` anonymizes logging for the current user, `false` restores normal logging. RPC param key `shouldAnonymize`. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or request timeout. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart` does not mock or call `anonymizeUser` anywhere, despite it being fully wired on both platforms. +`test/appsflyer_sdk_test.dart` verifies in the cross-platform RPC mapping test that `anonymizeUser(true)` dispatches RPC method `anonymizeUser` with `{'shouldAnonymize': true}`. Error conversion is covered generically by the test asserting that a `PlatformException` becomes an `AppsFlyerException` on the shared RPC path. --- ## Known Limitations -- No test coverage at all, unlike most other setters in this file — a regression in argument key naming (`shouldAnonymize`) on either platform would go undetected by CI. -- The flag is process/instance-scoped (it toggles a property on the shared `AppsFlyerLib`/native singleton), not tied to a specific customer user ID — if the app switches logged-in users without also resetting this flag, the anonymization state can leak across user sessions. -- No way to read back the current anonymization state from Dart (no `getAnonymizeUser()` counterpart) — the app must track the last value it set itself. +- The flag is process/runtime state on the shared native SDK, not tied to a customer user ID. It remains in effect across foreground cycles until `anonymizeUser(false)` is called; the app must reapply its desired value after a cold start. +- No way to read back the current anonymization state from Dart. --- diff --git a/internal-docs/features/F-014-manual-deep-link-retrigger.md b/internal-docs/features/F-014-manual-deep-link-retrigger.md index 7d57405b..3e58dc82 100644 --- a/internal-docs/features/F-014-manual-deep-link-retrigger.md +++ b/internal-docs/features/F-014-manual-deep-link-retrigger.md @@ -1,65 +1,68 @@ --- id: F-014 -name: Manual Deep-Link Re-trigger (performOnDeepLinking) +name: Manual Deep-Link Re-trigger (performDeepLinking) type: deepLinking -platform: android +platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: ["F-037"] --- ## Business Purpose -Apps that delay `startSDK()` (manual-start mode) can miss deep-link resolution for the launch intent, because AppsFlyer normally inspects the intent during its own lifecycle hooks around SDK start. `performOnDeepLinking()` lets the host app force the native SDK to re-process the activity's current intent on demand — typically right before a delayed `startSDK()` call — so a OneLink click that launched the app is still resolved even though initialization was deferred. Without this API, manual-start integrators on Android would silently lose deep-link data for the launch that started the app. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Apps that defer `start()` (SDK 7 session model) can miss deep-link resolution for a link that arrived from a non-standard source, or when the launch URL was captured before the SDK was ready. `performDeepLinking(url, ...)` lets the host app hand a specific URL (a full URL, a OneLink, or an Android intent-data string) to the native SDK on demand and route the resolved result to the registered `registerDeepLinkListener` callback. It works for both intent and non-intent sources (for example a URL pulled from Firebase Messaging), so a OneLink that the SDK's own lifecycle hooks did not resolve is still delivered to the app. --- ## Trigger -Called explicitly by the host app, typically in a manual-start (`manualStart: true`) flow, immediately before invoking `startSDK()`/`startSDKwithHandler()` — e.g. after the app has finished its own startup gating (consent, config fetch, etc.) but still needs the original launch intent resolved for deep linking. +Awaited explicitly by the host app whenever it holds a URL it wants the SDK to resolve as a deep link — for example after extracting a link from a push payload handled outside the AppsFlyer flow, or when re-processing a launch URL after gating `start()` on consent or configuration. The app must already have called `registerDeepLinkListener(onDeepLink)`, otherwise the resolved result has nowhere to surface. --- ## Call Chain +This is a generic RPC call (no per-method channel handler). The Dart method name is `performDeepLinking` and it routes to a **different native RPC per platform**: Android uses `performDeepLinking`; iOS uses `performOnAppAttributionWithURL`. `shouldTriggerSession` is Android-only and is not sent on iOS. + ``` -AppsflyerSdk.performOnDeepLinking() [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("performOnDeepLinking") - → Android: AppsflyerSdkPlugin.onMethodCall("performOnDeepLinking") → performOnDeepLinking(call, result) [android/.../AppsflyerSdkPlugin.java] - → intent = activity.getIntent() - → AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication) - → afDeepLinkListener.onDeepLinking(DeepLinkResult) [if UDL subscribed] - → runOnUIThread(..., AF_UDL_CALLBACK, AF_SUCCESS) → callbackChannel "callListener" → Dart onDeepLinking callback (see F-037) - → iOS: no "performOnDeepLinking" case in AppsflyerSdkPlugin.m's handleMethodCall: → FlutterMethodNotImplemented +AppsFlyerSdk.performDeepLinking(String url, {bool shouldTriggerSession = false}) [lib/src/appsflyer_sdk.dart] + → Android: _invokeVoidRpc('performDeepLinking', {'url': url, 'shouldTriggerSession': shouldTriggerSession}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.getInstance().performOnDeepLinking(...) + → iOS: _invokeVoidRpc('performOnAppAttributionWithURL', {'url': url}) // shouldTriggerSession omitted + → AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → [[AppsFlyerLib shared] performOnAppAttributionWithURL:] + → successful reply completes Future + → PlatformException is converted to AppsFlyerException ``` +The resolved deep link surfaces asynchronously over the `af-events` EventChannel as a `_AppsFlyerEvent` (`onDeepLinking` on Android, `onDeepLinkReceived` on iOS), which `DeepLinkResult._fromEvent` maps and delivers to the registered `onDeepLink` callback (see F-037). The `Future` returned by this method only reports acceptance of the request, not the resolution outcome. --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `performOnDeepLinking()` — platform-agnostic Dart API, no `Platform.isAndroid` guard | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `performOnDeepLinking(call, result)` — reads `activity.getIntent()` and forwards it to `AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | No corresponding case in `handleMethodCall:` — the method name is entirely absent | +| `lib/src/appsflyer_sdk.dart` | `performDeepLinking(String url, {bool shouldTriggerSession = false})` — platform-branching wrapper: Android sends the `performDeepLinking` RPC with `{url, shouldTriggerSession}`; iOS sends `performOnAppAttributionWithURL` with `{url}`. Returns `Future`. | +| `lib/src/udl/deep_link_result.dart` | `DeepLinkResult._fromEvent` maps the resulting native event into `DeepLinkStatus` plus an optional `DeepLink` payload and `DeepLinkFailure` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` / `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler — the generic `executeRpc` dispatch forwards the JSON envelope to the native RPC bridge | --- ## Input / Output | | | |--|--| -| **Input** | None (no arguments passed from Dart) | -| **Output** | Android: `void`; internally errors `"NO_INTENT"` if `activity.getIntent()` is null, or `"NO_ACTIVITY"` if the activity is null (Dart call is fire-and-forget and does not await/inspect these). No direct return value — the actual payload, if any, arrives asynchronously via the `onDeepLinking` callback (F-037). iOS: `MissingPluginException` / `FlutterMethodNotImplemented` since the method is unhandled. | +| **Input** | `url` (`String`, required) — full URL, OneLink, or Android intent-data string. `shouldTriggerSession` (`bool`, default `false`) — when `true`, Android also enqueues a Launch for re-engagement; the parameter is not included in the iOS RPC params. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK invocation; it does not report the resolution result. Validation or bridge failures throw `AppsFlyerException`. Any resolved deep link is delivered asynchronously to the registered `onDeepLink` callback (F-037). | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `performOnDeepLinking`. +`test/appsflyer_sdk_test.dart` → `'maps deep-link, sharing, push, and uninstall APIs'` verifies both platform routes: the Android call dispatches `performDeepLinking` with `{'url': 'https://example.com/path', 'shouldTriggerSession': true}`, and the iOS call dispatches `performOnAppAttributionWithURL` with `{'url': 'https://example.com/path'}` only. `'normalizes Android and iOS deep-link status without hiding errors'` covers the `DeepLinkResult` mapping the resolved link travels through, and `'PlatformException becomes AppsFlyerException'` covers the shared error conversion. --- ## Known Limitations -- **iOS has no implementation at all** — unlike most other Dart APIs in this plugin, `performOnDeepLinking` is not merely a no-op stub on iOS (compare `setIsUpdate` in F-016); the method name doesn't appear in `AppsflyerSdkPlugin.m`'s `handleMethodCall:` chain, so the platform channel call falls through to `FlutterMethodNotImplemented`. Since the Dart method doesn't await or handle the channel result, this failure is silent to the caller. -- Documented as "Android Only!" in `doc/API.md`, confirming this is a deliberate platform restriction rather than an oversight — but the Dart API surface gives no compile-time signal of this, so cross-platform code calling it unconditionally will throw on iOS at the channel layer. -- Depends on `activity` and `activity.getIntent()` being non-null at call time; if the Flutter engine is detached from its activity (e.g. during a configuration change), the call errors out natively but this is invisible to the fire-and-forget Dart caller. +- **`shouldTriggerSession` is Android-only**: the default is `false`, so a bare `performDeepLinking(url)` does not trigger a session. On iOS the parameter is dropped before the RPC is sent, because `performOnAppAttributionWithURL` has no session-trigger option. +- **Different native API per platform**: Android resolves via `performOnDeepLinking`; iOS via `performOnAppAttributionWithURL`. The Dart surface hides this, but the two native paths can differ in edge-case behavior. +- **The awaited `Future` says nothing about resolution**: it completes as soon as the native RPC accepts the request. A URL that resolves to nothing, or fails resolution, is reported only through the `onDeepLink` callback as `DeepLinkStatus.notFound` or `DeepLinkStatus.error`. --- diff --git a/internal-docs/features/F-015-customer-user-id.md b/internal-docs/features/F-015-customer-user-id.md index 83a1fe0e..53eac355 100644 --- a/internal-docs/features/F-015-customer-user-id.md +++ b/internal-docs/features/F-015-customer-user-id.md @@ -4,60 +4,61 @@ name: Customer User ID (CUID) type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -AppsFlyer generates its own device-scoped unique ID (`getAppsFlyerUID`), but businesses need to join AppsFlyer's attribution/reporting data (CSV exports, Postback APIs) against their own internal user records (account ID, CRM ID, etc.). `setCustomerUserId` lets the app register its own developer-defined ID alongside AppsFlyer's, so every report and postback can be cross-referenced against the app's own user database — without it, correlating AppsFlyer attribution data with internal user analytics would require a fragile, manual matching process. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +AppsFlyer generates its own device-scoped unique ID (`getAppsFlyerUID`), but businesses need to join AppsFlyer's attribution/reporting data against their own internal user records. `setCustomerUserId` registers the app's developer-defined ID alongside AppsFlyer's, so every report and postback can be cross-referenced against the app's user database. In SDK 7, await the CUID **before** `start()` to attribute the first session with it (the SDK-6 `setCustomerIdAndLogSession` / `waitForCustomerUserId` pair has been removed — see [`doc/migration-guide.md`](/doc/migration-guide.md) and F-021). --- ## Trigger -Called by the host app whenever it knows the user's internal identifier — typically right after login/signup, or as soon as the app's own user-identity system resolves an ID. +The host app awaits `AppsFlyerSdk.instance.setCustomerUserId(id)` whenever it knows the user's internal identifier — typically right after login/signup, or before `start()` to gate the first session on the CUID. --- ## Call Chain +Generic RPC on both platforms. + ``` -AppsflyerSdk.setCustomerUserId(id) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setCustomerUserId", {'id': id}) - → Android: AppsflyerSdkPlugin.onMethodCall("setCustomerUserId") → setCustomerUserId(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setCustomerUserId(userId) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setCustomerUserId") → setCustomerUserId:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] setCustomerUserID:userId] +AppsFlyerSdk.setCustomerUserId(customerId) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setCustomerUserId', {'customerId': customerId}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.setCustomerUserId(...) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → [AppsFlyerLib shared] setCustomerUserID: + → PlatformException is converted to AppsFlyerException ``` -Note: the related (but distinct) Dart API `setCustomerIdAndLogSession(id)` invokes the channel method `"setCustomerIdAndLogSession"`, which Android handles with its own `setCustomerIdAndLogSession(call, result)` (calling `AppsFlyerLib.getInstance().setCustomerIdAndLogSession(userId, mContext)`), while iOS routes `"setCustomerIdAndLogSession"` to the *same* `setCustomerUserId:result:` handler as plain `setCustomerUserId` — iOS has no distinct "and log session" native behavior. --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setCustomerUserId(String)` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setCustomerUserId` native handler | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setCustomerUserId:result:` native handler | +| `lib/src/appsflyer_sdk.dart` | `setCustomerUserId(String customerId)` — dispatches the RPC with the `customerId` param | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | generic `setCustomerUserId` dispatch over `AppsFlyerRpcHandler` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | generic `setCustomerUserId` dispatch over `AppsFlyerRPCBridge` | --- ## Input / Output | | | |--|--| -| **Input** | `id` (String) — the developer-defined customer user ID. | -| **Output** | `void` — fire-and-forget; no confirmation returned to Dart. | +| **Input** | `customerId` (`String`) — the developer-defined customer user ID. Android RPC rejects an empty value; iOS RPC requires a string but does not reject an empty string. RPC param key `customerId`. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or request timeout. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setCustomerUserId call` (line 266) asserts the mocked channel receives `setCustomerUserId`. No assertion on the argument value reaching native code beyond the channel dispatch mock. +`test/appsflyer_sdk_test.dart` — `maps cross-platform configuration and identity APIs` verifies that `setCustomerUserId` dispatches the `setCustomerUserId` RPC with the value under the `customerId` param. --- ## Known Limitations -- No validation of the `id` string (empty string, whitespace, excessive length) before it is forwarded to native code — a blank ID is passed through unchanged on both platforms. -- iOS silently reuses the plain `setCustomerUserId:` implementation for the separate `setCustomerIdAndLogSession` Dart API, while Android gives it genuinely distinct native behavior (`setCustomerIdAndLogSession(userId, mContext)`, tied to `waitForCustomerUserId`'s delayed-session-log flow) — cross-platform behavior for that related API is not equivalent, which is easy to miss since both share the same underlying `setCustomerUserId` naming. +- Dart performs no value validation. Android RPC rejects an empty string, while iOS RPC accepts one; neither bridge rejects whitespace-only values. +- To associate the first session with the CUID, await it before `start()`; there is no dedicated "set CUID and log session" API in SDK 7. --- diff --git a/internal-docs/features/F-016-update-vs-fresh-install-flag.md b/internal-docs/features/F-016-update-vs-fresh-install-flag.md index f8bdd8fc..005b4089 100644 --- a/internal-docs/features/F-016-update-vs-fresh-install-flag.md +++ b/internal-docs/features/F-016-update-vs-fresh-install-flag.md @@ -4,29 +4,32 @@ name: Update vs. Fresh-Install Flag type: sdkCore platform: android status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Attribution logic needs to distinguish "this session came from a brand-new install" versus "this session came from an app that was just updated" — misclassifying updates as new installs would corrupt install-attribution counts and inflate campaign performance numbers. `setIsUpdate` lets the app tell the native SDK explicitly that the current launch follows an update (e.g. detected by comparing a stored app-version marker against the running version), which the SDK factors into its session/attribution logic on Android. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Attribution logic needs to distinguish "this session came from a brand-new install" from "this session came from an app that was just updated" — misclassifying updates as new installs would corrupt install-attribution counts. `setIsUpdate` tells the native SDK explicitly that the current launch follows an update, which the SDK factors into its session and attribution logic on Android. --- ## Trigger -Called by the host app at startup, after the app has itself determined (typically by comparing a persisted last-known app version against the current one) that this launch follows an update rather than a fresh install. +Awaited by the host app at startup, before `start()`, after it has determined (typically by comparing a persisted last-known app version against the current one) that this launch follows an update. --- ## Call Chain +Generic RPC with no Dart platform gate. Off Android the call is still dispatched, and the native RPC layer answers that it does not implement the method. + ``` -AppsflyerSdk.setIsUpdate(isUpdate) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setIsUpdate", {'isUpdate': isUpdate}) - → Android: AppsflyerSdkPlugin.onMethodCall("setIsUpdate") → setIsUpdate(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setIsUpdate(isUpdate) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setIsUpdate") → (no-op) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] +AppsFlyerSdk.setIsUpdate(bool isUpdate) [lib/src/appsflyer_sdk.dart] + → off Android: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('setIsUpdate', {'isUpdate': isUpdate}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.setIsUpdate(isUpdate) + → successful reply completes Future + → PlatformException is converted to AppsFlyerException ``` --- @@ -34,29 +37,27 @@ AppsflyerSdk.setIsUpdate(isUpdate) [lib/sr ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setIsUpdate(bool)` — platform-agnostic Dart API (no `Platform.isAndroid` guard) | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setIsUpdate` native handler — forwards to `AppsFlyerLib.getInstance().setIsUpdate(isUpdate)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `handleMethodCall:` contains an empty `else if([@"setIsUpdate" isEqualToString:call.method]){ }` branch — matched but intentionally does nothing | +| `lib/src/appsflyer_sdk.dart` | `setIsUpdate(bool isUpdate)` — dispatched through RPC without a Dart platform check | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic `setIsUpdate` dispatch through the Android RPC handler | --- ## Input / Output | | | |--|--| -| **Input** | `isUpdate` (bool) | -| **Output** | Android: `void`, fire-and-forget, and `result.success(null)` is called so the Dart-side `Future` (if awaited) would resolve normally. iOS: the method-call branch matches but never calls `result(...)` at all. | +| **Input** | `isUpdate` (`bool`), sent under the RPC param key `isUpdate`. | +| **Output** | On Android, `Future` completes after RPC validation and the synchronous SDK setter invocation; validation or bridge failures throw `AppsFlyerException`, with no native completion callback or timeout. On any non-Android platform the call is still dispatched and throws `AppsFlyerException` once the native RPC layer reports the method as unavailable. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setIsUpdate call` (line 136) asserts the mocked channel receives `setIsUpdate` with `isUpdate: true`, exercising only the Dart-to-channel dispatch (the mock test harness cannot and does not distinguish Android's real handling from iOS's no-op). +`test/appsflyer_sdk_test.dart` → `'maps every Android-only API'` verifies that `setIsUpdate(true)` dispatches RPC method `setIsUpdate` with params `{'isUpdate': true}` on the Android-configured instance. `'platform-only calls are forwarded to the native RPC instead of being swallowed in Dart'` asserts that `setIsUpdate` on iOS still dispatches the `setIsUpdate` RPC rather than being short-circuited, and `'PlatformException becomes AppsFlyerException'` covers the shared error conversion. The tests inject the platform through `AppsFlyerSdk.private(..., platform: ...)`, so both platforms are exercisable on the Dart test host. --- ## Known Limitations -- **iOS is a documented no-op**: in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:`, the `"setIsUpdate"` branch is matched (`if([@"setIsUpdate" isEqualToString:call.method]){ }`) but its body is empty — no native AppsFlyer API is called, and critically, `result(...)` is never invoked either. Since this branch matches inside an `if/else if` chain, control does not fall through to the trailing `result(FlutterMethodNotImplemented)` — the platform channel's pending reply for `setIsUpdate` on iOS is simply never resolved. Dart's `setIsUpdate()` is `void` and does not await the result, so this is silent to the caller today, but the update-vs-install distinction this API is meant to convey has **no effect whatsoever on iOS** — only Android attribution logic actually receives it. -- The Dart API has no platform guard and gives no compile-time or runtime signal that calling `setIsUpdate` on iOS is a no-op; an integrator relying on it cross-platform would reasonably but incorrectly assume parity with Android. -- No enforced ordering relative to `initSdk()` — the native SDK's own documentation-level expectation (call before init so the flag is available for the very first session) is not validated by either native handler. +- **Android-only**: calling `setIsUpdate` on iOS is not short-circuited in Dart — the call reaches the native RPC layer, which does not implement the method, so the `Future` completes with an `AppsFlyerException`. Cross-platform call sites must branch on `Platform.isAndroid` or catch the exception. +- No enforced ordering relative to `init()`/`start()` — the SDK's expectation that the flag is set before the first session is not validated by the plugin. --- diff --git a/internal-docs/features/F-017-sdk-kill-switch.md b/internal-docs/features/F-017-sdk-kill-switch.md index 064a0829..6b392465 100644 --- a/internal-docs/features/F-017-sdk-kill-switch.md +++ b/internal-docs/features/F-017-sdk-kill-switch.md @@ -4,30 +4,36 @@ name: SDK Kill Switch (stop) type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Some legal, privacy, or contractual situations (e.g. a user invokes a "right to be forgotten," a regulator order, or a licensing dispute) require the app to fully halt all AppsFlyer network activity immediately, not just for one user but for the whole SDK instance. `stop(true)` is the bluntest tool in the plugin: it tells the native SDK to stop communicating with AppsFlyer's servers entirely. Without it, the only way to achieve the same effect would be to prevent the SDK from ever calling `initSdk()`/`startSDK()`, which is not possible once the app is already running with the SDK live. This is documented as an "extreme case" API for legal/privacy compliance, distinct from the narrower per-user `anonymizeUser` (F-013). - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Some legal, privacy, or contractual situations (e.g. a "right to be forgotten," a regulator order, a licensing dispute) require the app to fully halt all AppsFlyer network activity immediately, for the whole SDK instance. `stop(true)` tells the native SDK to stop communicating with AppsFlyer's servers entirely, and is reversible with `stop(false)`. This is an "extreme case" API for legal/privacy compliance, distinct from the narrower per-user `anonymizeUser` (F-013). `isStopped()` reads the current state and is available only on Android. --- ## Trigger -Called by the host app at any point during the app's lifetime — typically in response to a privacy/legal requirement (e.g. a "kill switch" remote config flag, a consent withdrawal flow, or during automated compliance testing) — to start or stop all SDK network communication. +The host app awaits `AppsFlyerSdk.instance.stop(...)` at any point — typically in response to a privacy/legal requirement such as a remote "kill switch" flag, a consent-withdrawal flow, or compliance testing — to halt or resume all SDK network communication. --- ## Call Chain +`stop` is an awaitable RPC setter on both platforms. `isStopped` is an awaitable getter that only Android implements, but it is not gated in Dart. + ``` -AppsflyerSdk.stop(isStopped) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("stop", {'isStopped': isStopped}) - → Android: AppsflyerSdkPlugin.onMethodCall("stop") → stop(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().stop(isStopped, mContext) - → iOS: AppsflyerSdkPlugin.handleMethodCall("stop") → stop:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [AppsFlyerLib shared].isStopped = stop +AppsFlyerSdk.stop(shouldStop) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('stop', {'shouldStop': shouldStop}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.stop(...) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → PlatformException is converted to AppsFlyerException + +AppsFlyerSdk.isStopped() [Android only] + → off Android: native RPC reports the method as unavailable → AppsFlyerException + → _invokeRpc('isStopped') + → Android: dispatchRpc → AppsFlyerRpcHandler → AppsFlyerLib.isStopped() ``` --- @@ -35,30 +41,29 @@ AppsflyerSdk.stop(isStopped) [lib/src/ ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `stop(bool)` — Dart API surface | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `stop(call, result)` native handler, line 1033 | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `stop:result:` native handler (direct property assignment), line 734 | +| `lib/src/appsflyer_sdk.dart` | `stop(bool shouldStop)` on both platforms; `isStopped()` routed through RPC without a Dart platform check | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Forwards `stop` and `isStopped` through the Android RPC handler | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Forwards `stop` through the iOS RPC bridge | --- ## Input / Output | | | |--|--| -| **Input** | `isStopped` (bool) — `true` halts all SDK network communication/activity; `false` re-enables it. | -| **Output** | `void` — fire-and-forget; no confirmation returned to Dart. | +| **Input** | `stop`: `shouldStop` (`bool`) — `true` halts all SDK network activity, `false` re-enables it. RPC param key `shouldStop`. `isStopped()`: no parameters. | +| **Output** | `stop` → `Future` that completes after RPC validation and the synchronous native setter invocation; it has no completion callback or timeout. `isStopped()` → `Future` on Android; an unexpected native null reply throws `AppsFlyerException`. Calling it off Android dispatches the RPC anyway and throws `AppsFlyerException` rather than returning a fabricated `false`. Bridge or validation failures surface as `AppsFlyerException`. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check stop call` (line 143) asserts the mocked channel receives method `'stop'` with `capturedArguments['isStopped'] == true`. Native Android/iOS behavior is not exercised by any Dart test. +`test/appsflyer_sdk_test.dart` verifies in the cross-platform RPC mapping test that `stop(true)` dispatches RPC method `stop` with `{'shouldStop': true}`, and in the native-return-value test that `isStopped()` dispatches `isStopped` and returns the native `true`. `'an off-platform getter forwards rather than fabricating a value'` asserts that `isStopped()` on iOS still dispatches the `isStopped` RPC and returns the native value instead of a fabricated `false`, and `'platform-only getters surface the native method-not-found error'` covers the same call throwing `AppsFlyerException` when the native layer reports the method as unavailable. --- ## Known Limitations -- No way to read back the current stopped state from Dart — the host app must track the last value it set itself. -- Calling `stop(true)` does not clear or reset any previously buffered/queued native SDK state; resuming with `stop(false)` re-enables communication but the plugin doc explicitly frames this as an "extreme" API not meant for routine toggling. -- No test coverage of the native Android/iOS code paths, only the Dart-to-channel argument shape. -- Distinct from `anonymizeUser` (F-013): `stop` disables the entire SDK instance for all users/sessions, while `anonymizeUser` scopes an opt-out to the current user only. Using `stop` where `anonymizeUser` was intended would be a significant over-reach in production. +- `isStopped()` is implemented only on Android. On iOS the call is still dispatched and throws `AppsFlyerException` instead of returning a `false` that could not be distinguished from a genuine "not stopped" state. +- Distinct from `anonymizeUser` (F-013): `stop` disables the entire SDK instance for all users/sessions, while `anonymizeUser` scopes an opt-out to the current user only. +- `stop(false)` resumes SDK operation, but does not itself send a Launch. Normal per-foreground `start()` handling still applies after resumption. --- diff --git a/internal-docs/features/F-018-uninstall-measurement.md b/internal-docs/features/F-018-uninstall-measurement.md index f7d12595..6f9d9146 100644 --- a/internal-docs/features/F-018-uninstall-measurement.md +++ b/internal-docs/features/F-018-uninstall-measurement.md @@ -4,34 +4,35 @@ name: Uninstall Measurement type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Attribution isn't just about installs — media sources and marketers also need to measure uninstalls to calculate true retention/ROI. AppsFlyer measures uninstalls by receiving silent push notifications and needs the device's push token registered against the install. `updateServerUninstallToken` is how the host app hands that token (FCM token on Android, APNs device token on iOS) to the native SDK. Without it, uninstall events never reach AppsFlyer's backend and uninstall-based campaign reporting/ROI calculations would be silently incomplete. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Attribution isn't just about installs — media sources and marketers also need to measure uninstalls to calculate true retention/ROI. AppsFlyer measures uninstalls by receiving silent push notifications and needs the device's push token registered against the install. `updateServerUninstallToken(String token)` is how the host app hands that token (FCM token on Android, APNs device token on iOS) to the native SDK. Without it, uninstall events never reach AppsFlyer's backend and uninstall-based campaign reporting/ROI calculations would be silently incomplete. --- ## Trigger -Called by the host app whenever it obtains/refreshes its push token — typically inside a Firebase Messaging (`FirebaseMessaging.instance.getToken()` on Android / `getAPNSToken()` on iOS) callback, or from native `didRegisterForRemoteNotificationsWithDeviceToken:` on iOS. +The host app awaits `updateServerUninstallToken` whenever it obtains or refreshes its push token — typically from a Firebase Messaging callback (`FirebaseMessaging.instance.getToken()` on Android, `getAPNSToken()` on iOS, or the `onTokenRefresh` stream). --- ## Call Chain +One Dart method, one public parameter; the platform difference is confined to the RPC name and parameter key. The call is awaitable and native failures surface as `AppsFlyerException`. + ``` -AppsflyerSdk.updateServerUninstallToken(token) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("updateServerUninstallToken", {'token': token}) - → Android: AppsflyerSdkPlugin.onMethodCall("updateServerUninstallToken") → updateServerUninstallToken(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().updateServerUninstallToken(mContext, token) - → iOS: AppsflyerSdkPlugin.handleMethodCall("updateServerUninstallToken") → updateServerUninstallToken:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → hex-string token manually decoded into NSData - → [AppsFlyerLib shared] registerUninstall:deviceTokenData] - -AppsflyerSdk.enableUninstallTracking(senderId) [DEPRECATED — no-op] [lib/src/appsflyer_sdk.dart] - → prints a deprecation message only; does not invoke the method channel at all +AppsFlyerSdk.updateServerUninstallToken(token) [lib/src/appsflyer_sdk.dart] + → Android: _invokeVoidRpc('updateServerUninstallToken', {'token': token}) + → iOS: _invokeVoidRpc('registerUninstall', {'deviceToken': token}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → UpdateServerUninstallTokenRequest(token) // init: require(token.isNotEmpty()) + → appsFlyerLib.updateServerUninstallToken(context, token) // FCM token as-is + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → AFRPCRegisterUninstallRequest // hex string decoded to Data, else validationError + → [[AppsFlyerLib shared] registerUninstall:deviceTokenData] + → PlatformException is converted to AppsFlyerException ``` --- @@ -39,30 +40,29 @@ AppsflyerSdk.enableUninstallTracking(senderId) [DEPRECATED — no-op] [lib/s ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `updateServerUninstallToken(String)` (active), `enableUninstallTracking(String)` (`@Deprecated`, no-op) | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `updateServerUninstallToken(call, result)`, line 1027 | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `updateServerUninstallToken:result:`, line 740 — converts hex-string token to `NSData` before calling `registerUninstall:` | -| `doc/AdvancedAPI.md` | "Measure App Uninstalls" section documents both the iOS-native (`registerUninstall:` in `AppDelegate.m`) and plugin-side paths, and the Firebase Messaging integration pattern | +| `lib/src/appsflyer_sdk.dart` | `updateServerUninstallToken(String token)` — dispatches `updateServerUninstallToken` (Android) / `registerUninstall` (iOS) | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | No per-method handler — the generic `executeRpc` → `dispatchRpc` path forwards the envelope to `AppsFlyerRpcHandler` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler — the generic `executeRpc` → `dispatchRpc` path forwards the envelope to `AppsFlyerRPCBridge`, which decodes the hex token into `NSData` | +| `doc/advanced-features.md` | "Measure App Uninstalls" section documents both platforms and the Firebase Messaging integration pattern | --- ## Input / Output | | | |--|--| -| **Input** | `token` (String) — Android: FCM registration token, passed through as-is. iOS: APNs device token as a **hexadecimal string** (e.g. from `FirebaseMessaging.instance.getAPNSToken()`); the plugin strips spaces and manually converts each hex byte pair into raw `NSData` before calling `registerUninstall:`. | -| **Output** | `void` — fire-and-forget; no confirmation returned to Dart. | +| **Input** | `token` (`String`). Android: FCM registration token, passed through as-is under the `token` key. iOS: APNs device token as an even-length **hexadecimal string**, sent under the `deviceToken` key; the iOS RPC layer converts each hex byte pair into raw `Data` before calling `registerUninstall:`. | +| **Output** | `Future` that completes after native RPC validation and the synchronous SDK registration call. Native validation and bridge failures throw `AppsFlyerException`; neither platform waits for server registration and there is no RPC timeout. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check updateServerUninstallToken call` (line 150) asserts the mocked channel receives `'updateServerUninstallToken'` with `capturedArguments['token'] == 'token123'`. No test exercises `enableUninstallTracking` (there is nothing to assert — it never touches the channel), and no test covers the iOS hex-to-`NSData` conversion logic. +`test/appsflyer_sdk_test.dart` — `maps deep-link, sharing, push, and uninstall APIs` asserts both branches: the Android SDK instance dispatches `updateServerUninstallToken` with `{'token': 'fcm-token'}`, and the iOS SDK instance dispatches `registerUninstall` with `{'deviceToken': '0123456789abcdef'}`. The hex-to-`Data` conversion itself lives in the native iOS RPC layer and is covered by its own tests, not by the Dart suite. --- ## Known Limitations -- `enableUninstallTracking(senderId)` is `@Deprecated` and, unlike most other deprecated methods in this file, has been fully gutted — it only prints a message and does nothing else, even though the `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` method-dispatch table still has a (no-op) `enableUninstallTracking` branch left over from the old implementation. -- On iOS, `updateServerUninstallToken`'s hex-string parsing has no length/format validation — a malformed or odd-length hex string will silently produce truncated/incorrect `NSData` rather than raising an error back to Dart. -- The app is responsible for obtaining and refreshing the push token itself (e.g. via `firebase_messaging`); this API only forwards whatever string it is given, so a stale or missing token upstream silently degrades uninstall measurement with no error surfaced to the caller. +- The Flutter layer performs no token validation. An empty token (Android `require(token.isNotEmpty())`) or a malformed/odd-length hex string (iOS `validationError`) is rejected natively; the rejection now propagates back as `AppsFlyerException`, so the caller must `await` the call to observe it. +- The app is responsible for obtaining and refreshing the push token itself (e.g. via `firebase_messaging`). This API only forwards the string it is given, so a stale token upstream still degrades uninstall measurement without any error. --- diff --git a/internal-docs/features/F-019-user-email-collection.md b/internal-docs/features/F-019-user-email-collection.md deleted file mode 100644 index adb200f3..00000000 --- a/internal-docs/features/F-019-user-email-collection.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -id: F-019 -name: User Email Collection (with encryption) -type: sdkCore -platform: both -status: active -last_verified: 2026-07-15 -depends_on: [] ---- - -## Business Purpose -Some attribution and cross-device matching scenarios benefit from AppsFlyer knowing the user's email address(es) (e.g. matching web and app sessions for the same customer). Sending raw emails over the network is a privacy concern, so `setUserEmails` supports an optional SHA-256 hash instead of plaintext. Without this API, integrators wanting to correlate identities by email would have no supported channel to hand that data to the native SDK at all. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - ---- - -## Trigger -Called by the host app once the user's email(s) become known — typically right after login/signup, or whenever the app wants to (re)associate the current session with one or more email addresses. - ---- - -## Call Chain -``` -AppsflyerSdk.setUserEmails(emails, cryptType) [lib/src/appsflyer_sdk.dart] - → cryptTypeInt = EmailCryptType.values.indexOf(cryptType) (defaults to 0 / EmailCryptTypeNone if omitted) [lib/src/appsflyer_constants.dart] - → _methodChannel.invokeMethod("setUserEmails", {'emails': emails, 'cryptType': cryptTypeInt}) - → Android: AppsflyerSdkPlugin.onMethodCall("setUserEmails") → setUserEmails(call, result) [android/.../AppsflyerSdkPlugin.java] - → maps cryptTypeInt (0/1) to AppsFlyerProperties.EmailsCryptType.NONE / SHA256 (throws InvalidParameterException on any other value) - → AppsFlyerLib.getInstance().setUserEmails(cryptType, emails.toArray(new String[0])) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setUserEmails") → setUserEmails:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → maps cryptTypeInt to native EmailCryptType (EmailCryptTypeNone / EmailCryptTypeSHA256) - → [AppsFlyerLib shared] setUserEmails:cryptType:] -``` - ---- - -## Files -| File | Role | -|------|------| -| `lib/src/appsflyer_sdk.dart` | `setUserEmails(List, [EmailCryptType?])` — converts the enum to its integer index before sending | -| `lib/src/appsflyer_constants.dart` | `enum EmailCryptType { EmailCryptTypeNone, EmailCryptTypeSHA256 }` — index 0/1 is the wire format sent to native | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setUserEmails(call, result)`, line 983 — maps int to `AppsFlyerProperties.EmailsCryptType`, throws on unrecognized value | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setUserEmails:result:`, line 761 — maps int to native `EmailCryptType` | - ---- - -## Input / Output -| | | -|--|--| -| **Input** | `emails` (`List`, required) — one or more user email addresses. `cryptType` (`EmailCryptType?`, optional) — `EmailCryptTypeNone` (default, index 0) sends plaintext; `EmailCryptTypeSHA256` (index 1) hashes before sending. | -| **Output** | `void` — fire-and-forget; no confirmation returned to Dart. | - ---- - -## Tests -`test/appsflyer_sdk_test.dart` — `check setUserEmails call` (line 244) calls `setUserEmails(["user@example.com"], EmailCryptType.EmailCryptTypeSHA256)` and asserts `capturedArguments['emails']` contains the email and `capturedArguments['cryptType']` equals the enum's index (1). The default (omitted `cryptType`, defaulting to index 0) path is not separately tested. - ---- - -## Known Limitations -- The enum-to-int mapping (`EmailCryptType.values.indexOf(cryptType)`) is a fragile contract: if the enum's declared order in `lib/src/appsflyer_constants.dart` is ever changed or a new value is inserted in the middle, the integer sent over the channel silently shifts meaning on both native platforms without any compile-time check tying the three enumerations together. -- Android throws a Java `InvalidParameterException` for any `cryptTypeInt` outside `{0, 1}` — since the only public Dart entry point is the typed enum, this should be unreachable in practice, but a raw/dynamic method channel call bypassing the Dart API could trigger it. -- No corresponding getter exists to read back which emails/crypt type were last set. - ---- - -## Dependencies -```mermaid -flowchart LR - F019["F-019 · User Email Collection (with encryption)"]:::sdkCore - classDef sdkCore fill:#4C6EF5,color:#fff -``` diff --git a/internal-docs/features/F-019-user-pii-collection-and-clearing.md b/internal-docs/features/F-019-user-pii-collection-and-clearing.md new file mode 100644 index 00000000..efa1fd7c --- /dev/null +++ b/internal-docs/features/F-019-user-pii-collection-and-clearing.md @@ -0,0 +1,73 @@ +--- +id: F-019 +name: User PII Collection and Clearing +type: sdkCore +platform: both +status: active +last_verified: 2026-08-10 +depends_on: [] +--- + +## Business Purpose +Some attribution and cross-device matching workflows use user-provided identity data. SDK 7 exposes separate setters for email, phone, first name, last name, and Facebook App-Scoped ID, plus `clearUserPii()` to remove all values set through those APIs. The native SDK hashes email, phone, and names with SHA-256 before network transmission; the Facebook login ID is intentionally sent unhashed for partner matching. The removed SDK 6 `setUserEmails(List, cryptType)` surface no longer lets callers select a hashing mode. + +--- + +## Trigger +The host app calls only the setters covered by its privacy policy and consent state, typically after login or signup and before the first `start()` that should carry the values. Call `clearUserPii()` when the user logs out, withdraws consent, requests deletion of the locally held SDK identity values, or before switching accounts. Reapply required values after a cold start. + +--- + +## Call Chain +All methods are synchronous native setters exposed as `Future` through the shared RPC path. + +``` +AppsFlyerSdk.setUserEmail / setUserPhone / setUserFirstName / + setUserLastName / setUserFbLoginId / clearUserPii [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc(method, method-specific params) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → native SDK hashes email/phone/names; stores Facebook ID as supplied + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → native SDK hashes email/phone/names; stores Facebook ID as supplied + → PlatformException is converted to AppsFlyerException +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | Public PII setters and `clearUserPii()` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Forwards all PII methods through the Android RPC handler | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Forwards all PII methods through the iOS RPC bridge | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `setUserEmail(email)`; `setUserPhone(countryCode, phoneNumber)`; `setUserFirstName(firstName)`; `setUserLastName(lastName)`; `setUserFbLoginId(int fbLoginId)` where `0` clears only that ID; and parameterless `clearUserPii()`. Raw values cross the Flutter channel and native RPC boundary. Email, phone, and names are hashed by the native SDK; Facebook login ID is not hashed. Android RPC rejects empty string values, while iOS RPC currently performs type-only checks. | +| **Output** | Each method returns `Future` and completes after native RPC validation and the synchronous SDK setter/clear invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or request timeout. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` verifies in the cross-platform RPC mapping test that `setUserEmail('hash-me@example.com')` dispatches RPC method `setUserEmail` with `{'email': 'hash-me@example.com'}`. The same test covers the sibling PII setters and `clearUserPii`. + +--- + +## Known Limitations +- Hashing is performed by the native SDK, not Dart or the Flutter channel. Raw PII therefore exists in Dart and crosses the in-process channel/RPC serialization boundary before hashing. +- The Facebook App-Scoped ID is intentionally not hashed. `clearUserPii()` clears all values managed by this feature; pass `0` to `setUserFbLoginId` to clear only that ID. +- No getter exposes the last configured values. The Flutter plugin does not persist them across process launches. +- Dart does not validate formats. Android rejects empty string values; iOS accepts empty strings at the RPC layer, so malformed values can be handled differently by the native SDKs. + +--- + +## Dependencies +```mermaid +flowchart LR + F019["F-019 · User PII Collection and Clearing"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-020-appsflyer-uid-retrieval.md b/internal-docs/features/F-020-appsflyer-uid-retrieval.md index 2fec51c9..305ac72f 100644 --- a/internal-docs/features/F-020-appsflyer-uid-retrieval.md +++ b/internal-docs/features/F-020-appsflyer-uid-retrieval.md @@ -4,30 +4,33 @@ name: AppsFlyer UID Retrieval type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Every install gets a unique AppsFlyer-generated device/install ID, which is the primary key AppsFlyer uses internally to tie together attribution, in-app events, and reporting for that install. Host apps often need this same ID for their own backend correlation (e.g. sending it alongside server-side purchase records, or cross-referencing support tickets with AppsFlyer's dashboard/raw-data reports). `getAppsFlyerUID()` is the only supported way to read that ID from Dart. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Every install has an AppsFlyer device/install ID, generated by the SDK unless the integration successfully applies F-063's custom install ID. Host apps can use `getAppsFlyerUID()` to read the effective value for backend correlation and diagnostics. --- ## Trigger -Called on demand by the host app — typically after SDK init, to attach the AppsFlyer ID to internal analytics, support diagnostics, or server-side event payloads. +Awaited on demand by the host app — typically after `init()`, to attach the AppsFlyer ID to internal analytics, support diagnostics, or server-side event payloads. --- ## Call Chain +A generic RPC round trip with a typed native return value. Unlike `getHostName`/`getHostPrefix`, this getter is available on both platforms and has no platform guard. + ``` -AppsflyerSdk.getAppsFlyerUID() [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("getAppsFlyerUID") - → Android: AppsflyerSdkPlugin.onMethodCall("getAppsFlyerUID") → getAppsFlyerUID(result) [android/.../AppsflyerSdkPlugin.java] - → result.success(AppsFlyerLib.getInstance().getAppsFlyerUID(mContext)) - → iOS: AppsflyerSdkPlugin.handleMethodCall("getAppsFlyerUID") → getAppsFlyerUID:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → result([[AppsFlyerLib shared] getAppsFlyerUID]) +AppsFlyerSdk.getAppsFlyerUID() [lib/src/appsflyer_sdk.dart] + → _invokeNullableRpc('getAppsFlyerUID') + → MethodChannel('af-api').invokeMethod('executeRpc', {method: 'getAppsFlyerUID', params: {}}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.getInstance().getAppsFlyerUID(context) (returned on the RPC reply) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → [[AppsFlyerLib shared] getAppsFlyerUID] (returned on the RPC reply) + → resolves Future with the native value + → PlatformException is converted to AppsFlyerException ``` --- @@ -35,28 +38,30 @@ AppsflyerSdk.getAppsFlyerUID() [lib/src/a ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `getAppsFlyerUID()` — `Future` async round-trip | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `getAppsFlyerUID(result)`, line 797 | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `getAppsFlyerUID:result:`, line 602 | +| `lib/src/appsflyer_sdk.dart` | `getAppsFlyerUID()` — `Future` round trip over the `getAppsFlyerUID` RPC, with no platform guard and no post-processing of the native value | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | RPC bridge entry (`executeRpc`) routing `getAppsFlyerUID` to `AppsFlyerRpcHandler` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | RPC bridge entry (`executeRpc`) forwarding `getAppsFlyerUID` to `AppsFlyerRPCBridge` | --- ## Input / Output | | | |--|--| -| **Input** | None | -| **Output** | `Future` — the AppsFlyer-generated unique ID for this install; may resolve to `null`/empty if the SDK has not finished initializing/generating the ID yet. | +| **Input** | None. The RPC params map is empty. | +| **Output** | `Future` — the effective install ID, generated by AppsFlyer or replaced by an accepted F-063 custom value. It may resolve to `null` if the native SDK returns no value. Native errors surface as `AppsFlyerException`. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check getAppsFlyerUID call` (line 198) asserts the mocked channel receives `'getAppsFlyerUID'`. The test does not stub a return value, so the resolved-ID contract (nullable String) is not exercised. +`test/appsflyer_sdk_test.dart` → `'maps getters and native return values'` stubs the mocked `af-api` channel with `'uid'`, asserts that `getAppsFlyerUID()` returns it, and asserts the dispatched RPC method is `getAppsFlyerUID`. `'PlatformException becomes AppsFlyerException'` covers the shared error conversion this getter relies on. --- ## Known Limitations -- No documented guarantee of the ID's availability timing relative to `initSdk()`/`startSDK()` — calling it too early (before the native SDK has generated/persisted the ID) can return an empty string or `null` depending on platform/SDK version, and the Dart API gives no way to await "ID ready." -- No test coverage of the actual resolved value or of null/empty-string edge cases on either platform. +- **Unlike `getSdkVersion`, an empty or missing ID is not rejected**: `getAppsFlyerUID` returns the native value verbatim, including `null`. Callers must handle a missing ID themselves. +- No documented guarantee about the ID's availability timing relative to `init()`/`start()`. Calling it too early can return an empty string or `null` depending on platform and SDK version, and the Dart API offers no way to await "ID ready." +- On iOS, calling this getter before F-063 `setInstallId` can cache the generated value and prevent the later custom value from becoming the in-memory result; the custom-ID contract therefore requires setter-first ordering. +- The null and empty-string edge cases are not covered by tests on either platform. --- diff --git a/internal-docs/features/F-021-delayed-session-start-pending-cuid.md b/internal-docs/features/F-021-delayed-session-start-pending-cuid.md index 44f71c32..29e7dca0 100644 --- a/internal-docs/features/F-021-delayed-session-start-pending-cuid.md +++ b/internal-docs/features/F-021-delayed-session-start-pending-cuid.md @@ -3,38 +3,30 @@ id: F-021 name: Delayed Session Start Pending CUID type: sdkCore platform: android -status: active -last_verified: 2026-07-15 +status: removed +last_verified: 2026-08-10 depends_on: ["F-015"] --- ## Business Purpose -Some apps only know the user's own customer ID (CUID) after login, but want every session — including the very first one — attributed with that ID rather than logging an "anonymous" session first. `waitForCustomerUserId(true)` tells the SDK to hold off logging the launch/session event until `setCustomerIdAndLogSession()` explicitly supplies the CUID and unblocks it. Without this pair of APIs, an app that authenticates after launch would either lose the CUID association on the first session or have to accept an anonymous first session in its AppsFlyer reporting. +In SDK 6 the plugin exposed `waitForCustomerUserId(bool)` and `setCustomerIdAndLogSession(String)` so an app could hold the first session until it supplied a customer user ID after login. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +> **Removed in SDK 7.** Both APIs no longer exist in the Flutter plugin. SDK 7 replaces this pattern with the app-driven session model: `init()` does not send a session, so the app simply awaits `setCustomerUserId()` **before** `start()` to guarantee a CUID-attributed first session. See [`doc/migration-guide.md`](/doc/migration-guide.md) and F-002 (SDK Start). --- ## Trigger -`waitForCustomerUserId(true)` is called during startup configuration (typically before or instead of relying on auto-start) to arm the delay. `setCustomerIdAndLogSession(id)` is called later, once the app has resolved the user's customer ID (e.g. after login), to supply the ID and release the held session. +None. The APIs have been removed. To gate the first session on a CUID, defer `start()` — called from the callback registered with `registerSessionReadyListener()` — until after `setCustomerUserId()` has completed. --- ## Call Chain +There is no current call chain. Neither `waitForCustomerUserId` nor `setCustomerIdAndLogSession` exists in `lib/src/appsflyer_sdk.dart`, and neither is handled by the `executeRpc` dispatch on Android or iOS. The SDK 7 equivalent is call ordering: + ``` -AppsflyerSdk.waitForCustomerUserId(wait) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("waitForCustomerUserId", {'wait': wait}) - → Android: AppsflyerSdkPlugin.onMethodCall("waitForCustomerUserId") → waitForCustomerUserId(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().waitForCustomerUserId(wait) - → iOS: AppsflyerSdkPlugin.handleMethodCall("waitForCustomerUserId") → waitForCustomerId:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → NO-OP — the method body only calls result(nil); no native AppsFlyerLib API is invoked - -AppsflyerSdk.setCustomerIdAndLogSession(id) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setCustomerIdAndLogSession", {'id': id}) - → Android: AppsflyerSdkPlugin.onMethodCall("setCustomerIdAndLogSession") → setCustomerIdAndLogSession(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setCustomerIdAndLogSession(id, mContext) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setCustomerIdAndLogSession") → setCustomerUserId:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → routed to the same handler as plain setCustomerUserId — [AppsFlyerLib shared] setCustomerUserID:id]; no "log session" semantics +AppsFlyerSdk.setCustomerUserId(id) → RPC setCustomerUserId {customerId} [F-015] +AppsFlyerSdk.start() → RPC start {awaitResponse: false} [F-002] + (await setCustomerUserId() first, then call start() from the session-ready callback) ``` --- @@ -42,36 +34,32 @@ AppsflyerSdk.setCustomerIdAndLogSession(id) [lib/src/ ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `waitForCustomerIdAndLogSession` split into `waitForCustomerUserId(bool)` and `setCustomerIdAndLogSession(String)` — no `Platform.isAndroid` guard on either | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `waitForCustomerUserId(call, result)` (line 971), `setCustomerIdAndLogSession(call, result)` (line 1009) — both proxy real native APIs | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `waitForCustomerId:result:` (line 757, no-op stub), `setCustomerIdAndLogSession` dispatch aliased to `setCustomerUserId:result:` (line 107/722) | -| `doc/API.md` | Explicitly documents both APIs as **"Android only!"** (lines 440, 449) | +| — | No implementation remains. Removal is documented in [`doc/migration-guide.md`](/doc/migration-guide.md) and `CHANGELOG.md`. | --- ## Input / Output | | | |--|--| -| **Input** | `waitForCustomerUserId`: `wait` (bool) — `true` delays session logging until a CUID is set. `setCustomerIdAndLogSession`: `id` (String) — the customer user ID to attach and the trigger to release the held session. | -| **Output** | `void` for both — fire-and-forget; no confirmation returned to Dart. | +| **Input** | Removed: `waitForCustomerUserId(bool)` / `setCustomerIdAndLogSession(String)` | +| **Output** | None. Use F-015 and F-002, which each return `Future`. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check waitForCustomerUserId call` (line 260) asserts the mocked channel receives `'waitForCustomerUserId'`. No test exists for `setCustomerIdAndLogSession` — it is absent from the mock handler's recognized-method switch (line 24-66) entirely, so calling it in a test would not even register as a captured method. +No tests target the removed APIs. `test/appsflyer_sdk_test.dart` contains no references to `waitForCustomerUserId` or `setCustomerIdAndLogSession`; it separately verifies the current `setCustomerUserId` mapping and both `start()` values of `awaitResponse`, but does not run an end-to-end ordering test. --- ## Known Limitations -- **Effectively Android-only, despite no platform guard in Dart.** On iOS, `waitForCustomerId:` is a hollow stub (`result(nil)` only) — calling `waitForCustomerUserId(true)` on iOS has zero effect on session logging. `setCustomerIdAndLogSession` on iOS is silently routed to the same code as plain `setCustomerUserId` (just sets the customer ID property) with no "wait/release" behavior at all. This means an app that relies on this feature to guarantee CUID-attributed first sessions gets that guarantee only on Android; on iOS the first session logs immediately, unattributed, regardless of `waitForCustomerUserId(true)`. -- The official docs (`doc/API.md`) do flag both APIs "Android only," but the Dart API surface itself has no runtime warning, assertion, or `Platform.isAndroid` check — an integrator who skips the docs and only reads code/dartdoc could easily assume cross-platform parity. -- No test coverage at all for `setCustomerIdAndLogSession`, and no test verifies the delay/release semantics (mocks only assert the method name was invoked, not any ordering or blocking behavior). +- The delayed-session guarantee is now expressed through call ordering (`await setCustomerUserId()` before `start()`), not a dedicated API. +- The removed APIs must not be restored or simulated in Dart, because the SDK 7 session model already lets the app decide when the first session is sent. --- ## Dependencies ```mermaid flowchart LR - F021["F-021 · Delayed Session Start Pending CUID"]:::sdkCore -->|"iOS: routed to same native handler as"| F015["F-015 · Customer User ID (CUID)"]:::sdkCore + F021["F-021 · Delayed Session Start Pending CUID (removed)"]:::sdkCore -->|"replaced by CUID + start ordering"| F015["F-015 · Customer User ID (CUID)"]:::sdkCore classDef sdkCore fill:#4C6EF5,color:#fff ``` diff --git a/internal-docs/features/F-022-push-notification-deep-link-path-config.md b/internal-docs/features/F-022-push-notification-deep-link-path-config.md index 1cd63e82..8068b0fc 100644 --- a/internal-docs/features/F-022-push-notification-deep-link-path-config.md +++ b/internal-docs/features/F-022-push-notification-deep-link-path-config.md @@ -4,61 +4,67 @@ name: Push Notification Deep-Link Path Config type: deepLinking platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: ["F-037"] --- ## Business Purpose Push-notification re-engagement campaigns often embed a OneLink URL somewhere inside a custom, nested JSON payload rather than in a fixed top-level field — the exact location varies per app. `addPushNotificationDeepLinkPath` tells the native AppsFlyer SDK the JSON key-path where that OneLink URL lives, so the SDK can extract and resolve it as a deep link when the push payload is later handed to it. Without configuring this path, the SDK has no way to find the OneLink URL inside an arbitrarily-shaped push payload, and push-driven deep links silently fail to route users to the right in-app destination. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger -Called once by the host app during startup configuration, **before** `initSdk()`/`startSDK()` is invoked — per `doc/API.md`, calling it after SDK start is unsupported. This registers the path so it's in place before any push payload is later delivered (see F-031). +Awaited once by the host app during startup configuration, **before** `init()`. The dartdoc states this ordering requirement; nothing in the Flutter layer enforces it. Registering the path early puts it in place before any push payload is later delivered (see F-031). --- ## Call Chain +This is a generic RPC call (no per-method channel handler): the Dart wrapper sends `{method: 'addPushNotificationDeepLinkPath', params: {deepLinkPath: [...]}}` through the single `executeRpc` entry point (the list is **wrapped under the `deepLinkPath` map key**, not passed as the raw argument), and each platform's native RPC bridge parses it into a typed request and forwards it to the SDK. + ``` -AppsflyerSdk.addPushNotificationDeepLinkPath(List deeplinkPath) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("addPushNotificationDeepLinkPath", deeplinkPath) - → Android: AppsflyerSdkPlugin.onMethodCall("addPushNotificationDeepLinkPath") → addPushNotificationDeepLinkPath(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().addPushNotificationDeepLinkPath(String[] path) - → iOS: AppsflyerSdkPlugin.handleMethodCall("addPushNotificationDeepLinkPath") → addPushNotificationDeepLinkPath:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] addPushNotificationDeepLinkPath:deeplinkPath] +AppsFlyerSdk.addPushNotificationDeepLinkPath(List deepLinkPath) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('addPushNotificationDeepLinkPath', {'deepLinkPath': deepLinkPath}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AddPushNotificationDeepLinkPathRequest(deepLinkPath) // init: require(deepLinkPath.isNotEmpty()) + → AppsFlyerLib.getInstance().addPushNotificationDeepLinkPath(*deepLinkPath.toTypedArray()) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → AFRPCAddPushNotificationDeepLinkPathRequest(path) // guard: [String] && !isEmpty else missingParameter + → sdk.addPushNotificationDeepLinkPath(path) ([AppsFlyerLib shared]) + → successful reply completes Future + → PlatformException is converted to AppsFlyerException ``` -The configured path is later consulted when a push payload reaches the native SDK (Android: automatically, from the launch/new intent extras; iOS: when `sendPushNotificationData`/`handlePushNotification` is called — see F-031), and any OneLink URL found at that path is resolved and delivered through the UDL `onDeepLinking` callback (F-037). +The configured path is later consulted when a push payload reaches the native SDK — on Android automatically from the launch/new-intent extras, and on iOS when the app forwards the payload with `handlePushNotification(pushPayload)` (see F-031). Any OneLink URL found at that path is resolved and delivered to the registered `registerDeepLinkListener` callback (F-037). --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `addPushNotificationDeepLinkPath(List)` — passes the path array directly as method-channel arguments (no wrapping map) | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `addPushNotificationDeepLinkPath(call, result)` — casts arguments to `ArrayList`, converts to `String[]`, forwards to `AppsFlyerLib.getInstance().addPushNotificationDeepLinkPath` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `addPushNotificationDeepLinkPath:result:` — forwards the `NSArray` directly to `[AppsFlyerLib shared]` if non-nil | +| `lib/src/appsflyer_sdk.dart` | `addPushNotificationDeepLinkPath(List deepLinkPath)` — awaitable passthrough that sends the generic RPC `addPushNotificationDeepLinkPath` with `{deepLinkPath}`; performs no Dart-side validation. Also hosts the two deliberately non-unified push entry points: Android-only `sendPushNotificationData(...)` and iOS-only `handlePushNotification(pushPayload)`. | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic `executeRpc` dispatch — forwards the JSON envelope to the Android RPC handler | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Generic `executeRpc` dispatch — forwards the JSON envelope to the iOS RPC bridge | --- ## Input / Output | | | |--|--| -| **Input** | `deeplinkPath` (`List`) — ordered JSON keys describing where in the push payload the OneLink URL is nested (e.g. `["deeply", "nested", "deep_link"]`) | -| **Output** | `void` — fire-and-forget; both native handlers call `result.success(null)`/`result(nil)` unconditionally (Android does so even if `call.arguments` is null, since the `if` guard just skips the native call but still succeeds). | +| **Input** | `deepLinkPath` (`List`) — ordered JSON keys describing where in the push payload the OneLink URL is nested (for example `["deeply", "nested", "link"]`), sent wrapped under the `deepLinkPath` RPC params key. The native bridges require a non-empty list. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK configuration call. An empty list or bridge failure throws `AppsFlyerException`; there is no native completion callback or request timeout. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `addPushNotificationDeepLinkPath`. +`test/appsflyer_sdk_test.dart` → `'maps deep-link, sharing, push, and uninstall APIs'` verifies that `addPushNotificationDeepLinkPath(['data', 'link'])` dispatches RPC method `addPushNotificationDeepLinkPath` with params `{'deepLinkPath': ['data', 'link']}`, and covers the companion push APIs (`sendPushNotificationData` on Android, `handlePushNotification` on iOS). `'platform-only calls are forwarded to the native RPC instead of being swallowed in Dart'` covers `handlePushNotification` on the wrong platform and asserts that the RPC is dispatched there too rather than being short-circuited in Dart. Native contract enforcement (empty-list rejection, SDK forwarding) is covered by the native SDKs' own bridge tests. --- ## Known Limitations -- Must be called before SDK init/start per documentation; neither native handler nor the Dart method enforces or warns about ordering — calling it late is a silent no-op for that launch. -- On Android this path config is sufficient on its own (the SDK auto-extracts from intent extras); on iOS it configures the path but does nothing until the payload is separately forwarded to the SDK via F-031's `sendPushNotificationData`/`handlePushNotification` — an integrator who configures the path on iOS but skips that step will see push deep links silently fail to resolve. -- No validation of the path array shape (e.g. empty list, non-string elements) on either platform before forwarding to native code. +- Must be called before `init()` per the public Dart contract; nothing in Dart or RPC enforces the ordering. The implementation does not expose enough state to prove what a late call affects, so it must not be treated as supported for the current launch. +- On Android this path config is sufficient on its own (the SDK auto-extracts from intent extras). On iOS it configures the path but does nothing until the payload is separately forwarded with `handlePushNotification(pushPayload)` (F-031) — an integrator who configures the path on iOS but skips that step will see push deep links silently fail to resolve. +- **The push forwarding APIs are deliberately not unified**: `sendPushNotificationData({campaign, pid, isRetargeting, additionalParameters})` is Android-only and `handlePushNotification(Map pushPayload)` is iOS-only, because the native parameter shapes have nothing in common. Calling either on the wrong platform is not absorbed in Dart: the RPC is dispatched, the native layer reports the method as unavailable, and the call throws `AppsFlyerException` — so a misplaced call is a real failure, and cross-platform call sites must branch on `Platform.isAndroid` / `Platform.isIOS` or catch the exception. Because the two APIs take different inputs, such an app will normally branch anyway. +- **Empty list fails at the native bridge, not in Dart**: both bridges reject an empty `deepLinkPath` (Android `require(deepLinkPath.isNotEmpty())`; iOS `guard [String] && !isEmpty else missingParameter`). Because the method is awaitable, that rejection now surfaces as `AppsFlyerException`, but only after a round trip — Dart does not pre-validate. iOS also conflates missing and empty into `missingParameter`, so the two platforms report the same mistake with different error text. --- diff --git a/internal-docs/features/F-023-in-app-purchase-validation-v1.md b/internal-docs/features/F-023-in-app-purchase-validation-v1.md index 34a452e7..a382599e 100644 --- a/internal-docs/features/F-023-in-app-purchase-validation-v1.md +++ b/internal-docs/features/F-023-in-app-purchase-validation-v1.md @@ -3,41 +3,39 @@ id: F-023 name: In-App Purchase Validation V1 (Android/iOS separate APIs) type: purchaseValidation platform: both -status: deprecated -last_verified: 2026-07-15 -depends_on: ["F-038", "F-025"] +status: removed +last_verified: 2026-08-10 +depends_on: [] --- -## Business Purpose -Before the cross-platform V2 API existed, apps needed a way to send a purchase receipt directly to AppsFlyer's validation servers so that in-app-purchase revenue could be confirmed against the store (Google Play / App Store) rather than trusted at face value from the client. This is what lets AppsFlyer distinguish real, store-verified revenue from spoofed or refunded purchases in attribution and revenue reporting. `validateAndLogInAppAndroidPurchase` submits the Google Play `purchaseData`/`signature`/`publicKey` triple; `validateAndLogInAppIosPurchase` submits the App Store `productIdentifier`/`transactionId`. Both are now `@Deprecated` in favor of `validateAndLogInAppPurchaseV2` (F-024), but any app still calling them relies on this exact code path — removing it would break revenue validation for apps that have not migrated, with no automatic fallback. +## Status: REMOVED in SDK 7 + +The legacy V1 in-app purchase validation APIs — `validateAndLogInAppAndroidPurchase` (Google Play `publicKey`/`signature`/`purchaseData` triple) and `validateAndLogInAppIosPurchase` (the iOS six-parameter form) — are **not part of the plugin's public API**. Neither symbol exists in `lib/`. The underlying native V1 validation entry points were removed from the native AppsFlyer SDK 7, so per the [API Removal Rule](/doc/migration-guide.md#api-removal-rule) the plugin does not keep or emulate them. + +There is no separate V1 result listener either; the legacy notification-based callback is tombstoned as F-038. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +**Replacement:** `validateAndLogInAppPurchase(AFPurchaseDetails purchase, {Map? additionalParameters, bool awaitResponse = true})` (F-024) — a single cross-platform call that returns the validation result directly on its `Future` by default and throws `AppsFlyerException` on failure when the native RPC reports it. + +See [`doc/migration-guide.md`](/doc/migration-guide.md#removed-apis-and-their-replacements) and the [CHANGELOG](/CHANGELOG.md). + +--- + +## Business Purpose +This entry is retained as a tombstone for the former platform-split V1 validation APIs. Server-side purchase validation itself is still supported — it moved to the single cross-platform entry point documented by F-024, which selects the Android or App Store contract from the supplied `AFPurchaseDetails` implementation instead of exposing one Dart method per store. --- ## Trigger -Called by the host app immediately after it detects a completed purchase from the platform store (Google Play Billing on Android, StoreKit on iOS) and wants that purchase validated and logged to AppsFlyer. +None. The V1 methods are not part of the current public API and are not reachable through either platform's RPC bridge. --- ## Call Chain +There is no current call chain. The replacement is documented by F-024: + ``` -Android: -AppsflyerSdk.validateAndLogInAppAndroidPurchase(publicKey, signature, purchaseData, price, currency, additionalParameters) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("validateAndLogInAppAndroidPurchase", {publicKey, signature, purchaseData, price, currency, additionalParameters}) - → AppsflyerSdkPlugin.onMethodCall case "validateAndLogInAppAndroidPurchase" → validateAndLogInAppPurchase(call, result) [android/.../AppsflyerSdkPlugin.java] - → registerValidatorListener() // registers AppsFlyerInAppPurchaseValidatorListener (feeds F-038) - → AppsFlyerLib.getInstance().validateAndLogInAppPurchase(mContext, publicKey, signature, purchaseData, price, currency, additionalParameters) - → result.success(null) // Future resolves immediately; real result arrives later via the "validatePurchase" callback (F-038) - -iOS: -AppsflyerSdk.validateAndLogInAppIosPurchase(productIdentifier, price, currency, transactionId, additionalParameters) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("validateAndLogInAppIosPurchase", {productIdentifier, price, currency, transactionId, additionalParameters}) - → AppsflyerSdkPlugin.handleMethodCall case "validateAndLogInAppIosPurchase" → validateAndLogInAppPurchase:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [AppsFlyerLib shared] validateAndLogInAppPurchase:productIdentifier price:currency:transactionId:additionalParameters:success:failure: - → success block → onValidateSuccess: → [_streamHandler sendResponseToFlutter:@"validatePurchase" status:@"success" data:response] [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m] - → failure block → onValidateFail: → [_streamHandler sendResponseToFlutter:@"validatePurchase" status:@"failure" data:errorObject] - → result(nil) // Future resolves immediately, same fire-and-forget pattern as Android +AppsFlyerSdk.validateAndLogInAppPurchase(AFAndroidPurchaseDetails | AFIOSPurchaseDetails) + → RPC validateAndLogInAppPurchase (platform-specific params) ``` --- @@ -45,42 +43,36 @@ AppsflyerSdk.validateAndLogInAppIosPurchase(productIdentifier, price, currency, ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `validateAndLogInAppAndroidPurchase(...)` and `validateAndLogInAppIosPurchase(...)`, both annotated `@Deprecated` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `validateAndLogInAppPurchase(MethodCall, Result)` native handler; calls `registerValidatorListener()` and `AppsFlyerLib.getInstance().validateAndLogInAppPurchase(...)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `validateAndLogInAppPurchase:result:` native handler; calls `[AppsFlyerLib shared] validateAndLogInAppPurchase:...]` with success/failure blocks routed through `onValidateSuccess:`/`onValidateFail:` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `sendResponseToFlutter:status:data:` — forwards the async iOS validation result to Dart over the callback `MethodChannel` (`callbacks`) using `invokeMethod("callListener", ...)`, despite the class name suggesting an `EventChannel` | +| `lib/src/appsflyer_sdk.dart` | Contains the active `validateAndLogInAppPurchase` API; no V1 method remains | +| `lib/src/af_purchase_details.dart` | `AFPurchaseDetails` with the `AFAndroidPurchaseDetails` / `AFIOSPurchaseDetails` implementations used by the replacement | +| `doc/migration-guide.md` | Documents both removed V1 methods and their replacement | --- ## Input / Output | | | |--|--| -| **Input** | Android: `publicKey` (String), `signature` (String), `purchaseData` (String), `price` (String), `currency` (String), `additionalParameters` (Map?). iOS: `productIdentifier` (String), `price` (String), `currency` (String), `transactionId` (String), `additionalParameters` (Map). | -| **Output** | The Dart `Future` returned by both methods resolves to `null` immediately (fire-and-forget) — it does **not** carry the validation result. The actual validation outcome (success/failure + response payload) is delivered asynchronously, out-of-band, through the `onPurchaseValidation` callback listener (F-038), keyed by callback id `"validatePurchase"`. | +| **Input** | Removed: the Android `publicKey`/`signature`/`purchaseData`/`price`/`currency` parameters and the iOS six-parameter form | +| **Output** | None. Use F-024, which returns `Future>`. | --- ## Tests -`test/appsflyer_sdk_test.dart` — covers `validateAndLogInAppAndroidPurchase` only (asserts the method name string `"validateAndLogInAppAndroidPurchase"` and that `publicKey`/`price`/`currency` are forwarded correctly in the arguments map). No test exists for `validateAndLogInAppIosPurchase`. +No test references a V1 method. `test/appsflyer_sdk_test.dart` covers the replacement through `purchase validation sends the Android contract`, `purchase validation sends the iOS contract`, `purchase detail factories use the dedicated implementations`, and `purchase details reject the wrong platform`. --- ## Known Limitations -- Both APIs are `@Deprecated` with a doc comment pointing to `validateAndLogInAppPurchaseV2` (F-024), and are marked for removal in a future version — new integrations should not use them. -- The Dart `Future` resolves to `null` on both platforms as soon as the native call is dispatched, not when validation actually completes — callers cannot `await` a result from these methods; they must separately register `onPurchaseValidation` (F-038) to observe the outcome. This asynchronous split is easy to miss and is not documented in the dartdoc for either method. -- iOS delivers its result via `AppsFlyerStreamHandler.sendResponseToFlutter`, which despite its name and the class being wired to a `FlutterEventChannel`, actually pushes data through the callback `MethodChannel` (`callListener`) instead of an `EventSink` — the Dart-side `EventChannel` (`af-events`) instantiated in `appsflyer_sdk.dart` is never `.listen()`-ed to anywhere in `lib/`. -- No test coverage at all for the iOS path (`validateAndLogInAppIosPurchase`), only the Android path is asserted in `test/appsflyer_sdk_test.dart`. -- On Android, the validated result is only forwarded to Dart if `onPurchaseValidation` was registered *before* the validation completes (gated by the `validatePurchaseCallback` boolean flag); on iOS, `sendResponseToFlutter` has no such gate and always attempts to forward, which is an asymmetry between the two native implementations of the same nominal feature. +- Existing SDK 6 integrations must rewrite each store-specific call site to build an `AFAndroidPurchaseDetails` or `AFIOSPurchaseDetails` and await `validateAndLogInAppPurchase`. +- The removed methods must not be restored or simulated in Dart, because the native V1 validation entry points no longer exist to forward to. --- ## Dependencies ```mermaid flowchart LR - F023["F-023 · In-App Purchase Validation V1"]:::purchaseValidation - F038["F-038 · Legacy Purchase-Validation Notification Callback"]:::purchaseValidation - F025["F-025 · iOS Receipt Validation Sandbox Toggle"]:::purchaseValidation - F023 -->|"delivers async result via"| F038 - F023 -->|"iOS: validates against endpoint set by"| F025 + F023["F-023 · In-App Purchase Validation V1 (removed)"]:::purchaseValidation + F024["F-024 · In-App Purchase Validation V2"]:::purchaseValidation + F023 -->|"replaced by"| F024 classDef purchaseValidation fill:#F59F00,color:#fff ``` diff --git a/internal-docs/features/F-024-in-app-purchase-validation-v2.md b/internal-docs/features/F-024-in-app-purchase-validation-v2.md index bfc6ed29..9c5102cf 100644 --- a/internal-docs/features/F-024-in-app-purchase-validation-v2.md +++ b/internal-docs/features/F-024-in-app-purchase-validation-v2.md @@ -4,80 +4,86 @@ name: In-App Purchase Validation V2 (cross-platform) type: purchaseValidation platform: both status: active -last_verified: 2026-07-15 -depends_on: ["F-025"] +last_verified: 2026-08-10 +depends_on: [] --- ## Business Purpose -`validateAndLogInAppPurchaseV2` replaces the deprecated, platform-specific V1 APIs (F-023) with a single cross-platform entry point built around the `AFPurchaseDetails` model, so app developers write one call site instead of branching on `Platform.isAndroid`/`Platform.isIOS`. It lets AppsFlyer verify purchase/subscription revenue against the store (Google Play or App Store) and, unlike V1, returns the actual validation result (or a structured error) directly on the `Future`, so the app can react to a failed validation (e.g. refuse to unlock content) at the call site instead of wiring a separate global listener. Without this feature, apps would have to fall back to the deprecated, harder-to-use, fire-and-forget V1 APIs to get server-side purchase validation at all. +`validateAndLogInAppPurchase` is the single cross-platform entry point for server-side purchase validation. It lets AppsFlyer verify purchase and subscription revenue against the store (Google Play or App Store). With `awaitResponse: true`, it returns the validation result — or throws a structured error — directly on the `Future`, so the app can react at the call site. It replaces the removed platform-split V1 APIs (F-023). -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +The store contract is selected by the type of the supplied `AFPurchaseDetails`, not by a runtime platform check, so an app that ships the wrong store's purchase model fails loudly instead of sending an unusable payload. --- ## Trigger -Called by the host app after it detects a completed purchase or subscription renewal from the platform store, whenever it wants a synchronous (awaited) validation result back from AppsFlyer. +Called by the host app after it detects a completed purchase or subscription renewal from the platform store. On Android, the host can select whether the RPC awaits the validation result through `awaitResponse`; iOS always awaits it. --- ## Call Chain +Both platforms dispatch the same RPC method name, `validateAndLogInAppPurchase`, but the purchase parameter shape is produced by the platform-specific `AFPurchaseDetails` implementation. Android uses a flat schema and iOS uses nested `product` and `transaction` objects. `AppsFlyerSdk` appends the public `awaitResponse` value only to the Android payload because the iOS RPC does not expose that field. + ``` -AppsflyerSdk.validateAndLogInAppPurchaseV2(purchaseDetails, {additionalParameters}) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("validateAndLogInAppPurchaseV2", { - 'purchaseDetails': purchaseDetails.toMap(), // {purchaseType, purchaseToken, productId} [lib/src/af_purchase_details.dart] - 'additionalParameters': additionalParameters, - }) - → Android: AppsflyerSdkPlugin.onMethodCall case "validateAndLogInAppPurchaseV2" → validateAndLogInAppPurchaseV2(call, result) [android/.../AppsflyerSdkPlugin.java] - → mapPurchaseType(purchaseTypeString) // "subscription" → AFPurchaseType.SUBSCRIPTION, "one_time_purchase" → AFPurchaseType.ONE_TIME_PURCHASE - → new AFPurchaseDetails(purchaseType, purchaseToken, productId) - → AppsFlyerLib.getInstance().validateAndLogInAppPurchase(purchaseDetails, additionalParameters, AppsFlyerInAppPurchaseValidationCallback) - → onInAppPurchaseValidationFinished(...) → result.success(flutterResult) - → onInAppPurchaseValidationError(...) → result.error("VALIDATION_ERROR", errorMessage, flutterErrorResult) - → iOS: AppsflyerSdkPlugin.handleMethodCall case "validateAndLogInAppPurchaseV2" → validateAndLogInAppPurchaseV2:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → maps purchaseType string to AFSDKPurchaseType, purchaseToken → transactionId - → new AFSDKPurchaseDetails(productId, transactionId, purchaseType) - → [AppsFlyerLib shared] validateAndLogInAppPurchase:purchaseAdditionalDetails:completion: - → completion(response, nil) → result(response) - → completion(nil, error) → result([FlutterError code:"VALIDATION_ERROR" ...]) +AppsFlyerSdk.validateAndLogInAppPurchase( [lib/src/appsflyer_sdk.dart] + purchase, {additionalParameters, awaitResponse}) + → purchase.toRpcMap(platform: _platform, additionalParameters: ...) [lib/src/af_purchase_details.dart] + → AFAndroidPurchaseDetails: {purchaseType, purchaseToken, productId, + additionalParameters} + → AFIOSPurchaseDetails: {product: {productId}, + transaction: {transactionId, purchaseType}, + additionalParameters} + → wrong platform for the model, including any non-mobile platform → ArgumentError + → Android only: append {awaitResponse} + → _invokeNullableRpc?>>('validateAndLogInAppPurchase', params) + → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.executeRpc → dispatchRpc('validateAndLogInAppPurchase', ...) + → AppsFlyerRpcHandler.execute(json) → AppsFlyerLib.validateAndLogInAppPurchase(...) + → iOS: AppsflyerSdkPlugin.executeRpc → dispatchRpc:method:@"validateAndLogInAppPurchase" + → [AppsFlyerRPCBridge shared] executeJson:completion: → AFRPCRequestHandler → SDK + → unwrapValueForMethod: returns the `data` map (or {}) for this method + → successful per-call reply completes the Future with the validation-result map + → PlatformException is converted to AppsFlyerException ``` +A `null` native reply is normalized to an empty map rather than propagated as `null`. + --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `validateAndLogInAppPurchaseV2(AFPurchaseDetails, {additionalParameters})` | -| `lib/src/af_purchase_details.dart` | `AFPurchaseDetails` model (`purchaseType`, `purchaseToken`, `productId`) and `AFPurchaseType` enum (`oneTimePurchase`, `subscription`); `toMap()` serializes `purchaseType` to `"one_time_purchase"` / `"subscription"` strings for the channel | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `validateAndLogInAppPurchaseV2(MethodCall, Result)` handler; `mapPurchaseType(String)` translates the Dart string enum to the native `AFPurchaseType` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `validateAndLogInAppPurchaseV2:result:` handler; inline string comparison maps to `AFSDKPurchaseType` (note: `purchaseToken` from Dart is passed as iOS `transactionId`) | +| `lib/src/appsflyer_sdk.dart` | `validateAndLogInAppPurchase(AFPurchaseDetails purchase, {Map? additionalParameters, bool awaitResponse = true})` → `Future>`; delegates purchase parameter building to the model, appends `awaitResponse` for Android, and invokes the RPC | +| `lib/src/af_purchase_details.dart` | `sealed class AFPurchaseDetails` (closed to `AFAndroidPurchaseDetails` and `AFIOSPurchaseDetails`), `AFPurchaseType`, and the per-platform `toRpcMap` contracts | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | No per-method handler — generic `executeRpc` → `dispatchRpc('validateAndLogInAppPurchase', ...)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler; generic dispatch, with `unwrapValueForMethod:` returning the `data` map for `validateAndLogInAppPurchase` | --- ## Input / Output | | | |--|--| -| **Input** | `purchaseDetails` (`AFPurchaseDetails` → map with `purchaseType` string, `purchaseToken`, `productId`), `additionalParameters` (`Map?`, optional). | -| **Output** | `Future>` — resolves with the native SDK's validation-finished result map on success; on failure the platform channel throws (Android: `PlatformException` with code `"VALIDATION_ERROR"` or `"INVALID_ARGUMENTS"`/`"INVALID_PURCHASE_TYPE"`; iOS: `PlatformException` with code `"VALIDATION_ERROR"` or `"INVALID_ARGUMENTS"`, details include `error_code`/`error_domain`). Unlike V1 (F-023), the result is delivered synchronously on the same `Future` — no separate listener is needed. | +| **Input** | `purchase` (`AFPurchaseDetails`) — `AFAndroidPurchaseDetails(purchaseType, productId, purchaseToken)` for Google Play or `AFIOSPurchaseDetails(purchaseType, productId, transactionId)` for the App Store; `additionalParameters` (`Map?`, optional); `awaitResponse` (`bool`, optional, default `true`; mapped only to Android RPC). `AFPurchaseType` serializes as `"one_time_purchase"` / `"subscription"` on Android and `"oneTimePurchase"` / `"subscription"` on iOS. | +| **Output** | `Future>` — with the default `awaitResponse: true`, completes with the native validation-result map. Android waits up to 5 seconds; `false` starts validation without a callback and completes with an empty map. The current iOS RPC 7.0.12 does not expose the flag, always awaits validation, and uses a 30-second timeout. Native and bridge failures throw `AppsFlyerException`. Passing the wrong platform's model throws `ArgumentError`, including on a non-mobile platform. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart` does not register a mock handler for `"validateAndLogInAppPurchaseV2"` or call `validateAndLogInAppPurchaseV2` anywhere; the only purchase-validation test present covers the deprecated `validateAndLogInAppAndroidPurchase` (F-023). The `example/` app does exercise this method (`example/lib/main_page.dart`, `validatePurchase()` helper), but that is a manual/demo path, not an automated test. +`test/appsflyer_sdk_test.dart`: +- `purchase validation sends the Android contract` — asserts the flat Android parameter map (`purchaseType: 'one_time_purchase'`, `purchaseToken`, `productId`, `additionalParameters`, `awaitResponse: true`) and that the mocked result map is returned to the caller. +- `purchase validation sends the iOS contract` — asserts the nested iOS parameter map (`product.productId`, `transaction.transactionId`, `transaction.purchaseType`, `additionalParameters`) without an unsupported `awaitResponse` field. +- `purchase validation forwards awaitResponse only to Android` — asserts that an explicit `false` reaches Android RPC and that the unsupported field is omitted from the iOS payload. +- `purchase details reject the wrong platform` — asserts `ArgumentError` when an Android model is used on iOS and vice versa, and when either model is serialized on a non-mobile target platform (`macOS`, `windows`). --- ## Known Limitations -- No automated test coverage — a regression in the `purchaseType` string values (`"one_time_purchase"` / `"subscription"`), which must match exactly across `af_purchase_details.dart`, `AppsflyerSdkPlugin.java`'s `mapPurchaseType`, and the iOS string comparison, would not be caught by CI. -- The field name is inconsistent across platforms: Dart/Android call it `purchaseToken`, but the iOS handler maps that same value onto `transactionId` (`NSString* transactionId = purchaseDetailsMap[@"purchaseToken"];`) — functionally correct today, but a naming trap for anyone reading only one side of the bridge. -- Invalid `purchaseType` strings are handled inconsistently in shape: Android returns a distinct `"INVALID_PURCHASE_TYPE"` error code, while iOS silently defaults any non-`"subscription"` string to `AFSDKPurchaseTypeOneTimePurchase` instead of validating and erroring — a typo'd purchase type on iOS would silently validate as the wrong purchase type rather than fail loudly. +- The platform/model pairing is enforced at runtime by `toRpcMap`, not by the type system, so a mismatched model compiles and only throws `ArgumentError` when the call is made. +- The purchase-type wire values differ between platforms (`one_time_purchase` on Android, `oneTimePurchase` on iOS) because each native RPC parser expects its own casing; the Dart enum hides this, but the payloads are not interchangeable. +- The returned validation-result map is untyped (`Map`) and passed through from the native reply, so its keys are defined by the native SDK rather than by the Flutter API. +- The Android RPC honors `awaitResponse: false`; the current iOS RPC 7.0.12 exposes neither this field nor a fire-and-forget branch for purchase validation. Full behavioral parity requires iOS RPC support. +- A timeout fails the Dart Future but does not cancel the store/server validation already started by the native SDK. A late result is not delivered through a second Flutter callback. --- ## Dependencies -```mermaid -flowchart LR - F024["F-024 · In-App Purchase Validation V2"]:::purchaseValidation - F025["F-025 · iOS Receipt Validation Sandbox Toggle"]:::purchaseValidation - F024 -->|"iOS: validates against endpoint set by"| F025 - classDef purchaseValidation fill:#F59F00,color:#fff -``` +No required feature dependency. F-025 is an optional iOS environment switch used only for sandbox validation. diff --git a/internal-docs/features/F-025-ios-receipt-validation-sandbox-toggle.md b/internal-docs/features/F-025-ios-receipt-validation-sandbox-toggle.md index a5f9c0ec..dd152a91 100644 --- a/internal-docs/features/F-025-ios-receipt-validation-sandbox-toggle.md +++ b/internal-docs/features/F-025-ios-receipt-validation-sandbox-toggle.md @@ -4,71 +4,71 @@ name: iOS Receipt Validation Sandbox Toggle type: purchaseValidation platform: ios status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Apple's StoreKit sandbox (TestFlight / Xcode debug builds) issues receipts that Apple's production receipt-validation endpoint rejects, and vice versa. `useReceiptValidationSandbox` lets a host app tell AppsFlyer's native iOS SDK which Apple endpoint to call when it later validates an in-app purchase (F-023 V1 iOS path, or F-024 V2), so QA/TestFlight builds can validate sandbox receipts without those calls failing against the production endpoint. Without this toggle, developers testing purchase validation on non-production builds would see every validation call fail against Apple's servers, even though the purchase itself is legitimate in the sandbox. +Apple's StoreKit sandbox (TestFlight and Xcode debug builds) issues receipts that Apple's production receipt-validation endpoint rejects, and vice versa. `setUseReceiptValidationSandbox` tells AppsFlyer's native iOS SDK which Apple endpoint to call when it later validates an in-app purchase (F-024), so QA and TestFlight builds can validate sandbox receipts without those calls failing against the production endpoint. Without this toggle, developers testing purchase validation on non-production builds would see every validation call fail against Apple's servers even though the purchase is legitimate in the sandbox. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +The companion `setUseUninstallSandbox` toggles the equivalent sandbox/production environment for uninstall-measurement validation. --- ## Trigger -Called by the host app during setup/configuration (typically before or alongside SDK init), whenever it needs to toggle whether subsequent iOS purchase-validation calls (F-023, F-024) hit Apple's sandbox or production receipt-validation environment. +Called by the host app during setup or configuration, before the purchase-validation or uninstall-measurement calls whose environment it affects. --- ## Call Chain +Both toggles are awaitable RPC calls, iOS-only at the native RPC layer. Dart no longer short-circuits them off iOS; wrong-platform calls reach the native RPC dispatcher and surface `AppsFlyerException`. + ``` -AppsflyerSdk.useReceiptValidationSandbox(bool isSandboxEnabled) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("useReceiptValidationSandbox", isSandboxEnabled) - → AppsflyerSdkPlugin.handleMethodCall case "useReceiptValidationSandbox" - → useReceiptValidationSandbox:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → _isSandboxEnabled = isSandboxEnabled.boolValue - → [AppsFlyerLib shared].useReceiptValidationSandbox = _isSandboxEnabled - → result(nil) +AppsFlyerSdk.setUseReceiptValidationSandbox(bool sandbox) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setUseReceiptValidationSandbox', {'sandbox': sandbox}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → iOS: AppsflyerSdkPlugin.executeRpc → dispatchRpc:method:@"setUseReceiptValidationSandbox" + → [AppsFlyerRPCBridge shared] executeJson:completion: → AFRPCRequestHandler → SDK + → Android: unknown method → AppsFlyerException (422 interim) + → successful per-call reply completes Future + → PlatformException is converted to AppsFlyerException + +AppsFlyerSdk.setUseUninstallSandbox(bool sandbox) + → _invokeVoidRpc('setUseUninstallSandbox', {'sandbox': sandbox}) ``` -There is no Android implementation: the method channel argument is only handled on the iOS side. --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `useReceiptValidationSandbox(bool isSandboxEnabled)` — sends the raw bool as the method-call argument (not wrapped in a map) | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `useReceiptValidationSandbox:result:` (line ~410) — guards with `isKindOfClass:[NSNumber class]`, stores into static `_isSandboxEnabled`, and forwards to `[AppsFlyerLib shared].useReceiptValidationSandbox` | +| `lib/src/appsflyer_sdk.dart` | `setUseReceiptValidationSandbox(bool sandbox)` → RPC `setUseReceiptValidationSandbox` with `{sandbox}`; `setUseUninstallSandbox(bool sandbox)` → RPC `setUseUninstallSandbox` with `{sandbox}`. Both iOS-only at the native RPC layer | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler — generic `executeRpc` → `dispatchRpc` forwards to `AppsFlyerRPCBridge` | --- ## Input / Output | | | |--|--| -| **Input** | `isSandboxEnabled` (`bool`) — sent as the bare method-call argument, not nested in a map. | -| **Output** | None — `void` method; native side calls `result(nil)` and the call is fire-and-forget. The effect is purely a stateful flag on `AppsFlyerLib` that changes the behavior of subsequent `validateAndLogInAppPurchase`/`validateAndLogInAppPurchaseV2` calls (F-023, F-024). | +| **Input** | `sandbox` (`bool`), sent under the `sandbox` params key | +| **Output** | `Future` completes after RPC validation and the synchronous native SDK property assignment. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or request timeout. On a non-iOS platform the call reaches the Android RPC dispatcher and throws `AppsFlyerException` (code `422` interim until the native RPC fix lands). The effect is a stateful flag on the native iOS SDK that changes subsequent `validateAndLogInAppPurchase` (F-024) or uninstall-measurement behavior. | --- ## Tests -No dedicated test found. `grep` of `test/` and `example/` for `useReceiptValidationSandbox`/`isSandboxEnabled` returns no matches — neither an automated test nor the example app exercises this API. +`test/appsflyer_sdk_test.dart` — `maps every iOS-only API` asserts that `setUseReceiptValidationSandbox(true)` and `setUseUninstallSandbox(true)` each dispatch their own RPC method with `{'sandbox': true}`. + +`platform-only calls are forwarded to the native RPC instead of being swallowed in Dart` still covers other iOS-only APIs. `'platform-only setters surface the native error'` covers both sandbox toggles on Android and expects `AppsFlyerException` with code `422`. --- ## Known Limitations -- iOS-only: there is no Android method-channel handler or native equivalent for `useReceiptValidationSandbox`. Calling it on Android is a silent no-op from the Dart side (the platform channel simply has nothing registered to receive it on the Android plugin, since Android doesn't implement this case), which is undocumented in the dartdoc (`/// set sandbox for iOS purchase validation` is the only hint). -- No automated or example-app coverage — a regression that stops forwarding the flag to `[AppsFlyerLib shared].useReceiptValidationSandbox` would not be caught by CI. -- The static `_isSandboxEnabled` variable is process-global (`static BOOL`), matching the plugin's existing pattern for other boolean toggles (e.g. `disableSKAdNetwork`), but means the flag persists across plugin instances within the same process. +- iOS-only at the native RPC layer: calling either method on Android throws `AppsFlyerException` (code `422` interim). +- Use the sandbox toggles only for test/sandbox environments or when AppsFlyer support instructs you to do so; production builds normally leave both disabled. +- Neither toggle has example-app coverage. +- The flag is native SDK state with no read-back API, so the Flutter layer cannot report which endpoint is currently selected. --- ## Dependencies -```mermaid -flowchart LR - F025["F-025 · iOS Receipt Validation Sandbox Toggle"]:::purchaseValidation - F023["F-023 · In-App Purchase Validation V1"]:::purchaseValidation - F024["F-024 · In-App Purchase Validation V2"]:::purchaseValidation - F025 -->|"sets Apple endpoint used by"| F023 - F025 -->|"sets Apple endpoint used by"| F024 - classDef purchaseValidation fill:#F59F00,color:#fff -``` +No required feature dependency. F-024 consumes this setting only when the app has explicitly selected the sandbox receipt-validation environment. diff --git a/internal-docs/features/F-026-additional-custom-data.md b/internal-docs/features/F-026-additional-custom-data.md index 6a3be71a..19c3a5a0 100644 --- a/internal-docs/features/F-026-additional-custom-data.md +++ b/internal-docs/features/F-026-additional-custom-data.md @@ -4,32 +4,32 @@ name: Additional Custom Data type: eventsAndRevenue platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Some integrations need to enrich every outbound AppsFlyer SDK request with custom key/value context that doesn't fit any dedicated setter (e.g. app-specific segmentation flags, experiment identifiers, or partner-required metadata) — data that then flows into raw data/Pull-Push API exports alongside attribution and event data for downstream analysis. `setAdditionalData` gives the host app a generic escape hatch to attach arbitrary custom data to the SDK's requests. Without it, any custom context not covered by a named AppsFlyer API (customer user ID, currency, etc.) would have no way to travel with the SDK's payload at all. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Some integrations need to enrich every outbound AppsFlyer SDK request with custom key/value context that doesn't fit any dedicated setter (e.g. app-specific segmentation flags, experiment identifiers, or partner-required metadata) — data that then flows into raw data / Pull-Push API exports alongside attribution and event data for downstream analysis. `setAdditionalData` gives the host app a generic escape hatch to attach arbitrary custom data to the SDK's requests. Without it, any custom context not covered by a named AppsFlyer API would have no way to travel with the SDK's payload. --- ## Trigger -Called by the host app whenever it needs to attach custom key/value context to subsequent AppsFlyer SDK requests — typically once at startup, but callable at any point. +The host app awaits `AppsFlyerSdk.instance.setAdditionalData(...)` whenever it needs to attach custom key/value context to subsequent AppsFlyer SDK requests — typically once at startup before `start()`, but callable at any point. Passing an empty map clears previously supplied data. --- ## Call Chain +`setAdditionalData` is an awaitable RPC setter available on both platforms. + ``` -AppsflyerSdk.setAdditionalData(customData) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setAdditionalData", {'customData': customData}) - → Android: AppsflyerSdkPlugin.onMethodCall("setAdditionalData") → setAdditionalData(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setAdditionalData((HashMap) customData) - → result.success(null) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setAdditionalData") → setAdditionalData:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] setAdditionalData:data] - → result(nil) +AppsFlyerSdk.setAdditionalData(customData) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setAdditionalData', {'customData': customData}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.setAdditionalData(...) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → [AppsFlyerRPCBridge shared] executeJson:completion: → AFRPCRequestHandler → SDK + → PlatformException is converted to AppsFlyerException ``` --- @@ -37,31 +37,30 @@ AppsflyerSdk.setAdditionalData(customData) ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setAdditionalData(Map? customData)` — platform-agnostic Dart API, `void` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setAdditionalData(MethodCall, Result)` — casts the `customData` argument directly to `HashMap` and forwards to `AppsFlyerLib.getInstance().setAdditionalData(...)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setAdditionalData:result:` — reads `customData` as an `NSDictionary` and forwards to `[[AppsFlyerLib shared] setAdditionalData:]` | -| `doc/API.md` | Public documentation for `setAdditionalData` | +| `lib/src/appsflyer_sdk.dart` | `setAdditionalData(Map customData)` — awaitable, platform-agnostic RPC setter; non-null `Map` (pass an empty map to clear) | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | No per-method handler — generic `executeRpc` → `dispatchRpc('setAdditionalData', ...)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler — generic `executeRpc` → `dispatchRpc` | +| `doc/api-reference.md` | Public documentation for `setAdditionalData` | --- ## Input / Output | | | |--|--| -| **Input** | `customData` (`Map?`, nullable) — arbitrary key/value pairs | -| **Output** | `void` on the Dart side; both native handlers unconditionally call `result(nil)`/`result.success(null)` regardless of whether `customData` was null, empty, or well-formed | +| **Input** | `customData` (`Map`, non-null) — arbitrary key/value pairs; pass an empty map to clear. RPC param key `customData`. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or request timeout. Both native SDKs replace the current map, and an empty map clears it. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setAdditionalData call` (line 254) calls `setAdditionalData(null)` and asserts the mocked channel receives the `setAdditionalData` invocation; it only verifies the null-safe dispatch path and does not exercise a populated map, nor either native handler's cast/forward logic. +`test/appsflyer_sdk_test.dart` verifies in the cross-platform RPC mapping test that `setAdditionalData({'source': 'flutter'})` dispatches RPC method `setAdditionalData` with `{'customData': {'source': 'flutter'}}`. --- ## Known Limitations -- **Unsafe native cast on Android**: `AppsflyerSdkPlugin.java` casts the incoming argument directly to `(HashMap) call.argument("customData")` with no type check — if Dart ever sends a `Map` that isn't backed by a `HashMap` (e.g. a different `LinkedHashMap`/immutable map from platform-channel deserialization changes) this would throw a `ClassCastException` uncaught by any try/catch in that method, unlike `logAdRevenue`'s more defensive argument handling in the same file. -- No test coverage for the non-null path (a populated `customData` map) on either the Dart dispatch or native handlers — only the `null` case is exercised. -- No documented or enforced key/value shape — arbitrary nested values are passed straight through to the native SDK with no serialization validation in this plugin layer; malformed values would only surface as a native SDK-level failure outside this code. -- No API to read back or clear previously set additional data; each call presumably replaces (rather than merges into) the native SDK's stored additional data, but that merge-vs-replace behavior lives entirely in the native `AppsFlyerLib.setAdditionalData` implementation, outside this plugin's code. +- No documented or enforced key/value shape — arbitrary nested values are passed straight through to the native SDK with no serialization validation in this plugin layer; malformed values surface only as a native RPC failure, reported as `AppsFlyerException`. +- No API reads the current map back. Each call replaces the native runtime value rather than merging it; callers that need additive updates must merge locally and resend the full map. +- The value is retained across foreground cycles in the same process but must be reapplied after a cold start. --- diff --git a/internal-docs/features/F-027-user-invite-link-generation-onelink.md b/internal-docs/features/F-027-user-invite-link-generation-onelink.md index 7c3a1d75..3f858cdd 100644 --- a/internal-docs/features/F-027-user-invite-link-generation-onelink.md +++ b/internal-docs/features/F-027-user-invite-link-generation-onelink.md @@ -4,36 +4,35 @@ name: User Invite Link Generation (OneLink) type: oneLinkAndGrowth platform: both status: active -last_verified: 2026-07-15 -depends_on: ["F-028", "F-056"] +last_verified: 2026-08-10 +depends_on: ["F-028"] --- ## Business Purpose -Referral/invite growth loops (e.g. "invite a friend and get X") need a personalized, attributable deep link that carries the referrer's identity, campaign, and channel so that when the invited user installs the app, AppsFlyer can attribute the install back to the referrer. `generateInviteLink` wraps the native AppsFlyer User-Invite-API (`ShareInviteHelper` / `AppsFlyerShareInviteHelper`) so the Flutter app can build such a OneLink without any native code. Without this feature, apps would have to drop to native platform channels themselves to construct invite links, losing the plugin's cross-platform convenience and the built-in referrer/customParams mapping. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Referral and invite flows need a personalized OneLink carrying channel, campaign, referrer, and custom values. `generateInviteLink` delegates link generation to the native User Invite API, while `logInvite` records the `af_invite` event when the user shares the generated link. --- ## Trigger -Called by the host app whenever it needs to hand a user a shareable invite/referral link (e.g. tapping an "Invite Friends" button). Requires a base OneLink ID to already be configured, either at init time (`appInviteOneLink` option, F-056) or at runtime via `setAppInviteOneLinkID` (F-028). +Called when the host app needs a shareable invite URL. Configure the base OneLink ID first with `setAppInviteOneLink` (F-028). --- ## Call Chain +Both methods use the standard per-call RPC reply. Invite-link success and errors are correlated to the originating `Future`; they are not routed through global callback slots or the event stream. + ``` -AppsflyerSdk.generateInviteLink(params, success, error) [lib/src/appsflyer_sdk.dart] - → _translateInviteLinkParamsToMap(params) [lib/src/appsflyer_sdk.dart] - → startListening(success, "generateInviteLinkSuccess") [lib/src/callbacks.dart] - → startListening(error, "generateInviteLinkFailure") [lib/src/callbacks.dart] - → _methodChannel.invokeMethod("generateInviteLink", paramsMap) - → Android: AppsflyerSdkPlugin.onMethodCall("generateInviteLink") → generateInviteLink(call, result) [android/.../AppsflyerSdkPlugin.java] - → ShareInviteHelper.generateInviteUrl(mContext) → LinkGenerator.generateLink(mContext, listener) (native AppsFlyer Android SDK) - → listener.onResponse(url) / onResponseError(error) → runOnUIThread(...) → mCallbackChannel.invokeMethod("callListener", ...) - → iOS: AppsflyerSdkPlugin.handleMethodCall("generateInviteLink") → generateInviteLink:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → AppsFlyerShareInviteHelper generateInviteUrlWithLinkGenerator:completionHandler: (native AppsFlyer iOS SDK) - → _streamHandler sendResponseToFlutter:responseID:status:data: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m] - → Dart: callbacks.dart _methodCallHandler("callListener") → _callbacksById["generateInviteLinkSuccess"/"generateInviteLinkFailure"](data) [lib/src/callbacks.dart] +AppsFlyerSdk.generateInviteLink({parameters, awaitResponse}) [lib/src/appsflyer_sdk.dart] + → AppsFlyerInviteLinkParams.toRpcMap(isIOS: platform) + → Android only: append {awaitResponse} + → _invokeRpc('generateInviteLink', params) + → Android/iOS RPC generateInviteLink + → non-empty URL completes Future + → missing URL throws AppsFlyerException (`generateInviteLink returned no value`) + +AppsFlyerSdk.logInvite(channel, [eventParameters]) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('logInvite', {channel, eventParameters}) + → Android/iOS RPC logInvite ``` --- @@ -41,43 +40,38 @@ AppsflyerSdk.generateInviteLink(params, success, error) ## Files | File | Role | |------|------| -| `lib/src/appsflyer_invite_link_params.dart` | `AppsFlyerInviteLinkParams` — Dart model for channel, campaign, referrerName, referrerImageUrl, customerID, baseDeepLink, brandDomain, customParams | -| `lib/src/appsflyer_sdk.dart` | `generateInviteLink()` (public API) and `_translateInviteLinkParamsToMap()` — builds the method-channel payload and registers the two callbacks | -| `lib/src/callbacks.dart` | `startListening()` registers the success/failure callback IDs; `_methodCallHandler` dispatches `"callListener"` invocations back to the registered Dart callback | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `generateInviteLink(call, result)` — maps arguments onto `LinkGenerator`, invokes the native `ShareInviteHelper`, and forwards the async result via `runOnUIThread` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `generateInviteLink:result:` — same mapping onto `AppsFlyerLinkGenerator`, using `AppsFlyerShareInviteHelper` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `sendResponseToFlutter:status:data:` — JSON-encodes the callback payload and invokes `"callListener"` on the callback channel | +| `lib/src/appsflyer_invite_link_params.dart` | Typed optional invite parameters and platform-specific RPC key mapping | +| `lib/src/appsflyer_sdk.dart` | Awaitable `generateInviteLink` and `logInvite` public APIs | +| Android/iOS RPC modules | Native link generation and invite-event logging | --- ## Input / Output | | | |--|--| -| **Input** | `AppsFlyerInviteLinkParams?` (all fields optional: `channel`, `campaign`, `referrerName`, `referrerImageUrl`, `customerID`, `baseDeepLink`, `brandDomain`, `customParams`), plus `success` and `error` callback functions | -| **Output** | `generateInviteLink` itself is `void` / fire-and-forget (`result.success(null)` / `result(nil)` resolve immediately, independent of link generation). The actual OneLink URL arrives asynchronously via the callback channel: success delivers `{"userInviteURL": ""}` decoded into `{"status": ..., "payload": {...}}`; failure is meant to deliver `{"error": ""}` but see Known Limitations for platform-specific delivery defects | +| **Input** | Optional named `parameters` (`AppsFlyerInviteLinkParams`) with `channel`, `campaign`, `referrerName`, `referrerImageUrl`, `referrerCustomerId`, `baseDeepLink`, `brandDomain`, and `userParams`; optional `awaitResponse` (`bool`, default `true`; mapped only to Android RPC). `logInvite` accepts `channel` plus optional `eventParameters`; Android RPC requires a non-empty channel, while iOS currently treats it as optional. | +| **Output** | `generateInviteLink` returns `Future` with the generated URL. With `awaitResponse: true`, Android awaits asynchronous generation for up to 10 seconds; `false` returns the synchronously generated long link. The current iOS RPC 7.0.12 does not expose the flag and always awaits asynchronous generation with a 10-second timeout. `logInvite` returns `Future` after validation and synchronous SDK invocation. RPC failures are exposed as `AppsFlyerException`. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check generateInviteLink call` (line 186) only asserts that calling `generateInviteLink(null, success, error)` dispatches the `"generateInviteLink"` method over the mocked channel; it does not exercise the success/failure callback payload shape, `_translateInviteLinkParamsToMap`, or either native implementation. +`test/appsflyer_sdk_test.dart` verifies per-call URL return, Android `customerId` and iOS `referrerCustomerId` mapping, `userParams`, the default Android `awaitResponse: true`, the Android `false` override, and omission of the unsupported field from iOS requests. --- ## Known Limitations -- **Android failure path likely crashes with `ClassCastException`**: in `AppsflyerSdkPlugin.java`, `LinkGenerator.ResponseListener.onResponseError(String error)` builds a `JSONObject obj` (`obj.put("error", error)`) but then calls `runOnUIThread(error, "generateInviteLinkFailure", AF_FAILURE)` passing the raw `error` `String` instead of `obj`. `runOnUIThread` unconditionally casts non-UDL payloads with `JSONObject dataJSON = (JSONObject) data;`, which throws when `data` is a `String`. This means any real invite-link-generation failure on Android is likely to throw inside a posted `Runnable` on the UI thread rather than deliver the intended `{"error": ...}` payload to Dart. -- **Success/failure callback shapes are inconsistent in Dart**: `lib/src/callbacks.dart`'s `_methodCallHandler` special-cases `"generateInviteLinkSuccess"` (JSON-decodes `data` and wraps it as `{"status": ..., "payload": ...}`), but `"generateInviteLinkFailure"` is not in that case list, so it falls into the `default` branch and delivers the raw (still JSON-encoded, undecoded) string to the `error` callback — callers must handle two different payload shapes for the same feature's two callbacks. -- **No validation that a OneLink ID is configured**: `generateInviteLink` does not check whether `setAppInviteOneLinkID` (F-028) or the `appInviteOneLink` init option (F-056) has been set before invoking the native link generator; behavior in that case is left entirely to the native AppsFlyer SDK. -- The Dart method is `void`, not awaitable — callers cannot `await` the actual link; they must rely on the `success`/`error` callback functions registered via the shared `startListening` callback-channel mechanism. +- Dart does not duplicate native validation that a OneLink ID has already been configured. +- Android and iOS use different RPC keys for the same public `referrerCustomerId` field; `toRpcMap` preserves that platform difference. +- The Android RPC honors `awaitResponse`; the current iOS RPC 7.0.12 does not expose it for invite-link generation and always waits for the callback. Full behavioral parity requires iOS RPC support. +- Invite-generation timeout does not cancel native generation. A link produced after timeout is not delivered to the original Dart call. +- `logInvite('')` is platform-asymmetric: Android rejects an empty channel, while iOS forwards an absent/empty channel to the native API. +- `logInvite` has no native network-completion callback; its `Future` confirms RPC acceptance. --- ## Dependencies ```mermaid flowchart LR - F027["F-027 · User Invite Link Generation (OneLink)"]:::oneLinkAndGrowth - F028["F-028 · App Invite OneLink ID Configuration"]:::oneLinkAndGrowth - F056["F-056 · App Invite Link OneLink ID (init-time)"]:::oneLinkAndGrowth - F028 -->|"provides base OneLink ID"| F027 - F056 -->|"provides base OneLink ID"| F027 + F027["F-027 · User Invite Link Generation"]:::oneLinkAndGrowth -->|"requires base OneLink ID from"| F028["F-028 · App Invite OneLink ID Configuration"]:::oneLinkAndGrowth classDef oneLinkAndGrowth fill:#7048E8,color:#fff ``` diff --git a/internal-docs/features/F-028-app-invite-onelink-id-configuration.md b/internal-docs/features/F-028-app-invite-onelink-id-configuration.md index 64312d70..61d75c3a 100644 --- a/internal-docs/features/F-028-app-invite-onelink-id-configuration.md +++ b/internal-docs/features/F-028-app-invite-onelink-id-configuration.md @@ -4,32 +4,28 @@ name: App Invite OneLink ID Configuration type: oneLinkAndGrowth platform: both status: active -last_verified: 2026-07-15 -depends_on: ["F-056"] +last_verified: 2026-08-10 +depends_on: [] --- ## Business Purpose -The User-Invite-API (F-027) needs to know which OneLink template/ID to base generated invite links on. `setAppInviteOneLinkID` lets the host app set (or change) that base OneLink ID at runtime, independent of SDK initialization — useful for apps that resolve the correct OneLink ID dynamically (e.g. per region, per experiment, or fetched from a remote config) after the SDK has already started. Without it, invite links generated via `generateInviteLink` would have no base link to attach referrer metadata to, and the referral/growth loop would not function. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +The User Invite API needs a base OneLink template ID before it can generate invite URLs. `setAppInviteOneLink` provides one cross-platform, awaitable Flutter API for setting or changing that native value. --- ## Trigger -Called explicitly by the host app at any point after SDK initialization, typically before the first call to `generateInviteLink` (F-027), whenever the app determines (or changes) which OneLink ID should back invite links. +Called explicitly by the host app before the first `generateInviteLink` call, or whenever the application changes the OneLink ID. Neither Flutter nor native RPC requires it to run after `init()`. --- ## Call Chain +The Flutter method is a thin RPC passthrough and has no callback slot or event-stream side effect. + ``` -AppsflyerSdk.setAppInviteOneLinkID(oneLinkID, callback) [lib/src/appsflyer_sdk.dart] - → startListening(callback, "setAppInviteOneLinkIDCallback") [lib/src/callbacks.dart] - → _methodChannel.invokeMethod("setAppInviteOneLinkID", {'oneLinkID': oneLinkID}) - → Android: AppsflyerSdkPlugin.onMethodCall("setAppInviteOneLinkID") → setAppInivteOneLinkID(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setAppInviteOneLink(oneLinkId) → runOnUIThread(..., "setAppInviteOneLinkIDCallback", AF_SUCCESS) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setAppInviteOneLinkID") → setAppInviteOneLinkID:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [AppsFlyerLib shared].appInviteOneLinkID = oneLinkID → _streamHandler sendResponseToFlutter:... - → Dart: callbacks.dart _methodCallHandler("callListener") → _callbacksById["setAppInviteOneLinkIDCallback"](data) [lib/src/callbacks.dart] +AppsFlyerSdk.setAppInviteOneLink(oneLinkId) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setAppInviteOneLink', {'oneLinkId': oneLinkId}) + → Android RPC setAppInviteOneLink → native setAppInviteOneLink + → iOS RPC setAppInviteOneLink → native appInviteOneLinkID ``` --- @@ -37,39 +33,29 @@ AppsflyerSdk.setAppInviteOneLinkID(oneLinkID, callback) ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setAppInviteOneLinkID(String, Function)` — public API; registers the callback and invokes the method channel | -| `lib/src/callbacks.dart` | `startListening()` / `_methodCallHandler` — generic callback-channel plumbing shared with other async APIs | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setAppInivteOneLinkID(call, result)` (note the native method's typo — "Inivte") — forwards to `AppsFlyerLib.getInstance().setAppInviteOneLink(oneLinkId)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setAppInviteOneLinkID:result:` — sets `[AppsFlyerLib shared].appInviteOneLinkID` | +| `lib/src/appsflyer_sdk.dart` | Public `setAppInviteOneLink(String oneLinkId)` API and RPC mapping | +| Android/iOS RPC modules | Map `oneLinkId` to the native SDK configuration | --- ## Input / Output | | | |--|--| -| **Input** | `oneLinkID` (`String`), `callback` (`Function`) invoked with the async result | -| **Output** | Android: if `oneLinkID` is `null` or empty, `result.success(null)` is returned and the native setter is **not** called (no error surfaced); otherwise the native SDK's OneLink ID is updated and, if a callback was registered, `{"status": "success"}` is delivered via the callback channel. iOS: always sets `appInviteOneLinkID` (even if `nil`/empty) and, if a callback was registered, delivers `{"status": "success"}`. Neither native call ever reports failure — the callback fires only on success. | +| **Input** | `oneLinkId` (`String`), sent under the RPC key `oneLinkId` on both platforms. Android RPC rejects an empty string; iOS RPC currently performs a type-only check. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK setter invocation. Validation or bridge failures are exposed as `AppsFlyerException`; there is no native completion callback or timeout. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setAppInviteOneLinkID call` (line 180) only asserts that `setAppInviteOneLinkID("oneLinkID", (msg) {})` dispatches the `"setAppInviteOneLinkID"` method over the mocked channel; it does not assert the `oneLinkID` argument's value, the callback payload, or exercise either native implementation. +`test/appsflyer_sdk_test.dart` verifies that `setAppInviteOneLink('one-link')` sends RPC method `setAppInviteOneLink` with `{oneLinkId: 'one-link'}`. --- ## Known Limitations -- **Android silently no-ops on empty/null `oneLinkID`**: `setAppInivteOneLinkID` in `AppsflyerSdkPlugin.java` checks `if (oneLinkId == null || oneLinkId.length() == 0)` and simply calls `result.success(null)` without setting anything or notifying any registered callback — the host app has no way to detect that the OneLink ID was not actually applied. -- **iOS has no equivalent empty-string guard**: `setAppInviteOneLinkID:result:` on iOS assigns `oneLinkID` to `appInviteOneLinkID` unconditionally, so passing an empty string behaves differently across platforms (Android ignores it, iOS sets it). -- **No failure callback path exists on either platform** — the registered callback (mapped to `"setAppInviteOneLinkIDCallback"`) is only ever invoked with a success payload; there is no way to be notified of a rejected/invalid OneLink ID from the native SDK. -- Native Android method name (`setAppInivteOneLinkID`) contains a typo, though this is internal and does not affect the public Dart API or the method-channel string name. +- Dart does not validate the value. Android rejects an empty ID, while iOS accepts it at the RPC layer, so invalid input can fail differently downstream. +- The former init-time `AppsFlyerOptions.appInviteOneLink` path was removed; F-056 is retained only as a tombstone. --- ## Dependencies -```mermaid -flowchart LR - F028["F-028 · App Invite OneLink ID Configuration"]:::oneLinkAndGrowth - F056["F-056 · App Invite Link OneLink ID (init-time)"]:::oneLinkAndGrowth - F028 -->|"shares same native OneLink-ID property, last write wins"| F056 - classDef oneLinkAndGrowth fill:#7048E8,color:#fff -``` +F-027 consumes the configured OneLink ID when generating an invite URL. diff --git a/internal-docs/features/F-029-cross-promotion-impression-click-tracking.md b/internal-docs/features/F-029-cross-promotion-impression-click-tracking.md index 990bcfb8..6b62a83f 100644 --- a/internal-docs/features/F-029-cross-promotion-impression-click-tracking.md +++ b/internal-docs/features/F-029-cross-promotion-impression-click-tracking.md @@ -4,38 +4,41 @@ name: Cross-Promotion Impression/Click Tracking type: oneLinkAndGrowth platform: both status: active -last_verified: 2026-07-15 -depends_on: ["F-027"] +last_verified: 2026-08-10 +depends_on: [] --- ## Business Purpose -Advertisers who own multiple apps often promote one app from within another (cross-promotion). To measure whether these in-house house-ads actually drive installs, AppsFlyer needs to see both the impression (ad shown) and the click-to-store-open event, attributed to the promoted app's own AppsFlyer app ID and campaign. `logCrossPromotionImpression` and `logCrossPromotionAndOpenStore` wrap the native `CrossPromotionHelper` / `AppsFlyerCrossPromotionHelper` APIs so this measurement and (on Android) the store-open action can be triggered from Dart. Without this, cross-promotion campaigns between an advertiser's own apps would have no attribution signal distinguishing them from ordinary organic or paid installs. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Advertisers who own multiple apps often promote one app from within another (cross-promotion). To measure whether these in-house house-ads actually drive installs, AppsFlyer needs to see both the impression (ad shown) and the click-to-store-open event, attributed to the promoted app's own AppsFlyer app ID and campaign. `logCrossPromoteImpression` and `logAndOpenStore` wrap the native cross-promotion APIs so this measurement and the store-open action can be triggered from Dart. Without this, cross-promotion campaigns between an advertiser's own apps would have no attribution signal distinguishing them from ordinary organic or paid installs. --- ## Trigger -- `logCrossPromotionImpression`: called by the host app whenever a house-ad for another of the advertiser's apps is displayed to the user. -- `logCrossPromotionAndOpenStore`: called by the host app when the user taps/clicks that house-ad, to log the click and send the user to the promoted app's store listing. +- `logCrossPromoteImpression`: awaited by the host app whenever a house-ad for another of the advertiser's apps is displayed to the user. +- `logAndOpenStore`: awaited by the host app when the user taps that house-ad, to log the click and send the user to the promoted app's store listing. --- ## Call Chain +Both are awaitable RPC calls over the single `executeRpc` entry point. `logAndOpenStore` is the one method that iOS orchestrates plugin-side: it reads the click URL out of the RPC result and opens it with `UIApplication`. + ``` -AppsflyerSdk.logCrossPromotionImpression(appId, campaign, data) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("logCrossPromotionImpression", {...}) - → Android: AppsflyerSdkPlugin.onMethodCall("logCrossPromotionImpression") → logCrossPromotionImpression(call, result) [android/.../AppsflyerSdkPlugin.java] - → CrossPromotionHelper.logCrossPromoteImpression(mContext, appId, campaign, data) → result.success(null) (native AppsFlyer Android SDK) - → iOS: AppsflyerSdkPlugin.handleMethodCall("logCrossPromotionImpression") → logCrossPromotionImpression:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [AppsFlyerCrossPromotionHelper logCrossPromoteImpression:appId campaign:campaign parameters:parameters] (native AppsFlyer iOS SDK) - -AppsflyerSdk.logCrossPromotionAndOpenStore(appId, campaign, params) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("logCrossPromotionAndOpenStore", {...}) - → Android: AppsflyerSdkPlugin.onMethodCall("logCrossPromotionAndOpenStore") → logCrossPromotionAndOpenStore(call, result) [android/.../AppsflyerSdkPlugin.java] - → CrossPromotionHelper.logAndOpenStore(mContext, appId, campaign, data) → result.success(null) (native AppsFlyer Android SDK) - → iOS: AppsflyerSdkPlugin.handleMethodCall("logCrossPromotionAndOpenStore") → logCrossPromotionAndOpenStore:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → AppsFlyerShareInviteHelper generateInviteUrlWithLinkGenerator:completionHandler: → [[UIApplication sharedApplication] openURL:...] (see Known Limitations) +AppsFlyerSdk.logCrossPromoteImpression(appId, campaign: ..., userParams: ...) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('logCrossPromoteImpression', {appId, campaign, userParams}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → LogCrossPromoteImpressionRequest // init: require(appId.isNotEmpty()) + → CrossPromotionHelper.logCrossPromoteImpression(context, appId, campaign, userParams) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + +AppsFlyerSdk.logAndOpenStore(promotedAppId, campaign: ..., userParams: ...) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('logAndOpenStore', {promotedAppId, campaign, userParams}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → LogAndOpenStoreRequest // init: require(promotedAppId.isNotEmpty()) + → CrossPromotionHelper.logAndOpenStore(context, promotedAppId, campaign, userParams) + → iOS: AppsflyerSdkPlugin.logAndOpenStoreFromRpc:params:result: [ios/.../AppsflyerSdkPlugin.swift] + → AppsFlyerRPCBridge executeJson → result.data.clickURL → UIApplication openURL:options:completionHandler: + → PlatformException is converted to AppsFlyerException ``` --- @@ -43,37 +46,32 @@ AppsflyerSdk.logCrossPromotionAndOpenStore(appId, campaign, params) ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `logCrossPromotionImpression()` and `logCrossPromotionAndOpenStore()` — public API, both `void`/fire-and-forget | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `logCrossPromotionImpression(call, result)` and `logCrossPromotionAndOpenStore(call, result)` — forward to native `CrossPromotionHelper`, guarded by a non-empty `appId` check, always call `result.success(null)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `logCrossPromotionImpression:result:` and `logCrossPromotionAndOpenStore:result:` — see Known Limitations for behavioral divergence from Android | +| `lib/src/appsflyer_sdk.dart` | `logCrossPromoteImpression(String appId, {String campaign, Map? userParams})` and `logAndOpenStore(String promotedAppId, {String campaign, Map? userParams})` — both return `Future` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | No per-method handler — both methods go through the generic `executeRpc` → `dispatchRpc` path to the native RPC bridge | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | `logAndOpenStoreFromRpc:params:result:` — plugin-orchestrated: reads `data.clickURL` from the RPC result and opens it with `UIApplication`. `logCrossPromoteImpression` uses the generic dispatch. | --- ## Input / Output | | | |--|--| -| **Input** | `logCrossPromotionImpression(String appId, String campaign, Map? data)`; `logCrossPromotionAndOpenStore(String appId, String campaign, Map? params)` | -| **Output** | Android: `void`, always resolves the method-channel `Future` via `result.success(null)`. iOS: `void`, but see Known Limitations — the channel `Future` is never resolved. | +| **Input** | `logCrossPromoteImpression`: `appId` (required positional) plus optional `campaign` (defaults to `''`) and `userParams`, sent as `{appId, campaign, userParams}`. `logAndOpenStore`: `promotedAppId` (required positional) plus the same optional named arguments, sent as `{promotedAppId, campaign, userParams}`. | +| **Output** | `Future` for both. `logCrossPromoteImpression` completes after validation and synchronous SDK invocation. On Android, `logAndOpenStore` also returns after synchronous invocation; on iOS it awaits click-URL generation (10-second RPC timeout), then completes after `UIApplication.open` calls its completion handler. RPC/native failures are exposed as `AppsFlyerException`; the click URL is not returned to Dart. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check logCrossPromotionAndOpenStore call` (line 165) asserts `appId`/`campaign`/`params` are passed through to the channel correctly; `check logCrossPromotionImpression call` (line 174) only asserts the method name is dispatched. Neither test exercises native behavior or the iOS/Android divergence described below. +`test/appsflyer_sdk_test.dart` — `maps deep-link, sharing, push, and uninstall APIs` asserts that `logCrossPromoteImpression('promoted', campaign: 'campaign', userParams: {...})` dispatches RPC `logCrossPromoteImpression` with `{appId, campaign, userParams}`, and that `logAndOpenStore('promoted', ...)` dispatches RPC `logAndOpenStore` with `{promotedAppId, campaign, userParams}`. Neither test exercises native behavior or the iOS store-open side effect. --- ## Known Limitations -- **iOS `logCrossPromotionImpression:result:` and `logCrossPromotionAndOpenStore:result:` never call `result(...)`**: unlike every other handler in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`, these two methods have no `result(nil)` (or any `result` call) at the end. The Dart-side `Future` returned by `_methodChannel.invokeMethod` for these calls is therefore never resolved on iOS — callers awaiting it (if any were added later) would hang indefinitely; today both Dart methods are `void` and don't await, so this is currently silent but latent. -- **iOS `logCrossPromotionAndOpenStore:result:` does not use the native cross-promotion "open store" API at all**: instead of calling an equivalent to Android's `CrossPromotionHelper.logAndOpenStore`, it generates a plain invite link via `AppsFlyerShareInviteHelper generateInviteUrlWithLinkGenerator:` (setting only `campaign` and custom params — `appId` is read from `call.arguments` on Android but is **never read** on iOS) and then opens that URL with `UIApplication openURL:options:completionHandler:`. This means the promoted app's ID is not passed to the underlying attribution call on iOS, unlike Android. -- Android's `logCrossPromotionImpression`/`logCrossPromotionAndOpenStore` silently skip the native call entirely (but still return success) if `appId` is `null` or `""`. +- **iOS store-open is plugin-orchestrated**: the iOS RPC layer has no "log and open store" action, so `logAndOpenStoreFromRpc` opens the store by reading `clickURL` from the RPC result and calling `UIApplication openURL:options:completionHandler:`. If the bridge returns no `clickURL`, the Future still completes successfully but nothing opens. +- An iOS cross-promotion timeout fails the Dart Future but does not cancel native work; a late native completion can still occur after the caller has received `AppsFlyerException`. +- No validation in Dart of `appId`/`promotedAppId`/`campaign`. Android rejects an empty app ID in the RPC request (`require(...isNotEmpty())`), which surfaces to the caller as `AppsFlyerException`. +- The generated click URL is not exposed to Dart, so an app cannot intercept or rewrite it before the store page opens. --- ## Dependencies -```mermaid -flowchart LR - F029["F-029 · Cross-Promotion Impression/Click Tracking"]:::oneLinkAndGrowth - F027["F-027 · User Invite Link Generation (OneLink)"]:::oneLinkAndGrowth - F029 -->|"iOS: reuses same invite-URL generator helper as"| F027 - classDef oneLinkAndGrowth fill:#7048E8,color:#fff -``` +No required feature dependency. The iOS native implementation reuses a URL-generator helper, but the public cross-promotion APIs do not require F-027 invite-link generation or F-028 invite configuration. diff --git a/internal-docs/features/F-030-custom-branded-onelink-domains.md b/internal-docs/features/F-030-custom-branded-onelink-domains.md index 6723dd5b..cdd5a7c6 100644 --- a/internal-docs/features/F-030-custom-branded-onelink-domains.md +++ b/internal-docs/features/F-030-custom-branded-onelink-domains.md @@ -4,30 +4,34 @@ name: Custom/Branded OneLink Domains type: oneLinkAndGrowth platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose Apps that use a custom/branded domain for their OneLinks (instead of the default `*.onelink.me` domain) need the native SDK to recognize those domains as valid AppsFlyer deep-link/OneLink hosts — otherwise links on the branded domain would not be resolved/attributed correctly by the SDK when the app is opened via one of them. `setOneLinkCustomDomain` registers the list of branded domains with the native AppsFlyer SDK so it can correctly parse and attribute links served from them. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger -Called by the host app during setup/configuration, before relying on branded-domain OneLinks being correctly resolved. Not tied to any specific runtime event. +Awaited by the host app during setup/configuration, before relying on branded-domain OneLinks being correctly resolved. Not tied to any specific runtime event. --- ## Call Chain +An awaitable RPC call with no per-method channel handler. The Dart wrapper sends `{method: 'setOneLinkCustomDomain', params: {domains: [...]}}` through the single `executeRpc` entry point (the list is **wrapped under the `domains` key**, not passed as the raw argument), and each platform's native RPC bridge parses it into a typed request before forwarding it to the SDK. + ``` -AppsflyerSdk.setOneLinkCustomDomain(brandDomains) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setOneLinkCustomDomain", brandDomains) - → Android: AppsflyerSdkPlugin.onMethodCall("setOneLinkCustomDomain") → setOneLinkCustomDomain(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setOneLinkCustomDomain(brandDomainsArray) → result.success(null) - → iOS: AppsflyerSdkPlugin.handleMethodCall("setOneLinkCustomDomain") → setOneLinkCustomDomain:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] setOneLinkCustomDomains:brandDomains] → result(nil) +AppsFlyerSdk.setOneLinkCustomDomain(List domains) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setOneLinkCustomDomain', {'domains': domains}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler [plugin_bridge/.../AppsFlyerRpcHandler.kt] + → JsonRpcRequestParser → SetOneLinkCustomDomainRequest(domains) // init: require(domains.isNotEmpty()) + → appsFlyerLib.setOneLinkCustomDomain(*domains.toTypedArray()) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge [AppsFlyerRPC framework] + → AFRPCParser → AFRPCSetOneLinkCustomDomainsRequest(domains) // empty list rejected + → AFRPCComplexConfigHandler → sdk.oneLinkCustomDomains = domains ([AppsFlyerLib shared]) + → PlatformException is converted to AppsFlyerException ``` --- @@ -35,29 +39,30 @@ AppsflyerSdk.setOneLinkCustomDomain(brandDomains) ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setOneLinkCustomDomain(List)` — public API, passes the list directly as the method-channel arguments (no wrapping map) | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setOneLinkCustomDomain(call, result)` — casts `call.arguments` to `ArrayList`, converts to `String[]`, forwards to `AppsFlyerLib.getInstance().setOneLinkCustomDomain(...)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setOneLinkCustomDomain:result:` — forwards `call.arguments` directly to `[AppsFlyerLib shared] setOneLinkCustomDomains:]` | +| `lib/src/appsflyer_sdk.dart` | `setOneLinkCustomDomain(List domains)` — awaitable passthrough that sends the RPC `setOneLinkCustomDomain` with `{domains}`; it does not pre-validate the list | +| `android/.../plugin_bridge` (native SDK, not the Flutter plugin) | `SetOneLinkCustomDomainRequest(domains)` — `init { require(domains.isNotEmpty()) }`; handler → `appsFlyerLib.setOneLinkCustomDomain(*domains.toTypedArray())` | +| `AppsFlyerRPC` framework (native iOS SDK, not the Flutter plugin) | `AFRPCSetOneLinkCustomDomainsRequest(domains)` — rejects an empty list; `AFRPCComplexConfigHandler` → `sdk.oneLinkCustomDomains = domains` | +| `android/.../AppsflyerSdkPlugin.kt` / `ios/.../AppsflyerSdkPlugin.swift` | No per-method handler — the generic `executeRpc` dispatch forwards the JSON envelope to the native RPC bridge above | --- ## Input / Output | | | |--|--| -| **Input** | `brandDomains` (`List`) — sent as the raw method-channel argument, not wrapped in a map | -| **Output** | `void` on both platforms; both native handlers call `result` with `null` unconditionally after forwarding to the native SDK, regardless of whether the domain list was valid | +| **Input** | `domains` (`List`) — sent wrapped in the RPC params map under the `domains` key (`{'domains': domains}`). The list must be non-empty. | +| **Output** | `Future` that completes after native RPC validation and the synchronous SDK setter invocation. Either bridge rejects an empty list with `AppsFlyerException`; there is no native completion callback or request timeout. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setOneLinkCustomDomain call` (line 157) asserts `setOneLinkCustomDomain(["brandDomains"])` dispatches the `"setOneLinkCustomDomain"` method with a `List` argument containing `"brandDomains"`. Native behavior on either platform is not exercised. +`test/appsflyer_sdk_test.dart` — `maps deep-link, sharing, push, and uninstall APIs` asserts that `setOneLinkCustomDomain(['links.example.com'])` dispatches RPC `setOneLinkCustomDomain` with `{'domains': ['links.example.com']}`. The native contract (empty-list rejection, SDK forwarding) is covered by the native SDKs' own bridge tests (`RpcRequestValidationTest` / `AppsFlyerRPCParseNewMethodsTests`); no Dart test exercises the empty-list rejection. --- ## Known Limitations -- Android's cast `(ArrayList) call.arguments` will throw a `ClassCastException` if the platform channel deserializes the Dart `List` as a different concrete `List` implementation; this is untested and relies on Flutter's standard codec producing an `ArrayList`. -- Neither platform validates the domain strings (e.g. well-formed host names) before forwarding them to the native SDK — malformed entries are the native SDK's responsibility to reject. -- No callback/confirmation path exists — the call is fire-and-forget on both platforms with no way to detect misconfiguration from Dart. +- **Empty list is rejected natively, and the rejection now reaches the caller**: both bridges reject an empty `domains` list (Android `require(domains.isNotEmpty())`, iOS validation error). Because the Dart method is awaitable, `await setOneLinkCustomDomain([])` throws `AppsFlyerException` instead of failing silently — but a caller that does not await the Future still sees nothing. +- Dart does not pre-validate the list, so the empty-list round trip costs a channel hop before the error is raised. +- Neither the plugin nor the bridge validates the domain strings for well-formedness (e.g. valid host names) — malformed entries are the native SDK's responsibility to reject. --- diff --git a/internal-docs/features/F-031-push-notification-data-handling.md b/internal-docs/features/F-031-push-notification-data-handling.md index 01d10958..5635bf8a 100644 --- a/internal-docs/features/F-031-push-notification-data-handling.md +++ b/internal-docs/features/F-031-push-notification-data-handling.md @@ -4,75 +4,82 @@ name: Push Notification Data Handling type: deepLinking platform: both status: active -last_verified: 2026-07-15 -depends_on: ["F-022"] +last_verified: 2026-08-10 +depends_on: [] --- ## Business Purpose -Push-notification re-engagement campaigns need to be measured (so their ROI shows up in AppsFlyer reporting) and, when the payload carries a OneLink URL, routed as a deep link into the right in-app screen. `sendPushNotificationData` hands the raw push payload to the native SDK so it can attribute the re-engagement and, if a deep-link path was configured (F-022), extract and resolve the embedded OneLink URL. Without this, push campaigns cannot be measured for re-engagement and push-embedded deep links never reach the SDK for resolution. The older `setPushNotification(bool)` toggle is deprecated in favor of this data-carrying API. +Push-notification re-engagement campaigns need to be measured (so their ROI shows up in AppsFlyer reporting) and, when the payload carries a OneLink URL, routed as a deep link into the right in-app screen. This feature hands the push campaign data to the native SDK so it can attribute the re-engagement and, if a deep-link path was configured (F-022), extract and resolve the embedded OneLink URL. Without it, push campaigns cannot be measured for re-engagement and push-embedded deep links never reach the SDK for resolution. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +> The legacy `setPushNotification(bool)` toggle was **removed in SDK 7**. See the [migration guide](/doc/migration-guide.md). --- ## Trigger -Called by the host app whenever a push notification is received or tapped (foreground, background, or — via a persisted "pending push" pattern documented in `doc/API.md` — after a cold launch from a terminated state), passing the notification's data payload. +Awaited by the host app whenever a push notification is received or tapped (foreground, background, or after a cold launch from a terminated state via a persisted "pending push" pattern). Each platform has its own entry point, so the app must branch and call the API that belongs to the platform it is running on. --- ## Call Chain +The push surface is **deliberately not unified**: the two platforms take different data, so the plugin exposes two platform-specific methods instead of one method with a platform-dependent map shape. Neither is gated in Dart — both are awaitable RPC calls with no per-method channel handler, and the native layer rejects the one it does not implement. + ``` -AppsflyerSdk.sendPushNotificationData(Map? userInfo) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("sendPushNotificationData", userInfo) - → Android: AppsflyerSdkPlugin.onMethodCall("sendPushNotificationData") → sendPushNotificationData(call, result) [android/.../AppsflyerSdkPlugin.java] - → jsonToBundle(pushPayload) → Bundle - → activity.getIntent().putExtras(bundle); activity.setIntent(intent) - → AppsFlyerLib.getInstance().sendPushNotificationData(activity) - → iOS: AppsflyerSdkPlugin.handleMethodCall("sendPushNotificationData") → sendPushNotificationData:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] handlePushNotification:userInfo] - -AppsflyerSdk.setPushNotification(bool isEnabled) [DEPRECATED, use sendPushNotificationData instead] - → _methodChannel.invokeMethod("setPushNotification", isEnabled) - → Android: setPushNotification(call, result) → AppsFlyerLib.getInstance().sendPushNotificationData(activity) [the isEnabled arg itself is never read] - → iOS: setPushNotification:result: → stores `_isPushNotificationEnabled` static BOOL [never read anywhere else in the file] +AppsFlyerSdk.sendPushNotificationData(campaign:, pid:, isRetargeting:, additionalParameters:) [Android only] + → off Android: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('sendPushNotificationData', {campaign, pid, isRetargeting, additionalParameters}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler [android/.../AppsflyerSdkPlugin.kt] + → SendPushNotificationDataRequest // init: require(campaign.isNotEmpty()), require(pid.isNotEmpty()) + → AFPushData(campaign, pid, isRetargeting, additionalParameters) + → appsFlyerLib.sendPushNotificationData(pushData) + +AppsFlyerSdk.handlePushNotification(pushPayload) [iOS only] + → off iOS: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('handlePushNotification', {'pushPayload': pushPayload}) + → AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge [ios/.../AppsflyerSdkPlugin.swift] + → non-empty pushPayload required, else a validation error + → [[AppsFlyerLib shared] handlePushNotification:] → deep-link event (F-037) + + → PlatformException is converted to AppsFlyerException ``` +Any OneLink URL found at the configured path (F-022) is resolved and delivered asynchronously over the `af-events` EventChannel and surfaces as a `DeepLinkResult` in the registered `registerDeepLinkListener` callback (F-037). --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `sendPushNotificationData(Map?)` (active) and `setPushNotification(bool)` (`@Deprecated`) | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `sendPushNotificationData` — converts the JSON payload to a `Bundle` via `jsonToBundle`, stuffs it into the current activity's intent extras, then calls `AppsFlyerLib.getInstance().sendPushNotificationData(activity)`; `setPushNotification` — ignores its boolean argument and just re-invokes `sendPushNotificationData(activity)` with whatever extras are already on the intent | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `sendPushNotificationData:result:` — passes `userInfo` straight to `[AppsFlyerLib shared] handlePushNotification:]`; `setPushNotification:result:` — stores an unused static flag | +| `lib/src/appsflyer_sdk.dart` | `sendPushNotificationData({required String campaign, required String pid, bool isRetargeting = false, Map? additionalParameters})` (Android-only) and `handlePushNotification(Map pushPayload)` (iOS-only), both dispatched through RPC without a Dart platform check | +| `android/.../AppsflyerSdkPlugin.kt` / `ios/.../AppsflyerSdkPlugin.swift` | No per-method handler — the generic `executeRpc` → `dispatchRpc` path forwards the JSON envelope to the native RPC bridge | +| `android/.../plugin_bridge` / `AppsFlyerRPC` framework (native SDKs, not the Flutter plugin) | Parse the request and call `sendPushNotificationData` (Android) / `handlePushNotification:` (iOS) | +| `doc/api-reference.md` | Documents both methods and the per-platform push + deep-link matrix | --- ## Input / Output | | | |--|--| -| **Input** | `userInfo` / `pushPayload` (`Map?`) — the push notification's data payload (e.g. FCM/APNs message data) | -| **Output** | `void`. Android: if `pushPayload` is null, the handler logs and returns **without ever calling `result.success`/`result.error`**; if `activity`/`activity.getIntent()` is null, it logs an error message but, again, never calls `result(...)`. iOS: always calls `result(nil)`. Neither platform returns parsed deep-link data directly — any resolved OneLink URL is delivered asynchronously via the UDL `onDeepLinking` callback (F-037), gated by the path configured in F-022. | +| **Input** | Android: named arguments `campaign` and `pid` (both required by the native SDK), plus optional `isRetargeting` and `additionalParameters`; sent as `{campaign, pid, isRetargeting, additionalParameters}`. iOS: the complete APNs notification `userInfo` dictionary, sent as `{pushPayload}`. | +| **Output** | `Future` for both. Each completes after native RPC validation and the synchronous SDK invocation; neither confirms attribution or deep-link resolution and neither has a request timeout. Validation or bridge failures throw `AppsFlyerException`. Called on the wrong platform, each is still dispatched and throws `AppsFlyerException` once the native RPC layer reports the method as unavailable. If F-022 was configured and F-037 registered, a resolved OneLink URL arrives asynchronously in the registered `onDeepLink` callback. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check sendPushNotificationData call` (around line 335) asserts the mocked channel receives `sendPushNotificationData` with the payload map; this exercises only the Dart-to-channel dispatch, not native bundle conversion, intent mutation, or deep-link extraction. No test covers the deprecated `setPushNotification`. +`test/appsflyer_sdk_test.dart`: +- `maps deep-link, sharing, push, and uninstall APIs` — asserts `sendPushNotificationData(campaign: 'campaign', pid: 'media-source', isRetargeting: true, additionalParameters: {...})` dispatches RPC `sendPushNotificationData` with those four params, and that `handlePushNotification({'aps': {}})` dispatches RPC `handlePushNotification` with `{'pushPayload': {'aps': {}}}`. +- `platform-only calls are forwarded to the native RPC instead of being swallowed in Dart` — asserts that `handlePushNotification` on Android still dispatches the `handlePushNotification` RPC; the mirror case, `sendPushNotificationData` on iOS, is not covered separately. + +No Dart test covers native attribution or the deep-link extraction that follows. --- ## Known Limitations -- **Significant Android/iOS asymmetry**: Android re-derives the push payload by mutating the *current activity's intent* (`putExtras` + `setIntent`) and re-running `sendPushNotificationData(activity)`, which only works if an `activity` and its `intent` are currently available; iOS passes the raw `NSDictionary` payload directly to `handlePushNotification:`, with no intent/activity dependency. The two platforms' failure modes for a "no activity" state are therefore completely different. -- **Silent failure path on Android**: when `pushPayload` is null, or when `activity`/`intent` is null, the native handler returns without ever calling `result.success(null)` or `result.error(...)`. Since the Dart `sendPushNotificationData` is `void` and not awaited, this is invisible to the caller — pending method-channel replies are simply never sent, though because Dart doesn't await them this manifests only as silently dropped data rather than a hang. -- **Deprecated `setPushNotification` behaves differently per platform**: on Android it *actively* re-sends whatever is already in the intent extras to the native SDK regardless of the `isEnabled` value passed in (the argument is read from the channel but never inspected); on iOS it only stores an internal flag (`_isPushNotificationEnabled`) that is never read anywhere else in `AppsflyerSdkPlugin.m` — so on iOS, calling the deprecated API has no observable effect on the native SDK at all. -- The iOS "MUST also call `sendPushNotificationData`" requirement for OneLink-URL-in-push deep linking (per `doc/API.md`) is not enforced anywhere in code — an integrator who configures `addPushNotificationDeepLinkPath` (F-022) but skips this call on iOS gets no deep-link resolution and no error signal. +- **The two APIs are not interchangeable**: Android needs the structured campaign fields, iOS needs the raw APNs dictionary. Calling the wrong one is no longer absorbed by the Dart layer — it reaches the bridge, which does not implement it, and the call throws `AppsFlyerException`. A cross-platform app must therefore branch on `Platform.isAndroid` / `Platform.isIOS` (or catch the exception), which it would normally do anyway because the two APIs take different inputs. +- **iOS requires this call for push deep links** (per `doc/deep-linking.md`): configuring `addPushNotificationDeepLinkPath` (F-022) on iOS does nothing until the payload is forwarded through `handlePushNotification`; nothing in code enforces the ordering. +- Native rejections (empty Android `campaign`/`pid`, empty iOS payload) surface as `AppsFlyerException`, so the caller must await the Future to observe them. +- On Android the native call triggers a new Launch even if one was already sent in the current session. --- ## Dependencies -```mermaid -flowchart LR - F031["F-031 · Push Notification Data Handling"]:::deepLinking -->|"requires deep-link key-path from"| F022["F-022 · Push Notification Deep-Link Path Config"]:::deepLinking - F031 -->|"resolved OneLink URL surfaces via"| F037["F-037 · Unified Deep Linking (UDL) Callback & Models"]:::deepLinking - classDef deepLinking fill:#E64980,color:#fff -``` +No required feature dependency for push attribution itself. F-022 and F-037 are optional workflow components when the payload also contains a OneLink URL that the app wants delivered through UDL. diff --git a/internal-docs/features/F-032-facebook-deferred-app-links.md b/internal-docs/features/F-032-facebook-deferred-app-links.md index cbee6a4f..68997082 100644 --- a/internal-docs/features/F-032-facebook-deferred-app-links.md +++ b/internal-docs/features/F-032-facebook-deferred-app-links.md @@ -4,14 +4,14 @@ name: Facebook Deferred App Links type: deepLinking platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose Apps that run Facebook Ads alongside AppsFlyer OneLink need deferred deep links to resolve correctly even when Facebook's own SDK has already claimed the deferred-app-link resolution flow. `enableFacebookDeferredApplinks` tells the native AppsFlyer SDK to interoperate with the Facebook SDK's `FBSDKAppLinkUtility` class so both attribution sources can coexist instead of one silently overriding or racing the other. Without enabling this, apps combining Facebook Ads and AppsFlyer OneLink risk deferred deep links resolving incorrectly (or not at all) for users who install after clicking a Facebook ad. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +A companion **iOS-only** API, `setFacebookDeferredAppLink(String? url)`, lets an app that already holds the deferred link set (or clear, with `null`) it directly, bypassing the Facebook SDK fetch. --- @@ -21,13 +21,26 @@ Called once by the host app during startup configuration (before/around SDK init --- ## Call Chain +Since the SDK 7 / RPC migration this is a generic RPC call (no per-method channel handler): the Dart wrapper sends `{method:'enableFacebookDeferredApplinks', params:{isEnabled:}}` (params key is **`isEnabled`**) through the single `executeRpc` entry point, and each platform's native RPC bridge parses and forwards it. Both Dart methods are awaitable and surface native failures as `AppsFlyerException`. ``` -AppsflyerSdk.enableFacebookDeferredApplinks(bool isEnabled) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("enableFacebookDeferredApplinks", {'isFacebookDeferredApplinksEnabled': isEnabled}) - → Android: AppsflyerSdkPlugin.onMethodCall("enableFacebookDeferredApplinks") → enableFacebookDeferredApplinks(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().enableFacebookDeferredApplinks(true|false) - → iOS: AppsflyerSdkPlugin.handleMethodCall("enableFacebookDeferredApplinks") → enableFacebookDeferredApplinks:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → only if isEnabled == true: [[AppsFlyerLib shared] enableFacebookDeferredApplinksWithClass:NSClassFromString(@"FBSDKAppLinkUtility")] +AppsFlyerSdk.enableFacebookDeferredApplinks(bool isEnabled) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('enableFacebookDeferredApplinks', {'isEnabled': isEnabled}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsFlyerRpcHandler.execute(json) [plugin_bridge/.../AppsFlyerRpcHandler.kt] + → JsonRpcRequestParser → EnableFacebookDeferredApplinksRequest(isEnabled) // optBoolean("isEnabled", false) + → AppsFlyerLib.getInstance().enableFacebookDeferredApplinks(isEnabled) // true|false forwarded as-is + → RpcResponse.Success + → iOS: AppsFlyerRPCBridge / AFRPCRequestHandler [AppsFlyerRPC framework] + → AFRPCParser → AFRPCEnableFacebookDeferredApplinksRequest(enable) // requireBool("isEnabled") + → AFRPCDeepLinkHandler → fbClass = enable ? NSClassFromString("FBSDKAppLinkUtility") : nil + → sdk.enableFacebookDeferredApplinks(with: fbClass) ([AppsFlyerLib shared]) +``` +The iOS-only companion routes the same way: +``` +AppsFlyerSdk.setFacebookDeferredAppLink(String? url) [lib/src/appsflyer_sdk.dart] + → off iOS: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('setFacebookDeferredAppLink', {'url': url}) + → iOS: AppsFlyerRPCBridge / AFRPCRequestHandler → [AppsFlyerLib shared] (unsafe schemes rejected) ``` --- @@ -35,29 +48,31 @@ AppsflyerSdk.enableFacebookDeferredApplinks(bool isEnabled) ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `enableFacebookDeferredApplinks(bool)` — wraps the flag in `{'isFacebookDeferredApplinksEnabled': isEnabled}` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `enableFacebookDeferredApplinks(call, result)` — explicitly calls the native API with either `true` or `false` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `enableFacebookDeferredApplinks:result:` — only calls the native enabling API when `isEnabled == true`; a `false` value is a no-op | +| `lib/src/appsflyer_sdk.dart` | `Future enableFacebookDeferredApplinks(bool isEnabled)` — thin passthrough that sends the generic RPC `enableFacebookDeferredApplinks` with `{isEnabled}`. Also `Future setFacebookDeferredAppLink(String? url)` — **iOS only**, but with no Dart platform check; sends the `setFacebookDeferredAppLink` RPC with `{url}`. | +| `android/.../plugin_bridge` (native SDK, not the Flutter plugin) | `EnableFacebookDeferredApplinksRequest(isEnabled)`; handler → `AppsFlyerLib.getInstance().enableFacebookDeferredApplinks(isEnabled)` — `true`/`false` forwarded as-is | +| `AppsFlyerRPC` framework (native iOS SDK, not the Flutter plugin) | `AFRPCEnableFacebookDeferredApplinksRequest(enable)`; `AFRPCDeepLinkHandler` maps `enable → NSClassFromString("FBSDKAppLinkUtility")` (true) / `nil` (false), then `sdk.enableFacebookDeferredApplinks(with:)` | +| `android/.../AppsflyerSdkPlugin.kt` / `ios/.../AppsflyerSdkPlugin.swift` | No per-method handler — the generic `executeRpc` dispatch forwards the JSON envelope to the native RPC bridge above. | --- ## Input / Output | | | |--|--| -| **Input** | `isEnabled` (bool) | -| **Output** | `void` — fire-and-forget; both handlers always call `result.success(null)`/`result(nil)`. Resolved deferred-link data (if any) is not returned here — it surfaces through whichever conversion/attribution channel the app has registered (legacy `onInstallConversionData`/`onAppOpenAttribution`, or UDL `onDeepLinking`), which are native-SDK internal behaviors this plugin does not directly wire to this flag. | +| **Input** | `enableFacebookDeferredApplinks`: `isEnabled` (bool). `setFacebookDeferredAppLink`: `url` (`String?`; `null` clears the current URL). | +| **Output** | `Future` — completes after native RPC validation and the synchronous SDK configuration call. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or timeout. On Android, `setFacebookDeferredAppLink` is still dispatched and throws `AppsFlyerException` once the Android RPC layer reports the method as unavailable. Any resolved data is delivered later through a separately registered conversion-data or UDL stream. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check enableFacebookDeferredApplinks call` (around line 342) asserts the mocked channel receives `enableFacebookDeferredApplinks` with `isFacebookDeferredApplinksEnabled: true`. This exercises only the Dart-to-channel dispatch; it does not verify native behavior or the `false` no-op path on iOS. +`test/appsflyer_sdk_test.dart` → `'maps deep-link, sharing, push, and uninstall APIs'` verifies that `enableFacebookDeferredApplinks(true)` dispatches RPC method `enableFacebookDeferredApplinks` with `{'isEnabled': true}`, and that `setFacebookDeferredAppLink(null)` dispatches `setFacebookDeferredAppLink` with `{'url': null}` on iOS. No test exercises `setFacebookDeferredAppLink` on Android; the shared off-platform contract is covered generically by `'platform-only calls are forwarded to the native RPC instead of being swallowed in Dart'` and `'platform-only setters surface the native error'`, which use other platform-only setters. Native behavior (true→class / false→nil on iOS, bool forwarding on Android) is covered by the native SDK's own bridge tests. --- ## Known Limitations -- **Android/iOS asymmetry on disabling**: Android's handler calls the native API with the literal `isEnabled` value either way, so passing `false` actively disables the feature; iOS's handler only acts on `true` — passing `false` is silently ignored, so once enabled on iOS it cannot be turned back off via this API. -- Depends on the Facebook SDK (`FBSDKAppLinkUtility`) being present in the host app; the iOS handler resolves the class dynamically via `NSClassFromString`, so if the Facebook SDK isn't linked, the native AppsFlyer SDK receives a nil class with behavior determined entirely outside this plugin's code (not verified here). -- No signal is returned to Dart indicating whether Facebook deferred-app-link interop actually engaged (e.g. class not found, Facebook SDK version mismatch) — this call is purely fire-and-forget configuration. +- **No more disable asymmetry (RPC migration)**: both platforms now honor `false`. Android forwards the literal bool; the iOS RPC bridge maps `false → nil` and calls `enableFacebookDeferredApplinks(with: nil)`, so the feature can be turned back off on iOS. (The pre-RPC iOS handler treated `false` as a no-op — that limitation no longer applies.) +- **iOS requires the Facebook SDK linked**: the iOS bridge resolves `FBSDKAppLinkUtility` via `NSClassFromString`, so if the Facebook SDK isn't linked into the app, enabling passes a `nil` class and is effectively a no-op. Android's flag is self-contained. This platform difference is called out in the Dart dartdoc. +- The awaited `Future` confirms that the native RPC request succeeded, not that Facebook deferred-app-link interop actually engaged. A missing `FBSDKAppLinkUtility` class on iOS still resolves successfully. +- `setFacebookDeferredAppLink` is iOS-only: on Android the call reaches the RPC layer, which does not implement it, and throws `AppsFlyerException`. Shared code cannot call it unconditionally — it must branch on `Platform.isIOS` or catch the exception. --- diff --git a/internal-docs/features/F-033-skadnetwork-opt-out.md b/internal-docs/features/F-033-skadnetwork-opt-out.md index afd797d7..fd05fe3a 100644 --- a/internal-docs/features/F-033-skadnetwork-opt-out.md +++ b/internal-docs/features/F-033-skadnetwork-opt-out.md @@ -4,58 +4,65 @@ name: SKAdNetwork Opt-out (iOS) type: platformIntegration platform: ios status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Apple's SKAdNetwork is the privacy-preserving attribution framework AppsFlyer's iOS SDK uses automatically post-iOS 14. Some advertisers run their own SKAdNetwork conversion-value scheme, use a different measurement partner for it, or need to suppress AppsFlyer's SKAdNetwork registration/postback handling entirely for compliance or contractual reasons. `disableSKAdNetwork` lets the host app flip that behavior off (the SDK still sends the SKAdNetwork registration request, but AppsFlyer stops returning/acting on conversion-value rules) without disabling the rest of AppsFlyer attribution. Without it, an app that needs to hand SKAdNetwork off to another party would have no supported way to do so short of not integrating the AppsFlyer SDK's SKAdNetwork handling path at all. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Apple's SKAdNetwork is the privacy-preserving attribution framework AppsFlyer's iOS SDK uses automatically post-iOS 14. Some advertisers run their own SKAdNetwork conversion-value scheme, use a different measurement partner for it, or need to suppress AppsFlyer's SKAdNetwork registration and postback handling entirely for compliance or contractual reasons. `setDisableSKAdNetwork` lets the host app flip that behavior off without disabling the rest of AppsFlyer attribution. Without it, an app that needs to hand SKAdNetwork off to another party would have no supported way to do so short of not integrating AppsFlyer's SKAdNetwork handling path at all. --- ## Trigger -Called by the host app during startup configuration, before `AppsFlyerLib` starts, whenever the app wants to opt out of AppsFlyer's automatic SKAdNetwork conversion-value handling on iOS. +Called by the host app during startup configuration, before the first session (`start()`), whenever the app wants to opt out of AppsFlyer's automatic SKAdNetwork handling. The method is iOS-only; on Android the call is still dispatched and throws `AppsFlyerException`. No ordering relative to `init()` is enforced. --- ## Call Chain +An ordinary fire-and-forget RPC setter returning `Future`. There is no Dart platform gate, so the channel call is made on every platform and the native RPC layer decides whether the method exists. + ``` -AppsflyerSdk.disableSKAdNetwork(isEnabled) [lib/src/appsflyer_sdk.dart:566] - → _methodChannel.invokeMethod("disableSKAdNetwork", isEnabled) - → iOS: AppsflyerSdkPlugin handleMethodCall: case "disableSKAdNetwork" → disableSKAdNetwork:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:153] - → [AppsFlyerLib shared].disableSKAdNetwork = _isSKADEnabled [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:401] +AppsFlyerSdk.setDisableSKAdNetwork(disable) [lib/src/appsflyer_sdk.dart] + → off iOS: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('setDisableSKAdNetwork', {'disable': disable}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge.executeJson + → native SKAdNetwork handling opt-out + → PlatformException is converted to AppsFlyerException ``` -No `case "disableSKAdNetwork"` exists in `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java`'s method-call switch — on Android the call falls through to the default branch and returns `MethodNotImplemented`. --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `disableSKAdNetwork(bool)` — platform-agnostic Dart API surface (no `Platform.isIOS` guard) | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `disableSKAdNetwork:result:` native handler, sets `[AppsFlyerLib shared].disableSKAdNetwork` | +| `lib/src/appsflyer_sdk.dart` | `setDisableSKAdNetwork(bool disable)` — no Dart platform check; sends the `setDisableSKAdNetwork` RPC with `{disable}` | +| `lib/src/appsflyer_exception.dart` | `AppsFlyerException` — the failure type raised for native failures | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler — the generic `executeRpc` → `dispatchRpc` forwards the JSON envelope to `AppsFlyerRPCBridge`, which applies it to the SDK | --- ## Input / Output | | | |--|--| -| **Input** | `isEnabled` (bool) — `true` disables AppsFlyer's SKAdNetwork handling; native only applies the change if the argument is an `NSNumber` (boolean), otherwise silently no-ops. | -| **Output** | `void` — fire-and-forget; native always calls `result(nil)` regardless of whether the value was applied. | +| **Input** | `disable` (`bool`) — `true` disables AppsFlyer's SKAdNetwork handling. Sent under the `disable` param key. | +| **Output** | `Future` completes after RPC validation and the synchronous native SDK setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or timeout. Calling on Android dispatches the RPC anyway and throws `AppsFlyerException` once the Android layer reports the method as unavailable. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check disableSKAdNetwork call` (around line 349) asserts the mocked channel receives the method name `disableSKAdNetwork` with the boolean argument. The Dart test harness cannot verify the native iOS assignment to `AppsFlyerLib.shared.disableSKAdNetwork` actually takes effect. +`test/appsflyer_sdk_test.dart` covers both the mapping and the off-platform behavior: +- `maps every iOS-only API` asserts that `iosSdk.setDisableSKAdNetwork(true)` dispatches RPC method `setDisableSKAdNetwork` with `{'disable': true}`. +- `platform-only calls are forwarded to the native RPC instead of being swallowed in Dart` asserts that `androidSdk.setDisableSKAdNetwork(true)` still dispatches the `setDisableSKAdNetwork` RPC. + +The Dart harness cannot verify that the native SDK assignment takes effect. --- ## Known Limitations -- **iOS-only**: no Android implementation exists (concept doesn't apply — SKAdNetwork is an Apple/iOS-specific framework). The Dart API has no `Platform.isIOS` guard, so calling it on Android silently fails with `MissingPluginException`/`FlutterMethodNotImplemented` at the native layer rather than a documented no-op. -- Native code silently ignores non-boolean arguments (`isKindOfClass:[NSNumber class]` check) instead of surfacing an error to the caller, which can mask integration mistakes. -- Disabling SKAdNetwork handling here does not stop iOS from sending the registration call itself (`registerAppForAdNetworkAttribution`/`updateConversionValue` are OS-level, not AppsFlyer-level) — it only stops AppsFlyer's SDK-side processing of it. +- **iOS-only**: SKAdNetwork is Apple-specific and no Android implementation exists. Calling the method on Android is not a no-op: the plugin dispatches the RPC and the Android layer's "unknown method" answer surfaces as `AppsFlyerException`. +- The native implementation disables AppsFlyer's SKAdNetwork object and cancels its timer. The plugin exposes no callback or getter to prove whether any OS-level call had already happened before the setter ran, which is why the call belongs before the first `start()`. +- The native API has no completion callback, so a completed `Future` confirms only that the RPC layer accepted the call. --- diff --git a/internal-docs/features/F-034-advertising-identifier-collection-disable.md b/internal-docs/features/F-034-advertising-identifier-collection-disable.md index 53e80cfd..cc2dff7c 100644 --- a/internal-docs/features/F-034-advertising-identifier-collection-disable.md +++ b/internal-docs/features/F-034-advertising-identifier-collection-disable.md @@ -4,40 +4,33 @@ name: Advertising Identifier Collection Disable type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Privacy regulations (GDPR, CCPA) and platform policy changes increasingly require apps to be able to fully opt out of collecting device advertising identifiers (GAID/AAID/OAID on Android, IDFA on iOS) rather than just anonymizing individual users. `setDisableAdvertisingIdentifiers` gives the host app a single cross-platform switch for this, usable both as a one-time init-time option and as a runtime toggle. Without it, an app could not comply with a user's advertising-ID opt-out request without disabling the SDK entirely (F-017). - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Privacy regulations (GDPR, CCPA) and platform policy changes increasingly require apps to be able to fully opt out of collecting device advertising identifiers (GAID/AAID/OAID on Android, IDFA on iOS) rather than just anonymizing individual users. `setDisableAdvertisingIdentifiers` gives the host app a single cross-platform switch for this. Without it, an app could not comply with a user's advertising-ID opt-out request without disabling the SDK entirely (F-017). --- ## Trigger -Two distinct trigger points exist: (1) at SDK init time, via the `disableAdvertisingIdentifier` field on `AppsFlyerOptions`/init map, applied once during `initSdk()`; (2) at any later point, via the standalone `setDisableAdvertisingIdentifiers(bool)` runtime method. +The host app calls `setDisableAdvertisingIdentifiers(true)` before `start()` when the first session must omit advertising identifiers, or later to change the runtime setting. The public Flutter API does not require `init()` to run first. --- ## Call Chain +A single cross-platform method dispatches the `setDisableAdvertisingIdentifiers` RPC. Dart splits the param key per platform to match each native RPC contract: Android expects `isDisable`, iOS expects `disable`. + ``` -# Init-time path -AppsflyerSdk._validateAFOptions / _validateMapOptions [lib/src/appsflyer_sdk.dart] - → validatedOptions[DISABLE_ADVERTISING_IDENTIFIER] = options.disableAdvertisingIdentifier ?? false - → _methodChannel.invokeMethod("initSdk", validatedOptions) - → Android: AppsflyerSdkPlugin.initSdk(call, result) [android/.../AppsflyerSdkPlugin.java] - → if (advertiserIdDisabled) instance.setDisableAdvertisingIdentifiers(true) [only applies `true`; never explicitly re-enables] - → iOS: AppsflyerSdkPlugin.initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → resolves selector `setDisableAdvertisingIdentifier:` via objc_msgSend runtime dispatch, only if disableAdvertisingIdentifier == true - -# Runtime path -AppsflyerSdk.setDisableAdvertisingIdentifiers(isEnabled) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setDisableAdvertisingIdentifiers", isEnabled) - → Android: AppsflyerSdkPlugin.onMethodCall("setDisableAdvertisingIdentifiers") → setDisableAdvertisingIdentifiers(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setDisableAdvertisingIdentifiers(isEnabled) [handles both true and false explicitly] - → iOS: AppsflyerSdkPlugin.handleMethodCall("setDisableAdvertisingIdentifiers") → setDisableAdvertisingIdentifiers:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [AppsFlyerLib shared] setDisableAdvertisingIdentifier:_isAdvertiserIdEnabled] +AppsFlyerSdk.setDisableAdvertisingIdentifiers(disable) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setDisableAdvertisingIdentifiers', + isIOS ? {'disable': disable} : {'isDisable': disable}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.executeRpc → dispatchRpc → AppsFlyerRpcHandler + → native setDisableAdvertisingIdentifiers(isDisable) + → iOS: AppsflyerSdkPlugin.executeRpc → dispatchRpc → AppsFlyerRPCBridge.executeJson + → [[AppsFlyerLib shared] setDisableAdvertisingIdentifier:disable] + → PlatformException is converted to AppsFlyerException ``` --- @@ -45,32 +38,29 @@ AppsflyerSdk.setDisableAdvertisingIdentifiers(isEnabled) [lib/src/ ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setDisableAdvertisingIdentifiers(bool)` runtime API; `_validateAFOptions`/`_validateMapOptions` init-time option handling | -| `lib/src/appsflyer_options.dart` | `disableAdvertisingIdentifier` field on `AppsFlyerOptions` | -| `lib/src/appsflyer_constants.dart` | `DISABLE_ADVERTISING_IDENTIFIER` string key | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `initSdk` (init-time, line 1072), `setDisableAdvertisingIdentifiers(call, result)` (runtime, line 564) | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` (init-time, uses `objc_msgSend` runtime dispatch to `setDisableAdvertisingIdentifier:`, line ~841-855), `setDisableAdvertisingIdentifiers:result:` (runtime, line 380) | -| `doc/BasicIntegration.md` | Documents the field as "Opt-out of the collection of Advertising Identifiers, which include OAID, AAID, GAID and IDFA." | +| `lib/src/appsflyer_sdk.dart` | `setDisableAdvertisingIdentifiers(bool disable)` — sends `isDisable` on Android and `disable` on iOS | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic `executeRpc` → `dispatchRpc` routing to `AppsFlyerRpcHandler` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Generic `executeRpc` → `dispatchRpc` forwarding to `AppsFlyerRPCBridge` | --- ## Input / Output | | | |--|--| -| **Input** | Init-time: `disableAdvertisingIdentifier` (bool?, defaults to `false` if unset). Runtime: `isEnabled` (bool) — `true` disables collection of GAID/AAID/OAID (Android) or IDFA (iOS). | -| **Output** | `void` — fire-and-forget in both paths; no confirmation returned to Dart. | +| **Input** | `disable` (`bool`) — `true` disables collection of GAID/AAID/OAID (Android) or IDFA (iOS). Sent under `isDisable` on Android and `disable` on iOS. | +| **Output** | `Future` completes after RPC validation and the synchronous native SDK setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or timeout. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setDisableAdvertisingIdentifiers call` (line 355) asserts the mocked channel receives `'setDisableAdvertisingIdentifiers'` with `capturedArguments == true`. The init-time option path (`disableAdvertisingIdentifier` inside `initSdk`) is not separately asserted — the `check initSdk call` test only checks that `'initSdk'` was invoked, not the validated map's contents. +`test/appsflyer_sdk_test.dart` — `maps cross-platform configuration and identity APIs` asserts both platform payloads for the same Dart call: `androidSdk.setDisableAdvertisingIdentifiers(true)` dispatches `setDisableAdvertisingIdentifiers` with `{'isDisable': true}`, and `iosSdk.setDisableAdvertisingIdentifiers(true)` dispatches it with `{'disable': true}`. --- ## Known Limitations -- **Init-time and runtime paths are asymmetric on Android.** The `initSdk` handler only calls `setDisableAdvertisingIdentifiers(true)` if the flag is `true`; if it's `false` (the default), it does nothing (relies on native SDK default rather than explicitly calling `setDisableAdvertisingIdentifiers(false)`). The standalone runtime method, by contrast, always calls the native API with the exact boolean passed (both `true` and `false` explicitly). -- **iOS init-time path uses Objective-C runtime dispatch (`objc_msgSend` via `NSSelectorFromString`)** instead of calling the SDK method directly, apparently to guard against an SDK version where the selector might not exist (`respondsToSelector:` check). This is inconsistent with the runtime-toggle path (`setDisableAdvertisingIdentifiers:result:`), which calls `[AppsFlyerLib shared] setDisableAdvertisingIdentifier:]` directly — a version mismatch between the two could cause the init-time flag to silently no-op while the runtime toggle continues to work (or vice versa). +- The Dart method sends different param keys per platform (`isDisable` on Android, `disable` on iOS) because the two native RPC contracts differ. A future change to either key would break one platform without a compile-time check; the per-platform test assertions are the only guard. - No getter exists to read back the current disabled state from Dart. +- The native API has no completion callback, so a completed `Future` confirms only that the RPC layer accepted the call. --- diff --git a/internal-docs/features/F-035-conversion-data-callback.md b/internal-docs/features/F-035-conversion-data-callback.md index 91d849aa..909655ca 100644 --- a/internal-docs/features/F-035-conversion-data-callback.md +++ b/internal-docs/features/F-035-conversion-data-callback.md @@ -4,82 +4,99 @@ name: Conversion Data Callback (GCD) type: deepLinking platform: both status: active -last_verified: 2026-07-15 -depends_on: ["F-001"] +last_verified: 2026-08-10 +depends_on: ["F-001", "F-002"] --- ## Business Purpose -When a user installs the app after clicking an attributed link (or organically), the app often needs to know immediately — before the user even signs in — which campaign drove the install and whether it carries a deferred deep link, so it can personalize the very first session (e.g. show a specific onboarding screen or promo). `onInstallConversionData` ("Get Conversion Data", GCD) is the legacy API that delivers this attribution/conversion payload to Dart right after install. Without it, apps lose the ability to react to install-time attribution data and legacy deferred-deep-link payloads inside the app itself. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +When a user installs the app after clicking an attributed link (or organically), the app often needs to know immediately — before the user even signs in — which campaign drove the install and whether it carries a deferred deep link, so it can personalize the very first session (e.g. show a specific onboarding screen or promo). "Get Conversion Data" (GCD) delivers this attribution/conversion payload to Dart right after install, through the `onSuccess` and `onFailure` callbacks passed to `registerConversionListener()`. Without it, apps lose the ability to react to install-time attribution data and deferred-deep-link payloads inside the app itself. --- ## Trigger -Native SDK fires this once conversion data has been fetched from AppsFlyer's servers following an app install/launch — gated end-to-end by the `registerConversionDataCallback` flag passed to `initSdk()` (F-001) and by the Dart app having called `onInstallConversionData(callback)` to subscribe before that init. +The host app calls `registerConversionListener(onSuccess:, onFailure:)` after `init()`, then follows the normal session-ready flow and calls `start()`. Listener registration only installs the delegate; the Launch sent by `start()` triggers the conversion-data request whose result reaches the callbacks. There is no init-time flag. --- ## Call Chain +Registration is an ordinary awaitable RPC. Results arrive on the **`af-events` EventChannel** as a native RPC JSON envelope, are parsed into a `_AppsFlyerEvent` (`name` + `data`), and are dispatched by event name to the registered callback. + ``` -AppsflyerSdk.initSdk(registerConversionDataCallback: true, ...) [lib/src/appsflyer_sdk.dart] - → validatedOptions[AF_GCD] = registerConversionDataCallback || registerOnAppOpenAttributionCallback - → _methodChannel.invokeMethod("initSdk", validatedOptions) - → Android: initSdk(call, result) → if (getGCD) gcdListener = afConversionListener; instance.init(afDevKey, gcdListener, mContext) [android/.../AppsflyerSdkPlugin.java] - → iOS: initSdkWithCall:result: → if (isConversionData) [[AppsFlyerLib shared] setDelegate:_streamHandler] [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - -AppsflyerSdk.onInstallConversionData(Function callback) [lib/src/appsflyer_sdk.dart] - → startListening(callback, "onInstallConversionData") [lib/src/callbacks.dart] - → _channel(AF_CALLBACK_CHANNEL).invokeMethod("startListening", "onInstallConversionData") - → Android: startListening(...) → gcdCallback = true (when callbackName == AF_GCD_CALLBACK == "onInstallConversionData") [android/.../AppsflyerSdkPlugin.java] - → iOS: startListening:result: → _gcdCallback = true (when callbackId == afGCDCallback == "onInstallConversionData") [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - -Native SDK conversion data arrives: - Android: afConversionListener.onConversionDataSuccess(map) / onConversionDataFail(s) - → if (gcdCallback) runOnUIThread(data, AF_GCD_CALLBACK, status) → mCallbackChannel.invokeMethod("callListener", jsonArgs) - iOS: AppsFlyerStreamHandler.onConversionDataSuccess:/onConversionDataFail: → sends JSON via AppsflyerSdkPlugin.callbackChannel "callListener" - → Dart: _methodCallHandler(call) [lib/src/callbacks.dart] → callMap["id"] == "onInstallConversionData" - → _callbacksById["onInstallConversionData"]({"status": ..., "payload": decodedData}) +AppsFlyerSdk.registerConversionListener(onSuccess:, onFailure:) [lib/src/appsflyer_sdk.dart] + → _ensureEventsSubscribed() — one af-events subscription for the plugin, attached on first registration + → _listeners.on('onConversionDataSuccess', …) / .on('onConversionDataFail', …) + (one callback slot per event, replaced on re-registration) + → _invokeVoidRpc('registerConversionListener') + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + +Native conversion data arrives (Android RpcEventNotifier / iOS AFRPCBridge event handler): + → Android: AppsFlyerEventBus.publish(json) → EventChannel('af-events'), buffered until a sink attaches + → iOS: deliverEvent(json) on EventChannel('af-events'), buffered in pendingEvents until Dart subscribes + → _AppsFlyerEvent.fromNative(json) [lib/src/appsflyer_event.dart] + → _AppsFlyerListenerRegistry.dispatch(event) [lib/src/appsflyer_listener_registry.dart] + → event.name == 'onConversionDataSuccess' + → onSuccess(Map) + → event.name == 'onConversionDataFail' + → onFailure(Map) (raw payload, not an RPC exception) ``` +Android also exposes `unregisterConversionListener()`; on iOS it still drops the Dart callbacks first and then throws `AppsFlyerException`, because the iOS RPC layer does not implement the method. + --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `onInstallConversionData(Function)` — registers the Dart callback via `startListening` | -| `lib/src/callbacks.dart` | `_methodCallHandler` — decodes the `callListener` JSON envelope and dispatches `{"status", "payload"}` to the registered `"onInstallConversionData"` callback | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `afConversionListener.onConversionDataSuccess/onConversionDataFail` — native `AppsFlyerConversionListener` implementation; `initSdk` registers it with `AppsFlyerLib.getInstance().init(...)` only when `AF_GCD` is true; also caches results (`cachedOnConversionDataSuccess`/`cachedOnConversionDataFail`) across activity detach/reattach (`RD-65582`) | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `onConversionDataSuccess:`/`onConversionDataFail:` — `AppsFlyerLibDelegate` implementation, gated by `[AppsflyerSdkPlugin gcdCallback]` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` — sets `_streamHandler` as the `AppsFlyerLib` delegate only if the `GCD` flag is true | +| `lib/src/appsflyer_sdk.dart` | `registerConversionListener(onSuccess:, onFailure:)`, the `OnConversionDataSuccess` / `OnConversionDataFailure` typedefs, and the Android-only `unregisterConversionListener()` | +| `lib/src/appsflyer_listener_registry.dart` | `_AppsFlyerListenerRegistry` — one callback slot per native event name, replaced on re-registration | +| `lib/src/appsflyer_event.dart` | `_AppsFlyerEvent.fromNative` — parses the RPC envelope (`event`, map-or-null `data`) | +| `android/.../AppsflyerSdkPlugin.kt` | `rpcEventNotifier` hops bridge events to the main thread and publishes them to `AppsFlyerEventBus`; `createEventSink` adapts this engine's `af-events` sink | +| `android/.../AppsFlyerEventBus.kt` | Process-scoped buffer and FIFO replay, so conversion data arriving while no engine is attached reaches the next subscriber | +| `android/.../AppsFlyerRpcBridge.kt` | Process-scoped owner of the `AppsFlyerRpcHandler` that registers the native conversion listener, so it survives engine recreation | +| `ios/.../AppsflyerSdkPlugin.swift` | `deliverEvent` forwards bridge events to the `af-events` sink and buffers them in `pendingEvents` until Dart subscribes | --- ## Input / Output | | | |--|--| -| **Input** | None from Dart beyond registering the callback; the payload itself originates from AppsFlyer's attribution servers via the native SDK. | -| **Output** | `{"status": "success"|"failure", "payload": Map?}` delivered to the Dart callback passed to `onInstallConversionData`. On failure, native code wraps the error string into the same envelope shape (`buildJsonResponse`) rather than a distinct failure structure. | +| **Input** | The `onSuccess` callback (required) and `onFailure` callback (optional) passed to `registerConversionListener()`; the payload itself originates from AppsFlyer's attribution servers via the native SDK. | +| **Output** | `onSuccess` receives `Map` (the raw conversion payload). `onFailure` receives the raw failure payload as `Map` — not an RPC exception, since registration itself already succeeded. Payload shape differs by platform: Android sends `{"error": String}` with no error code; iOS sends `{"error": String, "code": int}`. `registerConversionListener()` returns `Future` after synchronous listener registration; it does not wait for conversion data and has no request timeout. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `onInstallConversionData` or the `callListener`/`onInstallConversionData` dispatch path in `lib/src/callbacks.dart`. +`test/appsflyer_sdk_test.dart`: +- `delivers conversion data to the registered success callback` — emits an `onConversionDataSuccess` envelope on `af-events` and asserts the decoded payload reaches `onSuccess`. +- `the failure callback passes through the raw native payload (no synthesized RPC exception)` — asserts the `onConversionDataFail` payload reaches `onFailure` unchanged. +- `a conversion failure without a failure callback is not an error` — asserts a failure event with no `onFailure` registered is a no-op. +- `re-registering replaces the callback instead of adding a second one` — asserts the second registration's callback receives the event and the first does not. +- `subscribes to af-events only when the first listener is registered` — asserts the `listen` handshake is sent on the first registration and not repeated for later ones. +- `listeners are registered explicitly` — asserts `registerConversionListener(onSuccess:)` dispatches the `registerConversionListener` RPC. +- `maps every Android-only API` — asserts `unregisterConversionListener` dispatches the matching RPC with no params. +No test covers `unregisterConversionListener` on iOS; the shared off-platform contract is exercised generically by `platform-only calls are forwarded to the native RPC instead of being swallowed in Dart`. + +`test/appsflyer_sdk_test.dart` — `ignores transport-only envelope fields on conversion events` covers the `onConversionDataSuccess` envelope shape (`event`, `data`). --- ## Known Limitations -- **Shared registration flag, independent dispatch flags**: `initSdk`'s `AF_GCD`/`GCD` flag is `registerConversionDataCallback || registerOnAppOpenAttributionCallback` — enabling *either* flag registers the native conversion listener/delegate for *both* channels (F-035 and F-036 share one native registration). But each channel only actually forwards data to Dart if its own `gcdCallback`/`oaoaCallback` (Android) or `_gcdCallback`/`_oaoaCallback` (iOS) flag was separately flipped by calling `onInstallConversionData`/`onAppOpenAttribution` from Dart. An app that sets only `registerOnAppOpenAttributionCallback: true` but never calls `onInstallConversionData()` will still have the native listener registered but conversion-data events for that channel are simply dropped (Android) or dropped (iOS) rather than queued. -- Documentation (`doc/API.md`) explicitly requires the Dart-side `onInstallConversionData` implementation to be registered **before** SDK initialization; nothing in code enforces or warns about this ordering. -- Android caches at most one conversion-data outcome (success or fail) across an activity-detach window (`RD-65582` static fields); if multiple attach/detach cycles occur before Dart reattaches its listener, only the most recent cached result survives — no queueing of multiple missed callbacks. -- Error payloads use the same JSON envelope as success payloads (`buildJsonResponse` wraps the error string as `"data"`), so Dart-side consumers must inspect `status` rather than relying on a distinct shape to detect failure. +- The plugin holds **one callback per event** and replaces it on re-registration, matching the native SDK; there is no public stream, so a single conversion event cannot fan out to several handlers in the app. A conversion event arriving before `registerConversionListener()` has run is held by `_AppsFlyerListenerRegistry` and replayed when the listener registers — the native replay flushes on the first `register*Listener()` call of any kind, which is often the deep-link listener. After the listener has been registered once, an event arriving while it is unregistered is logged and dropped instead. +- Registering the listener does not issue the conversion-data network request. The app must still call `start()` for the foreground cycle; otherwise no Launch is sent and no conversion result is expected. +- Both platforms buffer native events until Dart attaches to `af-events` (RD-65582), so an install-conversion event emitted before the stream is attached is not lost at the native layer. Android buffers in the process-scoped `AppsFlyerEventBus`, which also covers conversion data arriving while the Flutter engine is torn down; iOS buffers per plugin instance for the lifetime of the engine. Both buffers hold at most 64 events and drop the oldest beyond that. +- Engine recreation does not carry the Dart callbacks: the app must call `registerConversionListener()` again once a new engine attaches. On Android the `AppsFlyerRpcHandler` behind that call is process-scoped (`AppsFlyerRpcBridge`), so re-registration reuses the listener already registered on `AppsFlyerLib` instead of building a new one against an SDK that is already configured. +- `onFailure` receives the raw native payload unchanged: on Android it never carries a `code` field (the native delegate only supplies an error message), while on iOS it does. Callers that need a `code` must handle its absence on Android rather than relying on a synthesized default. +- `unregisterConversionListener()` is Android-only; iOS integrations cannot stop conversion-data delivery through the RPC bridge. Calling it on iOS is also not free of side effects: the Dart callbacks are dropped locally before the RPC is dispatched, so the app stops receiving conversion data *and* the call throws `AppsFlyerException`. --- ## Dependencies ```mermaid flowchart LR - F035["F-035 · Conversion Data Callback (GCD)"]:::deepLinking -->|"listener registration gated by GCD flag set in"| F001["F-001 · SDK Initialization & Options Validation"]:::sdkCore + F035["F-035 · Conversion Data Callback (GCD)"]:::deepLinking -->|"listener registered after"| F001["F-001 · SDK Initialization"]:::sdkCore + F035 -->|"Launch from start triggers request"| F002["F-002 · SDK Start"]:::sdkCore classDef deepLinking fill:#E64980,color:#fff classDef sdkCore fill:#4C6EF5,color:#fff ``` diff --git a/internal-docs/features/F-036-app-open-attribution-callback.md b/internal-docs/features/F-036-app-open-attribution-callback.md index 37590cf6..1749d7a8 100644 --- a/internal-docs/features/F-036-app-open-attribution-callback.md +++ b/internal-docs/features/F-036-app-open-attribution-callback.md @@ -3,83 +3,43 @@ id: F-036 name: App-Open Attribution Callback (OAOA) type: deepLinking platform: both -status: active -last_verified: 2026-07-15 -depends_on: ["F-001"] +status: removed +last_verified: 2026-08-10 +depends_on: [] --- -## Business Purpose -When an already-installed app is (re)opened via an attributed link — e.g. a user taps a OneLink pointing to specific content while the app is already on their device — the app needs to know what that link resolved to in order to route the user to the right place. `onAppOpenAttribution` ("On App Open Attribution", OAOA) is the legacy direct-deep-linking API that delivers this attribution payload to Dart. Without it, apps relying on the legacy (pre-UDL) deep-linking model cannot react to attributed app-open events for existing users. +## Status: REMOVED in SDK 7 -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +App-Open Attribution (OAOA) — the legacy `onAppOpenAttribution` / `registerOnAppOpenAttributionCallback` direct-deep-linking API — was **removed from both native AppsFlyer SDKs in v7** and is therefore **removed from the Flutter plugin**. There is no `onAppOpenAttribution` Dart method, no OAOA init flag, and no native handler for it. ---- +Per the [API Removal Rule](/doc/migration-guide.md#api-removal-rule), the plugin preserves SDK 7 behavior rather than SDK 6 APIs; OAOA is not kept as a no-op stub. -## Trigger -Native SDK fires this when a deep link is clicked by a user who already has the app installed — gated end-to-end by the `AF_GCD`/`GCD` flag passed to `initSdk()` (F-001, set true when `registerOnAppOpenAttributionCallback` is requested) and by the Dart app having called `onAppOpenAttribution(callback)` to subscribe before init. Per `doc/API.md`, this callback does **not** fire when the app has migrated to Unified Deep Linking (F-037) — the two are mutually exclusive delivery paths for direct deep linking. +### Replacement +Use **Unified Deep Linking (UDL)** — an explicit `registerDeepLinkListener(onDeepLink)` call that takes the handling callback as its argument (see F-037). There is no init-time callback flag: register the native listener before `init()`, so Android's one-shot deferred-resolution gate sees it. ---- +```dart +final appsFlyer = AppsFlyerSdk.instance; -## Call Chain +await appsFlyer.registerDeepLinkListener((DeepLinkResult result) { + // result.status, result.deepLink, result.error +}); +await appsFlyer.init(devKey: 'YOUR_DEV_KEY', appId: 'YOUR_APP_ID'); ``` -AppsflyerSdk.initSdk(registerOnAppOpenAttributionCallback: true, ...) [lib/src/appsflyer_sdk.dart] - → validatedOptions[AF_GCD] = registerConversionDataCallback || registerOnAppOpenAttributionCallback - → _methodChannel.invokeMethod("initSdk", validatedOptions) - → Android: initSdk(call, result) → if (getGCD) gcdListener = afConversionListener; instance.init(afDevKey, gcdListener, mContext) [android/.../AppsflyerSdkPlugin.java] - → iOS: initSdkWithCall:result: → if (isConversionData) [[AppsFlyerLib shared] setDelegate:_streamHandler] [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] -AppsflyerSdk.onAppOpenAttribution(Function callback) [lib/src/appsflyer_sdk.dart] - → startListening(callback, "onAppOpenAttribution") [lib/src/callbacks.dart] - → _channel(AF_CALLBACK_CHANNEL).invokeMethod("startListening", "onAppOpenAttribution") - → Android: startListening(...) → oaoaCallback = true (when callbackName == AF_OAOA_CALLBACK == "onAppOpenAttribution") [android/.../AppsflyerSdkPlugin.java] - → iOS: startListening:result: → _oaoaCallback = true (when callbackId == afOAOACallback == "onAppOpenAttribution") [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] +`registerDeepLinkListener()` maps to `subscribeForDeepLink` on Android and `registerDeeplinkListener` on iOS, and delivers both direct (app already installed) and deferred deep links as a single `DeepLinkResult`, superseding the legacy OAOA path. Android also exposes `unregisterDeeplinkListener()`, a soft unsubscribe that drops further bridge deep-link events; on iOS it drops the Dart callback and then throws `AppsFlyerException`, because the iOS RPC layer does not implement the method. -Native SDK app-open attribution arrives: - Android: afConversionListener.onAppOpenAttribution(map) / onAttributionFailure(errorMessage) - → if (oaoaCallback) runOnUIThread(data, AF_OAOA_CALLBACK, status) → mCallbackChannel.invokeMethod("callListener", jsonArgs) - iOS: AppsFlyerStreamHandler.onAppOpenAttribution:/onAppOpenAttributionFailure: → sends JSON via AppsflyerSdkPlugin.callbackChannel "callListener" - → Dart: _methodCallHandler(call) [lib/src/callbacks.dart] → callMap["id"] == "onAppOpenAttribution" - → _callbacksById["onAppOpenAttribution"]({"status": ..., "payload": decodedData}) -``` +Install-time attribution/conversion data is still available via GCD (F-035), now as the `onSuccess` and `onFailure` callbacks passed to an explicit `registerConversionListener()` call — the SDK 6 `onInstallConversionData` callback no longer exists. ---- - -## Files -| File | Role | -|------|------| -| `lib/src/appsflyer_sdk.dart` | `onAppOpenAttribution(Function)` — registers the Dart callback via `startListening` | -| `lib/src/callbacks.dart` | `_methodCallHandler` — decodes the `callListener` JSON envelope and dispatches `{"status", "payload"}` to the registered `"onAppOpenAttribution"` callback | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `afConversionListener.onAppOpenAttribution/onAttributionFailure` — native `AppsFlyerConversionListener` methods, gated by `oaoaCallback`; also cached across activity detach/reattach (`cachedOnAppOpenAttribution`/`cachedOnAttributionFailure`, `RD-65582`) | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `onAppOpenAttribution:`/`onAppOpenAttributionFailure:` — `AppsFlyerLibDelegate` methods, gated by `[AppsflyerSdkPlugin oaoaCallback]` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` — sets `_streamHandler` as the `AppsFlyerLib` delegate only if the `GCD` flag is true (shared with F-035) | - ---- - -## Input / Output -| | | -|--|--| -| **Input** | None from Dart beyond registering the callback; the payload originates from the native SDK's link-resolution logic. | -| **Output** | `{"status": "success"|"failure", "payload": Map?}` delivered to the Dart callback passed to `onAppOpenAttribution`. | - ---- - -## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `onAppOpenAttribution` or its dispatch path in `lib/src/callbacks.dart`. - ---- +See the [migration guide](/doc/migration-guide.md) for the full removal/replacement details. -## Known Limitations -- **Mutually exclusive with UDL**: per `doc/DeepLink.md`, once an app migrates to Unified Deep Linking, `onAppOpenAttribution` "will not be called" — nothing in code enforces this exclusivity or warns an integrator who registers both `registerOnAppOpenAttributionCallback` and `registerOnDeepLinkingCallback` (F-037) simultaneously. -- Shares its native listener/delegate registration with F-035 (both gated by the same combined `AF_GCD`/`GCD` flag) — see F-035's Known Limitations for the registration-vs-dispatch flag mismatch this creates. -- Documentation requires the Dart-side `onAppOpenAttribution` implementation to be registered **before** SDK initialization; this ordering is not enforced in code. -- Android caches only the single most recent success or failure outcome across an activity-detach window (`RD-65582`); rapid multiple attribution events during a detach period are not individually queued. +> Note: the feature INDEX previously listed F-036 as active — that is stale. This feature is removed. --- ## Dependencies ```mermaid flowchart LR - F036["F-036 · App-Open Attribution Callback (OAOA)"]:::deepLinking -->|"listener registration gated by GCD flag set in"| F001["F-001 · SDK Initialization & Options Validation"]:::sdkCore + F036["F-036 · App-Open Attribution (REMOVED)"]:::removed -->|"replaced by"| F037["F-037 · Unified Deep Linking (UDL)"]:::deepLinking classDef deepLinking fill:#E64980,color:#fff - classDef sdkCore fill:#4C6EF5,color:#fff + classDef removed fill:#868E96,color:#fff ``` diff --git a/internal-docs/features/F-037-unified-deep-linking-callback-and-models.md b/internal-docs/features/F-037-unified-deep-linking-callback-and-models.md index 25c94177..8d23640a 100644 --- a/internal-docs/features/F-037-unified-deep-linking-callback-and-models.md +++ b/internal-docs/features/F-037-unified-deep-linking-callback-and-models.md @@ -4,94 +4,103 @@ name: Unified Deep Linking (UDL) Callback & Models type: deepLinking platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: ["F-001", "F-039", "F-040"] --- ## Business Purpose -Unified Deep Linking is AppsFlyer's current recommended API for both direct and deferred deep linking: a single Dart callback (`onDeepLinking`) delivers one strongly-shaped result (`DeepLinkResult` — a `Status`, an optional `Error`, and an optional `DeepLink` payload) regardless of whether the link was clicked while the app was already installed or triggered a deferred install. Without it, integrators would have to juggle the two legacy, loosely-typed callbacks (`onAppOpenAttribution` / `onInstallConversionData`, F-035/F-036) and hand-parse raw maps to build a single personalized-routing experience (e.g. OneLink-driven deep content). - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Unified Deep Linking is AppsFlyer's current recommended API for both direct and deferred deep linking: a single Dart callback (registered through `registerDeepLinkListener`) delivers one strongly-shaped result (`DeepLinkResult` — a `DeepLinkStatus`, an optional `DeepLinkFailure`, and an optional `DeepLink` payload) regardless of whether the link was clicked while the app was already installed or triggered a deferred install. It is the SDK 7 replacement for the legacy, now-removed OAOA path (F-036); install-time attribution is still available via GCD (F-035). --- ## Trigger -Native SDK resolves a deep link (direct click while installed, or deferred deep link surfaced after a fresh install) and invokes its UDL delegate/listener — gated end-to-end by the `UDL` flag passed to `initSdk(registerOnDeepLinkingCallback: true)` (F-001) and by the Dart app having called `onDeepLinking(callback)` to subscribe before init. The underlying native trigger differs per platform: on Android it is `AppsFlyerLib.getInstance().performOnDeepLinking(...)`, invoked from the plugin's `onNewIntent` forwarding (F-040) as well as the SDK's own `onResume` intent inspection; on iOS it is `[AppsFlyerLib shared] handleOpenUrl:`/`continueUserActivity:`, invoked from the app-delegate/scene entry points buffered by `AppsFlyerAttribution` (F-039). +The host app calls `registerDeepLinkListener(onDeepLink)` **before** `init()`, passing the callback that receives the result. The native SDK then resolves a deep link (direct click while installed, or deferred deep link surfaced after a fresh install) and invokes its UDL delegate/listener. There is no init-time flag: registration is an explicit RPC call that maps to `subscribeForDeepLink` on Android and `registerDeeplinkListener` on iOS. The underlying native trigger differs per platform: on Android the SDK inspects the launch/new intent (F-040); on iOS it is `handleOpenUrl:`/`continueUserActivity:` buffered by `AppsFlyerAttribution` (F-039). + +The pre-`init()` ordering is load-bearing on Android, not a style preference. `AppsFlyerLibCore.init()` calls `registerAndroidLifecycleListener(context)` synchronously, and because the plugin passes the **Activity** as the init context (`AppsflyerSdkPlugin.initFromRpc`), `AndroidLifecycleManagerImpl` replays `onActivityResumed` immediately. That reaches `AFDeepLinkManager.unifiedDeepLinking()`, whose deferred-resolution gate (`shouldRunDeferredDeeplinkFlow`) requires `listener != null`, and which then persists `ddl_sent = true` regardless of the outcome. A listener registered after `init()` loses that race — the plugin needs a second MethodChannel round trip — so the deferred resolution request is never sent for that install and is not retried on later launches. Direct deep links still work because they are resolved from the intent on each foreground. iOS has no equivalent gate (the delegate only has to be set before `start()`), but registration before `init()` is supported there too. --- ## Call Chain +Registration is an ordinary awaitable RPC. Results arrive on the **`af-events` EventChannel** as a native RPC JSON envelope, are parsed into a `_AppsFlyerEvent` (`name` + `data`), and are mapped into a `DeepLinkResult`. + ``` -AppsflyerSdk.initSdk(registerOnDeepLinkingCallback: true, ...) [lib/src/appsflyer_sdk.dart] - → validatedOptions[AF_UDL] = registerOnDeepLinkingCallback - → _methodChannel.invokeMethod("initSdk", validatedOptions) - → Android: initSdk(call, result) → if (getUdl) instance.subscribeForDeepLink(afDeepLinkListener) [android/.../AppsflyerSdkPlugin.java] - → iOS: initSdkWithCall:result: → if (isUDP) [AppsFlyerLib shared].deepLinkDelegate = _streamHandler [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - -AppsflyerSdk.onDeepLinking(Function(DeepLinkResult) callback) [lib/src/appsflyer_sdk.dart] - → startListeningToUDL(callback, "onDeepLinking") [lib/src/callbacks.dart] - → _channel(AF_CALLBACK_CHANNEL).invokeMethod("startListening", "onDeepLinking") - → Android: startListening(...) → udlCallback = true (when callbackName == AF_UDL_CALLBACK == "onDeepLinking") [android/.../AppsflyerSdkPlugin.java] - → iOS: startListening:result: → _udpCallback = true (when callbackId == afUDPCallback == "onDeepLinking") [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - -Native deep link resolved (via F-039 iOS entry points / F-040 Android onNewIntent, or SDK-internal resume/link-resolution): - Android: afDeepLinkListener.onDeepLinking(DeepLinkResult) [com.appsflyer.deeplink.DeepLinkResult, native SDK type] - → if (udlCallback) runOnUIThread(deepLinkResult, AF_UDL_CALLBACK, AF_SUCCESS) - → args {"id", "deepLinkStatus", "deepLinkError"?, "deepLinkObj"? } → mCallbackChannel.invokeMethod("callListener", jsonArgs) - iOS: AppsFlyerStreamHandler.didResolveDeepLink: (AppsFlyerDeepLinkDelegate) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m] - → if ([AppsflyerSdkPlugin udpCallback]) build {"id", "deepLinkStatus", "deepLinkError"?, "deepLinkObj"?} → AppsflyerSdkPlugin.callbackChannel invokeMethod:"callListener" - Dart: _methodCallHandler(call) [lib/src/callbacks.dart] → callMap["id"] == "onDeepLinking" - → error = callMap["deepLinkError"]?.errorFromString() - → status = callMap["deepLinkStatus"]?.statusFromString() ?? Status.PARSE_ERROR - → deepLink = callMap["deepLinkObj"] != null ? DeepLink(map) : null - → _udlCallback!(DeepLinkResult(error, deepLink, status)) [lib/src/udl/deep_link_result.dart, lib/src/udl/deeplink.dart] +AppsFlyerSdk.registerDeepLinkListener(onDeepLink) [lib/src/appsflyer_sdk.dart] + → _ensureEventsSubscribed() — one af-events subscription for the plugin, attached on first registration + → _listeners.on('onDeepLinking', …) and .on('onDeepLinkReceived', …) + (the same callback for both platform event names; one slot each, replaced on re-registration) + → _invokeVoidRpc(isAndroid ? 'subscribeForDeepLink' : 'registerDeeplinkListener') + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + +Native deep link resolved (via F-039 iOS entry points / F-040 Android intent handling): + → Android: AppsFlyerEventBus.publish(json) → EventChannel('af-events'), buffered until a sink attaches + → iOS: deliverEvent(json) on EventChannel('af-events'), buffered in pendingEvents until Dart subscribes + → _AppsFlyerEvent.fromNative(json) [lib/src/appsflyer_event.dart] + → _AppsFlyerListenerRegistry.dispatch(event) [lib/src/appsflyer_listener_registry.dart] + → DeepLinkResult._fromEvent(event, platform: _platform) [lib/src/udl/deep_link_result.dart] + status = data['status'] normalized to DeepLinkStatus + deepLink = DeepLink(decoded data['deepLink']) [lib/src/udl/deeplink.dart] + error = Android ? DeepLinkFailure(type: ...) : DeepLinkFailure(message: ...) + → onDeepLink(DeepLinkResult) ``` +Android also exposes `unregisterDeeplinkListener()`, which dispatches `unsubscribeForDeepLink`; on iOS it still drops the Dart callback first and then throws `AppsFlyerException`, because the iOS RPC layer does not implement the method. + --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `onDeepLinking(Function(DeepLinkResult))` — registers the Dart UDL callback; `initSdk(registerOnDeepLinkingCallback: ...)` sets the `AF_UDL` init flag | -| `lib/src/callbacks.dart` | `startListeningToUDL` — stores a single `_udlCallback` (unlike the multi-key `_callbacksById` map used for other callbacks); `_methodCallHandler`'s `"onDeepLinking"` branch parses `deepLinkStatus`/`deepLinkError`/`deepLinkObj` into a `DeepLinkResult` | -| `lib/src/udl/deeplink.dart` | `DeepLink` — typed accessors (`deepLinkValue`, `matchType`, `mediaSource`, `campaign`, `afSub1..5`, `isDeferred`, etc.) over the raw click-event map | -| `lib/src/udl/deep_link_result.dart` | `DeepLinkResult`, `Status` (`FOUND`/`NOT_FOUND`/`ERROR`/`PARSE_ERROR`), `Error` (`TIMEOUT`/`NETWORK`/`HTTP_STATUS_CODE`/`UNEXPECTED`/`DEVELOPER_ERROR`) enums and string-conversion extensions used to decode the wire payload | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `afDeepLinkListener` (`com.appsflyer.deeplink.DeepLinkListener`) — registered via `AppsFlyerLib.getInstance().subscribeForDeepLink(...)` only when `AF_UDL` is true; `runOnUIThread` serializes `DeepLinkResult` into the `deepLinkStatus`/`deepLinkError`/`deepLinkObj` JSON shape; caches `cachedDeepLinkResult` across activity detach/reattach (`RD-65582`) | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `didResolveDeepLink:` (`AppsFlyerDeepLinkDelegate`) — gated by `[AppsflyerSdkPlugin udpCallback]`; builds the same JSON shape as Android | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` sets `[AppsFlyerLib shared].deepLinkDelegate = _streamHandler` only if the `UDL` flag is true; `startListening:` flips the internal `_udpCallback` flag when `callbackId == afUDPCallback` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | Defines `afUDL` (`"UDL"`), `afUDPCallback` (`"onDeepLinking"`) — note the `udpCallback`/`_udpCallback` naming (likely a "UDL"→"UDP" typo) used throughout the iOS plugin for this feature | +| `lib/src/appsflyer_sdk.dart` | `registerDeepLinkListener(onDeepLink)` (platform-specific RPC name), the `OnDeepLinkReceived` typedef, and the Android-only `unregisterDeeplinkListener()` | +| `lib/src/appsflyer_listener_registry.dart` | `_AppsFlyerListenerRegistry` — one callback slot per native event name, replaced on re-registration | +| `lib/src/appsflyer_event.dart` | `_AppsFlyerEvent.fromNative` — parses the RPC envelope (`event`, map-or-null `data`) | +| `lib/src/udl/deeplink.dart` | `DeepLink` — the raw `clickEvent` map plus typed accessors (`deepLinkValue`, `matchType`, `clickHttpReferrer`, `mediaSource`, `campaign`, `campaignId`, `afSub1..5`, `isDeferred`) and `getStringValue(key)` | +| `lib/src/udl/deep_link_result.dart` | `DeepLinkResult` (`status`, `deepLink`, `error`, `toJson`), `DeepLinkResult._fromEvent`, `DeepLinkStatus` (`found`/`notFound`/`error`/`unknown`), and `DeepLinkFailure` (`type`, `message`) | +| `android/.../AppsflyerSdkPlugin.kt` | `rpcEventNotifier` hops the bridge deep-link event to the main thread and publishes it to `AppsFlyerEventBus`; `createEventSink` adapts this engine's `af-events` sink | +| `android/.../AppsFlyerEventBus.kt` | Process-scoped buffer and FIFO replay, so a deep link resolved while no engine is attached is delivered to the next subscriber instead of being lost | +| `android/.../AppsFlyerRpcBridge.kt` | Process-scoped owner of the `AppsFlyerRpcHandler` that holds the UDL listener, so `subscribeForDeepLink` after engine recreation reuses it | +| `ios/.../AppsflyerSdkPlugin.swift` | `deliverEvent` forwards the event to the `af-events` sink and buffers it in `pendingEvents` until Dart subscribes | --- ## Input / Output | | | |--|--| -| **Input** | None from Dart beyond registering the callback; the deep-link click event itself originates from AppsFlyer's OneLink resolution, delivered into the native SDK via F-039 (iOS) / F-040 (Android) entry points or the SDK's own intent/URL inspection. | -| **Output** | `DeepLinkResult { Status status, Error? error, DeepLink? deepLink }` delivered to the Dart callback passed to `onDeepLinking`. `DeepLink` exposes the raw click-event map plus typed getters (`deepLinkValue`, `matchType`, `clickHttpReferrer`, `mediaSource`, `campaign`, `campaignId`, `afSub1..5`, `isDeferred`). Per `doc/DeepLink.md`, UDL privacy protection means new users' payloads are limited to `deep_link_value`/`deep_link_sub1-10`; other fields (`media_source`, `campaign`, `af_sub1-5`) return `null`. | +| **Input** | None from Dart beyond `registerDeepLinkListener()`; the deep-link click event originates from AppsFlyer's OneLink resolution, delivered into the native SDK via F-039 (iOS) / F-040 (Android) entry points or the SDK's own intent/URL inspection. | +| **Output** | `DeepLinkResult { DeepLinkStatus status, DeepLink? deepLink, DeepLinkFailure? error }` passed to the registered `onDeepLink` callback. `DeepLink` exposes the raw click-event map plus typed getters. UDL privacy protection means new users' payloads are limited to `deep_link_value`/`deep_link_sub1-10`; other getters may return `null`. `DeepLinkResult.toJson()` serializes the result back to its JSON form. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `onDeepLinking`, `startListeningToUDL`, or the `"onDeepLinking"` branch of `_methodCallHandler` in `lib/src/callbacks.dart`. +`test/appsflyer_sdk_test.dart`: +- `normalizes Android and iOS deep-link status without hiding errors` — builds `DeepLinkResult._fromEvent` from an Android `onDeepLinking` envelope (`FOUND` + JSON-string `deepLink`) and an iOS `onDeepLinkReceived` failure envelope, asserting the mapped `DeepLinkStatus`, `deepLinkValue`, `isDeferred`, and that the iOS failure carries a `message` but no `type`. +- `listeners are registered explicitly` — asserts `registerDeepLinkListener(onDeepLink)` dispatches `subscribeForDeepLink` on Android and `registerDeeplinkListener` on iOS. +- `maps every Android-only API` — asserts `unregisterDeeplinkListener` dispatches `unsubscribeForDeepLink`. +No test covers `unregisterDeeplinkListener` on iOS; the shared off-platform contract is exercised generically by `platform-only calls are forwarded to the native RPC instead of being swallowed in Dart`. + +`test/appsflyer_sdk_test.dart` — `routes deep-link events with an object data payload` covers an `onDeepLinkReceived` envelope whose `data` is a JSON object. --- ## Known Limitations -- **Single global callback, no queueing/multi-subscriber support**: `startListeningToUDL` stores the callback in a single module-level `_udlCallback` variable (not the keyed `_callbacksById` map other callbacks use), so registering `onDeepLinking` more than once silently replaces the previous subscriber rather than supporting multiple listeners. -- **iOS naming inconsistency**: the iOS native layer names its UDL-gating flag/method `udpCallback`/`_udpCallback` (`AppsflyerSdkPlugin.h`/`.m`), apparently a typo for "UDL" — functionally correct (still keyed off the `"onDeepLinking"` string) but a maintenance trap for anyone searching for `udl` in the iOS code. -- **Mutually exclusive with legacy direct deep linking**: per `doc/DeepLink.md`, migrating to UDL means `onAppOpenAttribution` (F-036) "will not be called" — nothing in code enforces or warns if an app registers both `registerOnDeepLinkingCallback` and `registerOnAppOpenAttributionCallback` simultaneously. -- Documentation requires the Dart-side `onDeepLinking` implementation to be registered **before** SDK initialization; nothing in code enforces or warns about this ordering. -- Android caches only the single most recent `DeepLinkResult` across an activity-detach window (`RD-65582` `cachedDeepLinkResult`); rapid multiple deep-link resolutions during a detach period are not individually queued — only the latest survives. -- `deepLinkStatus`/`deepLinkError` string parsing (`statusFromString`/`errorFromString`) uses `firstWhere(..., orElse: null)`, which throws if the native string doesn't match a known enum value rather than falling back cleanly (a `Status.PARSE_ERROR` default is only applied when the field itself is null/missing, not when it's an unrecognized string). +- The plugin holds **one deep-link callback**, replaced on re-registration, matching the native SDK's single UDL listener slot; there is no public stream, so one resolved deep link cannot be routed twice by the app. A deep-link event arriving before `registerDeepLinkListener()` has run is held by `_AppsFlyerListenerRegistry` and replayed when the listener registers. After the listener has been registered once, an event arriving while it is unregistered is logged and dropped instead. +- **Unrecognized status strings** fall back to `DeepLinkStatus.unknown`; `DeepLinkResult._fromEvent` never throws on an unexpected status. +- Failure detail is asymmetric by design: Android populates `DeepLinkFailure.type` (a stable error type) and iOS populates `DeepLinkFailure.message` (a localized string). Neither platform fills both. +- Android RPC 7.0.1 serializes the native click event via `org.json.JSONObject.toString()`, which is valid JSON. `_decodeDeepLink` decodes it directly with `jsonDecode` (types, including `is_deferred`, are preserved); a malformed/non-JSON string is treated as "no deep link" (`null`) rather than partially parsed. +- **`DeepLink.isDeferred` is unreliable on iOS**: the native `AppsFlyerDeepLink.clickEvent` dictionary never includes an `is_deferred` key (the flag lives on a separate native `isDeferred` property that the iOS RPC bridge's `didResolveDeepLink` does not forward). `isDeferred` always returns `null` for iOS deep links regardless of the actual deferred/direct outcome. Android reliably sets `is_deferred` on every resolved deep link. Fixing this requires a native iOS RPC change (forwarding `deepLink.isDeferred` alongside `clickEvent`) — out of scope for the Flutter plugin. +- `unregisterDeeplinkListener()` is an Android-only soft unsubscribe: the native SDK keeps its listener (it exposes no public unsubscribe API) and the RPC bridge drops subsequent deep-link events. Because the handler holding that reference is process-scoped on Android, the soft unsubscribe also outlives the engine that requested it — an app that unsubscribes on teardown must call `registerDeepLinkListener()` again after the next engine attaches. On iOS the call is not inert either: the Dart callback is dropped locally before the RPC is dispatched, so deep links stop reaching the app *and* the call throws `AppsFlyerException`. +- **Registration order is unenforced.** Nothing in the plugin prevents an app from calling `registerDeepLinkListener()` after `init()`, and nothing reports the resulting loss of Android deferred deep linking: the native gate fails silently (no `[DDL]` log covers the listener-null case) and `ddl_sent` makes it permanent for that install, so reproducing a fix requires a reinstall or a data wipe. Only the documentation and the example app express the requirement. +- Both platforms buffer native events until Dart attaches to `af-events` (RD-65582) and replay them on attach. Dart attaches that single subscription lazily on the first `register*Listener()` call, and `_AppsFlyerListenerRegistry` holds replayed events whose listener has not registered yet, so the replay is not drained into nothing. Android buffers in the process-scoped `AppsFlyerEventBus`, so a deep link resolved while the Flutter engine is torn down (back press, then a link tap) survives engine recreation. iOS buffers per plugin instance and removes its bridge handler on engine detach (only if that instance still owns the bridge's single handler slot), so it has no equivalent cross-engine replay. Both buffers hold at most 64 events and drop the oldest beyond that. --- ## Dependencies ```mermaid flowchart LR - F037["F-037 · Unified Deep Linking (UDL) Callback & Models"]:::deepLinking -->|"listener registration gated by UDL flag set in"| F001["F-001 · SDK Initialization & Options Validation"]:::sdkCore - F039["F-039 · Native iOS Deep-Link Entry Points"]:::deepLinking -->|"forwards openURL/continueUserActivity/scene events to native SDK, which triggers"| F037 - F040["F-040 · Android New-Intent Deep-Link Forwarding"]:::deepLinking -->|"forwards onNewIntent to native SDK, which triggers"| F037 + F037["F-037 · Unified Deep Linking (UDL) Callback & Models"]:::deepLinking -->|"listener registered before"| F001["F-001 · SDK Initialization"]:::sdkCore + F037 -->|"iOS input arrives through"| F039["F-039 · Native iOS Deep-Link Entry Points"]:::deepLinking + F037 -->|"Android warm-intent state is synchronized by"| F040["F-040 · Android New-Intent Deep-Link Forwarding"]:::deepLinking classDef deepLinking fill:#E64980,color:#fff classDef sdkCore fill:#4C6EF5,color:#fff ``` diff --git a/internal-docs/features/F-038-legacy-purchase-validation-notification-callback.md b/internal-docs/features/F-038-legacy-purchase-validation-notification-callback.md index 222f6d9c..586b37b3 100644 --- a/internal-docs/features/F-038-legacy-purchase-validation-notification-callback.md +++ b/internal-docs/features/F-038-legacy-purchase-validation-notification-callback.md @@ -3,94 +3,28 @@ id: F-038 name: Legacy Purchase-Validation Notification Callback type: purchaseValidation platform: both -status: active -last_verified: 2026-07-15 +status: removed +last_verified: 2026-08-10 depends_on: [] --- -## Business Purpose -The legacy V1 purchase-validation APIs (F-023, `validateAndLogInAppAndroidPurchase` / `validateAndLogInAppIosPurchase`) are fire-and-forget: their Dart `Future` resolves as soon as the native call is dispatched, before AppsFlyer's servers have actually validated the receipt against the store. `onPurchaseValidation` is the only way a host app can find out whether that validation ultimately succeeded or failed — it registers a Dart callback that native code invokes asynchronously, once, whenever a `"validatePurchase"` event arrives from the native `AppsFlyerInAppPurchaseValidatorListener` (Android) or the `validateAndLogInAppPurchase` success/failure blocks (iOS). Without this callback, apps using the deprecated V1 validation APIs would have no way to observe the validation outcome at all, since V1 does not return it on the call's own `Future` (unlike V2 / F-024). +## Status: REMOVED in SDK 7 -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +`onPurchaseValidation` — the callback registration that delivered the asynchronous, out-of-band result of the legacy V1 purchase-validation APIs (the `"validatePurchase"` event) — has been **removed from the Flutter plugin** in the SDK 7 migration. ---- - -## Trigger -Called by the host app once, typically during setup (before or shortly after calling the V1 validation APIs), to register a listener for the `"validatePurchase"` event. The registered callback then fires asynchronously whenever the native SDK later completes (or fails) a legacy in-app-purchase validation triggered by F-023. - ---- - -## Call Chain -``` -Registration: -AppsflyerSdk.onPurchaseValidation(Function callback) [lib/src/appsflyer_sdk.dart] - → startListening(callback, "validatePurchase") [lib/src/callbacks.dart] - → _callbacksById["validatePurchase"] = callback - → _channel(AF_CALLBACK_CHANNEL /* "callbacks" */).invokeMethod("startListening", "validatePurchase") - → Android: AppsflyerSdkPlugin.callbacksHandler → startListening(arguments, result) [android/.../AppsflyerSdkPlugin.java] - → validatePurchaseCallback = true // gates delivery, see registerValidatorListener() - → iOS: no native handler observed for "startListening" on the callbacks channel (see Known Limitations) - -Delivery (Android): -AppsFlyerInAppPurchaseValidatorListener (registered by registerValidatorListener(), called from - validateAndLogInAppPurchase() in F-023's V1 flow) [android/.../AppsflyerSdkPlugin.java] - → onValidateInApp() / onValidateInAppFailure(String) - → if (validatePurchaseCallback) runOnUIThread(data, AF_VALIDATE_PURCHASE /* "validatePurchase" */, AF_SUCCESS|AF_FAILURE) - → mCallbackChannel.invokeMethod("callListener", jsonArgs) // args = {id, status, data} - → Dart: _methodCallHandler case 'callListener' → case "validatePurchase" [lib/src/callbacks.dart] - → decodes data, builds {"status", "payload"}, invokes _callbacksById["validatePurchase"](fullResponse) - → the app's registered callback runs +It only ever existed to serve the V1 validation APIs (F-023), which are themselves removed. The transport it relied on is also gone: SDK 7 has no `"callbacks"` MethodChannel and no `AppsFlyerStreamHandler`; all reverse events now flow over the single `af-events` EventChannel, and there is no `"validatePurchase"` event on it. -Delivery (iOS): -[AppsFlyerLib shared] validateAndLogInAppPurchase:...success:/failure: (F-023's V1 flow) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → onValidateSuccess:/onValidateFail: - → [_streamHandler sendResponseToFlutter:afValidatePurchase(@"validatePurchase") status:... data:...] [AppsFlyerStreamHandler.m] - → Dart: same _methodCallHandler case 'callListener' → case "validatePurchase" path as Android -``` - ---- - -## Files -| File | Role | -|------|------| -| `lib/src/appsflyer_sdk.dart` | `onPurchaseValidation(Function callback)` — thin wrapper calling `startListening(callback, "validatePurchase")` | -| `lib/src/callbacks.dart` | `startListening()` registers the callback in `_callbacksById` and tells native to start listening; `_methodCallHandler` routes incoming `"callListener"` calls whose `id == "validatePurchase"` to the registered callback, wrapping the payload as `{"status", "payload"}` | -| `lib/src/appsflyer_constants.dart` | `AF_VALIDATE_PURCHASE = "validatePurchase"` — the shared event id constant | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `startListening(Object, Result)` sets `validatePurchaseCallback = true`; `registerValidatorListener()` builds the `AppsFlyerInAppPurchaseValidatorListener` whose `onValidateInApp()`/`onValidateInAppFailure(String)` gate on that flag and call `runOnUIThread(...)` to push the event to Dart over the `"callbacks"` (`mCallbackChannel`) `MethodChannel` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | `AF_VALIDATE_PURCHASE = "validatePurchase"` — native-side mirror of the Dart constant | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `onValidateSuccess:`/`onValidateFail:` (fed by F-023's `validateAndLogInAppPurchase:result:`) call `[_streamHandler sendResponseToFlutter:afValidatePurchase ...]` to forward the result | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define afValidatePurchase @"validatePurchase"` — iOS-side mirror of the same event id | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `sendResponseToFlutter:status:data:` — forwards the result to Dart via `invokeMethod("callListener", ...)` on the callback channel (same channel/protocol Android uses) | - ---- - -## Input / Output -| | | -|--|--| -| **Input** | `callback` (`Function`) — a Dart function accepting one `dynamic` argument, registered once via `onPurchaseValidation`. | -| **Output** | The registered callback is invoked with `{"status": "success"\|"failure", "payload": }` whenever native code reports a `"validatePurchase"` event triggered by a prior F-023 V1 validation call. On Android, `payload` is empty `{}` on success and `{"error": ""}` on failure; on iOS it is the raw validation response dictionary on success and `{"error": ""}` on failure. `onPurchaseValidation` itself returns nothing (`void`, `async` with no awaited work). | - ---- - -## Tests -No dedicated test found. `grep` of `test/` for `onPurchaseValidation`/`validatePurchase` (as a callback registration, not the V1 validate-and-log call already covered by F-023's test) returns no matches — the callback-delivery path is untested by the Dart unit suite. The `example/` app also does not appear to call `onPurchaseValidation`. - ---- +**Replacement:** `validateAndLogInAppPurchase` (F-024) returns the validation result (or throws) directly on its own `Future` — no separate listener registration is needed. -## Known Limitations -- Deprecated-adjacent: this callback only exists to serve the deprecated V1 validation APIs (F-023). V2 (F-024) delivers its result directly on the call's own `Future` and does not need this listener. Apps that have fully migrated to V2 have no reason to register `onPurchaseValidation`. -- On Android, delivery is gated by the `validatePurchaseCallback` boolean, which is only set `true` once `onPurchaseValidation` → `startListening("validatePurchase")` has round-tripped to native; if a V1 validation call resolves before that registration completes, the resulting event is dropped (no buffering/replay), and the app never learns the outcome. -- `_callbacksById` in `callbacks.dart` is a single global map keyed by event id string — calling `onPurchaseValidation` more than once silently replaces the previously registered callback rather than fanning out to multiple listeners, and there is no corresponding `cancelListening` call exposed for this specific API (though the underlying `startListening` helper does return a `CancelListening` closure that `onPurchaseValidation` discards). -- iOS delivery is not gated by any equivalent boolean flag: `AppsFlyerStreamHandler.sendResponseToFlutter` always attempts to forward a `"validatePurchase"` event whenever `onValidateSuccess:`/`onValidateFail:` fire, regardless of whether the Dart side ever called `onPurchaseValidation` — an asymmetry with Android noted already in F-023's Known Limitations. -- No automated test coverage of the callback-delivery path on either platform. +See [`doc/migration-guide.md`](/doc/migration-guide.md#removed-apis-and-their-replacements) and the [CHANGELOG](/CHANGELOG.md). --- ## Dependencies ```mermaid flowchart LR - F038["F-038 · Legacy Purchase-Validation Notification Callback"]:::purchaseValidation - F023["F-023 · In-App Purchase Validation V1"]:::purchaseValidation - F023 -->|"delivers async result via"| F038 + F038["F-038 · Legacy Purchase-Validation Notification Callback (removed)"]:::purchaseValidation + F024["F-024 · In-App Purchase Validation V2"]:::purchaseValidation + F038 -->|"replaced by direct Future result of"| F024 classDef purchaseValidation fill:#F59F00,color:#fff ``` diff --git a/internal-docs/features/F-039-native-ios-deep-link-entry-points.md b/internal-docs/features/F-039-native-ios-deep-link-entry-points.md index 5904492a..358ad096 100644 --- a/internal-docs/features/F-039-native-ios-deep-link-entry-points.md +++ b/internal-docs/features/F-039-native-ios-deep-link-entry-points.md @@ -4,14 +4,12 @@ name: Native iOS Deep-Link Entry Points (URL scheme / Universal Links / Scenes) type: deepLinking platform: ios status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -iOS only tells an app about an incoming deep link through OS delegate callbacks (`application:openURL:...`, `application:continueUserActivity:...`) or, on the UIScene lifecycle (iOS 13+, and required by Flutter 3.41+'s UIScene migration), `scene:...` methods. The AppsFlyer SDK must intercept every one of these entry points — including the cold-start case where the OS delivers the launch URL/activity before the Flutter/Dart bridge exists — and pass it to the native AppsFlyer SDK so it can resolve OneLink attribution and, ultimately, deliver a `DeepLinkResult` to Dart via F-037. Without this interception layer, deep links opened while the app is fully cold (not yet running) would be silently lost. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +iOS only tells an app about an incoming deep link through OS delegate callbacks (`application:openURL:...`, `application:continueUserActivity:...`) or, on the UIScene lifecycle (iOS 13+, and required by Flutter 3.41+'s UIScene migration), `scene:...` methods. The AppsFlyer SDK must intercept every one of these entry points — including the cold-start case where the OS delivers the launch URL/activity before the Flutter/Dart bridge exists — and pass it to the native AppsFlyer SDK so it can resolve OneLink attribution and, ultimately, deliver a `DeepLinkResult` to Dart via F-037. Without this interception layer, deep links opened while the app is fully cold (not yet running) would be silently lost. Flutter drives these entry points automatically (the plugin registers as an application/scene delegate), so the host `AppDelegate` does not need to forward `openURL`/`continueUserActivity` manually. --- @@ -23,43 +21,44 @@ Fires whenever iOS launches or resumes the app via a deep link: URI-scheme opens ## Call Chain ``` iOS OS-level deep-link delivery (app already running or resuming): - application:openURL:options: (iOS 9+) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerAttribution shared] handleOpenUrl:url options:options] [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.m] + application:openURL:options: (iOS 9+) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift] + → [[AppsFlyerAttribution shared] handleOpenUrl:url options:options] [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.swift] application:openURL:sourceApplication:annotation: (iOS 8 and below) → [[AppsFlyerAttribution shared] handleOpenUrl:url sourceApplication:annotation:] application:continueUserActivity:restorationHandler: (Universal Links) - → [[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:] + → [[AppsFlyerAttribution shared] continueUserActivity:userActivity] iOS UIScene-based delivery (Flutter 3.41+ UIScene migration, iOS 13+, only compiled when FlutterSceneLifeCycle.h is available): scene:openURLContexts: → for each context → [[AppsFlyerAttribution shared] handleOpenUrl:context.URL options:opts] scene:willConnectToSession:options: (cold start via UISceneConnectionOptions) → for each URLContext → handleOpenUrl:options: - → for each userActivity of type NSUserActivityTypeBrowsingWeb → continueUserActivity:restorationHandler:nil - scene:continueUserActivity: → [[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:nil] + → for each userActivity of type NSUserActivityTypeBrowsingWeb → continueUserActivity: + scene:continueUserActivity: → [[AppsFlyerAttribution shared] continueUserActivity:userActivity] -AppsFlyerAttribution (buffering singleton, isBridgeReady initially NO) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.m] +AppsFlyerAttribution (queueing singleton, isBridgeReady initially NO) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.swift] handleOpenUrl:.../continueUserActivity:... - → if isBridgeReady == YES: forward immediately to [AppsFlyerLib shared] handleOpenUrl:/continueUserActivity: - → else: buffer url/options/sourceApplication/annotation/userActivity/restorationHandler on self - -AppsflyerSdkPlugin initSdkWithCall:result: (Dart called initSdk → method channel → native init) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → ... [AppsFlyerLib shared] init/start ... - → [AppsFlyerAttribution shared].isBridgeReady = YES - → [[NSNotificationCenter defaultCenter] postNotificationName:AF_BRIDGE_SET object:self] - → AppsFlyerAttribution receiveBridgeReadyNotification: (registered as observer in -init) - → flushes any buffered url/options/sourceApplication/annotation/userActivity to [AppsFlyerLib shared] handleOpenUrl:/continueUserActivity: - → native SDK resolves the deep link → triggers F-037 (UDL) delivery to Dart + → builds an RPC envelope: handleOpenUrl / handleOpenURL / continueUserActivity with {url, options|activityType} + → executeOrQueueMethod:params: + → if isBridgeReady == YES: [[AppsFlyerRPCBridge shared] executeJson:completion:] + → else: append {method, params} to the pendingRequests queue + +AppsflyerSdkPlugin initFromRpc:result: (Dart AppsFlyerSdk.init() → af-api executeRpc('init') → native init sequence) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift] + → runSequence: setPluginInfo → initialize + → markBridgeReady(markedBy: pluginInstance) [plugin-internal; not on the @objc surface] + → isBridgeReady = YES, records the owning plugin instance, then drains pendingRequests through [[AppsFlyerRPCBridge shared] executeJson:] + → native SDK resolves the deep link → triggers F-037 (UDL) delivery to Dart ``` +Deep-link listener registration is no longer part of the init sequence. Dart registers it explicitly with `AppsFlyerSdk.registerDeepLinkListener()`, which sends the `registerDeeplinkListener` RPC on iOS. --- ## Files | File | Role | |------|------| -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `application:openURL:options:`, `application:openURL:sourceApplication:annotation:`, `application:continueUserActivity:restorationHandler:`, and (behind `FlutterSceneLifeCycle.h` availability) `scene:openURLContexts:`, `scene:willConnectToSession:options:`, `scene:continueUserActivity:` — all OS/Scene entry points, each forwarding into `AppsFlyerAttribution`; `initSdkWithCall:result:` sets `isBridgeReady = YES` and posts `AF_BRIDGE_SET` once Dart's `initSdk` call reaches native code | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerAttribution.h` | Declares the `AppsFlyerAttribution` singleton interface: buffering properties (`userActivity`, `restorationHandler`, `url`, `options`, `sourceApplication`, `annotation`), `isBridgeReady` flag, and the `AF_BRIDGE_SET` notification name constant | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.m` | Singleton implementation — `handleOpenUrl:...`/`continueUserActivity:...` either forward immediately to `AppsFlyerLib` or buffer until `isBridgeReady`; `receiveBridgeReadyNotification:` flushes exactly one buffered event (checked in priority order: sourceApplication+annotation form, then options form, then userActivity form) when notified | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `AppsflyerSdkPlugin` class declaration; conditionally conforms to `FlutterSceneLifeCycleDelegate` when available | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | `application:openURL:options:`, `application:openURL:sourceApplication:annotation:`, `application:continueUserActivity:restorationHandler:`, and (registered only when the registrar responds to `addSceneDelegate:`) `scene:openURLContexts:`, `scene:willConnectToSession:options:`, `scene:continueUserActivity:` — all OS/Scene entry points, each forwarding into `AppsFlyerAttribution`; `initFromRpc:result:` calls plugin-internal `markBridgeReady(markedBy:)` once Dart's `init()` (`executeRpc('init')`) RPC sequence completes | +| Generated `appsflyer_sdk-Swift.h` | Exposes the `AppsFlyerAttribution` singleton interface to Objective-C: the three `handleOpenUrl:`/`continueUserActivity:` entry points, all pinned with explicit `@objc(...)` selectors in the Swift source. Bridge readiness is opened only from `AppsflyerSdkPlugin` after `init()`; it is not a public `@objc` method. | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.swift` | Singleton implementation — private `isBridgeReady` flag and `pendingRequests` queue; `handleOpenUrl:...`/`continueUserActivity:...` build an RPC envelope and pass it to `executeOrQueueMethod:params:`, which either sends it through `AppsFlyerRPCBridge` or appends to the queue; plugin-internal `markBridgeReady(markedBy:)` records the owning engine, sets `isBridgeReady`, and drains that queue in arrival order | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` (registration) | `@objc(AppsflyerSdkPlugin)` pins the runtime class name; scene-lifecycle support is registered at runtime via `registrar.responds(to: #selector(addSceneDelegate:))` instead of a compile-time header check, since Swift has no `__has_include` equivalent for a framework subheader | --- @@ -67,20 +66,24 @@ AppsflyerSdkPlugin initSdkWithCall:result: (Dart called initSdk → method chann | | | |--|--| | **Input** | `NSURL`/`NSDictionary` options (URI-scheme opens), `NSUserActivity` (Universal Links), or `UISceneConnectionOptions`/`UIOpenURLContext` sets (UIScene cold start/live events) — all supplied by iOS, not by Dart. | -| **Output** | No direct Dart-facing output from this feature; it forwards raw URL/activity data into `[AppsFlyerLib shared]`, which performs OneLink resolution and (if UDL is subscribed, F-037) surfaces a `DeepLinkResult` back through the existing callback channel. | +| **Output** | No direct Dart-facing output from this feature; it forwards the URL/activity data through `AppsFlyerRPCBridge` into the native SDK, which performs OneLink resolution and (if the deep-link listener is registered, F-037) surfaces a `DeepLinkResult` to the registered `registerDeepLinkListener` callback over the `af-events` EventChannel. | --- ## Tests -No dedicated test found — this logic lives entirely in Objective-C native code with no automated coverage found under `test/` (Dart tests only) or any discoverable native (XCTest) test target in `ios/`. +No dedicated test found — this logic lives entirely in Swift native code with no automated coverage found under `test/` (Dart tests only) or any discoverable native (XCTest) test target in `ios/`. --- ## Known Limitations -- **Single-slot buffer, not a queue**: `AppsFlyerAttribution` buffers only one pending deep-link event at a time (a fixed set of instance properties, not a list); if the OS delivers multiple deep-link-shaped events before `isBridgeReady` flips to `YES` (e.g. both a URL and a Universal Link in rapid succession during cold start), only the values from the last call survive — earlier ones are silently overwritten. -- **All delegate methods return `NO`**: every intercepted method explicitly returns `NO`/is documented as "Results of this are ORed and NO doesn't affect other delegate interceptors' result" — by design, so as not to block other plugins/interceptors from also handling the same URL, but it also means AppsFlyer's interception is invisible to code checking the return value for "was this URL handled." -- **UIScene support is conditionally compiled**: the `scene:...` methods only exist when `__has_include()` is true (Flutter 3.41+); on older Flutter/Flutter engine versions without UIScene support, only the legacy `UIApplicationDelegate` methods run, and per `doc/DeepLink.md` those legacy methods are also documented as unnecessary from plugin v6.4.0+ if the app doesn't override them itself (i.e. AppsFlyer intercepts automatically via method swizzling/plugin registration, not by requiring the host `AppDelegate` to call these directly). -- The `isBridgeReady`/`AF_BRIDGE_SET` handshake depends on Dart actually calling `initSdk`; if the Dart app never initializes the SDK (or does so much later), buffered deep-link data waits indefinitely in `AppsFlyerAttribution`'s single-slot buffer. +- **Queued, but only JSON-serializable data survives**: `AppsFlyerAttribution` keeps every early event in the `pendingRequests` queue and drains them in arrival order, so multiple deep-link-shaped events during cold start are all forwarded. Each entry is an RPC envelope. `openURL` `options`/`annotation` values are filtered through `jsonSafeOptionsFromDictionary:` before queueing or send; non-JSON entries are omitted rather than failing the whole deep link. +- **`restorationHandler` is not forwarded**: `UIApplicationDelegate` requires the parameter on `continueUserActivity:`, but attribution only needs `webpageURL` for RPC (the AppsFlyer SDK ignores the handler as well). Handoff/UI restoration remains the host app's responsibility. +- **All delegate methods return `NO`**: every intercepted method (including `application:didFinishLaunchingWithOptions:`) explicitly returns `NO`. Flutter ORs the results of its registered delegates, so returning `NO` avoids blocking other plugins from handling the same URL — but it also means AppsFlyer's interception is invisible to code that checks the return value for "was this URL handled." +- **UIScene support is conditionally compiled**: the `scene:...` methods only exist when `__has_include()` is true (Flutter 3.41+); on older Flutter engine versions without UIScene support, only the legacy `UIApplicationDelegate` methods run. Either way the host `AppDelegate` does not have to forward anything, because the plugin is registered as a delegate itself. +- The bridge-ready handshake depends on Dart actually calling `AppsFlyerSdk.init()`; if the app never initializes the SDK (or does so much later), queued deep-link data waits indefinitely in `pendingRequests`. A failed init sequence returns the error to Dart without marking the bridge ready, so the queue is never drained for that launch. Only `markBridgeReady(markedBy:)` (plugin-internal) may open the gate; a parameterless public variant was removed because it opened the gate without recording an owner, so `resetBridgeStateIfOwned(by:)` on engine detach could not clear stale state. When the owning Flutter engine detaches, `resetBridgeStateIfOwned(by:)` clears the singleton gate and pending queue so a recreated engine does not inherit a stale open gate from the previous instance. +- **Interim architecture**: this class JSON-encodes native deep-link entry points into `AFRPCBridge.executeJson` because the Flutter plugin predates the upstream RPC lifecycle-callback wrapper (`AFRPCContinueUserActivityRequest`, `AFRPCHandleOpenURLRequest`, etc.). Migrate to the typed lifecycle API when that wrapper ships; this class is then a deletion candidate rather than a long-term home for queue state. +- **RPC failures are logged, not surfaced to the host**: `execute(method:params:)` logs serialization and RPC/SDK errors through `os_log`; there is still no synchronous host callback — UDL results arrive on `af-events` only. +- **`handleLaunchOptions` is forwarded at launch**: `application:didFinishLaunchingWithOptions:` sanitizes launch options and immediately sends `handleLaunchOptions` through `AFRPCBridge` (fire-and-forget). The native SDK has no `initialize` dependency on that call; it only sets a pending-deeplink flag that `registerSessionReadyListener` samples later. `NSURL` values are converted to strings and non-JSON values are dropped. - No test coverage exists for any of the buffering/forwarding logic described here. --- @@ -88,6 +91,6 @@ No dedicated test found — this logic lives entirely in Objective-C native code ## Dependencies ```mermaid flowchart LR - F039["F-039 · Native iOS Deep-Link Entry Points"]:::deepLinking -->|"forwards resolved URL/activity to native SDK, which triggers"| F037["F-037 · Unified Deep Linking (UDL) Callback & Models"]:::deepLinking + F039["F-039 · Native iOS Deep-Link Entry Points"]:::deepLinking classDef deepLinking fill:#E64980,color:#fff ``` diff --git a/internal-docs/features/F-040-android-new-intent-deep-link-forwarding.md b/internal-docs/features/F-040-android-new-intent-deep-link-forwarding.md index 4e782867..39436ac2 100644 --- a/internal-docs/features/F-040-android-new-intent-deep-link-forwarding.md +++ b/internal-docs/features/F-040-android-new-intent-deep-link-forwarding.md @@ -4,49 +4,46 @@ name: Android New-Intent Deep-Link Forwarding type: deepLinking platform: android status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -The native AppsFlyer Android SDK normally inspects the hosting `Activity`'s intent for deep-link data during `onResume()`. For a warm-started app (already running, brought back to the foreground by a new `VIEW` intent — e.g. tapping a OneLink while the app sits in the background), Android delivers that new intent via `onNewIntent`, and the SDK's own `onResume` handling stamps the intent URI with `af_consumed=true` once it has processed it. If the Flutter plugin didn't forward the intent to the SDK itself before that auto-consumption happens, warm-start deep links would either be missed entirely or race against the SDK's own resume-time handling. This feature exists purely to guarantee that warm-start deep links reliably reach AppsFlyer's resolution logic (and, from there, the UDL callback layer, F-037) as reliably as cold-start links do. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +The native AppsFlyer Android SDK inspects the hosting `Activity`'s intent during its lifecycle (`onResume`). For a warm-started app, Android delivers a new `VIEW` intent through `onNewIntent`. Initialization attaches the native SDK to lifecycle handling, while the explicit F-037 listener registration subscribes for UDL results. This feature keeps the `Activity`'s current intent in sync so the SDK sees the newly delivered intent on the next resume. --- ## Trigger -Fires whenever Android calls `onNewIntent` on the host `Activity` while the Flutter engine's activity is attached — i.e. the app is warm (already running, not being freshly launched) and receives a new `Intent` (typically a `VIEW` intent from a deep link click). +Fires whenever Android calls `onNewIntent` on the host `Activity` while the Flutter engine's activity is attached — i.e. the app is warm (already running) and receives a new `Intent` (typically a `VIEW` intent from a deep-link click). --- ## Call Chain ``` Android delivers a new Intent to the running Activity (app already warm) - → PluginRegistry.NewIntentListener.onNewIntent(Intent intent) [android/.../AppsflyerSdkPlugin.java] - → activity.setIntent(intent) // keep Activity's intent in sync - → if (mApplication != null): AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication) - // forwarded BEFORE the SDK's own onResume auto-handler stamps the URI with af_consumed=true - → native SDK resolves the deep link from the intent - → afDeepLinkListener.onDeepLinking(DeepLinkResult) (if subscribeForDeepLink was called, F-037/UDL path) - → ... delivered to Dart via the callListener/onDeepLinking channel (see F-037) - → onNewIntent returns false (does not claim exclusive handling of the intent) + → PluginRegistry.NewIntentListener.onNewIntent(Intent intent) [android/.../AppsflyerSdkPlugin.kt] + → if (activity != null) activity.setIntent(intent) // keep the Activity's intent in sync + → return false // does not claim exclusive handling + // init() establishes SDK lifecycle handling; registerDeepLinkListener() separately + // installs the UDL listener. The SDK examines the current VIEW intent on onResume. + → afDeepLinkListener → onDeepLinking (F-037), delivered to Dart over the af-events EventChannel ``` +Note: unlike SDK 6, the listener no longer calls `AppsFlyerLib.performOnDeepLinking(intent, ...)` from `onNewIntent`; the SDK's own lifecycle handling performs the resolution. The listener only synchronizes the `Activity` intent. --- ## Files | File | Role | |------|------| -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `onNewIntentListener` (`PluginRegistry.NewIntentListener`) — calls `activity.setIntent(intent)` then `AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication)`; registered via `binding.addOnNewIntentListener(onNewIntentListener)` in both `onAttachedToActivity` and `onReattachedToActivityForConfigChanges` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | `onNewIntentListener` (`PluginRegistry.NewIntentListener`) — calls `activity.setIntent(intent)` (guarded by `activity != null`) and returns `false`; registered via `binding.addOnNewIntentListener(onNewIntentListener)` in both `onAttachedToActivity` and `onReattachedToActivityForConfigChanges` | --- ## Input / Output | | | |--|--| -| **Input** | The Android `Intent` delivered to `onNewIntent` (typically a `VIEW` intent carrying a deep-link/OneLink URI), plus the plugin's cached `Activity`/`Application` references. | -| **Output** | No direct Dart-facing output — this feature only forwards the intent into `AppsFlyerLib.getInstance().performOnDeepLinking(...)`, which performs deep-link resolution and (if subscribed) delivers a `DeepLinkResult` through the existing UDL callback path (F-037). `onNewIntent` itself returns `false`, signaling it does not consume the intent for any other listener. | +| **Input** | The Android `Intent` delivered to `onNewIntent` (typically a `VIEW` intent carrying a deep-link/OneLink URI), plus the plugin's cached `Activity` reference. | +| **Output** | No direct Dart-facing output — the listener only syncs the `Activity`'s intent. The native SDK's lifecycle handling resolves the deep link and (if subscribed via F-037) delivers a `DeepLinkResult` over the `af-events` EventChannel. `onNewIntent` returns `false`, so it does not consume the intent for other listeners. | --- @@ -56,17 +53,16 @@ No dedicated test found — no native (JUnit/Robolectric) test target under `and --- ## Known Limitations -- **Guarded by `mApplication` nullability, not by activity-attach state generally**: `performOnDeepLinking` is only called `if (mApplication != null)`; `mApplication` is set in `onAttachedToActivity` and never explicitly nulled elsewhere in the visible code except implicitly via activity detach handling, so a new intent arriving in a narrow window before `onAttachedToActivity` runs (or after certain teardown paths) would silently skip forwarding. -- **Race with the native SDK's own `onResume` consumption**: the inline comment in code explicitly documents the reason this forwarding exists — "Forward the intent to the SDK before its own onResume auto-handler runs and stamps the URI with `af_consumed=true`. Without this, warm-app VIEW intents get silently consumed and the registered DeepLinkListener never fires for the Dart side." This means the correctness of this feature depends on `onNewIntent` always running before the Activity's `onResume` in the observed lifecycle ordering — an assumption inherent to the Android lifecycle but not enforced/asserted in code. -- **No iOS equivalent by nature**: iOS has no concept of `onNewIntent`; the warm-start-equivalent cases on iOS are handled by the always-active `application:openURL:...`/`continueUserActivity:...`/`scene:...` delegate methods (F-039), which do not need a separate "already consumed" race to guard against. -- `onNewIntent` always returns `false`, so it never signals to the Flutter engine's intent-handling chain that it fully handled the intent — other registered `NewIntentListener`s (e.g. app-level routing) still run. -- No automated test coverage exists for this listener or its interaction with SDK-internal `onResume` consumption timing. +- **Depends on activity attachment**: `setIntent` is only called `if (activity != null)`; a new intent arriving in a narrow window before `onAttachedToActivity` runs (or after teardown) is not synced. +- **Relies on SDK-internal onResume resolution**: correctness depends on the SDK's lifecycle subscription resolving the current intent on `onResume`. Listener registration is explicit in SDK 7, so if the app never called `registerDeepLinkListener()` (Android RPC `subscribeForDeepLink`), no UDL resolution occurs. +- **No iOS equivalent by nature**: iOS has no `onNewIntent`; the warm-start-equivalent cases are handled by the always-active `application:openURL:...`/`continueUserActivity:...`/`scene:...` delegate methods (F-039). +- `onNewIntent` always returns `false`, so other registered `NewIntentListener`s (e.g. app-level routing) still run. --- ## Dependencies ```mermaid flowchart LR - F040["F-040 · Android New-Intent Deep-Link Forwarding"]:::deepLinking -->|"forwards intent to native SDK before auto-consumption, which triggers"| F037["F-037 · Unified Deep Linking (UDL) Callback & Models"]:::deepLinking + F040["F-040 · Android New-Intent Deep-Link Forwarding"]:::deepLinking classDef deepLinking fill:#E64980,color:#fff ``` diff --git a/internal-docs/features/F-041-current-device-language-override.md b/internal-docs/features/F-041-current-device-language-override.md index a6ad6617..bbc27b2a 100644 --- a/internal-docs/features/F-041-current-device-language-override.md +++ b/internal-docs/features/F-041-current-device-language-override.md @@ -4,58 +4,59 @@ name: Current Device Language Override type: platformIntegration platform: ios status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -AppsFlyer's attribution and in-app-event reporting includes the device's language/locale as a dimension used for reporting and, for some integrated partners, for postback enrichment. Apps that manage their own in-app localization independently of the OS locale (e.g. a language switcher that doesn't change `NSLocale`) need a way to tell AppsFlyer which language the user is actually seeing, rather than relying on the OS-reported value. `setCurrentDeviceLanguage` provides that override. Without it, AppsFlyer would only ever see the OS-level device language, which can diverge from the language actually presented to the user and skew language-based reporting/segmentation for partners that consume it. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +AppsFlyer's attribution and in-app-event reporting includes the device's language/locale as a dimension used for reporting and, for some integrated partners, for postback enrichment. Apps that manage their own in-app localization independently of the OS locale (e.g. a language switcher that doesn't change `NSLocale`) need a way to tell AppsFlyer which language the user is actually seeing, rather than relying on the OS-reported value. `setCurrentDeviceLanguage` provides that override. Without it, AppsFlyer would only ever see the OS-level device language, which can diverge from the language actually presented to the user and skew language-based reporting/segmentation. --- ## Trigger -Called by the host app whenever it needs to explicitly declare (or correct) the language reported to AppsFlyer — typically during startup configuration or right after an in-app language change. +Awaited by the host app whenever it needs to explicitly declare (or correct) the language reported to AppsFlyer — typically during startup configuration or right after an in-app language change. --- ## Call Chain +An awaitable RPC call over the single `executeRpc` entry point. Only iOS implements the method, but Dart does not gate it; on any other platform the RPC is still dispatched and the native "unknown method" answer surfaces as `AppsFlyerException`. + ``` -AppsflyerSdk.setCurrentDeviceLanguage(language) [lib/src/appsflyer_sdk.dart:597] - → _methodChannel.invokeMethod("setCurrentDeviceLanguage", language) - → iOS: AppsflyerSdkPlugin handleMethodCall: case "setCurrentDeviceLanguage" → setCurrentDeviceLanguage:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:155] - → [AppsFlyerLib shared] setCurrentDeviceLanguage: language [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:395] +AppsFlyerSdk.setCurrentDeviceLanguage(language) [lib/src/appsflyer_sdk.dart] + → off iOS: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('setCurrentDeviceLanguage', {'language': language}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → AFRPCSimpleConfigHandler → sdk.currentDeviceLanguage = language + → PlatformException is converted to AppsFlyerException ``` -No `case "setCurrentDeviceLanguage"` exists in `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java`'s method-call switch — on Android the call falls through to the default branch and returns `MethodNotImplemented`. --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setCurrentDeviceLanguage(String)` — platform-agnostic Dart API surface (no `Platform.isIOS` guard) | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setCurrentDeviceLanguage:result:` native handler, forwards to `AppsFlyerLib.shared` | +| `lib/src/appsflyer_sdk.dart` | `setCurrentDeviceLanguage(String language)` — no Dart platform check; sends the `setCurrentDeviceLanguage` RPC with `{language}` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler — the generic `executeRpc` → `dispatchRpc` forwards the JSON envelope to the `AppsFlyerRPC` bridge | --- ## Input / Output | | | |--|--| -| **Input** | `language` (String) — an IETF/ISO language code (e.g. `"en"`) forwarded as-is; native performs no validation of the string's format. | -| **Output** | `void` — fire-and-forget; native always calls `result(nil)`. | +| **Input** | `language` (`String`) — an IETF/ISO language code (e.g. `"en"`) forwarded as-is under the `language` param key; no format validation in Dart. | +| **Output** | `Future` that completes after RPC validation and the synchronous native SDK property assignment. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or timeout. On a non-iOS platform the call is still dispatched and completes with `AppsFlyerException` once the native RPC layer reports the method as unavailable. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart`'s mock method-call handler does not include a `case 'setCurrentDeviceLanguage'`, and no `test(...)` block exercises `instance.setCurrentDeviceLanguage(...)`. +`test/appsflyer_sdk_test.dart` — `maps every iOS-only API` asserts that `setCurrentDeviceLanguage('en')` dispatches RPC `setCurrentDeviceLanguage` with `{'language': 'en'}`. `platform-only calls are forwarded to the native RPC instead of being swallowed in Dart` calls `setCurrentDeviceLanguage('en')` on Android and asserts that the `setCurrentDeviceLanguage` RPC is dispatched there too. --- ## Known Limitations -- **iOS-only**: no Android implementation exists. The Dart API has no `Platform.isIOS` guard, so calling it on Android fails with `MissingPluginException`/`FlutterMethodNotImplemented` at the native layer rather than a documented no-op — Android integrators must consult documentation to learn this method has no effect there. -- No dedicated automated test coverage for this method, unlike most other Dart API surface methods in this plugin. -- Native code does not validate the `language` string (e.g. against a locale code list), so malformed input is passed straight through to the underlying SDK. +- **iOS-only**: no Android implementation exists. Calling it on Android is not a no-op — the RPC is dispatched and the Android layer's "unknown method" answer surfaces to the calling code as `AppsFlyerException`, so the mismatch is visible to the caller rather than only in the device log. +- No validation of the `language` string (e.g. against a locale code list); malformed input is passed straight through to the underlying SDK. --- diff --git a/internal-docs/features/F-042-partner-postback-sharing-filter.md b/internal-docs/features/F-042-partner-postback-sharing-filter.md index f2330a49..449d0ad3 100644 --- a/internal-docs/features/F-042-partner-postback-sharing-filter.md +++ b/internal-docs/features/F-042-partner-postback-sharing-filter.md @@ -4,14 +4,12 @@ name: Partner Postback Sharing Filter type: platformIntegration platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -AppsFlyer forwards install/event data to integrated partner networks (ad networks, MMPs, analytics vendors) via server-to-server postbacks and API. Advertisers sometimes need to block that forwarding for specific partners or for all of them — to comply with GDPR/CCPA data-sharing restrictions, honor a user's opt-out choice, or enforce a business rule about which vendors may receive attribution data. `setSharingFilterForPartners` (and its deprecated predecessors `setSharingFilter`/`setSharingFilterForAllPartners`) is the only API surface for this; without it, the app would have no way to suppress third-party data sharing short of disabling the AppsFlyer SDK entirely via `stop()`, which would also break the advertiser's own attribution. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +AppsFlyer forwards install/event data to integrated partner networks (ad networks, MMPs, analytics vendors) via server-to-server postbacks and API. Advertisers sometimes need to block that forwarding for specific partners or for all of them — to comply with GDPR/CCPA data-sharing restrictions, honor a user's opt-out choice, or enforce a business rule about which vendors may receive attribution data. `setSharingFilterForPartners` is the API surface for this; without it, the app would have no way to suppress third-party data sharing short of disabling the AppsFlyer SDK entirely via `stop()`, which would also break the advertiser's own attribution. --- @@ -21,49 +19,49 @@ Called by the host app during startup configuration or in direct response to a u --- ## Call Chain +Awaitable RPC call over the single `executeRpc` entry point. (The legacy `setSharingFilter`/`setSharingFilterForAllPartners` Dart methods no longer exist — SDK 7 exposes only `setSharingFilterForPartners`.) + +Clearing the filter — passing `null` or an empty list — is expressed on the wire as `partners: null`. Dart normalizes an empty list to `null` before dispatch so `null` and `[]` are interchangeable for callers. The plugin forwards every call to the native RPC layer and does not short-circuit clear requests in Dart. + ``` -AppsflyerSdk.setSharingFilterForPartners(partners) [lib/src/appsflyer_sdk.dart:615] - → _methodChannel.invokeMethod("setSharingFilterForPartners", partners) - → Android: AppsflyerSdkPlugin.onMethodCall("setSharingFilterForPartners") → setSharingFilterForPartners(call, result) [android/.../AppsflyerSdkPlugin.java:349,555] - → AppsFlyerLib.getInstance().setSharingFilterForPartners(partners) (only if call.arguments != null) - → iOS: AppsflyerSdkPlugin handleMethodCall: case "setSharingFilterForPartners" → setSharingFilterForPartners:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:157,389] - → [AppsFlyerLib shared] setSharingFilterForPartners: partners - -AppsflyerSdk.setSharingFilter(partners) [DEPRECATED] [lib/src/appsflyer_sdk.dart:603] - → setSharingFilterForPartners(partners) (re-routed in Dart to the method above; native "setSharingFilter" channel handlers still exist but are unreachable from this Dart entry point) - -AppsflyerSdk.setSharingFilterForAllPartners() [DEPRECATED] [lib/src/appsflyer_sdk.dart:609] - → setSharingFilterForPartners(["all"]) (re-routed in Dart to the method above) +AppsFlyerSdk.setSharingFilterForPartners(List? partners) [lib/src/appsflyer_sdk.dart] + → empty list is normalized to null + → _invokeVoidRpc('setSharingFilterForPartners', {'partners': partners}) + → _invokeNullableRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: dispatchRpc → AppsFlyerRpcHandler.execute("setSharingFilterForPartners") → SDK setSharingFilterForPartners + → iOS: dispatchRpc → AppsFlyerRPCBridge executeJson("setSharingFilterForPartners") → SDK setSharingFilterForPartners: + → PlatformException is converted to AppsFlyerException ``` +On iOS, `null` clears the filter. On Android, the native SDK accepts a clear, but the current Android RPC request model rejects an empty partner list with validation error `422`; once that RPC-layer bug is fixed, the same Dart call will clear on Android without further plugin changes. + --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setSharingFilterForPartners(List)` (active); `setSharingFilter(List)` and `setSharingFilterForAllPartners()` (`@Deprecated`, both re-route to `setSharingFilterForPartners`) | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setSharingFilterForPartners` (active, dispatched via channel), plus dead `setSharingFilter`/`setSharingFilterForAllPartners` channel handlers no longer reachable from the current Dart API | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setSharingFilterForPartners:result:` (active, dispatched via channel), plus dead `setSharingFilter:result:`/`setSharingFilterForAllPartners:` channel handlers no longer reachable from the current Dart API | +| `lib/src/appsflyer_sdk.dart` | `Future setSharingFilterForPartners(List? partners)` — sends the `setSharingFilterForPartners` RPC with `{partners}` and normalizes an empty list to `null` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | No per-method handler — the generic `executeRpc` → `dispatchRpc` forwards to the native RPC bridge | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler — the generic `executeRpc` → `dispatchRpc` forwards to the native RPC bridge | --- ## Input / Output | | | |--|--| -| **Input** | `partners` (`List`) — partner ID strings (e.g. `'facebook_int'`, `'googleadwords_int'`), or the literal `'all'` to block every partner. Empty list or `null` resets to the default (no filtering). | -| **Output** | `void` — fire-and-forget; both native handlers always return success/`nil`. | +| **Input** | `partners` (`List?`) — partner ID strings (e.g. `'facebook_int'`, `'googleadwords_int'`), or the literal `'all'` to block every partner. `null` or an empty list clears the filter; both are sent as `null` under the `partners` param key. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or timeout. A clear request on Android currently throws from the RPC bridge until the native validation fix lands. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart`'s mock method-call handler includes `case 'setSharingFilterForAllPartners'` and `case 'setSharingFilter'` (but not `'setSharingFilterForPartners'`, the actual active channel method), and no `test(...)` block exercises any of `instance.setSharingFilter(...)`, `instance.setSharingFilterForAllPartners()`, or `instance.setSharingFilterForPartners(...)`. +`test/appsflyer_sdk_test.dart` → `'maps cross-platform configuration and identity APIs'` verifies that `setSharingFilterForPartners(['partner'])` dispatches RPC method `setSharingFilterForPartners` with `{'partners': ['partner']}`, and that both `null` and `[]` on iOS dispatch `{'partners': null}` — pinning the empty-to-null normalization. `'Android clear requests reach the native RPC layer'` verifies that both `null` and `[]` on Android dispatch `{'partners': null}` and surface the RPC bridge validation error as `AppsFlyerException`. --- ## Known Limitations -- The Android native handler for the legacy `setSharingFilter` channel method (`android/.../AppsflyerSdkPlugin.java:792`) calls `AppsFlyerLib.getInstance().setSharingFilter()` with **no arguments**, discarding whatever filter list was passed — this handler is dead code from the current Dart API (which no longer sends a `"setSharingFilter"` channel call), but it would silently misbehave if ever invoked directly via the channel. -- The Dart mock test harness registers channel-method cases for the deprecated `setSharingFilter`/`setSharingFilterForAllPartners` names rather than the actual active `setSharingFilterForPartners` channel call, so the test scaffolding does not match current production wiring and provides no real coverage for this feature. -- No validation in Dart or native code that partner ID strings are well-formed or recognized; typos silently fail to filter the intended partner. +- Dart and the RPC request models do not verify that individual partner IDs are recognized. The native SDK can ignore or filter unsupported values without returning a per-ID result, so a typo is not observable through the completed Future. +- Clearing the filter on Android is blocked today by `SetSharingFilterForPartnersRequest` enforcing `require(partners.isNotEmpty())`, so the bridge rejects the only payload that expresses a clear. The native Android SDK's own `SharingFilter` does accept an empty set, so this is an RPC-layer gap rather than an SDK limitation. The plugin forwards the clear request so the failure is visible as `AppsFlyerException` instead of being swallowed in Dart. --- diff --git a/internal-docs/features/F-043-out-of-store-install-source.md b/internal-docs/features/F-043-out-of-store-install-source.md index ab259544..b6c68514 100644 --- a/internal-docs/features/F-043-out-of-store-install-source.md +++ b/internal-docs/features/F-043-out-of-store-install-source.md @@ -4,15 +4,13 @@ name: Out-of-Store Install Source (Android) type: platformIntegration platform: android status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose Android apps aren't limited to Google Play distribution — they can be side-loaded or distributed via third-party app stores (Facebook, Samsung Galaxy Store, Amazon Appstore, direct APK, etc.). Play Install Referrer, which AppsFlyer normally uses to attribute installs, isn't available for these channels. `setOutOfStore`/`getOutOfStore` let the app declare (and later read back) a custom install-source label so AppsFlyer can still attribute and report on installs that didn't come through Google Play. Without it, installs from alternative distribution channels would show up unattributed or misattributed in AppsFlyer reporting. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger @@ -21,46 +19,48 @@ Android apps aren't limited to Google Play distribution — they can be side-loa --- ## Call Chain +Awaitable RPC calls over the single `executeRpc` entry point. Both methods are Android-only at the native RPC layer and route through `_invokeRpc` without a Dart guard. ``` -AppsflyerSdk.setOutOfStore(sourceName) [lib/src/appsflyer_sdk.dart:620] - → _methodChannel.invokeMethod("setOutOfStore", sourceName) - → Android: AppsflyerSdkPlugin.onMethodCall("setOutOfStore") → setOutOfStore(call, result) [android/.../AppsflyerSdkPlugin.java:355,530] - → AppsFlyerLib.getInstance().setOutOfStore(sourceName) (only if sourceName != null) - -AppsflyerSdk.getOutOfStore() [lib/src/appsflyer_sdk.dart:625] - → _methodChannel.invokeMethod("getOutOfStore") - → Android: AppsflyerSdkPlugin.onMethodCall("getOutOfStore") → getOutOfStore(result) [android/.../AppsflyerSdkPlugin.java:352,526] - → result.success(AppsFlyerLib.getInstance().getOutOfStore(this.mContext)) +AppsFlyerSdk.setOutOfStore(String sourceName) [lib/src/appsflyer_sdk.dart] + → iOS: native RPC reports method not found → AppsFlyerException + → _invokeVoidRpc('setOutOfStore', {'sourceName': sourceName}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: dispatchRpc → AppsFlyerRpcHandler.execute("setOutOfStore") → SDK setOutOfStore + +AppsFlyerSdk.getOutOfStore() [lib/src/appsflyer_sdk.dart] + → _invokeNullableRpc('getOutOfStore') + → Android: dispatchRpc → AppsFlyerRpcHandler.execute("getOutOfStore") → SDK getOutOfStore + → iOS: native RPC reports method not found → AppsFlyerException (404) + → PlatformException is converted to AppsFlyerException ``` -Neither `"setOutOfStore"` nor `"getOutOfStore"` has a case in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:` — on iOS both calls fall through to `result(FlutterMethodNotImplemented)`. --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setOutOfStore(String)`, `getOutOfStore()` — platform-agnostic Dart API surface (no `Platform.isAndroid` guard) | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setOutOfStore`, `getOutOfStore` native handlers | -| `doc/API.md` | Documents both methods as **"Android Only!"** with an explicit `if(Platform.isAndroid)` usage guard recommended in examples | +| `lib/src/appsflyer_sdk.dart` | `Future setOutOfStore(String sourceName)`, `Future getOutOfStore()` — Android-only at the native RPC layer, with no Dart platform check; send the `setOutOfStore`/`getOutOfStore` RPCs | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | No per-method handler — the generic `executeRpc` → `dispatchRpc` forwards to the native RPC bridge; `getOutOfStore`'s value is returned on the RPC reply | --- ## Input / Output | | | |--|--| -| **Input** | `setOutOfStore`: `sourceName` (String) — a custom install-source label (e.g. `"facebook_int"`); native no-ops if `null`. `getOutOfStore`: no input. | -| **Output** | `setOutOfStore`: `void`, fire-and-forget. `getOutOfStore`: `Future` resolving to the previously-set source label (or the native default if never set). | +| **Input** | `setOutOfStore`: non-empty `sourceName` (`String`) such as `"amazon"`, sent under the `sourceName` key. Android RPC rejects an empty string and the native SDK stores the value lowercased. `getOutOfStore`: no input. | +| **Output** | `setOutOfStore`: `Future` completing after RPC validation and synchronous SDK invocation, with no callback or timeout. `getOutOfStore`: `Future` resolving to the native stored value, or `null` when none exists. Bridge/validation failures surface as `AppsFlyerException`. Off Android, both throw `AppsFlyerException` when the native RPC layer reports the method as unavailable. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setOutOfStore call` (around line 290) asserts the mocked channel receives `'setOutOfStore'` with the string argument; `check getOutOfStore call` (around line 284) asserts the mocked channel receives `'getOutOfStore'`. Tests run in the Dart test harness only and cannot verify the native Android SDK read/write behavior. +`test/appsflyer_sdk_test.dart` → `'maps every Android-only API'` verifies that `setOutOfStore('amazon')` dispatches RPC method `setOutOfStore` with `{'sourceName': 'amazon'}`. `'maps getters and native return values'` verifies that `getOutOfStore()` dispatches `getOutOfStore` and returns the mocked native value. Off-platform behavior is covered too: `'platform-only calls are forwarded to the native RPC instead of being swallowed in Dart'` calls `setOutOfStore('source')` on iOS and asserts the RPC is dispatched there as well, and `'platform-only getters surface the native method-not-found error'` asserts `getOutOfStore()` throws `AppsFlyerException` with code `404` on iOS. The Dart harness cannot verify the native Android SDK read/write behavior. --- ## Known Limitations -- **Android-only**: no iOS implementation exists (out-of-store distribution/attribution is an Android-specific concern — iOS apps are Apple App Store only). The Dart API has no `Platform.isAndroid` guard, so calling either method from iOS results in `MissingPluginException`/`FlutterMethodNotImplemented`; `doc/API.md` documents the "Android Only!" restriction and recommends wrapping calls in `if(Platform.isAndroid)`, but this is not enforced in code. -- `setOutOfStore` silently no-ops if `sourceName` is `null` rather than surfacing an error, which can mask integration mistakes. +- **Android-only**: no iOS implementation exists (out-of-store distribution is an Android-specific concern). On another platform both methods throw `AppsFlyerException` once the native RPC layer reports the method as unavailable, so a cross-platform call site must branch on `Platform.isAndroid` or catch the exception. +- `getOutOfStore()` cannot distinguish "never set" from "native returned nothing" on Android — both surface as `null`. +- The Android SDK normalizes the stored source name to lowercase; `getOutOfStore()` can therefore return a different casing from the input. --- diff --git a/internal-docs/features/F-044-partner-specific-data.md b/internal-docs/features/F-044-partner-specific-data.md index 263ba57c..93b440fc 100644 --- a/internal-docs/features/F-044-partner-specific-data.md +++ b/internal-docs/features/F-044-partner-specific-data.md @@ -4,14 +4,12 @@ name: Partner-Specific Data type: platformIntegration platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Some AppsFlyer-integrated partner networks accept custom, partner-defined fields alongside standard attribution data (e.g. a partner's own user ID, campaign metadata, or other identifiers that only that partner's integration understands). `setPartnerData` lets the host app attach an arbitrary key/value payload to a named partner integration so it gets forwarded on postbacks to that specific partner. Without it, the app would have no way to enrich a specific partner's data beyond what the standard AppsFlyer event/attribution schema carries, limiting partner-side matching, deduplication, or reporting capabilities that depend on partner-specific fields. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Some AppsFlyer-integrated partner networks accept custom, partner-defined fields alongside standard attribution data (e.g. a partner's own user ID, campaign metadata, or other identifiers that only that partner's integration understands). `setPartnerData` lets the host app attach an arbitrary key/value payload to a named partner integration so it gets forwarded on postbacks to that specific partner. Without it, the app would have no way to enrich a specific partner's data beyond the standard AppsFlyer event/attribution schema, limiting partner-side matching, deduplication, or reporting that depends on partner-specific fields. --- @@ -21,13 +19,14 @@ Called by the host app whenever it needs to associate custom data with a named p --- ## Call Chain +Awaitable RPC call over the single `executeRpc` entry point. ``` -AppsflyerSdk.setPartnerData(partnerId, partnerData) [lib/src/appsflyer_sdk.dart:630] - → _methodChannel.invokeMethod("setPartnerData", {'partnerId': partnerId, 'partnersData': partnerData}) - → Android: AppsflyerSdkPlugin.onMethodCall("setPartnerData") → setPartnerData(call, result) [android/.../AppsflyerSdkPlugin.java:358,546] - → AppsFlyerLib.getInstance().setPartnerData(partnerId, partnerData) (only if partnerData != null) - → iOS: AppsflyerSdkPlugin handleMethodCall: case "setPartnerData" → setPartnerData:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:161,370] - → [AppsFlyerLib shared] setPartnerDataWithPartnerId:partnerId partnerInfo:partnersData +AppsFlyerSdk.setPartnerData(String partnerId, Map data) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setPartnerData', {'partnerId': partnerId, 'data': data}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: dispatchRpc → AppsFlyerRpcHandler.execute("setPartnerData") → SDK setPartnerData(partnerId, data) + → iOS: dispatchRpc → AppsFlyerRPCBridge executeJson("setPartnerData") → SDK setPartnerDataWithPartnerId:partnerInfo: + → PlatformException is converted to AppsFlyerException ``` --- @@ -35,29 +34,28 @@ AppsflyerSdk.setPartnerData(partnerId, partnerData) [lib/sr ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setPartnerData(String partnerId, Map partnerData)` — platform-agnostic Dart API surface | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setPartnerData` native handler | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setPartnerData:result:` native handler | +| `lib/src/appsflyer_sdk.dart` | `Future setPartnerData(String partnerId, Map data)` — sends the `setPartnerData` RPC with `{partnerId, data}` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | No per-method handler — the generic `executeRpc` → `dispatchRpc` forwards to the native RPC bridge | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler — the generic `executeRpc` → `dispatchRpc` forwards to the native RPC bridge | --- ## Input / Output | | | |--|--| -| **Input** | `partnerId` (String) — the AppsFlyer partner integration identifier. `partnerData` (`Map`) — arbitrary key/value payload; on Android the handler no-ops if this map is `null`, on iOS an `NSNull` value is normalized to `nil` before being forwarded. | -| **Output** | `void` — fire-and-forget; both native handlers always return success/`nil`. | +| **Input** | `partnerId` (`String`) — the AppsFlyer partner integration identifier; both native RPC parsers require it to be non-empty. `data` (`Map`, non-nullable) — arbitrary key/value payload, sent under the `data` param key. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or timeout. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setPartnerData call` (around line 320) asserts the mocked channel receives `'setPartnerData'` with the `{'partnerId': ..., 'partnersData': ...}` argument map. The Dart test harness cannot verify that native code actually forwards the data to `AppsFlyerLib`/`setPartnerDataWithPartnerId:partnerInfo:`, nor that a given partner integration consumes it correctly. +`test/appsflyer_sdk_test.dart` → `'maps cross-platform configuration and identity APIs'` verifies that `setPartnerData('partner', {'key': 'value'})` dispatches RPC method `setPartnerData` with `{'partnerId': 'partner', 'data': {'key': 'value'}}`. --- ## Known Limitations -- No validation that `partnerId` corresponds to an actual integrated/configured partner — an unrecognized ID silently has no effect (the data is simply never forwarded by that partner's integration). -- Android silently drops the call if `partnerData` is `null` rather than surfacing an error, which can mask integration mistakes; iOS instead normalizes `NSNull` to `nil` and still invokes the native SDK call. -- No schema/type validation on the contents of `partnerData` — arbitrary object values are passed through the channel as-is, so type mismatches would only surface as native-side runtime issues. +- No validation that `partnerId` corresponds to an actual integrated/configured partner — an unrecognized ID silently has no effect (the data is simply never forwarded by that partner's integration). The awaited `Future` still completes successfully in that case. +- No schema/type validation on the contents of `data` — arbitrary values are passed through the RPC as-is, so type mismatches surface only as native-side RPC errors. --- diff --git a/internal-docs/features/F-045-deep-link-url-resolution-allow-list.md b/internal-docs/features/F-045-deep-link-url-resolution-allow-list.md index 54af80a2..a77ac08c 100644 --- a/internal-docs/features/F-045-deep-link-url-resolution-allow-list.md +++ b/internal-docs/features/F-045-deep-link-url-resolution-allow-list.md @@ -4,33 +4,37 @@ name: Deep-Link URL Resolution Allow-list type: deepLinking platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose Advertisers sometimes wrap an AppsFlyer OneLink inside another Universal Link/App Link domain they control. Opening that wrapper link launches the app correctly, but by default the native SDK has no reason to treat the wrapper's own domain as something it should resolve for deep-link data — so the OneLink attribution/deep-link payload underneath never surfaces. `setResolveDeepLinkURLs` lets an app explicitly tell the SDK which additional URL/domains it should attempt to resolve as deep links, so wrapped OneLinks still deliver correct attribution and deep-link data to the app. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger -Called explicitly by the integrating Dart app, typically once at startup (independent of `initSdk`/SDK-start ordering — no code enforces call ordering relative to `initSdk`), whenever the app needs to configure which wrapped/custom domains the SDK should resolve as deep links. +Awaited explicitly by the integrating Dart app, typically once at startup, whenever the app needs to configure which wrapped/custom domains the SDK should resolve as deep links. Nothing in the Flutter layer enforces ordering relative to `init()` or `start()`, and the dartdoc states no ordering requirement. + +The sibling API for branded OneLink domains is `setOneLinkCustomDomain(List domains)`, which follows the same RPC shape with a `domains` parameter. --- ## Call Chain +This is a generic RPC call (no per-method channel handler): the Dart wrapper sends `{method: 'setResolveDeepLinkURLs', params: {urls: [...]}}` through the single `executeRpc` entry point, and each platform's native RPC bridge parses it into a typed request and forwards it to the SDK. + ``` -AppsflyerSdk.setResolveDeepLinkURLs(List urls) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setResolveDeepLinkURLs", urls) - → Android: onMethodCall(call, result) → case "setResolveDeepLinkURLs" → setResolveDeepLinkURLs(call, result) [android/.../AppsflyerSdkPlugin.java] - → urls = (ArrayList) call.arguments → urlsArr = urls.toArray(new String[0]) - → AppsFlyerLib.getInstance().setResolveDeepLinkURLs(urlsArr) - → result.success(null) - → iOS: handleMethodCall: → case "setResolveDeepLinkURLs" → setResolveDeepLinkURLs:call result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → urlsArr = call.arguments (NSArray) → if urlsArr != nil: [[AppsFlyerLib shared] setResolveDeepLinkURLs:urlsArr] - → result(nil) +AppsFlyerSdk.setResolveDeepLinkURLs(List urls) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setResolveDeepLinkURLs', {'urls': urls}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → SetResolveDeepLinkURLsRequest(urls) // init: require(urls.isNotEmpty()) + → AppsFlyerLib.getInstance().setResolveDeepLinkURLs(*urls.toTypedArray()) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → AFRPCSetResolveDeepLinkURLsRequest(urls) // guard: !urls.isEmpty else validationError + → sdk.resolveDeepLinkURLs = urls ([AppsFlyerLib shared]) + → successful reply completes Future + → PlatformException is converted to AppsFlyerException ``` --- @@ -38,32 +42,29 @@ AppsflyerSdk.setResolveDeepLinkURLs(List urls) ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setResolveDeepLinkURLs(List urls)` — thin passthrough invoking the `setResolveDeepLinkURLs` method channel call with the raw URL list | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `onMethodCall` dispatch `case "setResolveDeepLinkURLs"`; `setResolveDeepLinkURLs(MethodCall, Result)` — casts arguments to `ArrayList`, converts to `String[]`, calls `AppsFlyerLib.getInstance().setResolveDeepLinkURLs(urlsArr)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | Method-channel dispatch `case @"setResolveDeepLinkURLs"`; `setResolveDeepLinkURLs:result:` — passes `call.arguments` (an `NSArray`) directly to `[AppsFlyerLib shared] setResolveDeepLinkURLs:]`, guarded only by a nil check | -| `doc/API.md` | Documents the API (`setResolveDeepLinkURLs`) with the wrapped-OneLink rationale and a usage example; does not restrict it to a single platform | +| `lib/src/appsflyer_sdk.dart` | `setResolveDeepLinkURLs(List urls)` — awaitable passthrough that sends the generic RPC `setResolveDeepLinkURLs` with `{urls}`; performs no Dart-side validation | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic `executeRpc` dispatch — forwards the JSON envelope to the Android RPC handler | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Generic `executeRpc` dispatch — forwards the JSON envelope to the iOS RPC bridge | --- ## Input / Output | | | |--|--| -| **Input** | `List urls` — the domains/URLs (e.g. `"clickdomain.com"`) the SDK should attempt to resolve as deep links. | -| **Output** | None (`result.success(null)`/`result(nil)`) — this configures internal native SDK state; it does not itself deliver deep-link data. Once configured, subsequently opened URLs matching these domains become eligible for the same deep-link resolution flow that ordinarily feeds F-037 (UDL)/F-035/F-036 (legacy) callbacks. | +| **Input** | `urls` (`List`) — the domains/URLs (for example `"click.example.com"`) the SDK should attempt to resolve as deep links. The native bridges require a non-empty list. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK setter invocation. An empty list or bridge failure throws `AppsFlyerException`; there is no native completion callback or timeout. Configuring the allow-list does not itself deliver data; matching URLs become eligible for the UDL resolution flow in F-037. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `setResolveDeepLinkURLs`. +`test/appsflyer_sdk_test.dart` → `'maps deep-link, sharing, push, and uninstall APIs'` verifies that `setResolveDeepLinkURLs(['example.com'])` dispatches RPC method `setResolveDeepLinkURLs` with params `{'urls': ['example.com']}`. The same test covers the sibling `setOneLinkCustomDomain` mapping. `'PlatformException becomes AppsFlyerException'` covers the shared error conversion this method relies on. Native contract enforcement (empty-list rejection, SDK forwarding) is covered by the native SDKs' own bridge tests. --- ## Known Limitations -- **Both platforms implemented, contrary to some Android-only assumptions**: unlike several other Android-specific APIs in this plugin (e.g. `setOutOfStore`, explicitly documented "Android Only!" in `doc/API.md`), `setResolveDeepLinkURLs` has a real native implementation on both Android (`AppsFlyerLib.getInstance().setResolveDeepLinkURLs(String[])`) and iOS (`[AppsFlyerLib shared] setResolveDeepLinkURLs:]`) — `doc/API.md` does not flag any platform restriction for this call, and code confirms both platforms are wired. -- **Android**: casts `call.arguments` directly to `ArrayList` with no null/type check before calling `.toArray(...)` — passing `null` or a non-list argument from Dart would throw a `NullPointerException`/`ClassCastException` inside the plugin rather than failing gracefully. -- **iOS**: silently no-ops if `urlsArr` is `nil` (still calls `result(nil)` as if successful) — a caller passing an unexpected/null value gets no error signal that the call had no effect. -- No ordering guarantee relative to `initSdk`/`startSDK` is enforced or documented; whether URLs must be registered before the SDK starts resolving deep links (to catch a cold-start wrapped link) is not verified by code inspection alone. -- No automated test coverage exists on either the Dart bridge or native implementations for this feature. +- **Both platforms implemented**: `setResolveDeepLinkURLs` has a real native implementation on Android (`AppsFlyerLib.getInstance().setResolveDeepLinkURLs(String[])`) and iOS (`sdk.resolveDeepLinkURLs = [...]`), and both RPC bridges are wired. +- **Empty list fails at the native bridge, not in Dart**: both bridges reject an empty `urls` list. Because the method is awaitable, that rejection now reaches the caller as `AppsFlyerException` instead of being swallowed — but Dart still does not pre-validate, so the round trip happens before the error is known. +- No ordering guarantee relative to `init()`/`start()` is enforced. Whether URLs must be registered before the SDK starts resolving deep links (to catch a cold-start wrapped link) is not verified by code inspection alone. --- diff --git a/internal-docs/features/F-046-disable-network-data.md b/internal-docs/features/F-046-disable-network-data.md index c01c26f8..9e38639b 100644 --- a/internal-docs/features/F-046-disable-network-data.md +++ b/internal-docs/features/F-046-disable-network-data.md @@ -4,15 +4,13 @@ name: Disable Network Data Transfer type: sdkCore platform: android status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose Carrier/SIM operator name are device-level signals some privacy-conscious apps or regulatory regimes want excluded from what's sent to AppsFlyer, even while the rest of the SDK (attribution, events) stays fully active. `setDisableNetworkData` lets an Android app opt out of collecting the network operator name (carrier) and SIM operator name from the device, without having to disable the SDK (F-017) or anonymize the user (F-013) entirely. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger @@ -21,41 +19,44 @@ Called by the host app during startup configuration whenever the app needs to op --- ## Call Chain +The Dart method is Android-only but is not gated in Dart. On iOS the call is still dispatched and throws `AppsFlyerException`, because the iOS RPC layer does not implement the method. + ``` -AppsflyerSdk.setDisableNetworkData(disable) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("setDisableNetworkData", disable) - → Android: AppsflyerSdkPlugin.onMethodCall("setDisableNetworkData") → setDisableNetworkData(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().setDisableNetworkData(disable) +AppsFlyerSdk.setDisableNetworkData(isDisable) [lib/src/appsflyer_sdk.dart] + → off Android: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('setDisableNetworkData', {'isDisable': isDisable}) + → MethodChannel "af-api".invokeMethod('executeRpc', {method:'setDisableNetworkData', params:{isDisable}}) + → Android: AppsflyerSdkPlugin.executeRpc → dispatchRpc → AppsFlyerRpcHandler [android/.../AppsflyerSdkPlugin.kt] + → AppsFlyerLib.getInstance().setDisableNetworkData(disable) ``` -No iOS branch exists for `"setDisableNetworkData"` in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:` — the call falls through to `result(FlutterMethodNotImplemented)`. --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `setDisableNetworkData(bool)` — no `Platform.isAndroid` guard | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setDisableNetworkData(call, result)`, line 520 | -| `doc/API.md` | Documents the method as **"Android Only!"** and describes it as opting out of "collecting the network operator name (carrier) and sim operator name from the device" | +| `lib/src/appsflyer_sdk.dart` | `Future setDisableNetworkData(bool isDisable)` — dispatched through RPC without a Dart platform check | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | RPC bridge entry (`executeRpc`) routing `setDisableNetworkData` to `AppsFlyerRpcHandler` | +| `doc/api-reference.md` | Documents the method as **"Android Only!"** and describes it as opting out of "collecting the network operator name (carrier) and sim operator name from the device" | --- ## Input / Output | | | |--|--| -| **Input** | `disable` (bool) — `true` opts out of network/carrier data collection; `false` keeps default collection behavior. | -| **Output** | `void` — fire-and-forget; no confirmation returned to Dart. | +| **Input** | `isDisable` (bool) — `true` opts out of network/carrier data collection; `false` keeps default collection behavior. Sent under the `isDisable` param key. | +| **Output** | On Android, `Future` completes after RPC validation and synchronous SDK invocation, with no native completion callback or timeout. Validation or bridge failures throw `AppsFlyerException`. Off Android the call is still dispatched and throws `AppsFlyerException` once the native RPC layer reports the method as unavailable. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check setDisableNetworkData call` (line 314) asserts the mocked channel receives `'setDisableNetworkData'`. Test runs only through the Dart mock channel and cannot distinguish Android vs. iOS native behavior. +`test/appsflyer_sdk_test.dart` → `'maps every Android-only API'` verifies that `setDisableNetworkData(true)` dispatches RPC method `setDisableNetworkData` with `{'isDisable': true}`. No test exercises `setDisableNetworkData` off Android; the shared off-platform contract is covered generically by `'platform-only calls are forwarded to the native RPC instead of being swallowed in Dart'` and `'platform-only setters surface the native error'`, which use other Android-only setters. --- ## Known Limitations -- **Android-only**: no corresponding native implementation on iOS. Calling this from a Flutter app running on iOS results in a `MissingPluginException`/`FlutterMethodNotImplemented` at the native layer, since the Dart API has no platform guard. The official docs correctly flag it "Android Only!" with a usage example wrapped in `if (Platform.isAndroid)`, but nothing in the Dart API itself enforces or warns about this. -- The Dart method name (`setDisableNetworkData`) is broader-sounding than its actual, narrower scope (carrier/SIM operator name only, per `doc/API.md`) — an integrator relying on the method name alone could over-assume it disables all "network data" transfer generally. +- **Android-only**: there is no iOS equivalent in the native SDK, and the plugin no longer blocks the call in Dart — calling the Dart method on iOS dispatches the RPC and throws `AppsFlyerException` when the iOS RPC layer reports the method as unavailable. +- The Dart method name (`setDisableNetworkData`) is broader-sounding than its actual, narrower scope (carrier/SIM operator name only, per `doc/api-reference.md`) — an integrator relying on the method name alone could over-assume it disables all "network data" transfer generally. --- diff --git a/internal-docs/features/F-047-appset-id-collection-optout.md b/internal-docs/features/F-047-appset-id-collection-optout.md index a42a6a2b..cb4c6bcd 100644 --- a/internal-docs/features/F-047-appset-id-collection-optout.md +++ b/internal-docs/features/F-047-appset-id-collection-optout.md @@ -4,57 +4,59 @@ name: AppSet ID Collection Opt-out (Android) type: sdkCore platform: android status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose Starting with SDK v6.17.0, the Android SDK automatically collects the Google Play "AppSet ID" (a privacy-friendlier alternative to the Advertising ID for app-scoped or developer-scoped device identification). Some apps need to opt out of this automatic collection entirely for privacy-compliance reasons even though it isn't as sensitive as GAID. `disableAppSetId()` is the only way to turn that automatic collection off. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger -Called by the host app during startup configuration, on Android only, whenever it needs to opt out of automatic AppSet ID collection. +The host app awaits `AppsFlyerSdk.instance.disableAppSetId()` during startup configuration on Android, before `start()`, when it needs to opt out of automatic AppSet ID collection. --- ## Call Chain +`disableAppSetId` is an awaitable Android-only RPC call. AppSet ID is a Google Play Services concept, so only Android implements it; the Dart method itself is not platform-gated, so on any other platform the RPC is still dispatched and comes back as `AppsFlyerException`. + ``` -AppsflyerSdk.disableAppSetId() [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("disableAppSetId") - → Android: AppsflyerSdkPlugin.onMethodCall("disableAppSetId") → disableAppSetId(call, result) [android/.../AppsflyerSdkPlugin.java] - → AppsFlyerLib.getInstance().disableAppSetId() +AppsFlyerSdk.disableAppSetId() [lib/src/appsflyer_sdk.dart] + → off Android: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('disableAppSetId') + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params: {}}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.getInstance().disableAppSetId() + → PlatformException is converted to AppsFlyerException ``` -No iOS branch exists for `"disableAppSetId"` in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:` — the call falls through to `result(FlutterMethodNotImplemented)`. This is expected: AppSet ID is a Google Play Services / Android-only concept. --- ## Files | File | Role | |------|------| -| `lib/src/appsflyer_sdk.dart` | `disableAppSetId()` — no-argument, no `Platform.isAndroid` guard | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `disableAppSetId(call, result)`, line 1230 | -| `doc/API.md` | Documents the method as **"Android Only!"**, "Disables AppSet ID collection. Starting with v6.17.0, the SDK can automatically collect the AppSet ID." | +| `lib/src/appsflyer_sdk.dart` | `disableAppSetId()` — no-argument, awaitable, dispatched through RPC without a Dart platform check | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Forwards `disableAppSetId` through the Android RPC handler | +| `doc/api-reference.md` | Documents the method as Android-only, "Disables AppSet ID collection." | --- ## Input / Output | | | |--|--| -| **Input** | None | -| **Output** | `void` — fire-and-forget; no confirmation returned to Dart. | +| **Input** | None. The RPC is dispatched with an empty `params` map. | +| **Output** | On Android, `Future` completes after RPC validation and synchronous SDK invocation, with no native completion callback or timeout. Validation or bridge failures throw `AppsFlyerException`. Called off Android it dispatches the RPC anyway and throws `AppsFlyerException` once the native layer reports the method as unavailable. | --- ## Tests -`test/appsflyer_sdk_test.dart` — `check disableAppSetId call` (line 362) asserts the mocked channel receives `'disableAppSetId'`. Test runs only through the Dart mock channel and cannot distinguish Android vs. iOS/no-op native behavior. +`test/appsflyer_sdk_test.dart` verifies in the Android-only RPC mapping test that `disableAppSetId` dispatches RPC method `disableAppSetId` with empty params. `'platform-only calls are forwarded to the native RPC instead of being swallowed in Dart'` calls `disableAppSetId()` on iOS and asserts that the `disableAppSetId` RPC is still dispatched rather than short-circuited in Dart. --- ## Known Limitations -- **Android-only** (by design — AppSet ID is a Google Play Services concept with no iOS equivalent). Calling this from a Flutter app running on iOS results in a `MissingPluginException`/`FlutterMethodNotImplemented`, since the Dart API has no platform guard. Official docs correctly flag it "Android Only!" with a usage example wrapped in `if (Platform.isAndroid)`. +- **Android-only** by design — AppSet ID is a Google Play Services concept with no iOS equivalent. Calling the method on iOS is not a no-op: the RPC is dispatched and the iOS layer's "unknown method" answer surfaces as `AppsFlyerException`, so shared startup code must branch on `Platform.isAndroid` or catch it. - There is no way to re-enable AppSet ID collection once disabled within the same process — the call is one-directional (opt-out only), matching the native SDK's own API shape. - No getter to confirm whether AppSet ID collection is currently disabled. diff --git a/internal-docs/features/F-048-plugin-metadata-reporting.md b/internal-docs/features/F-048-plugin-metadata-reporting.md index 65cfcf93..9d85b2b4 100644 --- a/internal-docs/features/F-048-plugin-metadata-reporting.md +++ b/internal-docs/features/F-048-plugin-metadata-reporting.md @@ -4,74 +4,81 @@ name: Plugin Metadata Reporting to Native SDK type: sdkCore platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: ["F-001"] --- ## Business Purpose -AppsFlyer maintains multiple wrapper SDKs on top of its native Android/iOS SDKs (Flutter, React Native, Cordova, Unity, etc.). `setPluginInfo`/`setPluginInfoWith:` tells the native SDK "this install is running through the Flutter plugin, version X" so AppsFlyer's backend, support tooling, and internal dashboards can attribute traffic/bugs to the correct wrapper and version rather than treating every install as a bare native integration. This has no effect on attribution logic or app behavior — it is purely an internal identification tag with no host-app-facing API. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +AppsFlyer maintains several wrapper SDKs on top of its native Android and iOS SDKs (Flutter, React Native, Cordova, Unity). The `setPluginInfo` RPC tells the native SDK that this install runs through the Flutter plugin at a specific version, so AppsFlyer's backend, support tooling, and internal dashboards can attribute traffic and bugs to the correct wrapper and version instead of treating every install as a bare native integration. It does not affect attribution logic or app behavior — it is an internal identification tag with no host-app-facing API. --- ## Trigger -Runs automatically and unconditionally on every SDK initialization (`initSdk`/`initSdkWithCall:`), on both platforms. There is no Dart API, option, or flag that controls or disables it — it always fires as a side effect of `AppsflyerSdk.initSdk()`. +Runs automatically as the first step of the native `init` orchestration on both platforms, so it is reported once per `AppsFlyerSdk.init` call. No public Dart parameter, option, or flag controls or disables it. --- ## Call Chain +`setPluginInfo` is dispatched natively, ahead of native initialization, so the plugin name and version reach the first session payload. It is not part of the `init` outcome on either platform. + ``` -AppsflyerSdk.initSdk(...) [lib/src/appsflyer_sdk.dart] - → _methodChannel.invokeMethod("initSdk", validatedOptions) - → Android: AppsflyerSdkPlugin.onMethodCall("initSdk") → initSdk(call, result) [android/.../AppsflyerSdkPlugin.java] - → new PluginInfo(Plugin.FLUTTER, AppsFlyerConstants.PLUGIN_VERSION) (line 1095) - → AppsFlyerLib.getInstance().setPluginInfo(pluginInfo) (line 1096) - → AppsFlyerLib.getInstance().init(afDevKey, gcdListener, mContext) (called right after) - → iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") → initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → [[AppsFlyerLib shared] setPluginInfoWith:AFSDKPluginFlutter - pluginVersion:kAppsFlyerPluginVersion - additionalParams:nil] (line 857) - → [[AppsFlyerLib shared] start] (unless manualStart) +AppsFlyerSdk.init(devKey: ..., appId: ...) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('init', {devKey, appId?}) + → MethodChannel('af-api').invokeMethod('executeRpc', {method: 'init', params}) + → Android: AppsflyerSdkPlugin.initFromRpc [android/.../AppsflyerSdkPlugin.kt] + → executeRpcSync('setPluginInfo', {plugin: "flutter", pluginVersion: PLUGIN_VERSION}) + → executeRpcSync('init', {devKey}) + → iOS: AppsflyerSdkPlugin initFromRpc:result: [ios/.../AppsflyerSdkPlugin.swift] + → setPluginInfo {plugin: "flutter", pluginVersion: kAppsFlyerPluginVersion} + → then an ordered sequence in its completion: + → initialize {devKey, appId} + → the setPluginInfo outcome is ignored on both platforms; only an initialize failure + surfaces to Dart as an AppsFlyerException ``` +Both platforms send `flutter` as the plugin name. The Android RPC resolver also accepts legacy `android_flutter` by stripping the `android_` prefix, but this plugin reports the short form on both sides. + --- ## Files | File | Role | |------|------| -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `initSdk(call, result)` — builds `PluginInfo(Plugin.FLUTTER, AppsFlyerConstants.PLUGIN_VERSION)` and calls `setPluginInfo` (lines 1095–1096), immediately before `instance.init(...)` | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | `PLUGIN_VERSION = "6.18.0"` — the version string reported to the native SDK | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` — calls `setPluginInfoWith:AFSDKPluginFlutter pluginVersion:kAppsFlyerPluginVersion additionalParams:nil` (line 857) | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define kAppsFlyerPluginVersion @"6.18.0"` — the version string reported on iOS | +| `lib/src/appsflyer_sdk.dart` | `init()` — the Dart entry point whose native orchestration reports the metadata; `String get pluginVersion` exposes the Dart-side constant to the host app | +| `lib/src/appsflyer_constants.dart` | `PLUGIN_VERSION = "7.0.1"` returned by `pluginVersion` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | `initFromRpc(...)` — dispatches the `setPluginInfo` RPC with `{plugin: AF_PLUGIN_NAME, pluginVersion: PLUGIN_VERSION}` before the `init` RPC, ignoring its result | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerConstants.kt` | `AF_PLUGIN_NAME = "flutter"`, `PLUGIN_VERSION`, `RPC_METHOD_SET_PLUGIN_INFO` — the values reported to the native SDK | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Dispatches the `setPluginInfo` RPC to the RPC bridge and runs the ordered init sequence from its completion, regardless of the outcome | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` (constant) | `kAppsFlyerPluginVersion` — the version reported on iOS; a file-private Swift constant since the migration to Swift removed the public header that previously `#define`d it | -`Plugin`, `PluginInfo` (Android, package `com.appsflyer.internal.platform_extension`) and `AFSDKPluginFlutter` (iOS, an enum/constant defined inside the native `AppsFlyerLib` framework) are external types supplied by the native AppsFlyer SDK dependency, not defined in this repo. +The plugin does not construct the native `PluginInfo`/`AFSDKPluginFlutter` types directly. It passes `plugin` and `pluginVersion` as RPC params, and the `AppsFlyerRpcHandler`/`AppsFlyerRPCBridge` maps them to the native SDK's `setPluginInfo` call. --- ## Input / Output | | | |--|--| -| **Input** | None from the host app — no Dart parameter exists. The reported values (`Plugin.FLUTTER` / `AFSDKPluginFlutter`, and the hardcoded native `PLUGIN_VERSION` constant) are fixed by the plugin's native-layer source code. | -| **Output** | `void` — fire-and-forget call into the native SDK; nothing is returned to Dart. The metadata is transmitted internally by the native SDK to AppsFlyer's backend as part of its own request payloads. | +| **Input** | None from the host app — no Dart parameter exists. The reported values (`plugin: "flutter"` on both platforms, plus the native `PLUGIN_VERSION`/`kAppsFlyerPluginVersion` constant) are fixed in the plugin's source. | +| **Output** | Nothing is returned to Dart for this step. A `setPluginInfo` failure would only cost the integration label in reporting, so both platforms ignore its outcome and continue initializing; `init()` still succeeds. The metadata itself is transmitted by the native SDK as part of its own request payloads. | --- ## Tests -No dedicated test found. The call is not exposed as a distinct Dart method (it is embedded inside native `initSdk`/`initSdkWithCall:` handlers), so it cannot be observed or asserted from `test/appsflyer_sdk_test.dart`'s mocked `MethodChannel`, which only sees the single `"initSdk"` method invocation and its arguments map — `setPluginInfo`/`setPluginInfoWith:` happen entirely on the native side afterward. +No dedicated test found. The RPC is dispatched inside the native init orchestration and is not a distinct Dart method, so it cannot be observed through `test/appsflyer_sdk_test.dart`'s mocked `af-api` channel: the init tests (`init sends the iOS initialization parameters`, `init does not send appId to Android`, `init allows Android without appId`) only see the single `executeRpc` invocation for `init`. --- ## Known Limitations -- The reported plugin version is duplicated independently in three places and has drifted: Dart's `AppsflyerConstants.PLUGIN_VERSION` (`lib/src/appsflyer_constants.dart`) is `"6.17.9"`, while Android's `AppsFlyerConstants.PLUGIN_VERSION` and iOS's `kAppsFlyerPluginVersion` are both `"6.18.0"` (matching `pubspec.yaml`). Since this feature only ever reads the **native**-side constants, the value AppsFlyer's backend actually receives is `6.18.0`, not the value `AppsflyerSdk.getVersionNumber()` (F-003) returns to the host app — there is no single source of truth tying the three together. -- No public Dart API exists to inspect, override, or disable the reported plugin metadata; it is entirely internal and always fires on init with no error handling or confirmation callback. -- On Android, `setPluginInfo` is called before `instance.init(...)`; if `init` throws or the SDK is torn down and re-initialized, there is no guard against calling `setPluginInfo` more than once with a stale instance. +- The reported plugin version is a per-platform constant, currently aligned at `"7.0.1"` (Dart `_AppsFlyerConstants.PLUGIN_VERSION`, Android `PLUGIN_VERSION` in `AppsFlyerConstants.kt`, iOS `kAppsFlyerPluginVersion`). There is no single source of truth tying the three to each other or to `pubspec.yaml`; the `rc-release.yml` and `promote-release.yml` workflows rewrite all three during a release, so drift is only possible if a version is edited by hand. +- A `setPluginInfo` failure is not expected with the current fixed `"flutter"` value, which maps to `Plugin.FLUTTER`, but neither platform inspects the result. A future mapping or serialization regression would therefore be silent and would remove only the integration metadata, not fail `init()`. +- The Dart `pluginVersion` getter reads the Dart constant only. It reports what Dart believes the version is, not what the native side actually sent, so a drifted native constant would go unnoticed. +- No public Dart API exists to inspect, override, or disable the reported metadata; it always fires as part of init. +- The RPC is dispatched on every `init` call with no guard against reporting the metadata more than once if the app re-initializes. --- ## Dependencies ```mermaid flowchart LR - F048["F-048 · Plugin Metadata Reporting to Native SDK"]:::sdkCore -->|"runs inside the same native call as"| F001["F-001 · SDK Initialization & Options Validation"]:::sdkCore + F048["F-048 · Plugin Metadata Reporting to Native SDK"]:::sdkCore -->|"runs inside the same native call as"| F001["F-001 · SDK Initialization"]:::sdkCore classDef sdkCore fill:#4C6EF5,color:#fff ``` diff --git a/internal-docs/features/F-049-purchase-connector-configuration-lifecycle.md b/internal-docs/features/F-049-purchase-connector-configuration-lifecycle.md index 83d0c00f..22d2617c 100644 --- a/internal-docs/features/F-049-purchase-connector-configuration-lifecycle.md +++ b/internal-docs/features/F-049-purchase-connector-configuration-lifecycle.md @@ -4,20 +4,18 @@ name: Purchase Connector: Configuration & Lifecycle type: purchaseValidation platform: both status: active -last_verified: 2026-07-15 -depends_on: ["F-054", "F-051", "F-052"] +last_verified: 2026-08-10 +depends_on: ["F-054"] --- ## Business Purpose Apps that sell subscriptions or in-app purchases need AppsFlyer to automatically detect and validate those transactions server-side (ROI360 revenue measurement) instead of the app manually calling `logEvent` for every purchase. This feature is the on/off switch and settings panel for that automation: it creates the native `PurchaseConnector`/`PurchaseClient` singleton with the app's chosen options (log subscriptions, log in-apps, sandbox mode, StoreKit version on iOS) and then starts or stops the listener that watches the Play Billing Library / StoreKit transaction stream. Without it, no automatic purchase/subscription revenue would ever reach AppsFlyer — the app would be limited to manual event logging, losing ROI360 in-app revenue measurement entirely. It is also the foundational dependency for every other Purchase Connector capability (validation-result listeners, iOS combined callback, StoreKit version selection) — none of them can do anything until this configuration/lifecycle step has run. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger - **Configure**: runs once, the first time the app calls `PurchaseConnector(config: PurchaseConnectorConfiguration(...))` in Dart (factory constructor of `_PurchaseConnectorImpl`). -- **Start/Stop observing**: runs whenever the app explicitly calls `afPurchaseClient.startObservingTransactions()` / `.stopObservingTransactions()` — typically right after `AppsflyerSdk.startSDK()` (per `doc/PurchaseConnector.md`), and `stopObservingTransactions()` right before the core SDK's `stop()` if the user opts out of tracking. +- **Start/Stop observing**: runs whenever the app explicitly calls `afPurchaseClient.startObservingTransactions()` / `.stopObservingTransactions()` — typically right after `AppsFlyerSdk.instance.start()` (per [`doc/purchase-connector.md`](/doc/purchase-connector.md)), and `stopObservingTransactions()` right before the core SDK's `stop()` if the user opts out of tracking. --- @@ -41,6 +39,11 @@ afPurchaseClient.stopObservingTransactions() → _methodChannel.invokeMethod("stopObservingTransactions") → Android: connectorWrapper.stopObservingTransactions() → iOS: connector.stopObservingTransactions() + +Flutter engine detach (no Dart call involved) + → Android: AppsflyerSdkPlugin.onDetachedFromEngine → AppsFlyerPurchaseConnector.onDetachedFromEngine(binding) → EngineAttachment.dispose() + → iOS: AppsflyerSdkPlugin.detachFromEngineForRegistrar: → PurchaseConnectorPlugin.tearDownForEngineDetach(registrar:) [skipped unless that registrar still owns the channel] + → connector.stopObservingTransactions(); purchaseRevenueDelegate = nil; connector = nil; method channel handler cleared ``` --- @@ -52,9 +55,9 @@ afPurchaseClient.stopObservingTransactions() | `lib/src/purchase_connector/purchase_connector_configuration.dart` | `PurchaseConnectorConfiguration` — `logSubscriptions`, `logInApps`, `sandbox`, `storeKitVersion` | | `lib/src/purchase_connector/store_kit_version.dart` | `StoreKitVersion` enum (SK1=0, SK2=1) with `value`/`fromValue` int mapping sent over the channel | | `lib/src/appsflyer_constants.dart` | Channel name (`af-purchase-connector`) and argument key string constants | -| `android/src/main/include-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt` | Android native method-channel handler: `configure`, `startObservingTransactions`, `stopObservingTransactions` | +| `android/src/main/include-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt` | Android native method-channel handler: `configure`, `startObservingTransactions`, `stopObservingTransactions`; engine state keyed per `FlutterPluginBinding` for multi-engine add-to-app | | `android/src/main/include-connector/com/appsflyer/appsflyersdk/ConnectorWrapper.kt` | Wraps `PurchaseClient.Builder` (Play Billing) and the two validation listeners | -| `ios/PurchaseConnector/PurchaseConnectorPlugin.swift` | iOS native method-channel handler: `configure`, `startObservingTransactions`, `stopObservingTransactions`; owns the `PurchaseConnector.shared()` singleton | +| `ios/PurchaseConnector/PurchaseConnectorPlugin.swift` | iOS native method-channel handler: `configure`, `startObservingTransactions`, `stopObservingTransactions`; owns the `PurchaseConnector.shared()` singleton and releases it in `tearDownForEngineDetach(registrar:)` when its own engine detaches | --- @@ -62,7 +65,7 @@ afPurchaseClient.stopObservingTransactions() | | | |--|--| | **Input** | `configure`: `logSubscriptionPurchase` (bool), `logInAppPurchase` (bool), `sandbox` (bool), `storeKitVersion` (int, iOS only: 0=SK1, 1=SK2). `startObservingTransactions`/`stopObservingTransactions`: no arguments. | -| **Output** | `configure` returns `void`/`nil` on success; native returns a `FlutterError`/`MethodChannel.Result.error` with code `"401"` if already configured. `startObservingTransactions`/`stopObservingTransactions` return `void`/`nil` on success, or error code `"404"` ("Connector not configured, did you called `configure` first?") if called before `configure`. | +| **Output** | The Dart factory returns the singleton immediately; its native `configure` channel Future is not awaited. `startObservingTransactions()` and `stopObservingTransactions()` also return Dart `void` and do not await their channel Futures. Native success values are therefore not observable at the public Dart call site, and native `"401"` (already configured) / `"404"` (not configured) errors are not exposed as typed return values. | --- @@ -75,8 +78,9 @@ No dedicated test found. `test/appsflyer_sdk_test.dart` contains no references t - Re-configuration is silently ignored, not rejected: on the Dart side, calling the `PurchaseConnector(config: ...)` factory again after the singleton already exists just logs `AppsflyerConstants.RE_CONFIGURE_ERROR_MSG` via `debugPrint` and returns the existing instance — the new config is dropped with no exception, which can mask an app bug where a second call believed it changed sandbox/logging settings. On the native side (Android/iOS) a second raw `configure` MethodChannel call does return an explicit `"401"` error, so Dart and native disagree on how loudly a re-configure attempt is reported. - `startObservingTransactions`/`stopObservingTransactions` on the Dart side are fire-and-forget (`_methodChannel.invokeMethod(...)` result is not awaited or checked) — if native returns the `"404"` "not configured" error, the Dart caller never sees it. - iOS StoreKit 2 selection silently falls back to StoreKit 1 on iOS < 15.0 (`PurchaseConnectorPlugin.configure`), with only a `print` statement — an app targeting iOS 15+ that assumed SK2 semantics on an older OS gets SK1 behavior with no error surfaced to Dart. -- `doc/PurchaseConnector.md` documents "call `startObservingTransactions` right after `AppsflyerSdk.startSDK()`" and "call `stopObservingTransactions` right before the core SDK's `stop()`" as best practice, but nothing in code enforces or checks core-SDK start state — the ordering is a documentation convention only, not a code dependency (see F-003/sdkCore init — no genuine code coupling found). +- `doc/purchase-connector.md` documents calling `startObservingTransactions` right after core [`start`](/doc/getting-started.md#start) and `stopObservingTransactions` right before `stop()` as best practice, but nothing in code enforces or checks core-SDK start state — the ordering is a documentation convention only, not a code dependency. - Entire feature is a no-op unless the app opted in at build time (see F-054); nothing in the Dart-only view (this file's code) tells the caller whether the native side is even present. +- iOS keeps one connector and one channel per process, so with several Flutter engines the last one to register owns both and earlier engines stop receiving validation callbacks — Android instead keys them per `FlutterPluginBinding`. Engine detach is ownership-checked on iOS, so a detaching engine no longer stops observation for a live one, but per-engine connectors remain Android-only. --- @@ -85,10 +89,6 @@ No dedicated test found. `test/appsflyer_sdk_test.dart` contains no references t flowchart LR F049["F-049 · Purchase Connector: Configuration & Lifecycle"]:::purchaseValidation F054["F-054 · Purchase Connector: Build-Time Opt-in"]:::purchaseValidation - F051["F-051 · Purchase Connector: Android Validation Result Listeners"]:::purchaseValidation - F052["F-052 · Purchase Connector: iOS Combined Validation Callback"]:::purchaseValidation F049 -->|"only compiles/registers when enabled by"| F054 - F049 -->|"Android: requires listener object from"| F051 - F049 -->|"iOS: requires delegate from"| F052 classDef purchaseValidation fill:#F59F00,color:#fff ``` diff --git a/internal-docs/features/F-050-purchase-connector-storekit-version-selection.md b/internal-docs/features/F-050-purchase-connector-storekit-version-selection.md index 0c8596d9..9cf27890 100644 --- a/internal-docs/features/F-050-purchase-connector-storekit-version-selection.md +++ b/internal-docs/features/F-050-purchase-connector-storekit-version-selection.md @@ -4,15 +4,13 @@ name: "Purchase Connector: StoreKit Version Selection (iOS)" type: purchaseValidation platform: ios status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: ["F-049"] --- ## Business Purpose StoreKit 2 (iOS 15+) gives Apple's transaction-observation APIs better reliability and richer transaction data than the legacy StoreKit 1 API, but StoreKit 1 remains the only option on pre-iOS-15 devices. `storeKitVersion` on `PurchaseConnectorConfiguration` lets the app pick which StoreKit generation the native `PurchaseConnector` iOS SDK uses to auto-detect and validate purchases (feeding F-049's `startObservingTransactions`). Without this switch, the app would be stuck on whatever single default the native SDK picks, unable to opt into StoreKit 2's improvements on supported OS versions or to deliberately stay on StoreKit 1 for compatibility/testing reasons. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger diff --git a/internal-docs/features/F-051-purchase-connector-android-validation-result-listeners.md b/internal-docs/features/F-051-purchase-connector-android-validation-result-listeners.md index eb054539..e53e2cf1 100644 --- a/internal-docs/features/F-051-purchase-connector-android-validation-result-listeners.md +++ b/internal-docs/features/F-051-purchase-connector-android-validation-result-listeners.md @@ -4,15 +4,13 @@ name: "Purchase Connector: Android Validation Result Listeners" type: purchaseValidation platform: android status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: ["F-049"] --- ## Business Purpose On Android, F-049's `startObservingTransactions()` makes the native purchase-connector library automatically send every subscription (ARS) and in-app purchase (VIAP) transaction to AppsFlyer's server for validation, but that validation happens out-of-band from the app's own code. `setSubscriptionValidationResultListener` and `setInAppValidationResultListener` are how the app finds out the outcome of that server round trip — a typed success/failure result per purchase — so it can, for example, gate premium content on a confirmed-valid purchase or log a diagnostic when validation fails. Without these listeners the app would have automatic revenue attribution but zero visibility into whether any individual purchase was actually validated. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger @@ -73,10 +71,10 @@ No dedicated test found. Grepping `test/` for `PurchaseConnector`, `Subscription --- ## Known Limitations -- **Verified method-name mismatch that breaks delivery today**: `lib/src/appsflyer_constants.dart` defines the response/failure method names with a `#` separator (`"SubscriptionPurchaseValidationResultListener#onResponse"`, `"InAppValidationResultListener#onFailure"`, etc.), but `AppsFlyerPurchaseConnector.kt` actually invokes the method channel with a `:` separator (`"SubscriptionPurchaseValidationResultListener:onResponse"`, `"InAppValidationResultListener:onFailure"`). Since `MethodCall.method` on the Dart side is whatever string native sent, `_methodCallHandler`'s `switch` never matches these cases and falls through to `default: throw ArgumentError("Method not found: ...")`. As currently written, these two listeners cannot receive any event from Android — this is a functional break, not a hypothetical risk. +- **Separator contract (fixed as CR-075)**: the Dart method-name constants in `lib/src/appsflyer_constants.dart` and the strings `AppsFlyerPurchaseConnector.kt` invokes over the channel now both use a `:` separator (`"SubscriptionPurchaseValidationResultListener:onResponse"`/`":onFailure"`, `"InAppValidationResultListener:onResponse"`/`":onFailure"`), so `_methodCallHandler`'s `switch` matches and delivery works. A prior `#` separator on the Dart side silently broke delivery (Dart matched nothing Kotlin sent); this was corrected under CR-075. Both sides must be kept in lock-step — changing the separator on only one side would re-break delivery. The Dart `default` case now logs via `debugPrint` instead of throwing, so a future mismatch fails silently rather than crashing. - No listener exists on iOS for these two method names — they are only ever invoked from Android's `AppsFlyerPurchaseConnector.kt`. Calling either setter on iOS compiles and stores the handler but it will never fire (see F-052 for the iOS equivalent). - `JVMThrowable` models a JVM stack trace as a single joined string plus a recursively nested `cause` — a concept meaningless outside this Android validation-result path. -- Android's Purchase Connector source (`AppsFlyerPurchaseConnector.kt`, `ConnectorWrapper.kt`) only compiles when `appsflyer.enable_purchase_connector=true` in `gradle.properties` (Gradle selects the `include-connector` vs `exlude-connector` source set). If not opted in, the `exlude-connector` stub `AppsFlyerPurchaseConnector` object has no method-channel handler at all, and these listeners never receive anything even though the Dart setters succeed silently (see F-054). +- Android's Purchase Connector source (`AppsFlyerPurchaseConnector.kt`, `ConnectorWrapper.kt`) only compiles when `appsflyer.enable_purchase_connector=true` in `gradle.properties` (Gradle selects the `include-connector` vs `exclude-connector` source set). If not opted in, the `exclude-connector` stub `AppsFlyerPurchaseConnector` object has no method-channel handler at all, and these listeners never receive anything even though the Dart setters succeed silently (see F-054). --- @@ -84,6 +82,5 @@ No dedicated test found. Grepping `test/` for `PurchaseConnector`, `Subscription ```mermaid flowchart LR F051["F-051 · Purchase Connector: Android Validation Result Listeners"]:::purchaseValidation -->|"requires configuration from"| F049["F-049 · Purchase Connector: Configuration & Lifecycle"]:::purchaseValidation - F053["F-053 · Purchase Connector: Google Play Data Models"]:::purchaseValidation -->|"payload shape for"| F051 classDef purchaseValidation fill:#F59F00,color:#fff ``` diff --git a/internal-docs/features/F-052-purchase-connector-ios-combined-validation-callback.md b/internal-docs/features/F-052-purchase-connector-ios-combined-validation-callback.md index 96a54d17..8eb8a39a 100644 --- a/internal-docs/features/F-052-purchase-connector-ios-combined-validation-callback.md +++ b/internal-docs/features/F-052-purchase-connector-ios-combined-validation-callback.md @@ -4,15 +4,13 @@ name: "Purchase Connector: iOS Combined Validation Callback" type: purchaseValidation platform: ios status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: ["F-049"] --- ## Business Purpose On iOS, once F-049's `startObservingTransactions()` is active, the native `PurchaseConnector` SDK automatically sends every StoreKit transaction (subscription or in-app purchase) to AppsFlyer's server for revenue validation. `setDidReceivePurchaseRevenueValidationInfo` is the app's only window into that outcome on iOS — a single combined callback carrying the raw validation info and/or an error. Without it, revenue would still be attributed automatically, but the app would have no way to confirm a given purchase was validated (e.g. to gate premium content unlock, or to log/alert on validation failures). -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger diff --git a/internal-docs/features/F-053-purchase-connector-google-play-data-models.md b/internal-docs/features/F-053-purchase-connector-google-play-data-models.md index 6769e88c..41cef24c 100644 --- a/internal-docs/features/F-053-purchase-connector-google-play-data-models.md +++ b/internal-docs/features/F-053-purchase-connector-google-play-data-models.md @@ -4,15 +4,13 @@ name: "Purchase Connector: Google Play Purchase/Subscription Data Models" type: purchaseValidation platform: android status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: ["F-049", "F-051"] --- ## Business Purpose Google's Play Developer API represents subscriptions and one-time in-app purchases as deep, nested JSON objects (cancellation reasons, price-change details, prepaid-plan windows, Subscribe-with-Google identity info, etc.). `SubscriptionPurchase`/`ProductPurchase` and their nested classes are the typed Dart mirror of that shape, generated with `json_annotation`/`json_serializable`, so app code consuming F-051's validation-result listeners gets strongly-typed fields instead of having to parse raw maps by hand. Without these models, `SubscriptionValidationResult`/`InAppPurchaseValidationResult` (F-051) would have to expose validation payloads as untyped `Map`, pushing all of Google's nested-schema knowledge onto every app developer. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. - --- ## Trigger @@ -63,10 +61,10 @@ No dedicated test found. Grepping `test/` for `SubscriptionPurchase`, `ProductPu --- ## Known Limitations -- These models only exist to be functional because of F-051's listener plumbing — and F-051's Android delivery path currently doesn't work (see F-051's documented method-name mismatch between the `#`-separated Dart constants and the `:`-separated strings Kotlin actually sends). Until that is fixed, these models are effectively dead code at runtime even though they compile and are fully wired. +- These models are populated only through F-051's Android listener plumbing. F-051's delivery path is working: the Dart method-name constants and the strings Kotlin sends both use the `:` separator (a prior `#`-vs-`:` mismatch that broke delivery was fixed under CR-075). If that separator contract is ever broken again on one side, these models would go dead at runtime even though they still compile and are fully wired. - No custom `@JsonKey` mapping or manual value coercion exists anywhere in this model set — every field relies on an exact, case-sensitive key match between Kotlin's `toJsonMap()` and the Dart class; a rename on either side without updating the other would fail silently (`json['x'] as String` throws only if the key is present with the wrong type, but a missing/renamed key with a non-nullable field throws a `TypeError` deep inside `fromJson` with no context tying it back to Play Billing). - Several fields (`purchaseTimeMillis`, `startTime`, `expiryTime`, `cancelTime`, etc.) are modeled as `String` even though they represent epoch milliseconds — no `DateTime` parsing is applied on either side, so callers must convert these themselves. -- `SubscriptionPurchase`/`ProductPurchase` mirror the Google Play Developer API schema at a point in time; if the native `purchase-connector:2.2.0` dependency (see `doc/PurchaseConnector.md`'s Billing Library 8.x note) adds or changes fields, these Dart models must be manually kept in sync — there is no schema-validation step in the build. +- `SubscriptionPurchase`/`ProductPurchase` mirror the Google Play Developer API schema at a point in time; if the native `purchase-connector:2.2.0` dependency (see `doc/purchase-connector.md`'s Billing Library note) adds or changes fields, these Dart models must be manually kept in sync — there is no schema-validation step in the build. - This is Android/Google-Play-specific; there is no iOS equivalent typed model (F-052's `validationInfo` stays an untyped map). --- diff --git a/internal-docs/features/F-054-purchase-connector-build-time-opt-in.md b/internal-docs/features/F-054-purchase-connector-build-time-opt-in.md index 6badbc0e..da366a17 100644 --- a/internal-docs/features/F-054-purchase-connector-build-time-opt-in.md +++ b/internal-docs/features/F-054-purchase-connector-build-time-opt-in.md @@ -1,17 +1,15 @@ --- id: F-054 -name: "Purchase Connector: Build-Time Opt-in (Android include/exclude variants)" +name: "Purchase Connector: Build-Time Opt-in" type: purchaseValidation platform: both status: active -last_verified: 2026-07-19 -depends_on: [F-060] +last_verified: 2026-08-10 +depends_on: [] --- ## Business Purpose -The Purchase Connector depends on the Google Play Billing Library (Android) and StoreKit (iOS) — libraries the plugin does not bundle, because most apps that don't sell in-app purchases/subscriptions shouldn't have to pull in billing dependencies just to use core attribution. This feature is the build-time switch that lets an app pull the real native Purchase Connector implementation into its build only if it explicitly opts in; apps that don't opt in get an inert stub instead. Without this switch, every consumer of the Flutter plugin would be forced to carry Play Billing Library / StoreKit purchase-connector native code (and satisfy their ProGuard/versioning constraints) even if they never call any Purchase Connector Dart API, which is unacceptable to plugins that just want attribution and deep linking. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Purchase Connector adds optional native purchase-observation dependencies and code that core-attribution apps do not need. This feature is the build-time switch that includes and registers the real native implementation only when the consuming app opts in. Android compiles either the connector source set or an inert stub; CocoaPods conditionally includes the iOS Purchase Connector subspec and compile flag. Without the opt-in, the public Dart classes still compile but native channel calls cannot be served. --- @@ -32,7 +30,7 @@ Android (Gradle, evaluated at build configuration time): def includeConnector = project.findProperty('appsflyer.enable_purchase_connector')?.toBoolean() ?: false sourceSets.main.java.srcDirs += includeConnector ? ['src/main/include-connector'] → real AppsFlyerPurchaseConnector.kt + ConnectorWrapper.kt (Play Billing Library, PurchaseClient) - : ['src/main/exlude-connector'] → stub AppsFlyerPurchaseConnector.kt (no MethodChannel registered) + : ['src/main/exclude-connector'] → stub AppsFlyerPurchaseConnector.kt (no MethodChannel registered) iOS (CocoaPods, evaluated at `pod install` time): ios/appsflyer_sdk.podspec @@ -40,24 +38,25 @@ iOS (CocoaPods, evaluated at `pod install` time): s.default_subspecs = 'Core', 'PurchaseConnector' subspec 'PurchaseConnector' → depends on CocoaPods 'PurchaseConnector' pod → pod_target_xcconfig sets GCC_PREPROCESSOR_DEFINITIONS 'ENABLE_PURCHASE_CONNECTOR=1' + and SWIFT_ACTIVE_COMPILATION_CONDITIONS '$(inherited) ENABLE_PURCHASE_CONNECTOR' + (the Swift compiler ignores GCC_PREPROCESSOR_DEFINITIONS) else s.default_subspecs = 'Core' (PurchaseConnector subspec/pod not included at all) - ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m (compiled per the xcconfig macro above): - #ifdef ENABLE_PURCHASE_CONNECTOR - #import "appsflyer_sdk/appsflyer_sdk-Swift.h" - #endif - ... - + (void)registerWithRegistrar:... - #ifdef ENABLE_PURCHASE_CONNECTOR - [PurchaseConnectorPlugin registerWithRegistrar:registrar]; - #endif + ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift (compiled per the xcconfig condition above): + public static func register(with registrar: FlutterPluginRegistrar) { + #if ENABLE_PURCHASE_CONNECTOR + PurchaseConnectorPlugin.register(with: registrar) + #endif + ... + } + (no bridging-header import is needed — both files compile into the same Swift module) iOS (SPM, resolved at `swift build`/`flutter build` time — third gate, added by F-060): ios/appsflyer_sdk/Package.swift targets: [.target(name: "appsflyer_sdk", ...)] — Core only, no PurchaseConnector target/product exists → ENABLE_PURCHASE_CONNECTOR is never defined for this target (SPM has no equivalent of CocoaPods' pod_target_xcconfig) - → the same AppsflyerSdkPlugin.m above compiles with the #ifdef guard resolving false, identically to the CocoaPods not-opted-in path + → the same AppsflyerSdkPlugin.swift above compiles with the #if guard resolving false, identically to the CocoaPods not-opted-in path ``` --- @@ -68,11 +67,11 @@ iOS (SPM, resolved at `swift build`/`flutter build` time — third gate, added b | `android/build.gradle` | Reads `appsflyer.enable_purchase_connector` Gradle property, switches `sourceSets.main.java.srcDirs` between the two variants | | `android/src/main/include-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt` | Real Android implementation: registers the `af-purchase-connector` MethodChannel and handles `configure`/`startObservingTransactions`/`stopObservingTransactions` | | `android/src/main/include-connector/com/appsflyer/appsflyersdk/ConnectorWrapper.kt` | Wraps `PurchaseClient` (Play Billing Library) — only compiled in the include-connector variant | -| `android/src/main/exlude-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt` | No-op stub: implements `FlutterPlugin` but registers no `MethodChannel` at all | -| `ios/appsflyer_sdk.podspec` | Defines the `PurchaseConnector` CocoaPods subspec conditionally on `$AppsFlyerPurchaseConnector`, and sets the `ENABLE_PURCHASE_CONNECTOR=1` preprocessor macro for that subspec only | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `#ifdef ENABLE_PURCHASE_CONNECTOR` guards both the Swift-bridging header import and the `[PurchaseConnectorPlugin registerWithRegistrar:registrar]` call | +| `android/src/main/exclude-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt` | No-op stub: implements `FlutterPlugin` but registers no `MethodChannel` at all | +| `ios/appsflyer_sdk.podspec` | Defines the `PurchaseConnector` CocoaPods subspec conditionally on `$AppsFlyerPurchaseConnector`, and sets both the `ENABLE_PURCHASE_CONNECTOR=1` preprocessor macro and the matching `SWIFT_ACTIVE_COMPILATION_CONDITIONS` entry for that subspec only | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | `#if ENABLE_PURCHASE_CONNECTOR` guards the `PurchaseConnectorPlugin.register(with: registrar)` call | | `ios/appsflyer_sdk/Package.swift` (added by F-060) | Declares only the Core target — has no PurchaseConnector target/product and no mechanism to define `ENABLE_PURCHASE_CONNECTOR`, so this gate is permanently "not opted in" for any SPM-only integration | -| `doc/PurchaseConnector.md` | App-facing opt-in instructions (`$AppsFlyerPurchaseConnector = true` in Podfile; `appsflyer.enable_purchase_connector=true` in gradle.properties) and an explicit "What Happens if You Use Dart Files Without Opting In?" section | +| `doc/purchase-connector.md` | App-facing opt-in instructions (`$AppsFlyerPurchaseConnector = true` in Podfile; `appsflyer.enable_purchase_connector=true` in gradle.properties) and an explicit "What Happens if You Use Dart Files Without Opting In?" section | --- @@ -90,22 +89,13 @@ No dedicated test found — this is a Gradle/CocoaPods build-configuration conce --- ## Known Limitations -- The exclude-connector stub (`android/src/main/exlude-connector/.../AppsFlyerPurchaseConnector.kt`) is genuinely inert: it implements `FlutterPlugin.onAttachedToEngine`/`onDetachedFromEngine` as empty (`= Unit`) and never constructs a `MethodChannel` or sets a call handler. It does not throw and does not log a warning — it simply never responds. Any Dart call on the `af-purchase-connector` channel (`configure`, `startObservingTransactions`, etc.) in an app built without opting in will fail with Flutter's own `MissingPluginException`, not an AppsFlyer-authored error, making the failure mode confusing to diagnose (confirmed by reading the stub source directly). +- The exclude-connector stub (`android/src/main/exclude-connector/.../AppsFlyerPurchaseConnector.kt`) is genuinely inert: it implements `FlutterPlugin.onAttachedToEngine`/`onDetachedFromEngine` as empty (`= Unit`) and never constructs a `MethodChannel` or sets a call handler. It does not throw and does not log a warning — it simply never responds. Any Dart call on the `af-purchase-connector` channel (`configure`, `startObservingTransactions`, etc.) in an app built without opting in will fail with Flutter's own `MissingPluginException`, not an AppsFlyer-authored error, making the failure mode confusing to diagnose (confirmed by reading the stub source directly). - iOS has the same silent-gap behavior by omission rather than an explicit stub: if `$AppsFlyerPurchaseConnector` is undefined, the `PurchaseConnector` subspec/macro/registration are all compiled out, so `PurchaseConnectorPlugin` never registers a handler for `af-purchase-connector` either — same `MissingPluginException` outcome as Android, but reached via a completely different mechanism (absent Ruby global vs. an explicit empty Kotlin object), which is easy for engineers modifying one platform to forget applies to the other. -- **F-049 (Purchase Connector: Configuration & Lifecycle) and every other Purchase Connector Dart API are entirely meaningless without this feature being correctly opted into on both platforms** — the Dart-side classes (`PurchaseConnector`, `PurchaseConnectorConfiguration`, etc.) are always compiled into the plugin regardless of opt-in status, so an app can write code against them, pass static analysis, and still get runtime `MissingPluginException`s in production if it forgot the Podfile/gradle.properties step on either platform (`doc/PurchaseConnector.md` calls this out explicitly). +- **F-049 (Purchase Connector: Configuration & Lifecycle) and every other Purchase Connector Dart API are entirely meaningless without this feature being correctly opted into on both platforms** — the Dart-side classes (`PurchaseConnector`, `PurchaseConnectorConfiguration`, etc.) are always compiled into the plugin regardless of opt-in status, so an app can write code against them, pass static analysis, and still get runtime `MissingPluginException`s in production if it forgot the Podfile/gradle.properties step on either platform (`doc/purchase-connector.md` calls this out explicitly). - The two opt-in mechanisms are asymmetric in strictness: Android checks a boolean value (`.toBoolean() ?: false`), so `appsflyer.enable_purchase_connector=false` or an unset/malformed property both cleanly resolve to "excluded." iOS checks mere *definedness* of `$AppsFlyerPurchaseConnector` (`defined?(...)`), so setting it to `false` in a Podfile still counts as "opted in" (`if defined?($AppsFlyerPurchaseConnector)` is true regardless of the assigned value) — a plausible copy-paste mistake (`$AppsFlyerPurchaseConnector = false` intending to disable it) silently enables the feature. -- **As of F-060 (Swift Package Manager Support), this gate has a third path with no opt-in mechanism at all**: an app integrated via SPM cannot enable Purchase Connector under any configuration this release — `ios/appsflyer_sdk/Package.swift` never defines `ENABLE_PURCHASE_CONNECTOR`, so the `#ifdef` guard always resolves false. Calling any Purchase Connector Dart API from an SPM-only integration fails with the same generic `MissingPluginException` described above for the CocoaPods not-opted-in case — this is not a new failure mode, but it is a third, permanent path to the same confusing outcome, not a temporary misconfiguration a developer can fix by setting a flag. Apps that need Purchase Connector must stay on CocoaPods until flutter/flutter#161182 (Flutter's own plugin tooling lacking conditional-compilation support) is resolved — see F-060 and `docs/researches/R-001-spm-support.md` for why SPM Package Traits do not currently offer a workaround. +- **As of F-060 (Swift Package Manager Support), this gate has a third path with no opt-in mechanism at all**: an app integrated via SPM cannot enable Purchase Connector under any configuration this release — `ios/appsflyer_sdk/Package.swift` never defines `ENABLE_PURCHASE_CONNECTOR`, so the `#ifdef` guard always resolves false. Calling any Purchase Connector Dart API from an SPM-only integration fails with the same generic `MissingPluginException` described above for the CocoaPods not-opted-in case — this is not a new failure mode, but it is a third, permanent path to the same confusing outcome, not a temporary misconfiguration a developer can fix by setting a flag. Apps that need Purchase Connector must stay on CocoaPods until flutter/flutter#161182 (Flutter's own plugin tooling lacking conditional-compilation support) is resolved — see F-060 and [`internal-docs/researches/R-001-spm-support.md`](../researches/R-001-spm-support.md) for why SPM Package Traits do not currently offer a workaround. --- ## Dependencies -```mermaid -flowchart LR - F054["F-054 · Purchase Connector: Build-Time Opt-in"]:::purchaseValidation - F049["F-049 · Purchase Connector: Configuration & Lifecycle"]:::purchaseValidation - F060["F-060 · Swift Package Manager Support"]:::sdkCore - F054 -->|"gates compilation/registration of"| F049 - F060 -->|"adds a third, permanently-excluded iOS path to"| F054 - classDef purchaseValidation fill:#F59F00,color:#fff - classDef sdkCore fill:#4C6EF5,color:#fff -``` +No required feature dependency. F-049 depends on this opt-in for native availability; F-060 documents that the current SPM product has no Purchase Connector opt-in path. diff --git a/internal-docs/features/F-055-purchase-connector-missing-configuration-guard.md b/internal-docs/features/F-055-purchase-connector-missing-configuration-guard.md index 758c51ab..95a98281 100644 --- a/internal-docs/features/F-055-purchase-connector-missing-configuration-guard.md +++ b/internal-docs/features/F-055-purchase-connector-missing-configuration-guard.md @@ -4,14 +4,12 @@ name: Missing-Configuration Guard for Purchase Connector type: purchaseValidation platform: both status: active -last_verified: 2026-07-15 +last_verified: 2026-08-10 depends_on: ["F-049"] --- ## Business Purpose -`PurchaseConnector` is a Dart-side singleton that must be seeded with a `PurchaseConnectorConfiguration` (log subscriptions/in-apps, sandbox, StoreKit version) the very first time it is created; every later use of the connector — starting/stopping transaction observation, registering validation listeners — assumes that configuration already exists. If an app called for the singleton before ever supplying a config (e.g. a widget deep in the app calls `PurchaseConnector()` with no args, expecting an already-configured instance from elsewhere, but app startup order was wrong), there would be no configuration to build the native connector from. This guard turns that programmer error into an immediate, typed Dart exception (`MissingConfigurationException`) at the call site instead of a null/uninitialized native connector failing silently or crashing later when a purchase actually occurs — which is far harder to trace back to a missing `configure()` call. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +`PurchaseConnector` is a Dart-side singleton that must be seeded with a `PurchaseConnectorConfiguration` the first time it is created. Later observation and listener calls assume that configuration exists. If the first call is `PurchaseConnector()` with no config, this guard throws `MissingConfigurationException` immediately instead of leaving the native connector uninitialized. Configuration is supplied through `PurchaseConnector(config: ...)`; there is no public Dart `configure()` method, even though the current exception string incorrectly tells the caller to use one. --- diff --git a/internal-docs/features/F-056-app-invite-link-onelink-id-init-time.md b/internal-docs/features/F-056-app-invite-link-onelink-id-init-time.md index 854faa07..cf2f38c7 100644 --- a/internal-docs/features/F-056-app-invite-link-onelink-id-init-time.md +++ b/internal-docs/features/F-056-app-invite-link-onelink-id-init-time.md @@ -1,36 +1,31 @@ --- id: F-056 -name: App Invite Link OneLink ID (init-time) +name: App Invite OneLink ID (init-time option) type: oneLinkAndGrowth platform: both -status: active -last_verified: 2026-07-15 -depends_on: ["F-028"] +status: removed +last_verified: 2026-08-10 +depends_on: [] --- ## Business Purpose -Apps that already know their invite-link OneLink ID at build/config time (rather than resolving it dynamically at runtime) want to configure it once, as part of the same `AppsFlyerOptions`/init-options object used to configure the dev key, app ID, and other startup flags — avoiding a separate `setAppInviteOneLinkID` (F-028) call after `initSdk()`. The `appInviteOneLink` init option sets this same underlying native OneLink ID at SDK-initialization time, so `generateInviteLink` (F-027) has a base link ready as soon as the SDK starts. +This entry is retained as a tombstone for the former `AppsFlyerOptions.appInviteOneLink` and map-based init option. The native-aligned SDK 7 Flutter API no longer accepts a configuration object or an init-time OneLink ID. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +Use the active F-028 API, `await AppsFlyerSdk.instance.setAppInviteOneLink(oneLinkId)`, before generating invite links. --- ## Trigger -Runs once, during `initSdk()`, whenever the host app constructed its `AppsFlyerOptions` (or the equivalent options `Map`) with a non-null `appInviteOneLink` value. +None. The init-time option is not part of the current public API and is not consumed by either Flutter platform implementation. --- ## Call Chain +There is no current call chain. The replacement is documented by F-028: + ``` -AppsFlyerOptions(appInviteOneLink: "...") [lib/src/appsflyer_options.dart] - → AppsflyerSdk.initSdk() [lib/src/appsflyer_sdk.dart] - → _validateAFOptions(afOptions) / _validateMapOptions(mapOptions) [lib/src/appsflyer_sdk.dart] - → validatedOptions[AppsflyerConstants.APP_INVITE_ONE_LINK] = appInviteOneLink - → _methodChannel.invokeMethod("initSdk", validatedOptions) - → Android: AppsflyerSdkPlugin.onMethodCall("initSdk") → initSdk(call, result) [android/.../AppsflyerSdkPlugin.java] - → call.argument(AppsFlyerConstants.AF_APP_INVITE_ONE_LINK) → AppsFlyerLib.getInstance().setAppInviteOneLink(appInviteOneLink) (only if non-null) - → iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") → initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → call.arguments[afInviteOneLink] → [AppsFlyerLib shared].appInviteOneLinkID = appInviteOneLink (only if non-nil and not NSNull) +AppsFlyerSdk.setAppInviteOneLink(oneLinkId) + → RPC setAppInviteOneLink {oneLinkId} ``` --- @@ -38,39 +33,29 @@ AppsFlyerOptions(appInviteOneLink: "...") ## Files | File | Role | |------|------| -| `lib/src/appsflyer_options.dart` | `AppsFlyerOptions.appInviteOneLink` — optional `String?` init-time field | -| `lib/src/appsflyer_sdk.dart` | `_validateAFOptions()` (lines ~56-61) and `_validateMapOptions()` (lines ~111-123) — copy `appInviteOneLink` into `validatedOptions[AppsflyerConstants.APP_INVITE_ONE_LINK]` under the wire key `"appInviteOneLink"`; `initSdk()` sends it as part of the `"initSdk"` method-channel call | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `initSdk(call, result)` (~line 1100) reads `AppsFlyerConstants.AF_APP_INVITE_ONE_LINK` and calls `AppsFlyerLib.getInstance().setAppInviteOneLink(appInviteOneLink)` if non-null, **after** `instance.init(...)` but before `instance.start(activity)` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` (~line 831) reads `afInviteOneLink` (`"appInviteOneLink"`) and sets `[AppsFlyerLib shared].appInviteOneLinkID` if non-nil and not `NSNull` | +| `doc/migration-guide.md` | Documents removal of `AppsFlyerOptions.appInviteOneLink` and its replacement | +| `lib/src/appsflyer_sdk.dart` | Contains the active `setAppInviteOneLink(String oneLinkId)` API; `init()` accepts only `devKey` and `appId` | --- ## Input / Output | | | |--|--| -| **Input** | `AppsFlyerOptions.appInviteOneLink` (`String?`) or `mapOptions["appInviteOneLink"]`, consumed only during `initSdk()` | -| **Output** | Sets the same underlying native OneLink ID property that `setAppInviteOneLinkID` (F-028) sets at runtime (`AppsFlyerLib.getInstance()` on Android, `[AppsFlyerLib shared].appInviteOneLinkID` on iOS) — no dedicated success/failure callback exists for the init-time path | +| **Input** | Removed: `AppsFlyerOptions.appInviteOneLink` / map init option | +| **Output** | None. Use F-028, which returns `Future`. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart`'s `check initSdk call` (line 93) exercises the general `initSdk()` path using `mapOptions: {'afDevKey': 'sdfhj2342cx'}` (set in `setUp()`, line 19) — no test sets or asserts `appInviteOneLink`/`APP_INVITE_ONE_LINK` specifically, on either the Dart validation logic or either native handler. +Current RPC mapping tests cover the replacement `setAppInviteOneLink` API. No test should expect an init-time OneLink option. --- ## Known Limitations -- **Assert-only validation**: both `_validateAFOptions` and `_validateMapOptions` in `lib/src/appsflyer_sdk.dart` only `assert(appInviteOneLink is String)` when non-null — `assert` is stripped in release (profile/release) Flutter builds, so a wrong type passed via the untyped `Map` init path would not be caught outside debug mode. -- **Silently overwritten by a later runtime call**: because F-056 (init-time) and F-028 (`setAppInviteOneLinkID`, runtime) both write to the exact same native property, calling `setAppInviteOneLinkID` after `initSdk()` completes silently overrides whatever was set via the `appInviteOneLink` init option, with no warning of the override. -- **Android sets it after `init()` but the codebase doesn't document why**: `AppsFlyerLib.getInstance().setAppInviteOneLink(appInviteOneLink)` is called after `instance.init(afDevKey, gcdListener, mContext)` and before `instance.start(activity)`; the ordering relative to `start()` is load-bearing for the native SDK but is not asserted or tested here. -- No way to detect, from Dart, whether the init-time `appInviteOneLink` value was actually applied by the native SDK (no callback/confirmation, unlike the explicit `setAppInviteOneLinkID` callback in F-028). +- Existing SDK 6 integrations must move the OneLink ID into an explicit `setAppInviteOneLink` call. +- The removed init-time option must not be restored or simulated in Dart because the approved SDK 7 API keeps initialization limited to native initialization parameters. --- ## Dependencies -```mermaid -flowchart LR - F056["F-056 · App Invite Link OneLink ID (init-time)"]:::oneLinkAndGrowth - F028["F-028 · App Invite OneLink ID Configuration"]:::oneLinkAndGrowth - F056 -->|"shares same native OneLink-ID property, last write wins"| F028 - classDef oneLinkAndGrowth fill:#7048E8,color:#fff -``` +No active feature depends on F-056. F-028 is the supported replacement. diff --git a/internal-docs/features/F-057-asa-collection-optout.md b/internal-docs/features/F-057-asa-collection-optout.md index d7d526d3..180a0b43 100644 --- a/internal-docs/features/F-057-asa-collection-optout.md +++ b/internal-docs/features/F-057-asa-collection-optout.md @@ -4,35 +4,36 @@ name: ASA (Apple Search Ads) Collection Opt-out type: sdkCore platform: ios status: active -last_verified: 2026-07-15 -depends_on: ["F-001"] +last_verified: 2026-08-10 +depends_on: [] --- ## Business Purpose -The native iOS SDK automatically queries Apple's Search Ads Attribution API (ASA) to enrich attribution data for installs originating from Apple Search Ads campaigns. Some apps — for privacy/compliance reasons, or because they don't run Apple Search Ads campaigns and want to avoid the extra API call/data collection — need to opt out of this automatic collection at init time. `disableCollectASA` is the init-time switch that turns it off before the SDK starts. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +The native iOS SDK automatically queries Apple's Search Ads Attribution API (ASA) to enrich attribution data for installs originating from Apple Search Ads campaigns. Some apps — for privacy or compliance reasons, or because they do not run Apple Search Ads campaigns and want to avoid the extra API call and data collection — need to opt out of this automatic collection. `setDisableCollectASA` is the iOS-only switch that turns it off. A companion iOS-only method, `setDisableAppleAdsAttribution(bool disable)`, dispatches the `setDisableAppleAdsAttribution` RPC; the iOS SDK needs **both** to fully suppress Apple Search Ads attribution. --- ## Trigger -Set once by the host app as part of `AppsFlyerOptions` (or the raw options `Map`) passed to the `AppsflyerSdk` constructor, and applied during `initSdk()`/`initSdkWithCall:`, before the SDK starts. iOS only — read and applied only when `Platform.isIOS` on the Dart side, and only has a corresponding native code path on iOS. +The host app calls `setDisableCollectASA(true)` and, when full Apple Ads attribution suppression is required, `setDisableAppleAdsAttribution(true)` before `start()`. Neither method requires `init()` to have run first. Both are iOS-only; on Android each is still dispatched and throws `AppsFlyerException`, because the Android RPC layer does not implement the method. --- ## Call Chain +Both methods are ordinary fire-and-forget RPC setters that return `Future`. Neither is gated in Dart, so the channel call is made on every platform and the native RPC layer decides whether the method exists. + ``` -AppsFlyerOptions(disableCollectASA: true) [lib/src/appsflyer_options.dart] - → AppsflyerSdk.initSdk(...) [lib/src/appsflyer_sdk.dart] - → _validateAFOptions(options) / _validateMapOptions(options) - → if Platform.isIOS is NOT required here — value is copied unconditionally on both platforms: - validatedOptions[AppsflyerConstants.DISABLE_COLLECT_ASA] = options.disableCollectASA (line 63-66 / 125-128) - → _methodChannel.invokeMethod("initSdk", validatedOptions) - → iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") → initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → disableCollectASA = call.arguments[afDisableCollectASA] (as NSNumber → BOOL) (line 836-840) - → [AppsFlyerLib shared].disableCollectASA = disableCollectASA (line 848) - → Android: AppsflyerSdkPlugin.initSdk(call, result) — value is never read; no `DISABLE_COLLECT_ASA` - constant exists in `AppsFlyerConstants.java` and Apple Search Ads has no Android equivalent +AppsFlyerSdk.setDisableCollectASA(disable) [lib/src/appsflyer_sdk.dart] + → off iOS: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('setDisableCollectASA', {'disable': disable}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge.executeJson + → native Apple Search Ads collection opt-out + → PlatformException is converted to AppsFlyerException + +# iOS-only companion +AppsFlyerSdk.setDisableAppleAdsAttribution(disable) [lib/src/appsflyer_sdk.dart] + → off iOS: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('setDisableAppleAdsAttribution', {'disable': disable}) ``` --- @@ -40,39 +41,35 @@ AppsFlyerOptions(disableCollectASA: true) [lib/src/ ## Files | File | Role | |------|------| -| `lib/src/appsflyer_options.dart` | `AppsFlyerOptions.disableCollectASA` (`bool?`, optional named constructor param) | -| `lib/src/appsflyer_sdk.dart` | `_validateAFOptions` / `_validateMapOptions` — copies `disableCollectASA` into the validated options map unconditionally (no `Platform.isIOS` guard on the Dart validation side) if non-null | -| `lib/src/appsflyer_constants.dart` | `DISABLE_COLLECT_ASA = "disableCollectASA"` — shared Dart↔native key | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define afDisableCollectASA @"disableCollectASA"` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` — parses the flag and sets `[AppsFlyerLib shared].disableCollectASA` (lines 836–848) | -| `doc/BasicIntegration.md`, `doc/API.md` | Document `disableCollectASA` as "Opt-out of the Apple Search Ads attributions" | +| `lib/src/appsflyer_sdk.dart` | `setDisableCollectASA(bool disable)` and `setDisableAppleAdsAttribution(bool disable)`, both dispatched through RPC without a Dart platform check | +| `lib/src/appsflyer_exception.dart` | `AppsFlyerException` for native failures | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Generic `executeRpc` → `dispatchRpc` forwarding to `AppsFlyerRPCBridge`; no per-method handler | --- ## Input / Output | | | |--|--| -| **Input** | `disableCollectASA` (`bool?`) via `AppsFlyerOptions` or the equivalent Map key, read at init time only | -| **Output** | `void` — sets a property on the native iOS SDK singleton before `start`; no confirmation returned to Dart. On Android the value is silently discarded. | +| **Input** | `disable` (`bool`) sent under the `disable` param key for both methods. | +| **Output** | `Future` completes after RPC validation and the synchronous native SDK setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or timeout. On Android the call is still dispatched and throws `AppsFlyerException` once the RPC layer reports the method as unavailable. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart`'s `check initSdk call` test only asserts that the `"initSdk"` method is invoked; it does not construct `AppsFlyerOptions` with `disableCollectASA` set, nor assert the resulting map contains the key, nor exercise the iOS-only native path (Dart `flutter test` runs on the host OS, not `Platform.isIOS`). +`test/appsflyer_sdk_test.dart` covers both the mapping and the off-platform behavior: +- `iOS ASA collection is configured through an explicit setter` asserts that `iosSdk.setDisableCollectASA(true)` dispatches RPC method `setDisableCollectASA` with `{'disable': true}`. +- `maps every iOS-only API` re-asserts the same mapping alongside `setDisableAppleAdsAttribution` with `{'disable': true}`. +- `platform-only calls are forwarded to the native RPC instead of being swallowed in Dart` asserts that `androidSdk.setDisableCollectASA(true)` still dispatches the `setDisableCollectASA` RPC; the off-platform path of `setDisableAppleAdsAttribution` is not covered separately. --- ## Known Limitations -- Android-side handling doesn't exist at all: there is no `DISABLE_COLLECT_ASA` constant in `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` and the Android `initSdk(call, result)` never reads the key — this is expected (ASA is an Apple-only concept) but is not documented anywhere as an explicit no-op; a host app setting `disableCollectASA: true` gets no feedback that it had no effect on Android. -- Dart-side validation (`_validateAFOptions`) copies `disableCollectASA` into `validatedOptions` unconditionally (not gated behind `Platform.isIOS` like `timeToWaitForATTUserAuthorization` and `appId` are) — inconsistent with how the same method gates other iOS-only fields. -- No getter exists to confirm whether ASA collection is currently disabled after init. -- One-directional: once set (or left at the default `NO`/false) at init time, there is no runtime API in this plugin to toggle it after the SDK has started. +- Apple Search Ads has no Android equivalent, so there is no Android behavior to configure; the Dart layer does not block the Android call, which therefore reaches the Android RPC layer and throws `AppsFlyerException` rather than doing nothing. +- Fully suppressing ASA on iOS requires **both** `setDisableCollectASA(true)` and `setDisableAppleAdsAttribution(true)`. +- No getter exists to confirm whether ASA collection is currently disabled. +- The native API has no completion callback, so a completed `Future` confirms only that the RPC layer accepted the call. --- ## Dependencies -```mermaid -flowchart LR - F057["F-057 · ASA Collection Opt-out"]:::sdkCore -->|"applied only during"| F001["F-001 · SDK Initialization & Options Validation"]:::sdkCore - classDef sdkCore fill:#4C6EF5,color:#fff -``` +No required feature dependency. Both settings are runtime properties that must be applied before the first `start()` they should affect. diff --git a/internal-docs/features/F-058-att-authorization-wait-timeout.md b/internal-docs/features/F-058-att-authorization-wait-timeout.md index a467a2c0..80e46234 100644 --- a/internal-docs/features/F-058-att-authorization-wait-timeout.md +++ b/internal-docs/features/F-058-att-authorization-wait-timeout.md @@ -3,38 +3,34 @@ id: F-058 name: ATT Authorization Wait Timeout (iOS) type: sdkCore platform: ios -status: active -last_verified: 2026-07-15 -depends_on: ["F-001"] +status: removed +last_verified: 2026-08-10 +depends_on: [] --- ## Business Purpose -Since iOS 14.5, apps must show Apple's App Tracking Transparency (ATT) prompt before collecting the IDFA. If the AppsFlyer SDK starts (and fires its first session/attribution request) before the user responds to that prompt, it may miss the IDFA and under-report attribution. `timeToWaitForATTUserAuthorization` lets the host app delay the SDK's `start()` call for up to N seconds so it can wait for the user to accept, decline, or time out on the consent dialog before the first session is sent — improving IDFA-based attribution accuracy without requiring the app to manually gate SDK start behind a callback. +This entry is retained as a tombstone for the former `AppsFlyerOptions.timeToWaitForATTUserAuthorization` init-time option, which asked the native iOS SDK to delay its first session for up to N seconds while the user answered Apple's App Tracking Transparency (ATT) prompt. -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +The native-aligned SDK 7 Flutter API has **no** ATT surface at all: there is no `waitForATT` method, no ATT-wait-timeout parameter, and no init-time configuration object to carry one. `init()` accepts only `devKey` and `appId`. + +The replacement is the explicit SDK 7 session model documented by F-002. Because initialization no longer sends a session, the application controls exactly when the first session is sent: request ATT authorization in application code, and only then call `await AppsFlyerSdk.instance.start()` from the session-ready callback. This replaces an opaque native timer with ordering the app can observe and test. --- ## Trigger -Set once by the host app as part of `AppsFlyerOptions` (or the raw options `Map`), read only on iOS (`Platform.isIOS`), and applied inside `initSdkWithCall:` before the SDK's `start` call. +None. No Dart API accepts an ATT wait interval, and neither platform implementation consumes such a key. --- ## Call Chain +There is no current call chain. The replacement is application-controlled session timing, documented by F-002: + ``` -AppsFlyerOptions(timeToWaitForATTUserAuthorization: 50.0) [lib/src/appsflyer_options.dart] - → AppsflyerSdk.initSdk(...) [lib/src/appsflyer_sdk.dart] - → _validateAFOptions(options) / _validateMapOptions(options) - → if (Platform.isIOS) { assert(value is double); - validatedOptions[AF_TIME_TO_WAIT_FOR_ATT_USER_AUTHORIZATION] = value } (lines 76-85 / 137-148) - → _methodChannel.invokeMethod("initSdk", validatedOptions) - → iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") → initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → timeToWaitForATTUserAuthorization = call.arguments[afTimeToWaitForATTUserAuthorization] doubleValue (line 796) - → if (timeToWaitForATTUserAuthorization != 0) { - [[AppsFlyerLib shared] waitForATTUserAuthorizationWithTimeoutInterval:timeToWaitForATTUserAuthorization] - } (lines 867-869) - → [[AppsFlyerLib shared] start] (unless manualStart) (line 873) - → Android: value is never read — no Android equivalent exists (ATT is an iOS-only framework) +await AppsFlyerSdk.instance.registerSessionReadyListener(() async { + // application requests ATT authorization here, then: + await AppsFlyerSdk.instance.start(); +}); + → RPC start {awaitResponse: false} // default; pass awaitResponse: true to await completion ``` --- @@ -42,39 +38,30 @@ AppsFlyerOptions(timeToWaitForATTUserAuthorization: 50.0) [lib/src ## Files | File | Role | |------|------| -| `lib/src/appsflyer_options.dart` | `AppsFlyerOptions.timeToWaitForATTUserAuthorization` (`double?`, optional named constructor param) | -| `lib/src/appsflyer_sdk.dart` | `_validateAFOptions` / `_validateMapOptions` — reads the value **only** when `Platform.isIOS`, asserts it is a `double`, copies into the validated options map | -| `lib/src/appsflyer_constants.dart` | `AF_TIME_TO_WAIT_FOR_ATT_USER_AUTHORIZATION = "timeToWaitForATTUserAuthorization"` — shared Dart↔native key | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define afTimeToWaitForATTUserAuthorization @"timeToWaitForATTUserAuthorization"` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` — parses the interval and calls `waitForATTUserAuthorizationWithTimeoutInterval:` before `start` (lines 796, 860-869) | -| `doc/BasicIntegration.md`, `doc/AdvancedAPI.md`, `doc/Guides.md`, `doc/API.md` | Document the option as delaying SDK start "for x seconds until the user either accepts the consent dialog, declines it, or the timer runs out" | +| `doc/migration-guide.md` | Lists `waitForATTUserAuthorization` under removed APIs and directs integrators to control the timing of `start()` in application code | +| `lib/src/appsflyer_sdk.dart` | Contains no ATT symbol; `init()` accepts only `devKey` and `appId`, and `start()` is the explicit session call | --- ## Input / Output | | | |--|--| -| **Input** | `timeToWaitForATTUserAuthorization` (`double?`, seconds) via `AppsFlyerOptions` or the equivalent Map key; only read/applied when `Platform.isIOS` | -| **Output** | `void` — delays the native SDK's internal `start()`/first session dispatch by up to the given interval (or until ATT authorization resolves, whichever comes first); no value or confirmation returned to Dart. | +| **Input** | Removed: `AppsFlyerOptions.timeToWaitForATTUserAuthorization` / the equivalent map init key | +| **Output** | None. Use F-002 `start()`, which returns `Future`. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart`'s `check initSdk call` test does not set `timeToWaitForATTUserAuthorization` in its options and, because Dart tests do not run with `Platform.isIOS == true`, the entire `if (Platform.isIOS) { ... }` validation branch (including the `assert(timeToWaitForATTUserAuthorization is double)` check and the iOS App ID regex validation alongside it) is untested. +No test references ATT. `test/appsflyer_sdk_test.dart` asserts that `init` sends only `devKey` (Android) or `devKey` and `appId` (iOS), and that `start` forwards the public `awaitResponse` value (default `false`). No test should expect an ATT wait option. --- ## Known Limitations -- Android has no equivalent: the option is silently ignored on Android (no `Platform.isIOS` guard exists on the *native* Android side because the key is simply never sent — the guard lives entirely in Dart's `_validateAFOptions`/`_validateMapOptions`). A host app relying on `Platform.isIOS` checks elsewhere but forgetting one here would have no functional impact, since Android's `initSdk` never looks for this key at all. -- iOS's `AppsflyerSdkPlugin.m` contains a large commented-out block (lines ~860-865) that shows an earlier `respondsToSelector:`/`objc_msgSend` based implementation of this same call, superseded by the direct `waitForATTUserAuthorizationWithTimeoutInterval:` call — dead code left in place, mildly confusing when reading the file. -- A value of exactly `0` is treated as "not set" (`if (timeToWaitForATTUserAuthorization != 0)`), so a host app cannot explicitly pass `0.0` to mean "no wait" versus simply omitting the option — both behave identically. -- The Dart-side `assert(timeToWaitForATTUserAuthorization is double)` is stripped in release builds, so passing a non-double dynamic value (e.g. via the raw `Map` options path) would silently misbehave in production rather than failing fast. +- Existing SDK 6 integrations that relied on the native wait timer must move ATT sequencing into application code: request authorization, then call `start()`. +- The removed option must not be restored or emulated in Dart. Emulating it would mean adding a Dart-side timer around `start()`, which would hide session timing from the application rather than expose it. +- The Flutter plugin does not wrap `ATTrackingManager`; requesting ATT authorization is the application's responsibility. --- ## Dependencies -```mermaid -flowchart LR - F058["F-058 · ATT Authorization Wait Timeout (iOS)"]:::sdkCore -->|"applied only during"| F001["F-001 · SDK Initialization & Options Validation"]:::sdkCore - classDef sdkCore fill:#4C6EF5,color:#fff -``` +No active feature depends on F-058. F-002 (SDK Start) is the supported replacement. diff --git a/internal-docs/features/F-059-debug-logging-toggle.md b/internal-docs/features/F-059-debug-logging-toggle.md index 453bccb0..2c390de2 100644 --- a/internal-docs/features/F-059-debug-logging-toggle.md +++ b/internal-docs/features/F-059-debug-logging-toggle.md @@ -4,37 +4,33 @@ name: Debug Logging Toggle type: sdkCore platform: both status: active -last_verified: 2026-07-15 -depends_on: ["F-001"] +last_verified: 2026-08-10 +depends_on: [] --- ## Business Purpose -During integration and QA, developers need verbose native SDK logging (request/response payloads, session lifecycle, error detail) to diagnose why attribution or events aren't showing up as expected. `showDebug` is the init-time switch that turns this on. AppsFlyer explicitly warns this must not ship to production, since verbose logs can leak internal request data into device logs. - -> TODO: enrich from product specs — provide a Notion database URL and re-run Phase 4 to fill this automatically. +During integration and QA, developers need verbose native SDK logging (request/response payloads, session lifecycle, error detail) to diagnose why attribution or events aren't showing up as expected. `enableDebug` is the switch that turns this on. AppsFlyer explicitly warns this must not ship to production, since verbose logs can leak internal request data into device logs. --- ## Trigger -Set once by the host app as part of `AppsFlyerOptions` (or the raw options `Map`), defaulting to `false`, and applied during `initSdk()`/`initSdkWithCall:` on both platforms before the native SDK starts. +The host app awaits `AppsFlyerSdk.instance.enableDebug(true)`. This is a standalone runtime call, not an init option: it may be called before `init()`, and must be called before `start()` so the first session is logged with the selected setting. Android integrations can additionally select a granular level with `setLogLevel(AFLogLevel)`. --- ## Call Chain ``` -AppsFlyerOptions(showDebug: true) [lib/src/appsflyer_options.dart] - → AppsflyerSdk.initSdk(...) [lib/src/appsflyer_sdk.dart] - → _validateAFOptions(options) / _validateMapOptions(options) - → validatedOptions[AF_IS_DEBUG] = options.showDebug ?? false (line 94-96 / 158-161) - → _methodChannel.invokeMethod("initSdk", validatedOptions) - → Android: AppsflyerSdkPlugin.onMethodCall("initSdk") → initSdk(call, result) [android/.../AppsflyerSdkPlugin.java] - → isDebug = call.argument(AF_IS_DEBUG) (line 1087) - → if (isDebug) { instance.setLogLevel(AFLogger.LogLevel.DEBUG); - instance.setDebugLog(true); } - else { instance.setDebugLog(false); } (lines 1088-1093) - → iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") → initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] - → isDebugValue = call.arguments[afIsDebug] (line 805) - → [AppsFlyerLib shared].isDebug = isDebug (line 813) +AppsFlyerSdk.enableDebug(enabled) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('isDebug', {'isDebug': enabled}) + → _invokeRpc → MethodChannel('af-api').invokeMethod('executeRpc', {method, params}) + → Android: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRpcHandler + → AppsFlyerLib.setDebugLog(enabled) + → iOS: AppsflyerSdkPlugin.dispatchRpc → AppsFlyerRPCBridge + → PlatformException is converted to AppsFlyerException + +AppsFlyerSdk.setLogLevel(logLevel) [Android only] + → _invokeVoidRpc('setLogLevel', {'logLevel': logLevel.rpcValue}) // "NONE".."VERBOSE" + → AppsFlyerRpcHandler → AppsFlyerLib.setLogLevel(...) ``` --- @@ -42,41 +38,36 @@ AppsFlyerOptions(showDebug: true) [lib/src ## Files | File | Role | |------|------| -| `lib/src/appsflyer_options.dart` | `AppsFlyerOptions.showDebug` (`bool`, defaults to `false`) | -| `lib/src/appsflyer_sdk.dart` | `_validateAFOptions` / `_validateMapOptions` — always writes `AF_IS_DEBUG` into the validated options map, defaulting to `false` if unset | -| `lib/src/appsflyer_constants.dart` | `AF_IS_DEBUG = "isDebug"` — shared Dart↔native key | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `initSdk(call, result)` — toggles `AppsFlyerLib.getInstance().setLogLevel(...)` and `.setDebugLog(...)` (lines 1087-1093) | -| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | `AF_IS_DEBUG = "isDebug"` — native Android mirror of the Dart key | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define afIsDebug @"isDebug"` | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` — sets `[AppsFlyerLib shared].isDebug` directly (lines 805, 813) | -| `doc/BasicIntegration.md`, `doc/API.md`, `doc/Testing.md` | Document `showDebug` and warn "do not release to production with this parameter set to `true`" | +| `lib/src/appsflyer_sdk.dart` | `enableDebug(bool enabled)` — maps to the `isDebug` RPC; `setLogLevel(AFLogLevel logLevel)` — Android-only, dispatched through RPC without a Dart platform check | +| `lib/src/appsflyer_constants.dart` | `AFLogLevel` (`none`, `error`, `warning`, `info`, `debug`, `verbose`) and its uppercase `rpcValue` | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | No per-method handler — generic `executeRpc` → `dispatchRpc` forwards `isDebug` / `setLogLevel` to `AppsFlyerRpcHandler` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | No per-method handler — generic `executeRpc` → `dispatchRpc` forwards `isDebug` to `AppsFlyerRPCBridge` | +| `doc/getting-started.md`, `doc/api-reference.md`, `doc/testing-and-troubleshooting.md` | Document `enableDebug` / `setLogLevel` and warn against releasing to production with debug logging enabled | --- ## Input / Output | | | |--|--| -| **Input** | `showDebug` (`bool`, defaults to `false`) via `AppsFlyerOptions` or the equivalent Map key | -| **Output** | `void` — toggles native SDK verbose logging as a side effect of init; no confirmation returned to Dart. | +| **Input** | `enabled` (`bool`) sent as the `isDebug` RPC parameter. Android only: `logLevel` (`AFLogLevel`) sent as its uppercase name. | +| **Output** | `Future` completes after native RPC validation and the synchronous SDK logging setter invocation. Validation or bridge failures throw `AppsFlyerException`; there is no native completion callback or timeout. Off Android, `setLogLevel` is still dispatched and throws `AppsFlyerException` once the native RPC layer reports the method as unavailable. | --- ## Tests -No dedicated test found. `test/appsflyer_sdk_test.dart`'s `check initSdk call` test uses `mapOptions: {'afDevKey': ...}` with no `isDebug` key set, so it only exercises the default-`false` path implicitly and never asserts the value of `AF_IS_DEBUG` in the resulting arguments map, nor exercises the `true` branch on either platform. +`test/appsflyer_sdk_test.dart`: +- `enableDebug maps to the isDebug RPC method` — asserts the RPC method is `isDebug` with params `{'isDebug': true}`. +- `maps every Android-only API` — asserts `setLogLevel` dispatches `setLogLevel` with the uppercase value for every `AFLogLevel`. +- `platform-only calls are forwarded to the native RPC instead of being swallowed in Dart` — asserts `setLogLevel(AFLogLevel.debug)` on iOS still dispatches the `setLogLevel` RPC. --- ## Known Limitations -- Android and iOS implement the flag differently: Android makes two separate native calls when enabling (`setLogLevel(AFLogger.LogLevel.DEBUG)` **and** `setDebugLog(true)`) but only one call when disabling (`setDebugLog(false)` — the log level is never explicitly reset), while iOS sets a single `isDebug` property that presumably controls both internally. This asymmetry is not tested and could produce subtly different logging verbosity between platforms if the native SDKs' internal defaults ever diverge. -- No public Dart getter exists to read back the current debug-logging state after init. -- The Dart-side null-coalescing comment (`// ignore: unnecessary_null_comparison`) on `options.showDebug != null` in `_validateAFOptions` suggests this check is dead code, since `showDebug` is a non-nullable `bool` with a default value in `AppsFlyerOptions` and can never be `null` at that call site. -- This is an init-time-only toggle — there is no runtime API in this plugin to turn debug logging on/off after `initSdk()` has already run. +- No public Dart getter exists to read back the current debug-logging state. +- Verbosity is not identical across platforms: `enableDebug` maps to the native Android `setDebugLog`, while a granular level requires the Android-only `setLogLevel`. iOS has no log-level equivalent in the RPC layer. +- The toggle is applied when the RPC is dispatched, so anything logged before the call (including a `start()` issued earlier) uses the previous setting. --- ## Dependencies -```mermaid -flowchart LR - F059["F-059 · Debug Logging Toggle"]:::sdkCore -->|"applied only during"| F001["F-001 · SDK Initialization & Options Validation"]:::sdkCore - classDef sdkCore fill:#4C6EF5,color:#fff -``` +No required feature dependency. `enableDebug` may run before `init()` and should run before the first `start()` whose diagnostics are needed. diff --git a/internal-docs/features/F-060-swift-package-manager-support.md b/internal-docs/features/F-060-swift-package-manager-support.md index 617fb980..379e124e 100644 --- a/internal-docs/features/F-060-swift-package-manager-support.md +++ b/internal-docs/features/F-060-swift-package-manager-support.md @@ -4,12 +4,12 @@ name: "Swift Package Manager (SPM) Support (Core, iOS)" type: sdkCore platform: ios status: active -last_verified: 2026-07-19 +last_verified: 2026-08-10 depends_on: [] --- ## Business Purpose -Flutter 3.44+ makes Swift Package Manager the default iOS integration mechanism, and CocoaPods trunk goes read-only on December 2, 2026 — after that date, this plugin could no longer publish new CocoaPods releases at all, and any app on Flutter 3.44+ that hadn't migrated would hit a hard build error instead of today's build warning. Without this feature, every consumer of the plugin would eventually be forced onto an unsupported distribution path, and competing attribution SDKs (Adjust, Singular) that already support SPM would have a real integration advantage. This feature adds a `Package.swift` manifest for the Core integration so apps can adopt SPM today, while leaving CocoaPods fully intact for apps that aren't ready to migrate or that need Purchase Connector (see Known Limitations). +Flutter 3.44+ uses Swift Package Manager as the default native dependency mechanism, while CocoaPods trunk is scheduled to become permanently read-only on December 2, 2026. Existing CocoaPods specs remain consumable, but publishing new podspec versions through trunk will no longer be possible. This feature adds a `Package.swift` manifest for the Core integration while retaining CocoaPods for apps that disable SPM or need Purchase Connector (see Known Limitations). Ticket: DELIVERY-125462. @@ -27,26 +27,32 @@ This feature has no runtime call chain — it is a build-time source-tree and ma ``` Shared source tree (used by both paths, single copy — no duplication): - ios/appsflyer_sdk/Sources/appsflyer_sdk/ - AppsflyerSdkPlugin.m (moved from ios/Classes/, content unmodified) - AppsFlyerAttribution.m (moved, unmodified) - AppsFlyerStreamHandler.m (moved, unmodified) - include/appsflyer_sdk/ - AppsflyerSdkPlugin.h (moved, unmodified — public header, pluginClass entry point) - AppsFlyerAttribution.h - AppsFlyerStreamHandler.h - FlutterAppDelegate+AppsFlyerStreamHandler.h - -SPM path (resolved by `flutter build`/`swift build` at build configuration time): - ios/appsflyer_sdk/Package.swift - → target "appsflyer_sdk" depends on product "AppsFlyerLib" from AppsFlyerFramework, pinned exactly to 6.18.0 - → compiles the shared Sources/ tree above as a ClangTarget, iOS 12.0 minimum - → does NOT reference ios/PurchaseConnector/ at all — no PurchaseConnector target/product exists in this manifest - -CocoaPods path (resolved by `pod install` at install time, unchanged behavior): + ios/appsflyer_sdk/Sources/appsflyer_sdk/ (Swift target — the only target) + AppsflyerSdkPlugin.swift (RPC bridge entry point, pluginClass entry point) + AppsFlyerAttribution.swift + AFRPCBridge.swift (main-actor-checked AppsFlyerRPCBridge access) + +SPM path (resolved by `flutter build` at build configuration time — not by standalone `swift package resolve` in the plugin checkout): + ios/appsflyer_sdk/Package.swift (swift-tools-version:5.9, platforms: [.iOS("13.0")]) + → path dependency `.package(name: "FlutterFramework", path: "../FlutterFramework")` per Flutter's + plugin-author SPM guide — resolves to ios/FlutterFramework relative to this manifest + → that directory is NOT committed in the plugin repo: Flutter tooling generates the + FlutterFramework Swift package in the consuming app's ephemeral build output during + `flutter pub get` / `flutter build`; removing the dependency breaks `import Flutter` + → binaryTarget "AppsFlyerRPC" — the RPC xcframework vendored directly by release URL + SHA-256 + (AppsFlyerRPC 7.0.12); at implementation review time, upstream tag 7.0.12's Package.swift + referenced the 7.0.1 asset / stale checksum, while the correction existed only on main + → dependency on AppsFlyerFramework, pinned exactly to 7.0.1 (AppsFlyerRPC 7.0.12 requires it) + → target "appsflyer_sdk" depends on FlutterFramework, AppsFlyerLib (from AppsFlyerFramework), + and AppsFlyerRPC — a single Swift target, so SPM's ban on mixing Swift and Objective-C + sources in one target never applies + → compiles the shared Sources/ tree above, iOS 13.0 minimum + → does NOT reference ios/PurchaseConnector/ — no PurchaseConnector target/product exists in this manifest + +CocoaPods path (resolved by `pod install` at install time): ios/appsflyer_sdk.podspec - subspec 'Core' → source_files/public_header_files repointed at the same shared Sources/ tree above - subspec 'PurchaseConnector' → untouched, still points at ios/PurchaseConnector/ (unmoved) + subspec 'Core' → source_files point at the shared Sources/ tree; depends on AppsFlyerRPC 7.0.12 + subspec 'PurchaseConnector' → depends on PurchaseConnector 7.0.1, still points at ios/PurchaseConnector/ ``` --- @@ -54,12 +60,11 @@ CocoaPods path (resolved by `pod install` at install time, unchanged behavior): ## Files | File | Role | |------|------| -| `ios/appsflyer_sdk/Package.swift` | New SPM manifest. `swift-tools-version:5.9` (Xcode 15.0+), `platforms: [.iOS("12.0")]` (matches the podspec's existing deployment target). Declares one product/target depending on `AppsFlyerFramework`'s `AppsFlyerLib` product, pinned `.exact("6.18.0")`, matching the podspec's exact CocoaPods pin. | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/*.m` | Core implementation files, moved verbatim from `ios/Classes/` via `git mv` (confirmed zero content diff) — now the single shared source tree for both CocoaPods and SPM. | -| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/*.h` | Public headers, moved verbatim from `ios/Classes/` — `AppsflyerSdkPlugin.h` is where `pluginClass: AppsflyerSdkPlugin` (declared in `pubspec.yaml`, unchanged) resolves from in both integration paths. | -| `ios/appsflyer_sdk.podspec` | `Core` subspec's `source_files`/`public_header_files` repointed to the new shared path; `PurchaseConnector` subspec is untouched. No marker added to declare SPM availability — Flutter's tooling detects it purely by the presence of `Package.swift` at the conventional path. | -| `ios/.gitignore` | Added `.build/` and `.swiftpm/` — local SPM resolution/build artifacts that must not be committed. | -| `CHANGELOG.md` | Documents SPM support added under the 6.18.0 entry, Purchase Connector's continued CocoaPods-only status, and a link to flutter/flutter#161182. | +| `ios/appsflyer_sdk/Package.swift` | SPM manifest. `swift-tools-version:5.9` (Xcode 15.0+), `platforms: [.iOS("13.0")]`. Declares the Flutter-required path dependency `.package(name: "FlutterFramework", path: "../FlutterFramework")` — the generated ephemeral package is created by Flutter in the consuming app, not checked into this repository (see Known Limitations). Vendors `AppsFlyerRPC` 7.0.12 as a `binaryTarget` (release URL + SHA-256 `14484bce…`). Depends on `AppsFlyerFramework` `.exact("7.0.1")` for `AppsFlyerLib`. | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/*.swift` | Core implementation files (`AppsflyerSdkPlugin.swift`, `AppsFlyerAttribution.swift`, `AFRPCBridge.swift`) — the single shared source tree for both CocoaPods and SPM, Swift only. `@objc(AppsflyerSdkPlugin)` is where `pluginClass: AppsflyerSdkPlugin` (declared in `pubspec.yaml`) resolves from in both integration paths. | +| `ios/appsflyer_sdk.podspec` | `Core` subspec depends on `AppsFlyerRPC 7.0.12` (which transitively pins `AppsFlyerFramework 7.0.1`); `PurchaseConnector` subspec depends on `PurchaseConnector 7.0.1`. No marker declares SPM availability — Flutter's tooling detects it purely by the presence of `Package.swift` at the conventional path. | +| `ios/.gitignore` | `.build/` and `.swiftpm/` — local SPM resolution/build artifacts that must not be committed. | +| `CHANGELOG.md` | Documents SPM support, Purchase Connector's continued CocoaPods-only status, and a link to flutter/flutter#161182. | --- @@ -72,33 +77,33 @@ CocoaPods path (resolved by `pod install` at install time, unchanged behavior): --- ## Tests -No dedicated automated test — this is a build-configuration/distribution-mechanism concern with no Dart or native runtime logic change, the same category as F-054 (Purchase Connector: Build-Time Opt-in), which sets the precedent that this class of change is verified via full builds rather than unit tests. Verification performed for this change: -- `swift package describe` — genuine dependency resolution against the live `AppsFlyerFramework` GitHub repository, confirming the manifest resolves product `AppsFlyerLib` at `Exact: 6.18.0` (corrected from an earlier `from:` range pin during review — see Known Limitations) and picks up all 3 Core `.m` sources correctly. -- `pod spec lint --quick --allow-warnings` — passed, confirming the podspec's repointed `source_files`/`public_header_files` globs resolve correctly against the moved tree. -- `flutter test test` — all 38 existing Dart tests pass unaffected (this change touches only iOS native file locations and build manifests, not Dart code). -- **Real-device iOS E2E, dispatched via GitHub Actions with real credentials — all 6 scenario phases PASS in each:** - - SPM, Core only, `.exact("6.18.0")` pin — [run 30191649705](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/actions/runs/30191649705). `getSDKVersion` confirmed resolving `6.18.0`, not a drifted patch release (an earlier run against the pre-fix `from:` pin had resolved `6.18.1` — see Known Limitations). **This is the only SPM configuration we recommend or support.** - - Pure CocoaPods, Core + PurchaseConnector, SPM explicitly disabled — [run 29901950273](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/actions/runs/29901950273). **This is the only supported configuration for apps using Purchase Connector.** - - SPM Core + CocoaPods PurchaseConnector configured simultaneously — [run 29848672331](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/actions/runs/29848672331). This run only demonstrates the app builds and links without a crash when both are configured at once (no duplicate-symbol failure) — it does **not** demonstrate Purchase Connector actually functions in this configuration, and CI's own logs suggest Flutter's tooling may silently drop the CocoaPods `PurchaseConnector` pod entirely once it detects the plugin has a `Package.swift`. **This combination is explicitly not supported or recommended** — see doc/Installation.md and doc/PurchaseConnector.md, both updated to state that apps using Purchase Connector must not enable SPM for this plugin at all. +No dedicated automated unit test — this is a build-configuration/distribution-mechanism concern with no Dart or native runtime logic change, the same category as F-054 (Purchase Connector: Build-Time Opt-in), which sets the precedent that this class of change is verified via full builds rather than unit tests. -> **Remaining gap**: whether SPM+PurchaseConnector configured simultaneously silently drops Purchase Connector or actively fails with `MissingPluginException` has not been conclusively confirmed either way — investigation was inconclusive (see above) and this combination is now explicitly unsupported regardless of the answer, so it was not pursued further. +**Automated CI (CocoaPods path only):** `.github/workflows/lint-test-build.yml` builds the iOS example app via `pod install` on every PR/push and RC release; `.github/workflows/ios-e2e.yml` runs the `.af-e2e/test-plan.json` scenario suite on a simulator after `pod install` (weekly cron + RC gate + manual `workflow_dispatch`). Neither workflow toggles Flutter SPM — they exercise the CocoaPods integration path only. Recent green runs: [Lint, Test & Build — Release 6.18.1](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/actions/runs/30246866162), [iOS E2E — weekly master](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/actions/runs/30185600580). + +**Recorded manual verification for the 7.0.x migration:** the migration notes record successful local Core builds. This documentation audit rechecked the current manifest and dependency declarations, but did not repeat a full Flutter/Xcode SPM build. There is still no dedicated CI matrix that toggles SPM: + +| Configuration | How verified | Status | +|---------------|--------------|--------| +| **SPM, Core only** (recommended) | `flutter build ios --simulator` (or device) with `flutter config --enable-swift-package-manager` and SPM enabled in the consuming app — Flutter generates the ephemeral `FlutterFramework` package and resolves the plugin manifest in that context. Do **not** use `swift package resolve` / opening `ios/appsflyer_sdk/Package.swift` in isolation as the gate; it fails because `../FlutterFramework` is absent from the plugin checkout by design. | Recorded migration verification used this path; manifest pins rechecked in audits | +| **CocoaPods, Core only** | `pod spec lint --quick --allow-warnings`; CI Lint/Test/Build + iOS E2E (above) | Covered by CI | +| **CocoaPods, Core + PurchaseConnector** | Example app with `$AppsFlyerPurchaseConnector = true`; ad-hoc iOS E2E on throwaway branch ([run 29848672331](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/actions/runs/29848672331), DELIVERY-125462 era, CocoaPods-only) | Historical 6.18.x evidence only; no current 7.0.1 run is recorded here | +| **SPM + PurchaseConnector** | Not supported — see Known Limitations | Explicitly not tested/recommended | + +Also verified for every release: `flutter test test` (Dart suite unaffected by iOS build-manifest changes). + +> **Gap:** a permanent CI job that builds the SPM Core path is not wired. Run the SPM Core-only row above for each release candidate and attach the result to the release checklist; also obtain current CocoaPods + Purchase Connector evidence when that optional component is in release scope. --- ## Known Limitations +- **The `FlutterFramework` path dependency is ephemeral and intentional.** `Package.swift` declares `.package(name: "FlutterFramework", path: "../FlutterFramework")` because [Flutter's SPM plugin-author guide](https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-plugin-authors) requires it and recent Flutter tool versions error at build time if it is missing. The directory is **not** part of the plugin repository — Flutter generates that Swift package in the consuming app's ephemeral build output when `flutter pub get` / `flutter build` runs. Running `swift package resolve`, `swift build`, or opening `ios/appsflyer_sdk/Package.swift` directly in Xcode against a bare plugin checkout therefore fails with "could not find package 'FlutterFramework'"; that is expected. The supported verification method is a full `flutter build ios` with Swift Package Manager enabled in the consuming app (see Tests table above). - **Purchase Connector is not available via SPM this release, with no opt-in mechanism at all.** `Package.swift` never references `ios/PurchaseConnector/` and has no equivalent of the podspec's `pod_target_xcconfig` macro injection, so `ENABLE_PURCHASE_CONNECTOR` is never defined for an SPM build under any configuration. Calling a Purchase Connector Dart API from an SPM-only integration fails with the same generic Flutter `MissingPluginException` that F-054 already documents for the CocoaPods not-opted-in case — this is not a new or worse failure mode, but it is a third, permanent path to it (not something a developer can fix by setting a flag, unlike the other two paths). Apps that need Purchase Connector must stay on CocoaPods until flutter/flutter#161182 is resolved. -- **SPM and Purchase Connector cannot be combined, even though nothing prevents an app from *configuring* both at once.** An app can set `$AppsFlyerPurchaseConnector = true` in its Podfile while also having SPM enabled — this doesn't crash or error at build time (verified: [run 29848672331](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/actions/runs/29848672331) built and linked successfully) — but CI's logs suggest Flutter's tooling may silently drop the CocoaPods `PurchaseConnector` pod once it decides the plugin is SPM-eligible, meaning the feature may silently not be present despite looking configured. This was not conclusively resolved either way; instead of continuing to investigate, this combination is explicitly documented as unsupported (`doc/Installation.md`, `doc/PurchaseConnector.md`): **apps using Purchase Connector must not enable SPM for this plugin at all.** +- **SPM and Purchase Connector cannot be combined.** An app can set `$AppsFlyerPurchaseConnector = true` in its Podfile while also having SPM enabled — this may build without duplicate-symbol errors, but Flutter's tooling can silently drop the CocoaPods `PurchaseConnector` pod once it detects the plugin has a `Package.swift`, meaning the feature may not be present despite looking configured. This combination is explicitly documented as unsupported (`doc/installation-guide.md`, `doc/purchase-connector.md`): **apps using Purchase Connector must not enable SPM for this plugin at all.** - **flutter/flutter#161182 (Flutter's own plugin tooling lacking conditional-compilation support under SPM) is the real blocker**, not a SwiftPM limitation — investigated during research (`internal-docs/researches/R-001-spm-support.md`), including whether SwiftPM Package Traits (Swift tools 6.1+) could work around it. They cannot: the issue's own text states Flutter would need to add trait support to its plugin tooling first, which it has not. - **Three architectural alternatives to bring Purchase Connector onto SPM were evaluated and rejected for this release** (see `internal-docs/researches/R-001-spm-support.md` addendum): a second product in the same `Package.swift` (not viable — Flutter's tooling only links one product per plugin, no documented support for a second), an environment-variable-gated compile flag (technically usable but fragile — requires every consuming app to set an env var on every build/CI run with silent failure if forgotten), and splitting Purchase Connector into its own federated pub.dev package (architecturally sound, no hidden blocker, but a separate, larger initiative with its own versioning/release pipeline — a candidate future initiative, not part of this ticket). --- ## Dependencies -```mermaid -flowchart LR - F060["F-060 · Swift Package Manager Support"]:::sdkCore - F054["F-054 · Purchase Connector: Build-Time Opt-in"]:::purchaseValidation - F060 -->|"adds a third, permanently-excluded iOS path to"| F054 - classDef sdkCore fill:#4C6EF5,color:#fff - classDef purchaseValidation fill:#F59F00,color:#fff -``` +No runtime feature dependency. The current SPM product intentionally excludes Purchase Connector; that is a build-support constraint, not a dependency on F-054. diff --git a/internal-docs/features/F-061-manual-location-logging.md b/internal-docs/features/F-061-manual-location-logging.md new file mode 100644 index 00000000..5b245527 --- /dev/null +++ b/internal-docs/features/F-061-manual-location-logging.md @@ -0,0 +1,49 @@ +--- +id: F-061 +name: Manual Location Logging +type: eventsAndRevenue +platform: both +status: active +last_verified: 2026-08-10 +depends_on: [] +--- + +## Business Purpose +`logLocation` reports a latitude/longitude pair as the native SDK's location event when an app has a permitted business reason to send location data. It is an explicit app call; the Flutter plugin does not request location permission or collect coordinates itself. + +## Trigger +Called after SDK setup when the app has coordinates and the required user permission/consent. The caller is responsible for platform permission handling and privacy disclosure. + +## Call Chain +``` +AppsFlyerSdk.logLocation(latitude:, longitude:) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('logLocation', {latitude, longitude}) + → MethodChannel('af-api').invokeMethod('executeRpc', envelope) + → Android RPC validates ranges → AppsFlyerLib.logLocation(context, latitude, longitude) + → iOS RPC validates ranges → AppsFlyerLib.logLocation(longitude: longitude, latitude: latitude) +``` + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | Public named-parameter API and RPC map | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic Android RPC forwarding | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Generic iOS RPC forwarding | +| Android `plugin_bridge/.../RpcRequest.kt` and `AppsFlyerRpcHandler.kt` | Range validation and SDK call | +| iOS `AppsFlyerRPC/.../AFRPCTypedRequests.swift` and `AFRPCDataHandler.swift` | Range validation and SDK call, including native longitude-first ordering | + +## Input / Output +| | | +|--|--| +| **Input** | `latitude` (`double`, -90...90) and `longitude` (`double`, -180...180). Both native RPC parsers reject out-of-range values. | +| **Output** | `Future` completes after RPC validation and synchronous native SDK invocation. It does not confirm event upload and has no request timeout. Validation/bridge failures surface as `AppsFlyerException`. | + +## Tests +`test/appsflyer_sdk_test.dart` verifies the complete Dart RPC map for `logLocation`. Native RPC suites cover range parsing and handler forwarding; the Dart test does not verify upload. + +## Known Limitations +- Dart performs no range validation, so invalid coordinates fail only after the channel round trip. +- Raw coordinates cross the Flutter channel. Permission, consent, minimization, and retention decisions belong to the host app and native SDK policy, not this bridge. + +## Dependencies +No required feature dependency. diff --git a/internal-docs/features/F-062-android-manual-session-logging.md b/internal-docs/features/F-062-android-manual-session-logging.md new file mode 100644 index 00000000..28c251ea --- /dev/null +++ b/internal-docs/features/F-062-android-manual-session-logging.md @@ -0,0 +1,47 @@ +--- +id: F-062 +name: Android Manual Session Logging +type: sdkCore +platform: android +status: active +last_verified: 2026-08-10 +depends_on: [] +--- + +## Business Purpose +`logSession()` exposes the Android native SDK's manual session logging API. It is an exceptional integration surface; normal Flutter lifecycle handling uses F-002 `start()` from each F-002 session-ready event. + +## Trigger +Called explicitly on Android only when an integration has a verified need to invoke the native manual-session API. It is not a replacement for initialization or the standard per-foreground `start()` workflow. + +## Call Chain +``` +AppsFlyerSdk.logSession() [lib/src/appsflyer_sdk.dart] + → off Android: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('logSession', {}) + → Android AppsflyerSdkPlugin generic RPC forwarding + → AppsFlyerRpcHandler → AppsFlyerLib.logSession(context) +``` + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | Public Android-only method, dispatched through RPC without a Dart platform check | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic RPC forwarding | +| Android `plugin_bridge/.../AppsFlyerRpcHandler.kt` | Invokes `AppsFlyerLib.logSession(context)` | + +## Input / Output +| | | +|--|--| +| **Input** | None; the RPC params map is empty. | +| **Output** | On Android, `Future` completes after synchronous native SDK invocation, with no delivery callback or timeout. Bridge failures surface as `AppsFlyerException`. Off Android the call is still dispatched and throws `AppsFlyerException` once the native RPC layer reports the method as unavailable. | + +## Tests +`test/appsflyer_sdk_test.dart` verifies the Android RPC name/empty params and, in `'platform-only calls are forwarded to the native RPC instead of being swallowed in Dart'`, that a non-Android call still reaches the RPC layer. Native handler tests cover forwarding. + +## Known Limitations +- The Flutter layer cannot tell whether the native SDK accepted or sent the manual session. +- Mixing this call with the normal F-002 start flow can create unexpected session accounting; use it only when the Android integration requirement is explicit. + +## Dependencies +No required feature dependency. F-002 remains the standard lifecycle workflow. diff --git a/internal-docs/features/F-063-custom-install-id.md b/internal-docs/features/F-063-custom-install-id.md new file mode 100644 index 00000000..2ff679b8 --- /dev/null +++ b/internal-docs/features/F-063-custom-install-id.md @@ -0,0 +1,51 @@ +--- +id: F-063 +name: Custom Install ID +type: sdkCore +platform: both +status: active +last_verified: 2026-08-10 +depends_on: [] +--- + +## Business Purpose +`setInstallId` replaces the SDK-generated AppsFlyer install ID with an app-supplied identifier for integrations that must correlate an existing install identity. This is an opt-in capability with deliberately different platform ordering. + +## Trigger +- iOS: set `AppsFlyerAllowCustomInstallId = YES` in `Info.plist` and call `setInstallId` before `init()` and before the first `getAppsFlyerUID()`. +- Android: add ``, call `init()`, then call `setInstallId` before `start()`. + +## Call Chain +``` +AppsFlyerSdk.setInstallId(installId) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setInstallId', {installId}) + → platform RPC requires a non-empty string + → native AppsFlyer SDK setInstallId + → accepted value becomes the value returned by getAppsFlyerUID() +``` + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | Public cross-platform API and ordering dartdoc | +| Android `plugin_bridge/.../RpcRequest.kt` and `AppsFlyerRpcHandler.kt` | Non-empty validation and SDK forwarding | +| Android `sdk_main/.../AppsFlyerLibCore.java` | Init/manifest guards and persistent install-ID update | +| iOS `AppsFlyerRPC/.../AFRPCTypedRequests.swift` and `AFRPCComplexConfigHandler.swift` | Non-empty validation and SDK forwarding | +| iOS `AppsFlyerLib/AppsFlyerLib.h` and `AppsFlyerLib.m` | Info.plist/order guards and native storage | + +## Input / Output +| | | +|--|--| +| **Input** | Non-empty `installId` (`String`) plus the platform opt-in flag and ordering above. | +| **Output** | `Future` confirms RPC validation and synchronous SDK invocation only. A missing opt-in flag or wrong native ordering is silently ignored by the SDK and is not returned as an error; bridge validation failures surface as `AppsFlyerException`. | + +## Tests +`test/appsflyer_sdk_test.dart` verifies the Dart RPC map. Native SDK tests cover the Android opt-in guard; the Flutter suite does not prove either platform's manifest/Info.plist setup. + +## Known Limitations +- This API changes install identity and should be used only for a deliberate migration/correlation design, not as a routine per-user identifier. +- The Future cannot distinguish an applied value from a native silent no-op. Verify `getAppsFlyerUID()` in integration testing. +- Ordering differs by platform and cannot be represented as one cross-platform call sequence. + +## Dependencies +No single dependency is valid on both platforms because iOS requires the call before F-001 while Android requires it after F-001. diff --git a/internal-docs/features/F-064-android-preinstall-attribution.md b/internal-docs/features/F-064-android-preinstall-attribution.md new file mode 100644 index 00000000..1193fd30 --- /dev/null +++ b/internal-docs/features/F-064-android-preinstall-attribution.md @@ -0,0 +1,52 @@ +--- +id: F-064 +name: Android Preinstall Attribution and Detection +type: platformIntegration +platform: android +status: active +last_verified: 2026-08-10 +depends_on: [] +--- + +## Business Purpose +OEM and manufacturer-distributed apps can explicitly label a preinstall campaign with `setPreinstallAttribution` and query native preinstall detection with `isPreInstalledApp()`. + +## Trigger +Call `setPreinstallAttribution` on Android before the first `start()` when the app has OEM campaign metadata. Call `isPreInstalledApp()` when the app needs the native SDK's current preinstall classification. + +## Call Chain +``` +setPreinstallAttribution(mediaSource, campaign:, siteId:) + → RPC {mediaSource, campaign, siteId} + → Android: AppsFlyerLib.setPreinstallAttribution(...) + → iOS: native RPC reports method not found → AppsFlyerException + +isPreInstalledApp() + → _invokeRpc('isPreInstalledApp') + → Android: AppsFlyerLib.isPreInstalledApp(context) → boolean reply + → iOS: native RPC reports method not found → AppsFlyerException (404) +``` + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | Public Android-only setter and `isPreInstalledApp()` getter, both routed through RPC without a Dart guard | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic RPC forwarding | +| Android `plugin_bridge/.../RpcRequest.kt`, `JsonRpcRequestParser.kt`, and `AppsFlyerRpcHandler.kt` | Validation, mapping, native calls, and getter reply | + +## Input / Output +| | | +|--|--| +| **Input** | `mediaSource` is required and non-empty; `campaign` and `siteId` are optional strings defaulting to `''`. `isPreInstalledApp()` has no input. | +| **Output** | The setter returns `Future` after synchronous SDK invocation; the getter returns `Future`. Off Android both throw `AppsFlyerException` when the native RPC layer reports the method as unavailable. On Android an unexpected native null reply also throws instead of being reported as `false`. Bridge failures surface as `AppsFlyerException`. | + +## Tests +`test/appsflyer_sdk_test.dart` verifies the setter map and the getter return value. `'platform-only getters surface the native method-not-found error'` covers `isPreInstalledApp()` on iOS; the setter's off-platform path has no dedicated test. Android native parser/handler tests cover validation and forwarding. + +## Known Limitations +- Dart does not validate an empty media source; Android RPC rejects it after the channel round trip. +- The setter Future does not confirm that the campaign was included in a Launch. It must run before the relevant `start()`. +- Off-platform calls throw `AppsFlyerException` instead of returning `false`. Unexpected native null replies on Android do the same. + +## Dependencies +No required feature dependency; this is configuration consumed by a later F-002 Launch. diff --git a/internal-docs/features/F-065-android-app-id-override.md b/internal-docs/features/F-065-android-app-id-override.md new file mode 100644 index 00000000..93b50e26 --- /dev/null +++ b/internal-docs/features/F-065-android-app-id-override.md @@ -0,0 +1,48 @@ +--- +id: F-065 +name: Android App ID Override +type: platformIntegration +platform: android +status: active +last_verified: 2026-08-10 +depends_on: [] +--- + +## Business Purpose +`setAppId` overrides the Android app ID reported by the native SDK for specialized distribution or attribution configurations. It is unrelated to the required iOS Apple App ID passed to `init()`. + +## Trigger +Called on Android before the first `start()` when the integration explicitly needs a reporting app-ID override. + +## Call Chain +``` +AppsFlyerSdk.setAppId(appId) [lib/src/appsflyer_sdk.dart] + → off Android: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc('setAppId', {appId}) + → Android RPC requires non-empty appId + → AppsFlyerLib.setAppId(appId) +``` + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | Public Android-only API, dispatched through RPC without a Dart platform check | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic RPC forwarding | +| Android `plugin_bridge/.../RpcRequest.kt` and `AppsFlyerRpcHandler.kt` | Non-empty validation and SDK setter | + +## Input / Output +| | | +|--|--| +| **Input** | Non-empty Android `appId` (`String`). | +| **Output** | On Android, `Future` completes after validation and synchronous native setter invocation, with no callback or timeout. Off Android the call is still dispatched and throws `AppsFlyerException` once the native RPC layer reports the method as unavailable. | + +## Tests +`test/appsflyer_sdk_test.dart` verifies the Android RPC map and, in `'platform-only calls are forwarded to the native RPC instead of being swallowed in Dart'`, that a non-Android call is forwarded to the RPC layer rather than swallowed. Android native tests cover parser and handler forwarding. + +## Known Limitations +- Dart does not validate an empty value; Android RPC returns the error as `AppsFlyerException`. +- The method name can be confused with iOS `init(appId:)`; the iOS initialization parameter is a different contract. +- No getter confirms the effective override. + +## Dependencies +No required feature dependency; the configured value is consumed by subsequent SDK requests. diff --git a/internal-docs/features/F-066-ios-device-data-collection-controls.md b/internal-docs/features/F-066-ios-device-data-collection-controls.md new file mode 100644 index 00000000..e5378113 --- /dev/null +++ b/internal-docs/features/F-066-ios-device-data-collection-controls.md @@ -0,0 +1,49 @@ +--- +id: F-066 +name: iOS Device Data Collection Controls +type: sdkCore +platform: ios +status: active +last_verified: 2026-08-10 +depends_on: [] +--- + +## Business Purpose +Two iOS-only privacy controls govern device metadata: `setDisableIDFVCollection` opts out of Identifier for Vendor collection, and `setShouldCollectDeviceName` opts into device-name collection, which is disabled by default. + +## Trigger +Apply the app's selected values before the first `start()` they should affect. Device-name collection should be enabled only when the app's privacy disclosures and consent basis cover it. + +## Call Chain +``` +AppsFlyerSdk.setDisableIDFVCollection(disable) +AppsFlyerSdk.setShouldCollectDeviceName(collect) [lib/src/appsflyer_sdk.dart] + → off iOS: native RPC reports the method as unavailable → AppsFlyerException + → _invokeVoidRpc(method, {disable|collect}) + → iOS simple-config RPC handler → native SDK property assignment +``` + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | Public iOS-guarded APIs and parameter keys | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift` | Generic RPC forwarding | +| iOS `AppsFlyerRPC/.../AFRPCTypedRequests.swift` and `AFRPCSimpleConfigHandler.swift` | Boolean parsing and SDK assignments | +| iOS `AppsFlyerLib/AppsFlyerLib.h` and `AppsFlyerLib.m` | Native properties/default behavior | + +## Input / Output +| | | +|--|--| +| **Input** | `disable` (`bool`) for IDFV; `collect` (`bool`) for device name. | +| **Output** | `Future` completes after RPC validation and synchronous native property assignment, with no callback or timeout. Off iOS each call logs, dispatches nothing, and completes normally. | + +## Tests +`test/appsflyer_sdk_test.dart` verifies both iOS RPC maps and the Android no-op guards. It does not verify actual identifier/name collection. + +## Known Limitations +- Neither setting has a public getter. +- These are native runtime settings; reapply the desired values after a cold start and before the first Launch. +- The Flutter plugin does not add consent UI or privacy-manifest declarations for the host app. + +## Dependencies +No required feature dependency. diff --git a/internal-docs/features/F-067-deep-link-resolution-timeout.md b/internal-docs/features/F-067-deep-link-resolution-timeout.md new file mode 100644 index 00000000..55b07444 --- /dev/null +++ b/internal-docs/features/F-067-deep-link-resolution-timeout.md @@ -0,0 +1,47 @@ +--- +id: F-067 +name: Deep-Link Resolution Timeout +type: deepLinking +platform: both +status: active +last_verified: 2026-08-10 +depends_on: [] +--- + +## Business Purpose +`setDeepLinkTimeout` controls how long the native SDK waits while resolving a deep-link URL. It is resolution configuration, not the timeout for a Flutter RPC call or the separate session-ready deep-link watchdog. + +## Trigger +Call before `init()` when the app needs to override the native default (3000 ms on Android, 60000 ms on iOS). + +## Call Chain +``` +AppsFlyerSdk.setDeepLinkTimeout(timeout) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('setDeepLinkTimeout', {timeout}) + → Android RPC: require timeout > 0 → AppsFlyerLib.setDeepLinkTimeout(timeout) + → iOS RPC: require timeout >= 0 → native deepLinkTimeout property +``` + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | Public API, milliseconds unit, defaults, and ordering dartdoc | +| Android `plugin_bridge/.../RpcRequest.kt` and `AppsFlyerRpcHandler.kt` | Positive-value validation and SDK call | +| iOS `AppsFlyerRPC/.../AFRPCTypedRequests.swift` and `AFRPCComplexConfigHandler.swift` | Non-negative validation and SDK property assignment | + +## Input / Output +| | | +|--|--| +| **Input** | `timeout` (`int`) in milliseconds. Android requires `> 0`; iOS accepts `0`, though a positive value is needed for cross-platform consistency. | +| **Output** | `Future` completes after validation and synchronous native configuration. It has no native completion callback or request timeout; validation/bridge failures surface as `AppsFlyerException`. | + +## Tests +`test/appsflyer_sdk_test.dart` verifies the Dart RPC map. Native RPC suites cover each platform's different zero-value rule. + +## Known Limitations +- Dart does not pre-validate the value, so `0` succeeds on iOS and fails on Android. +- Calling after `init()` violates the public ordering contract; the bridge does not detect or report that mistake. +- Do not confuse this value with F-002 awaited-request timeouts or the native session-ready service's own bounded deep-link condition. + +## Dependencies +No required feature dependency. diff --git a/internal-docs/features/F-068-deep-link-url-parameter-appending.md b/internal-docs/features/F-068-deep-link-url-parameter-appending.md new file mode 100644 index 00000000..519d16e5 --- /dev/null +++ b/internal-docs/features/F-068-deep-link-url-parameter-appending.md @@ -0,0 +1,47 @@ +--- +id: F-068 +name: Deep-Link URL Parameter Appending +type: deepLinking +platform: both +status: active +last_verified: 2026-08-10 +depends_on: [] +--- + +## Business Purpose +`appendParametersToDeepLinkingURL` configures parameters that the native SDK adds to matching deep-link URLs before resolution. It supports integrations that need stable attribution or routing parameters on links containing a known substring. + +## Trigger +Called during startup configuration before the matching URL is resolved, whether resolution comes from lifecycle entry points or `performDeepLinking`. + +## Call Chain +``` +AppsFlyerSdk.appendParametersToDeepLinkingURL(contains, parameters) [lib/src/appsflyer_sdk.dart] + → _invokeVoidRpc('appendParametersToDeepLinkingURL', {contains, parameters}) + → platform RPC validates request + → native SDK appendParametersToDeepLinkingURL(contains, parameters) +``` + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | Public API and string-map serialization | +| Android `plugin_bridge/.../RpcRequest.kt` and `AppsFlyerRpcHandler.kt` | Request parsing and SDK call | +| iOS `AppsFlyerRPC/.../AFRPCTypedRequests.swift` and `AFRPCDeepLinkHandler.swift` | Request validation and SDK call | + +## Input / Output +| | | +|--|--| +| **Input** | Non-empty `contains` substring and `Map parameters`. Android permits an empty map; iOS rejects one. | +| **Output** | `Future` completes after validation and synchronous native configuration, with no callback or timeout. Validation/bridge failures surface as `AppsFlyerException`. | + +## Tests +`test/appsflyer_sdk_test.dart` verifies the complete Dart RPC map. Native suites cover parser/handler behavior; there is no Flutter integration test that resolves a matching URL and inspects the rewritten result. + +## Known Limitations +- Dart does not enforce the platform difference for an empty parameter map. +- The Future confirms configuration only, not that a later URL matched or resolved. +- Matching and merge precedence are owned by the native SDK; the Flutter plugin does not inspect or sanitize URL values. + +## Dependencies +No required feature dependency. diff --git a/internal-docs/features/F-069-android-facebook-attribution-id.md b/internal-docs/features/F-069-android-facebook-attribution-id.md new file mode 100644 index 00000000..7a50bbdc --- /dev/null +++ b/internal-docs/features/F-069-android-facebook-attribution-id.md @@ -0,0 +1,47 @@ +--- +id: F-069 +name: Android Facebook Attribution ID Retrieval +type: platformIntegration +platform: android +status: active +last_verified: 2026-08-10 +depends_on: [] +--- + +## Business Purpose +`getAttributionId()` exposes the Facebook attribution ID visible to the Android native SDK, when available. It is a diagnostic/integration getter and is separate from F-032 Facebook deferred app links. + +## Trigger +Called on demand on Android when the app needs the native Facebook attribution identifier. + +## Call Chain +``` +AppsFlyerSdk.getAttributionId() [lib/src/appsflyer_sdk.dart] + → _invokeNullableRpc('getAttributionId', {}) + → Android AppsFlyerRpcHandler → AppsFlyerLib.getAttributionId(context) + → nullable string reply + → iOS: native RPC reports method not found → AppsFlyerException (404) +``` + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | Public Android-only nullable getter routed through RPC without a Dart guard | +| `android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.kt` | Generic RPC forwarding and reply serialization | +| Android `plugin_bridge/.../RpcRequest.kt` and `AppsFlyerRpcHandler.kt` | Getter request and native SDK call | + +## Input / Output +| | | +|--|--| +| **Input** | None; the RPC params map is empty. | +| **Output** | `Future` with the native value or `null`. Off Android it throws `AppsFlyerException` when the native RPC layer reports the method as unavailable. Bridge failures surface as `AppsFlyerException`. | + +## Tests +`test/appsflyer_sdk_test.dart` verifies the Android native-return mapping. `'platform-only getters surface the native method-not-found error'` covers the iOS wrong-platform path. Android handler tests verify the context-based SDK call. + +## Known Limitations +- `null` on Android means unavailable; wrong-platform calls throw `AppsFlyerException` instead of returning `null`. +- The Flutter plugin does not fetch, generate, validate, or persist this ID itself. + +## Dependencies +No required feature dependency. diff --git a/internal-docs/features/INDEX.md b/internal-docs/features/INDEX.md index 7ffa9557..276fe672 100644 --- a/internal-docs/features/INDEX.md +++ b/internal-docs/features/INDEX.md @@ -1,6 +1,8 @@ # AppsFlyer Flutter Plugin — Feature Catalog Index -60 features across 6 categories. See `DIAGRAM.md` for runtime/init dependency diagrams and the full dependency table. +69 catalogued features across 6 categories. Seven are `removed` in SDK 7 (F-008, F-021, F-023, F-036, F-038, F-056, F-058) — kept as tombstone entries pointing to the current API or migration documentation. See `DIAGRAM.md` for runtime/init dependency diagrams and the verified dependency table. + +> **Verification status:** Every entry was checked on **2026-08-10** against the current Dart API, platform plugin sources, pinned Android/iOS RPC implementations, relevant native SDK behavior, and available tests. `last_verified` records an implementation audit, not proof of end-to-end device behavior; each feature's Tests and Known Limitations sections identify remaining coverage gaps. --- @@ -10,31 +12,34 @@ SDK lifecycle, identity, privacy/consent, and low-level configuration. | ID | Name | Status | Platform | |----|------|--------|----------| -| F-001 | SDK Initialization & Options Validation | active | both | -| F-002 | SDK Start (auto/manual + result handler) | active | both | +| F-001 | SDK Initialization | active | both | +| F-002 | SDK Start (session launch) | active | both | | F-003 | SDK/Plugin Version Retrieval | active | both | | F-006 | Custom Host Configuration | active | both | -| F-007 | Device ID Collection Opt-out (IMEI/Android ID) | active | android | -| F-008 | Manual IMEI/Android ID Override | active | android | +| F-007 | Android ID Collection Opt-out | active | android | +| F-008 | Manual IMEI/Android ID Override | removed | android | | F-009 | Minimum Time Between Sessions | active | both | | F-011 | TCF/DMA Automatic Consent Collection | active | both | -| F-012 | Manual GDPR/DMA Consent API (V1 + V2) | active | both | +| F-012 | Manual GDPR/DMA Consent API | active | both | | F-013 | User Anonymization (Opt-out logging) | active | both | | F-015 | Customer User ID (CUID) | active | both | | F-016 | Update vs. Fresh-Install Flag | active | android | | F-017 | SDK Kill Switch (stop) | active | both | | F-018 | Uninstall Measurement | active | both | -| F-019 | User Email Collection (with encryption) | active | both | +| F-019 | User PII Collection and Clearing | active | both | | F-020 | AppsFlyer UID Retrieval | active | both | -| F-021 | Delayed Session Start Pending CUID | active | android | +| F-021 | Delayed Session Start Pending CUID | removed | android | | F-034 | Advertising Identifier Collection Disable | active | both | | F-046 | Disable Network Data Transfer | active | android | | F-047 | AppSet ID Collection Opt-out (Android) | active | android | | F-048 | Plugin Metadata Reporting to Native SDK | active | both | | F-057 | ASA (Apple Search Ads) Collection Opt-out | active | ios | -| F-058 | ATT Authorization Wait Timeout (iOS) | active | ios | +| F-058 | ATT Authorization Wait Timeout (iOS) | removed | ios | | F-059 | Debug Logging Toggle | active | both | | F-060 | Swift Package Manager (SPM) Support (Core, iOS) | active | ios | +| F-062 | Android Manual Session Logging | active | android | +| F-063 | Custom Install ID | active | both | +| F-066 | iOS Device Data Collection Controls | active | ios | ## eventsAndRevenue @@ -46,6 +51,7 @@ Reporting in-app events, ad revenue, and monetary context back to AppsFlyer. | F-005 | Ad Revenue Logging | active | both | | F-010 | Currency Code Setting | active | both | | F-026 | Additional Custom Data | active | both | +| F-061 | Manual Location Logging | active | both | ## purchaseValidation @@ -53,16 +59,16 @@ Server-side validation of purchases/subscriptions — legacy API and the Purchas | ID | Name | Status | Platform | |----|------|--------|----------| -| F-023 | In-App Purchase Validation V1 (Android/iOS separate APIs) | deprecated | both | +| F-023 | In-App Purchase Validation V1 (Android/iOS separate APIs) | removed | both | | F-024 | In-App Purchase Validation V2 (cross-platform) | active | both | | F-025 | iOS Receipt Validation Sandbox Toggle | active | ios | -| F-038 | Legacy Purchase-Validation Notification Callback | active | both | +| F-038 | Legacy Purchase-Validation Notification Callback | removed | both | | F-049 | Purchase Connector: Configuration & Lifecycle | active | both | | F-050 | Purchase Connector: StoreKit Version Selection (iOS) | active | ios | | F-051 | Purchase Connector: Android Validation Result Listeners | active | android | | F-052 | Purchase Connector: iOS Combined Validation Callback | active | ios | | F-053 | Purchase Connector: Google Play Purchase/Subscription Data Models | active | android | -| F-054 | Purchase Connector: Build-Time Opt-in (Android include/exclude variants) | active | both | +| F-054 | Purchase Connector: Build-Time Opt-in | active | both | | F-055 | Missing-Configuration Guard for Purchase Connector | active | both | ## deepLinking @@ -71,16 +77,18 @@ Resolving, forwarding, and delivering deep-link/attribution results across platf | ID | Name | Status | Platform | |----|------|--------|----------| -| F-014 | Manual Deep-Link Re-trigger (performOnDeepLinking) | active | android | +| F-014 | Manual Deep-Link Re-trigger (performDeepLinking) | active | both | | F-022 | Push Notification Deep-Link Path Config | active | both | | F-031 | Push Notification Data Handling | active | both | | F-032 | Facebook Deferred App Links | active | both | | F-035 | Conversion Data Callback (GCD) | active | both | -| F-036 | App-Open Attribution Callback (OAOA) | active | both | +| F-036 | App-Open Attribution Callback (OAOA) | removed | both | | F-037 | Unified Deep Linking (UDL) Callback & Models | active | both | | F-039 | Native iOS Deep-Link Entry Points (URL scheme / Universal Links / Scenes) | active | ios | | F-040 | Android New-Intent Deep-Link Forwarding | active | android | | F-045 | Deep-Link URL Resolution Allow-list | active | both | +| F-067 | Deep-Link Resolution Timeout | active | both | +| F-068 | Deep-Link URL Parameter Appending | active | both | ## oneLinkAndGrowth @@ -92,7 +100,7 @@ OneLink-based invite/referral link generation and cross-app promotion. | F-028 | App Invite OneLink ID Configuration | active | both | | F-029 | Cross-Promotion Impression/Click Tracking | active | both | | F-030 | Custom/Branded OneLink Domains | active | both | -| F-056 | App Invite Link OneLink ID (init-time) | active | both | +| F-056 | App Invite OneLink ID (init-time option) | removed | both | ## platformIntegration @@ -105,3 +113,6 @@ Partner-ecosystem hooks and platform-specific attribution quirks. | F-042 | Partner Postback Sharing Filter | active | both | | F-043 | Out-of-Store Install Source (Android) | active | android | | F-044 | Partner-Specific Data | active | both | +| F-064 | Android Preinstall Attribution and Detection | active | android | +| F-065 | Android App ID Override | active | android | +| F-069 | Android Facebook Attribution ID Retrieval | active | android | diff --git a/internal-docs/features/TEMPLATE.md b/internal-docs/features/TEMPLATE.md index c90ff645..bd459e7b 100644 --- a/internal-docs/features/TEMPLATE.md +++ b/internal-docs/features/TEMPLATE.md @@ -3,18 +3,25 @@ id: F-NNN name: Feature Name type: [category] platform: [platform] -status: active / planned / deprecated +status: active / planned / deprecated / removed last_verified: YYYY-MM-DD depends_on: [] --- +Metadata rules: + +- `active` means the current public/plugin/native implementation supports the feature. Use `removed` for a tombstone that documents a deleted API and its replacement. Use `planned` or `deprecated` only when implementation/release evidence supports that state. +- `platform` is exactly `android`, `ios`, or `both`. +- `depends_on` lists only feature IDs required by the implemented capability or its complete documented workflow. Do not add thematic similarity, optional configuration, implementation-helper reuse, or an inverse “is used by” relationship. In dependency diagrams, `A --> B` means A depends on B. +- Update `last_verified` only after checking the current Dart API, platform plugin, relevant native RPC/SDK layers, tests, and dependency configuration. A prose-only edit is not implementation verification. + ## Business Purpose Why this feature exists. What the user or product loses if it is removed. --- ## Trigger -When this feature runs. What condition activates it. +When this feature runs. State verified ordering such as before `init()`, after `init()`, before `start()`, once per foreground, or once per process. If ordering differs by platform, list each platform separately. --- @@ -39,11 +46,15 @@ EntryPoint::method() | **Input** | What comes in | | **Output** | What goes out | +For a `Future`, say whether completion means synchronous setter invocation, native callback completion, or event delivery. Name any timeout and whether native work can continue afterward. Keep validation errors, `AppsFlyerException`, event failure payloads, and platform no-op defaults distinct. + --- ## Tests `path/to/test_file` — what the tests cover. +State missing native, lifecycle, packaging, or end-to-end coverage explicitly. A Dart RPC-map test does not prove native SDK behavior. + --- ## Known Limitations @@ -57,3 +68,5 @@ flowchart LR FXXX["F-XXX · This Feature"]:::typeA -->|"relationship"| FYYY["F-YYY · Other Feature"]:::typeB [classDef blocks — one per approved category] ``` + +If `depends_on` is empty, use a standalone node or concise prose. Keep optional relationships out of dependency metadata and label them explicitly if they are useful to show elsewhere. diff --git a/internal-docs/prds/spm-support.md b/internal-docs/prds/spm-support.md index 3cd10f0d..2398cce4 100644 --- a/internal-docs/prds/spm-support.md +++ b/internal-docs/prds/spm-support.md @@ -2,10 +2,13 @@ ticket: DELIVERY-125462 priority: P1 target: v6.18.0, end of July 2026 +status: superseded --- # PRD: Swift Package Manager (SPM) Support +> **⚠️ Superseded — historical planning record (do not treat as current).** This PRD targeted SPM support for the SDK **6.18.0** line (DELIVERY-125462, "target: v6.18.0"). The plugin has since migrated to **SDK 7 / RPC**: iOS now vendors the **AppsFlyerRPC 7.0.12** static xcframework as a `binaryTarget` (→ AppsFlyerFramework 7.0.1) and targets **iOS 13.0**. Current SPM state lives in `ios/appsflyer_sdk/Package.swift`, `internal-docs/features/F-060-swift-package-manager-support.md`, and `internal-docs/ARCHITECTURE.md`. Kept for context only. + ## Problem The plugin's iOS integration ships only via CocoaPods (`ios/appsflyer_sdk.podspec`). Two industry shifts make this untenable on the current timeline: diff --git a/internal-docs/researches/R-001-spm-support.md b/internal-docs/researches/R-001-spm-support.md index 4b14358f..89387487 100644 --- a/internal-docs/researches/R-001-spm-support.md +++ b/internal-docs/researches/R-001-spm-support.md @@ -2,15 +2,18 @@ id: R-001 title: Swift Package Manager (SPM) support — feasibility, PurchaseConnector blocker, and prior art versions: "Flutter 3.24 (experimental) – 3.44+ (default); Swift tools 5.3 – 5.9; Xcode 12+ (Package.swift baseline), Xcode 15+ (this plugin's actual manifest)" -status: complete +status: superseded +research_outcome: complete date: 2026-07-19 -affects-features: [F-054] +affects-features: [F-054, F-060] related-issue-cases: [] --- +> **⚠️ Superseded — historical research record (do not treat as current).** This research supported SPM support on the SDK **6.18.0** line (DELIVERY-125462). The plugin has since migrated to **SDK 7 / RPC**: iOS vendors the **AppsFlyerRPC 7.0.12** static xcframework as a `binaryTarget` (→ AppsFlyerFramework 7.0.1), targets **iOS 13.0**, and does not consume the upstream RPC package manifest. At the implementation review that produced the current manifest, the 7.0.12 tag carried a stale asset/checksum. Current state: `ios/appsflyer_sdk/Package.swift`, `internal-docs/features/F-060-swift-package-manager-support.md`, and `internal-docs/ARCHITECTURE.md`. Kept for context only. + ## Summary -Researched for DELIVERY-125462 / PRD `docs/prds/spm-support.md`. No prior research or issue-case docs existed on this topic (`docs/researches/` and `docs/issue-cases/` are both empty in this repo). Checked GitHub directly (issues/PRs on this plugin's repo, flutter/flutter, and AppsFlyerSDK/AppsFlyerFramework) rather than relying on secondhand summaries. +Researched for DELIVERY-125462 / PRD `internal-docs/prds/spm-support.md`. No prior research or issue-case document existed on this topic when this record was created. Checked GitHub directly (issues/PRs on this plugin's repo, flutter/flutter, and AppsFlyerSDK/AppsFlyerFramework) rather than relying on secondhand summaries. Key finding: **Swift Package Manager Traits do NOT unblock PurchaseConnector.** flutter/flutter#161182 — the exact issue the ticket cites — is literally titled "[SwiftPM] Support conditional compilation in plugins" and is still **OPEN**, unassigned, P3. It states plainly: "Swift Package Manager does not support conditional compilation," and lists two possible fixes, neither shipped: (1) a documented hacky workaround, or (2) "Update Flutter to support Swift package traits **if/when that lands**." Traits are a SwiftPM-language feature (SE-0450, Swift tools 6.1+) — the blocker is that **Flutter's own plugin build tooling** has no support for conditional compilation of plugin code, with or without traits underneath. Until Flutter's tooling adds that support, PurchaseConnector cannot be conditionally included via SPM regardless of what SwiftPM itself offers. This confirms the PRD's non-goal was correctly scoped: don't chase traits for this release. diff --git a/internal-docs/tech-designs/spm-support.md b/internal-docs/tech-designs/spm-support.md index 5766fa06..d97da61c 100644 --- a/internal-docs/tech-designs/spm-support.md +++ b/internal-docs/tech-designs/spm-support.md @@ -2,16 +2,19 @@ ticket: DELIVERY-125462 prd: internal-docs/prds/spm-support.md research: internal-docs/researches/R-001-spm-support.md -planned_feature_doc: F-060 — doc to be written after development is complete +status: superseded +implemented_feature_doc: F-060 --- # Tech Design: Swift Package Manager (SPM) Support +> **⚠️ Superseded — historical design record (do not treat as current).** This plans SPM support for the SDK **6.18.0** line (DELIVERY-125462): `AppsFlyerFramework` 6.18.0, iOS 12.0, the old `ios/Classes/` layout, and files such as `AppsFlyerStreamHandler.*` / `FlutterAppDelegate+AppsFlyerStreamHandler.h`. The SDK 7 RPC migration replaced all of this: the iOS plugin now vendors the **AppsFlyerRPC 7.0.12** static xcframework as a `binaryTarget` (→ AppsFlyerFramework 7.0.1), targets **iOS 13.0**, and the stream-handler files above no longer exist. For the current setup see `ios/appsflyer_sdk/Package.swift`, `internal-docs/features/F-060-swift-package-manager-support.md`, and `internal-docs/ARCHITECTURE.md`. Retained for design context only. + ## Context table | Type | ID | Name | |------|----|------| -| Issue case | none | `docs/issue-cases/` does not exist in this repo yet — no hot-zone history to check | +| Issue case | none | No `internal-docs/issue-cases/` directory existed when this historical design was written | | Feature doc | F-054 | Purchase Connector: Build-Time Opt-in — directly extended by this design | ## Approach @@ -127,7 +130,7 @@ All four must be run on a real device build, not just `--no-codesign`, before Al ## Documentation impact (flag only — action in Phase 3) -- **F-054** (`docs/features/F-054-purchase-connector-build-time-opt-in.md`): add SPM as a third gating path in its Call Chain/Files sections, and add the corrected failure-mode bullet to Known Limitations (see above) once implementation lands. +- **F-054** (`internal-docs/features/F-054-purchase-connector-build-time-opt-in.md`): add SPM as a third gating path in its Call Chain/Files sections, and add the corrected failure-mode bullet to Known Limitations (see above) once implementation lands. - **F-060** (new): this feature's own catalog entry, written in Phase 3 from the real implemented code — supersedes the placeholder discussion from earlier in this session; do not reuse any earlier draft. - `CHANGELOG.md` and release notes (PRD requirement 5): document SPM support added for Core, PurchaseConnector's continued CocoaPods-only status, and link flutter/flutter#161182 for apps tracking when that might change. diff --git a/ios/PurchaseConnector/PurchaseConnectorPlugin.swift b/ios/PurchaseConnector/PurchaseConnectorPlugin.swift index 82ef7772..68bfc5d0 100644 --- a/ios/PurchaseConnector/PurchaseConnectorPlugin.swift +++ b/ios/PurchaseConnector/PurchaseConnectorPlugin.swift @@ -24,6 +24,14 @@ import Flutter /// Instance of method channel providing a bridge to Dart code. private var methodChannel: FlutterMethodChannel? = nil + + /// Registrar whose engine installed the channel this singleton currently holds. + /// + /// This plugin keeps one channel per process while registration happens per engine, so a host + /// running several engines hands the channel to whichever one registered last. Recording the + /// registrar lets a detaching engine tell whether the channel is still its own before tearing + /// anything down, the same guard `AFRPCBridge` applies to the RPC event handler. + private weak var owningRegistrar: FlutterPluginRegistrar? = nil private var logOptions: AutoLogPurchaseRevenueOptions = [] @@ -38,10 +46,47 @@ import Flutter /// Mandatory method needed to register the plugin with iOS part of Flutter app. public static func register(with registrar: FlutterPluginRegistrar) { /// Create a new method channel with the registrar. + shared.owningRegistrar = registrar shared.methodChannel = FlutterMethodChannel(name: AF_PURCHASE_CONNECTOR_CHANNEL, binaryMessenger: registrar.messenger()) shared.methodChannel!.setMethodCallHandler(shared.methodCallHandler) } + /// Releases everything this engine's registration owns, mirroring `EngineAttachment.dispose()` in + /// the Android connector: transaction observation stops, the delegate stops pointing at a channel + /// whose engine is gone, and `configure` becomes available again for the next engine. + /// + /// Called by `AppsflyerSdkPlugin.detachFromEngineForRegistrar:` — this plugin publishes no + /// instance of its own, so it has no detach callback to receive directly. + internal static func tearDownForEngineDetach(registrar: FlutterPluginRegistrar) { + onMain { + shared.tearDown(registrar: registrar) + } + } + + private func tearDown(registrar: FlutterPluginRegistrar) { + /// A stale engine must not stop observing transactions for an engine that registered after it. + guard owningRegistrar === registrar else { + return + } + owningRegistrar = nil + connector?.stopObservingTransactions() + connector?.purchaseRevenueDelegate = nil + connector = nil + logOptions = [] + methodChannel?.setMethodCallHandler(nil) + methodChannel = nil + } + + /// Engine detach is the one entry point that may run off the main thread, and both StoreKit + /// observation and channel teardown belong on it. + private static func onMain(_ body: @escaping () -> Void) { + if Thread.isMainThread { + body() + } else { + DispatchQueue.main.async(execute: body) + } + } + /// Method called when a Flutter method call occurs. It handles and routes flutter method invocations. private func methodCallHandler(call: FlutterMethodCall, result: @escaping FlutterResult) { switch(call.method) { diff --git a/ios/appsflyer_sdk.podspec b/ios/appsflyer_sdk.podspec index 3c35e2f6..e113ec1c 100644 --- a/ios/appsflyer_sdk.podspec +++ b/ios/appsflyer_sdk.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'appsflyer_sdk' - s.version = '6.18.1' + s.version = '7.0.1' s.summary = 'AppsFlyer Integration for Flutter' s.description = 'AppsFlyer is the market leader in mobile advertising attribution & analytics, helping marketers to pinpoint their targeting, optimize their ad spend and boost their ROI.' s.homepage = 'https://github.com/AppsFlyerSDK/flutter_appsflyer_sdk' @@ -8,9 +8,12 @@ Pod::Spec.new do |s| s.author = { "Appsflyer" => "build@appsflyer.com" } s.source = { :git => "https://github.com/AppsFlyerSDK/flutter_appsflyer_sdk.git", :tag => s.version.to_s } - s.ios.deployment_target = '12.0' + # SDK 7 requires iOS 13+ (AppsFlyerFramework 7.0.1 and the AppsFlyerRPC bridge both target iOS 13.0). + s.ios.deployment_target = '13.0' s.requires_arc = true s.static_framework = true + # Matches the SPM manifest's swift-tools-version:5.9. + s.swift_version = '5.9' if defined?($AppsFlyerPurchaseConnector) s.default_subspecs = 'Core', 'PurchaseConnector' else @@ -18,18 +21,22 @@ Pod::Spec.new do |s| end s.subspec 'Core' do |ss| - ss.source_files = 'appsflyer_sdk/Sources/appsflyer_sdk/**/*.{h,m}' - ss.public_header_files = 'appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/*.h' + ss.source_files = 'appsflyer_sdk/Sources/appsflyer_sdk/**/*.swift' ss.dependency 'Flutter' - ss.ios.dependency 'AppsFlyerFramework','6.18.1' + ss.ios.dependency 'AppsFlyerRPC', '7.0.12' end s.subspec 'PurchaseConnector' do |ss| ss.dependency 'Flutter' - ss.ios.dependency 'PurchaseConnector', '6.18.2' + ss.ios.dependency 'PurchaseConnector', '7.0.1' ss.source_files = 'PurchaseConnector/**/*' ss.public_header_files = 'PurchaseConnector/**/*.h' - ss.pod_target_xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) ENABLE_PURCHASE_CONNECTOR=1' } + # GCC_PREPROCESSOR_DEFINITIONS only reaches the Objective-C compiler; the Core plugin is Swift + # now, so the same opt-in has to be declared as a Swift compilation condition as well. + ss.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) ENABLE_PURCHASE_CONNECTOR=1', + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS' => '$(inherited) ENABLE_PURCHASE_CONNECTOR' + } end end diff --git a/ios/appsflyer_sdk/Package.swift b/ios/appsflyer_sdk/Package.swift index 69faf72a..ef424701 100644 --- a/ios/appsflyer_sdk/Package.swift +++ b/ios/appsflyer_sdk/Package.swift @@ -3,22 +3,31 @@ import PackageDescription let package = Package( name: "appsflyer_sdk", - platforms: [.iOS("12.0")], + platforms: [.iOS("13.0")], products: [ .library(name: "appsflyer-sdk", targets: ["appsflyer_sdk"]) ], dependencies: [ - .package(url: "https://github.com/AppsFlyerSDK/AppsFlyerFramework.git", .exact("6.18.0")) + .package(name: "FlutterFramework", path: "../FlutterFramework"), + .package( + url: "https://github.com/AppsFlyerSDK/AppsFlyerFramework.git", + exact: "7.0.1" + ) ], targets: [ + .binaryTarget( + name: "AppsFlyerRPC", + url: "https://github.com/AppsFlyerSDK/appsflyer-apple-rpc/releases/download/7.0.12/AppsFlyerRPC-static.xcframework.zip", + checksum: "14484bce262c2bea03cb4fb0ca85818560dd72831915246f5cc2686eb196f87f" + ), .target( name: "appsflyer_sdk", dependencies: [ - .product(name: "AppsFlyerLib", package: "AppsFlyerFramework") + .product(name: "FlutterFramework", package: "FlutterFramework"), + .product(name: "AppsFlyerLib", package: "AppsFlyerFramework"), + "AppsFlyerRPC" ], - cSettings: [ - .headerSearchPath("include/appsflyer_sdk") - ] + path: "Sources/appsflyer_sdk" ) ] ) diff --git a/ios/appsflyer_sdk/Sources/appsflyer_sdk/AFRPCBridge.swift b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AFRPCBridge.swift new file mode 100644 index 00000000..471d003d --- /dev/null +++ b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AFRPCBridge.swift @@ -0,0 +1,84 @@ +// +// AFRPCBridge.swift +// appsflyer_sdk +// + +import Foundation +import AppsFlyerRPC + +/// The plugin's single point of contact with the `@MainActor`-isolated `AppsFlyerRPCBridge`, in both +/// directions: outbound RPC calls from the non-isolated contexts this plugin runs in (Flutter channel +/// handlers, `UIApplication` and `UIScene` delegate callbacks, engine detach), and inbound events. +/// +/// All of those already run on the main thread, so `MainActor.assumeIsolated` keeps each call +/// synchronous — the request reaches the bridge before the channel handler returns, and the event +/// handler is attached before `init(messenger:)` returns — while turning that assumption into a +/// checked precondition. Hopping through `Task { @MainActor in }` instead would defer every call by +/// one main-actor turn, which the event-handler registration order and the `executeRpc` round trip +/// both rely on not happening. +/// +/// Engine detach is the one caller that may release the plugin off the main thread, so a queue hop +/// covers it rather than tripping the precondition. +enum AFRPCBridge { + + /// Owner of the handler currently installed in `AppsFlyerRPCBridge`'s single global slot. + /// + /// The slot holds one handler per process while plugin instances are per engine, so a host + /// running several engines (add-to-app, `FlutterEngineGroup`, multi-scene) hands the slot to + /// whichever instance registered last. Recording the owner lets a detaching instance tell + /// whether the installed handler is still its own, mirroring the `this.sink === sink` guard in + /// `AppsFlyerEventBus.detach`. Weak so a released plugin cannot keep itself alive here. + @MainActor private static weak var eventHandlerOwner: AnyObject? + + /// `completion` is always invoked on the main thread. + /// + /// AppsFlyerRPC documents main-thread delivery today, but the hop is one line inside a vendored + /// binary framework. Normalizing here means a future RPC version that resumes off the main actor + /// degrades into an extra queue hop instead of unsynchronized mutations in plugin state (for + /// example `markBridgeReady` / `pendingEvents`) from an RPC completion. + static func executeJson(_ jsonRequest: String, completion: @escaping (String) -> Void) { + onMainActor { + AppsFlyerRPCBridge.shared.executeJson(jsonRequest) { response in + onMainActor { completion(response) } + } + } + } + + /// `handler` is always invoked on the main thread, enqueued through `DispatchQueue.main.async` + /// even when the caller is already on the main thread. + /// + /// A same-thread fast path would let a main-thread emission deliver synchronously ahead of an + /// earlier background-thread emission still queued behind it, reordering af-events callbacks. + /// Android always posts through `uiThreadHandler` for the same reason. `Task { @MainActor in }` + /// is the wrong tool here — GCD's async enqueue is the documented strict-FIFO contract. + static func setEventHandler(owner: AnyObject, _ handler: @escaping (String) -> Void) { + onMainActor { + eventHandlerOwner = owner + AppsFlyerRPCBridge.shared.setEventHandler { jsonEvent in + DispatchQueue.main.async { handler(jsonEvent) } + } + } + } + + /// No-op unless `owner` still holds the global slot: an engine tearing down must not silence the + /// events of an engine that registered after it and is still alive. + static func removeEventHandler(owner: AnyObject) { + onMainActor { + guard eventHandlerOwner === owner else { + return + } + eventHandlerOwner = nil + AppsFlyerRPCBridge.shared.removeEventHandler() + } + } + + private static func onMainActor(_ body: @escaping @MainActor () -> Void) { + if Thread.isMainThread { + MainActor.assumeIsolated(body) + } else { + DispatchQueue.main.async { + MainActor.assumeIsolated(body) + } + } + } +} diff --git a/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.m b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.m deleted file mode 100644 index 0ed55ca8..00000000 --- a/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.m +++ /dev/null @@ -1,86 +0,0 @@ -// -// AppsFlyerAttribution.m -// flutter-appsflyer -// -// Created by Amit Kremer on 11/02/2021. -// - -#import -#import "AppsFlyerAttribution.h" - -@implementation AppsFlyerAttribution - -+ (id)shared { - static AppsFlyerAttribution *shared = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - shared = [[self alloc] init]; - }); - return shared; -} - -- (id)init { - if (self = [super init]) { - self.options = nil; - self.restorationHandler = nil; - self.url = nil; - self.userActivity = nil; - self.annotation = nil; - self.sourceApplication = nil; - self.isBridgeReady = NO; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(receiveBridgeReadyNotification:) - name:AF_BRIDGE_SET - object:nil]; - } - return self; -} - -- (void) continueUserActivity: (NSUserActivity*_Nullable) userActivity restorationHandler: (void (^_Nullable)(NSArray * _Nullable))restorationHandler{ - if(self.isBridgeReady == YES){ - [[AppsFlyerLib shared] continueUserActivity:userActivity restorationHandler:restorationHandler]; - }else{ - [AppsFlyerAttribution shared].userActivity = userActivity; - [AppsFlyerAttribution shared].restorationHandler = restorationHandler; - } -} - -- (void) handleOpenUrl:(NSURL *)url options:(NSDictionary *)options{ - if(self.isBridgeReady == YES){ - [[AppsFlyerLib shared] handleOpenUrl:url options:options]; - }else{ - [AppsFlyerAttribution shared].url = url; - [AppsFlyerAttribution shared].options = options; - } -} - -- (void) handleOpenUrl:(NSURL *)url sourceApplication:(NSString*)sourceApplication annotation:(id)annotation{ - if(self.isBridgeReady == YES){ - [[AppsFlyerLib shared] handleOpenURL:url sourceApplication:sourceApplication withAnnotation:annotation]; - }else{ - [AppsFlyerAttribution shared].url = url; - [AppsFlyerAttribution shared].sourceApplication = sourceApplication; - [AppsFlyerAttribution shared].annotation = annotation; - } - -} - -- (void) receiveBridgeReadyNotification:(NSNotification *) notification -{ - NSLog (@"AppsFlyer Debug: handle deep link"); - if(self.url && self.sourceApplication && self.annotation){ - [[AppsFlyerLib shared] handleOpenURL:self.url sourceApplication:self.sourceApplication withAnnotation:self.annotation]; - self.url = nil; - self.sourceApplication = nil; - self.annotation = nil; - }else if(self.options && self.url){ - [[AppsFlyerLib shared] handleOpenUrl:self.url options:self.options]; - self.options = nil; - self.url = nil; - }else if(self.userActivity){ - [[AppsFlyerLib shared] continueUserActivity:self.userActivity restorationHandler:nil]; - self.userActivity = nil; - self.restorationHandler = nil; - } -} -@end diff --git a/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.swift b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.swift new file mode 100644 index 00000000..e718f1b2 --- /dev/null +++ b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.swift @@ -0,0 +1,208 @@ +// +// AppsFlyerAttribution.swift +// flutter-appsflyer +// +// Created by Amit Kremer on 11/02/2021. +// +// Interim queue-and-forward layer for UIKit deep-link entry points. Native Swift should call the +// typed lifecycle API in AppsFlyerRPC directly once the upstream lifecycle-callback wrapper lands; +// this class (and its JSON round-trip through AFRPCBridge) is then expected to be removed. + +import Foundation +import os +import UIKit + +@objc(AppsFlyerAttribution) +public class AppsFlyerAttribution: NSObject { + + /// A deep-link RPC request captured before `initialize` completed. `params` holds the already + /// JSON-safe Foundation payload that is handed to the RPC layer unchanged. + private struct PendingRequest { + let method: String + let params: [String: Any] + } + + private static let sharedInstance = AppsFlyerAttribution() + private static let log = OSLog(subsystem: "com.appsflyer.appsflyer_sdk", + category: "AppsFlyerAttribution") + + private var isBridgeReady = false + private var pendingRequests: [PendingRequest] = [] + /// The plugin instance that last called `markBridgeReady(markedBy:)`. Used to reset queue state + /// on engine detach without affecting a live second engine in multi-engine hosts. + private weak var bridgeReadyOwner: AnyObject? + + @objc(shared) + public class func shared() -> AppsFlyerAttribution { + return sharedInstance + } + + @objc(continueUserActivity:) + public func continueUserActivity(_ userActivity: NSUserActivity?) { + guard let userActivity = userActivity, let webpageURL = userActivity.webpageURL else { + return + } + // UIKit annotates `activityType` as non-null, so the original `?: NSUserActivityTypeBrowsingWeb` + // fallback survives only as an explicit Optional widening. + let activityType = Optional(userActivity.activityType) ?? NSUserActivityTypeBrowsingWeb + executeOrQueue(method: "continueUserActivity", params: [ + "url": webpageURL.absoluteString, + "activityType": activityType + ]) + } + + @objc(handleOpenUrl:options:) + public func handleOpenUrl(_ url: URL?, options: [AnyHashable: Any]?) { + guard let url = url else { + return + } + executeOrQueue(method: "handleOpenUrl", params: [ + "url": url.absoluteString, + "options": jsonSafeOptions(from: options) + ]) + } + + @objc(handleOpenUrl:sourceApplication:annotation:) + public func handleOpenUrl(_ url: URL?, sourceApplication: String?, annotation: Any?) { + guard let url = url else { + return + } + var rawOptions: [AnyHashable: Any] = [:] + if let sourceApplication = sourceApplication { + rawOptions[UIApplication.OpenURLOptionsKey.sourceApplication.rawValue] = sourceApplication + } + if let annotation = annotation { + rawOptions[UIApplication.OpenURLOptionsKey.annotation.rawValue] = annotation + } + executeOrQueue(method: "handleOpenURL", params: [ + "url": url.absoluteString, + "options": jsonSafeOptions(from: rawOptions) + ]) + } + + /// Called from `AppsflyerSdkPlugin` after `init()` completes so detach can reset this singleton + /// only for the engine that marked it ready. Not exposed on the `@objc` surface: a parameterless + /// variant would open the gate without recording an owner and `resetBridgeStateIfOwned(by:)` could + /// not clear stale state on engine detach. + func markBridgeReady(markedBy owner: AnyObject) { + onMain { [self] in + applyBridgeReady(owner: owner) + } + } + + /// Clears queue state when the owning Flutter engine detaches. No-op for other engines. + func resetBridgeStateIfOwned(by owner: AnyObject) { + onMain { [self] in + guard bridgeReadyOwner === owner else { + return + } + bridgeReadyOwner = nil + isBridgeReady = false + pendingRequests.removeAll() + } + } + + private func applyBridgeReady(owner: AnyObject) { + bridgeReadyOwner = owner + isBridgeReady = true + let requests = pendingRequests + pendingRequests.removeAll() + for request in requests { + execute(method: request.method, params: request.params) + } + } + + private func executeOrQueue(method: String, params: [String: Any]) { + onMain { [self] in + if isBridgeReady { + execute(method: method, params: params) + } else { + pendingRequests.append(PendingRequest(method: method, params: params)) + } + } + } + + /// Serializes `isBridgeReady` / `pendingRequests` on the main queue. UIKit entry points are + /// already main-thread; `markBridgeReady` is normalized there by `AFRPCBridge`, but the public + /// `@objc` surface must not rely on caller thread affinity. Interim until the RPC lifecycle + /// wrapper absorbs this queue. + private func onMain(_ body: @escaping () -> Void) { + if Thread.isMainThread { + body() + } else { + DispatchQueue.main.async(execute: body) + } + } + + private func jsonSafeOptions(from options: [AnyHashable: Any]?) -> [String: Any] { + guard let options = options, !options.isEmpty else { + return [:] + } + var safe: [String: Any] = [:] + safe.reserveCapacity(options.count) + for (key, value) in options { + guard let stringKey = key as? String else { + continue + } + if let safeValue = jsonSafeValue(value) { + safe[stringKey] = safeValue + } + } + return safe + } + + private func jsonSafeValue(_ value: Any?) -> Any? { + guard let value = value, !(value is NSNull) else { + return nil + } + if value is String || value is NSNumber { + return value + } + if value is NSDictionary || value is NSArray { + return JSONSerialization.isValidJSONObject(value) ? value : nil + } + return JSONSerialization.isValidJSONObject([value]) ? value : nil + } + + private func execute(method: String, params: [String: Any]) { + let envelope: [String: Any] = ["method": method, "params": params] + guard JSONSerialization.isValidJSONObject(envelope), + let data = try? JSONSerialization.data(withJSONObject: envelope, options: []), + let json = String(data: data, encoding: .utf8) else { + Self.logError("Attribution RPC envelope serialization failed for method \(method)") + return + } + AFRPCBridge.executeJson(json) { response in + Self.logRpcFailureIfNeeded(method: method, response: response) + } + } + + private static func logRpcFailureIfNeeded(method: String, response: String) { + guard let data = response.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + logError("Attribution RPC response parse failed for method \(method)") + return + } + if let envelopeError = parsed["error"] as? [String: Any] { + let code = (envelopeError["code"] as? String) ?? "RPC_ERROR" + let message = (envelopeError["message"] as? String) ?? "unknown" + logError("Attribution RPC protocol error for \(method): \(code) — \(message)") + return + } + guard let resultObj = parsed["result"] as? [String: Any] else { + return + } + let success = (resultObj["success"] as? NSNumber)?.boolValue ?? true + if success { + return + } + let message = (resultObj["error"] as? String) + ?? (resultObj["message"] as? String) + ?? "SDK operation failed" + logError("Attribution RPC SDK error for \(method): \(message)") + } + + private static func logError(_ message: String) { + os_log(.error, log: log, "%{public}@", message) + } +} diff --git a/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m deleted file mode 100644 index 42404a3f..00000000 --- a/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m +++ /dev/null @@ -1,156 +0,0 @@ -// -// AppsFlyerStreamHandler.m -// appsflyer_sdk -// -// Created by Shahar Cohen on 05/09/2019. -// - -#import "AppsFlyerStreamHandler.h" - -@implementation AppsFlyerStreamHandler { - -} - -- (void)onConversionDataSuccess:(NSDictionary *)installData { - NSError *error; - - //use callbacks - if([AppsflyerSdkPlugin gcdCallback]){ - NSString *installDataJson = [self mapToJson:installData withError:error]; - NSDictionary *fullResponse = @{ - @"id": afGCDCallback, - @"data": installDataJson, - @"status": afSuccess - }; - NSString *JSONString = [self mapToJson:fullResponse withError:error]; - [AppsflyerSdkPlugin.callbackChannel invokeMethod:@"callListener" arguments:JSONString]; - return; - }else if (error) { - return; - } -} - -- (NSString *)mapToJson:(NSDictionary *)data withError:(NSError *)error{ - NSData *JSON = [NSJSONSerialization dataWithJSONObject:data options:0 error:&error]; - NSString *JSONString = [[NSString alloc] initWithData:JSON encoding:NSUTF8StringEncoding]; - return JSONString; -} - -- (void)onConversionDataFail:(NSError *)error { - //use callbacks - if([AppsflyerSdkPlugin gcdCallback]){ - NSDictionary *fullResponse = @{ - @"id": afGCDCallback, - @"data": error.localizedDescription, - @"status": afSuccess - }; - NSString *JSONString = [self mapToJson:fullResponse withError:error]; - [AppsflyerSdkPlugin.callbackChannel invokeMethod:@"callListener" arguments:JSONString]; - return; - } - - if (error) { - return; - } -} - - -- (void)onAppOpenAttribution:(NSDictionary *)attributionData { - NSError *error; - //use callbacks - if([AppsflyerSdkPlugin oaoaCallback]){ - NSString* attributionDataJson = [self mapToJson:attributionData withError:error]; - NSDictionary *fullResponse = @{ - @"id": afOAOACallback, - @"data": attributionDataJson, - @"status": afSuccess - }; - NSString *JSONString = [self mapToJson:fullResponse withError:error]; - [AppsflyerSdkPlugin.callbackChannel invokeMethod:@"callListener" arguments:JSONString]; - return; - } - - if (error) { - return; - } -} - -- (void)onAppOpenAttributionFailure:(NSError *)error { - if([AppsflyerSdkPlugin oaoaCallback]){ - NSDictionary *fullResponse = @{ - @"id": afOAOACallback, - @"data": error.localizedDescription, - @"status": afSuccess - }; - NSString *JSONString = [self mapToJson:fullResponse withError:error]; - [AppsflyerSdkPlugin.callbackChannel invokeMethod:@"callListener" arguments:JSONString]; - return; - } - -} - -- (void)didResolveDeepLink:(AppsFlyerDeepLinkResult* _Nonnull) deepLinkResult { - NSError *error; - if([AppsflyerSdkPlugin udpCallback]){ - NSMutableDictionary *fullResponse = [[NSMutableDictionary alloc] initWithCapacity:4]; - - fullResponse[ @"id"] = afUDPCallback; - fullResponse[ @"deepLinkStatus"] = [self getStatusAsString:deepLinkResult.status]; - if(deepLinkResult.deepLink != nil){ - NSMutableDictionary *dic = [[NSMutableDictionary alloc] initWithCapacity: deepLinkResult.deepLink.clickEvent.count + 1]; - [dic addEntriesFromDictionary:deepLinkResult.deepLink.clickEvent]; - dic[@"is_deferred"] = [NSNumber numberWithBool:deepLinkResult.deepLink.isDeferred]; - fullResponse [@"deepLinkObj"] = dic; - - } - if (deepLinkResult.error != nil && deepLinkResult.error.localizedDescription) { - fullResponse [@"deepLinkError"] = deepLinkResult.error.localizedDescription; - - } - NSString *JSONString = [self mapToJson:fullResponse withError:error]; - [AppsflyerSdkPlugin.callbackChannel invokeMethod:@"callListener" arguments:JSONString]; - return; - } - - if (error) { - return; - } - } - - -- (void)sendResponseToFlutter:(NSString *)responseID status:(NSString *)status data:(NSDictionary *)data{ - NSError *error; - NSString *JSONdata; - - if(data != nil){ - JSONdata = [self mapToJson:data withError:error]; - }else{ - JSONdata = @"empty data"; - } - if (error) { - return; - } - NSDictionary *fullResponse = @{ - @"id": responseID, - @"data": JSONdata, - @"status": status - }; - JSONdata = [self mapToJson:fullResponse withError:error]; - [AppsflyerSdkPlugin.callbackChannel invokeMethod:@"callListener" arguments:JSONdata]; -} - -- (NSString*) getStatusAsString:(AFSDKDeepLinkResultStatus)value{ - switch (value) { - case AFSDKDeepLinkResultStatusFound: - return @"FOUND"; - case AFSDKDeepLinkResultStatusNotFound: - return @"NOT_FOUND"; - default: - return @"ERROR"; - - } -} - - - -@end diff --git a/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m deleted file mode 100644 index 3a827814..00000000 --- a/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m +++ /dev/null @@ -1,1013 +0,0 @@ -#import "AppsflyerSdkPlugin.h" -#import "AppsFlyerStreamHandler.h" -#import - -#ifdef ENABLE_PURCHASE_CONNECTOR -#import "appsflyer_sdk/appsflyer_sdk-Swift.h" -#endif -typedef void (*bypassDidFinishLaunchingWithOption)(id, SEL, NSInteger); -typedef void (*bypassDisableAdvertisingIdentifier)(id, SEL, BOOL); -typedef void (*bypassWaitForATTUserAuthorization)(id, SEL, NSTimeInterval); - - -@implementation AppsflyerSdkPlugin { - FlutterEventChannel *_eventChannel; - AppsFlyerStreamHandler *_streamHandler; - -} -static NSMutableArray* _callbackById; -static FlutterMethodChannel* _callbackChannel; -static FlutterMethodChannel* _methodChannel; -static BOOL _gcdCallback = false; -static BOOL _oaoaCallback = false; -static BOOL _udpCallback = false; -static BOOL _isPushNotificationEnabled = false; -static BOOL _isSandboxEnabled = false; -static BOOL _isSKADEnabled = false; - - -+ (FlutterMethodChannel*)callbackChannel{ - return _callbackChannel; -} - -+ (FlutterMethodChannel*)methodChannel{ - return _methodChannel; -} - -+ (BOOL)gcdCallback{ - return _gcdCallback; -} - -+ (BOOL)oaoaCallback{ - return _oaoaCallback; -} - -+ (BOOL)udpCallback{ - return _udpCallback; -} - -- (instancetype)initWithMessenger:(nonnull NSObject *)messenger { - self = [super init]; - if (self) { - _streamHandler = [[AppsFlyerStreamHandler alloc] init]; - _callbackChannel = [FlutterMethodChannel methodChannelWithName:afCallbacksMethodChannel binaryMessenger:messenger]; - _eventChannel = [FlutterEventChannel eventChannelWithName:afEventChannel binaryMessenger:messenger]; - _methodChannel = [FlutterMethodChannel methodChannelWithName:afMethodChannel binaryMessenger:messenger]; - } - return self; -} - -+ (void)registerWithRegistrar:(NSObject*)registrar { -#ifdef ENABLE_PURCHASE_CONNECTOR - [PurchaseConnectorPlugin registerWithRegistrar:registrar]; -#endif - id messenger = [registrar messenger]; - FlutterMethodChannel *channel = [FlutterMethodChannel methodChannelWithName:afMethodChannel binaryMessenger:messenger]; - FlutterMethodChannel *callbackChannel = [FlutterMethodChannel methodChannelWithName:afCallbacksMethodChannel binaryMessenger:messenger]; - AppsflyerSdkPlugin *instance = [[AppsflyerSdkPlugin alloc] initWithMessenger:messenger]; - [registrar addMethodCallDelegate:instance channel:channel]; - [registrar addMethodCallDelegate:instance channel:callbackChannel]; - [registrar addApplicationDelegate:instance]; -#if __has_include() - if (@available(iOS 13.0, *)) { - [registrar addSceneDelegate:instance]; - } -#endif - -} - -- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { - - if([@"initSdk" isEqualToString:call.method]){ - [self initSdkWithCall:call result:result]; - }else if([@"getSDKVersion" isEqualToString:call.method]){ - [self getSDKVersion:result]; - }else if([@"startSDK" isEqualToString:call.method]){ - [self startSDK:call result:result]; - }else if([@"startSDKwithHandler" isEqualToString:call.method]){ - [self startSDKwithHandler:call result:result]; - } else if([@"logEvent" isEqualToString:call.method]){ - [self logEventWithCall:call result:result]; - }else if([@"waitForCustomerUserId" isEqualToString:call.method]){ - [self waitForCustomerId:call result:result]; - }else if([@"setUserEmails" isEqualToString:call.method]){ - [self setUserEmails:call result:result]; - }else if([@"updateServerUninstallToken" isEqualToString:call.method]){ - [self updateServerUninstallToken:call result:result]; - }else if([@"enableUninstallTracking" isEqualToString:call.method]){ - // - }else if([@"enableLocationCollection" isEqualToString:call.method]){ - // - }else if([@"stop" isEqualToString:call.method]){ - [self stop:call result:result]; - }else if([@"setIsUpdate" isEqualToString:call.method]){ - // - }else if([@"setCustomerUserId" isEqualToString:call.method]){ - [self setCustomerUserId:call result:result]; - }else if([@"setCustomerIdAndLogSession" isEqualToString:call.method]){ - [self setCustomerUserId:call result:result]; - }else if([@"setCurrencyCode" isEqualToString:call.method ]){ - [self setCurrencyCode:call result:result]; - }else if([@"setMinTimeBetweenSessions" isEqualToString:call.method]){ - [self setMinTimeBetweenSessions:call result:result]; - }else if([@"getHostPrefix" isEqualToString:call.method]){ - [self getHostPrefix:result]; - }else if([@"getHostName" isEqualToString:call.method]){ - [self getHostName:result]; - }else if([@"setHost" isEqualToString:call.method]){ - [self setHost:call result:result]; - }else if([@"setAdditionalData" isEqualToString:call.method]){ - [self setAdditionalData:call result:result]; - }else if([@"validateAndLogInAppIosPurchase" isEqualToString:call.method]){ - [self validateAndLogInAppPurchase:call result:result]; - }else if([@"validateAndLogInAppPurchaseV2" isEqualToString:call.method]){ - [self validateAndLogInAppPurchaseV2:call result:result]; - }else if([@"getAppsFlyerUID" isEqualToString:call.method]){ - [self getAppsFlyerUID:result]; - }else if([@"setSharingFilter" isEqualToString:call.method]){ - [self setSharingFilter:call result:result]; - }else if([@"setSharingFilterForAllPartners" isEqualToString:call.method]){ - [self setSharingFilterForAllPartners:result]; - }else if([@"generateInviteLink" isEqualToString:call.method]){ - [self generateInviteLink:call result:result]; - }else if([@"setAppInviteOneLinkID" isEqualToString:call.method]){ - [self setAppInviteOneLinkID:call result:result]; - }else if([@"logCrossPromotionImpression" isEqualToString:call.method]){ - [self logCrossPromotionImpression:call result:result]; - }else if([@"logCrossPromotionAndOpenStore" isEqualToString:call.method]){ - [self logCrossPromotionAndOpenStore:call result:result]; - }else if([@"startListening" isEqualToString:call.method]){ - [self startListening:call result:result]; - }else if([@"setOneLinkCustomDomain" isEqualToString:call.method]){ - [self setOneLinkCustomDomain:call result:result]; - }else if([@"setPushNotification" isEqualToString:call.method]){ - [self setPushNotification:call result:result]; - }else if([@"sendPushNotificationData" isEqualToString:call.method]){ - [self sendPushNotificationData:call result:result]; - }else if([@"useReceiptValidationSandbox" isEqualToString:call.method]){ - [self useReceiptValidationSandbox:call result:result]; - }else if([@"enableFacebookDeferredApplinks" isEqualToString:call.method]){ - [self enableFacebookDeferredApplinks:call result:result]; - }else if([@"anonymizeUser" isEqualToString:call.method]){ - [self anonymizeUser:call result:result]; - }else if([@"disableSKAdNetwork" isEqualToString:call.method]){ - [self disableSKAdNetwork:call result:result]; - }else if([@"setCurrentDeviceLanguage" isEqualToString:call.method]){ - [self setCurrentDeviceLanguage:call result:result]; - }else if([@"setSharingFilterForPartners" isEqualToString:call.method]){ - [self setSharingFilterForPartners:call result:result]; - }else if([@"setDisableAdvertisingIdentifiers" isEqualToString:call.method]){ - [self setDisableAdvertisingIdentifiers:call result:result]; - }else if([@"setPartnerData" isEqualToString:call.method]){ - [self setPartnerData:call result:result]; - }else if([@"setResolveDeepLinkURLs" isEqualToString:call.method]){ - [self setResolveDeepLinkURLs:call result:result]; - }else if([@"addPushNotificationDeepLinkPath" isEqualToString:call.method]){ - [self addPushNotificationDeepLinkPath:call result:result]; - }else if([@"enableTCFDataCollection" isEqualToString:call.method]){ - [self enableTCFDataCollection:call result:result]; - }else if([@"setConsentData" isEqualToString:call.method]){ - [self setConsentData:call result:result]; - }else if([@"setConsentDataV2" isEqualToString:call.method]){ - [self setConsentDataV2:call result:result]; - }else if([@"logAdRevenue" isEqualToString:call.method]){ - [self logAdRevenue:call result:result]; - } - else{ - result(FlutterMethodNotImplemented); - } -} - --(void)startSDKwithHandler:(FlutterMethodCall*)call result:(FlutterResult)result { - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appDidBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil]; - - [[AppsFlyerLib shared] startWithCompletionHandler:^(NSDictionary *dictionary, NSError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - if (error) { - [_methodChannel invokeMethod:@"onError" arguments:@{@"errorCode": @(error.code), @"errorMessage": error.localizedDescription ?: @"Unknown error"}]; - } else if (dictionary) { - [_methodChannel invokeMethod:@"onSuccess" arguments:dictionary]; - } else { - NSString *genericErrorMsg = @"SDK started without error or success data"; - [_methodChannel invokeMethod:@"onError" arguments:@{@"errorCode": @(0), @"errorMessage": genericErrorMsg}]; - } - result(nil); - }); - }]; -} - -- (void)startSDK:(FlutterMethodCall*)call result:(FlutterResult)result { - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appDidBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil]; - [[AppsFlyerLib shared] start]; - result(nil); -} - -- (void)setConsentData:(FlutterMethodCall*)call result:(FlutterResult)result { - NSDictionary* consentDict = call.arguments[@"consentData"]; - - BOOL isUserSubjectToGDPR = [consentDict[@"isUserSubjectToGDPR"] boolValue]; - BOOL hasConsentForDataUsage = [consentDict[@"hasConsentForDataUsage"] boolValue]; - BOOL hasConsentForAdsPersonalization = [consentDict[@"hasConsentForAdsPersonalization"] boolValue]; - - AppsFlyerConsent *consentData; - if(isUserSubjectToGDPR){ - consentData = [[AppsFlyerConsent alloc] initForGDPRUserWithHasConsentForDataUsage:hasConsentForDataUsage - hasConsentForAdsPersonalization:hasConsentForAdsPersonalization]; - }else{ - consentData = [[AppsFlyerConsent alloc] initWithNonGDPRUser]; - } - - [[AppsFlyerLib shared] setConsentData:consentData]; - result(nil); -} - -- (void)setConsentDataV2:(FlutterMethodCall*)call result:(FlutterResult)result { - @try { - // Extract the parameters directly from the arguments - NSNumber *isUserSubjectToGDPR = call.arguments[@"isUserSubjectToGDPR"]; - if ([isUserSubjectToGDPR isKindOfClass:[NSNull class]]) { - isUserSubjectToGDPR = nil; - } - - NSNumber *consentForDataUsage = call.arguments[@"consentForDataUsage"]; - if ([consentForDataUsage isKindOfClass:[NSNull class]]) { - consentForDataUsage = nil; - } - - NSNumber *consentForAdsPersonalization = call.arguments[@"consentForAdsPersonalization"]; - if ([consentForAdsPersonalization isKindOfClass:[NSNull class]]) { - consentForAdsPersonalization = nil; - } - - NSNumber *hasConsentForAdStorage = call.arguments[@"hasConsentForAdStorage"]; - if ([hasConsentForAdStorage isKindOfClass:[NSNull class]]) { - hasConsentForAdStorage = nil; - } - - // Create the consent object - AppsFlyerConsent *consentData = [[AppsFlyerConsent alloc] initWithIsUserSubjectToGDPR:isUserSubjectToGDPR - hasConsentForDataUsage:consentForDataUsage - hasConsentForAdsPersonalization:consentForAdsPersonalization - hasConsentForAdStorage:hasConsentForAdStorage]; - - // Set the consent data using AppsFlyer SDK - [[AppsFlyerLib shared] setConsentData:consentData]; - result(nil); - } - @catch (NSException *exception) { - NSLog(@"AppsFlyer: Error setting consent data v2: %@", exception.reason); - result([FlutterError errorWithCode:@"CONSENT_ERROR" - message:[NSString stringWithFormat:@"Failed to set consent data v2: %@", exception.reason] - details:nil]); - } -} - -- (void)logAdRevenue:(FlutterMethodCall*)call result:(FlutterResult)result { - @try { - NSString *monetizationNetwork = [self requireNonNullArgumentWithCall:call result:result argumentName:@"monetizationNetwork" errorCode:@"NULL_MONETIZATION_NETWORK"]; - if (monetizationNetwork == nil) return; - - NSString *currencyIso4217Code = [self requireNonNullArgumentWithCall:call result:result argumentName:@"currencyIso4217Code" errorCode:@"NULL_CURRENCY_CODE"]; - if (currencyIso4217Code == nil) return; - - NSNumber *revenueValue = [self requireNonNullArgumentWithCall:call result:result argumentName:@"revenue" errorCode:@"NULL_REVENUE"]; - if (revenueValue == nil) return; - - NSString *mediationNetworkString = [self requireNonNullArgumentWithCall:call result:result argumentName:@"mediationNetwork" errorCode:@"NULL_MEDIATION_NETWORK"]; - if (mediationNetworkString == nil) return; - - // Fetching the actual mediationNetwork Enum - AppsFlyerAdRevenueMediationNetworkType mediationNetwork = [self getEnumValueFromString:mediationNetworkString]; - if (mediationNetwork == -1) { //mediation network not found. - result([FlutterError errorWithCode:@"INVALID_MEDIATION_NETWORK" - message:@"The provided mediation network is not supported." - details:nil]); - return; - } - - NSDictionary *additionalParameters = call.arguments[@"additionalParameters"]; - if ([additionalParameters isEqual:[NSNull null]]) { - additionalParameters = nil; // Set to nil to avoid sending NSNull to the SDK which cannot be proseesed. - } - - AFAdRevenueData *adRevenueData = [[AFAdRevenueData alloc] - initWithMonetizationNetwork:monetizationNetwork - mediationNetwork:mediationNetwork - currencyIso4217Code:currencyIso4217Code - eventRevenue:revenueValue]; - - [[AppsFlyerLib shared] logAdRevenue:adRevenueData additionalParameters:additionalParameters]; - - } @catch (NSException *exception) { - result([FlutterError errorWithCode:@"UNEXPECTED_ERROR" - message:[NSString stringWithFormat:@"[logAdRevenue]: An error occurred retrieving method arguments: %@", exception.reason] - details:nil]); - NSLog(@"AppsFlyer, Exception occurred in [logAdRevenue]: %@", exception.reason); - } - -} - -- (AppsFlyerAdRevenueMediationNetworkType)getEnumValueFromString:(NSString *)mediationNetworkString { - NSDictionary *stringToEnumMap = @{ - @"google_admob": @(AppsFlyerAdRevenueMediationNetworkTypeGoogleAdMob), - @"ironsource": @(AppsFlyerAdRevenueMediationNetworkTypeIronSource), - @"applovin_max": @(AppsFlyerAdRevenueMediationNetworkTypeApplovinMax), - @"fyber": @(AppsFlyerAdRevenueMediationNetworkTypeFyber), - @"appodeal": @(AppsFlyerAdRevenueMediationNetworkTypeAppodeal), - @"admost": @(AppsFlyerAdRevenueMediationNetworkTypeAdmost), - @"topon": @(AppsFlyerAdRevenueMediationNetworkTypeTopon), - @"tradplus": @(AppsFlyerAdRevenueMediationNetworkTypeTradplus), - @"yandex": @(AppsFlyerAdRevenueMediationNetworkTypeYandex), - @"chartboost": @(AppsFlyerAdRevenueMediationNetworkTypeChartBoost), - @"unity": @(AppsFlyerAdRevenueMediationNetworkTypeUnity), - @"topon_pte": @(AppsFlyerAdRevenueMediationNetworkTypeToponPte), - @"custom_mediation": @(AppsFlyerAdRevenueMediationNetworkTypeCustom), - @"direct_monetization_network": @(AppsFlyerAdRevenueMediationNetworkTypeDirectMonetization) - }; - - NSNumber *enumValueNumber = stringToEnumMap[mediationNetworkString]; - if (enumValueNumber) { - return (AppsFlyerAdRevenueMediationNetworkType)[enumValueNumber integerValue]; - } else { - return -1; - } -} - -- (id)requireNonNullArgumentWithCall:(FlutterMethodCall*)call result:(FlutterResult)result argumentName:(NSString *)argumentName errorCode:(NSString *)errorCode { - id value = call.arguments[argumentName]; - if (value == nil) { - result([FlutterError - errorWithCode:errorCode - message:[NSString stringWithFormat:@"%@ must not be null", argumentName] - details:nil]); - NSLog(@"AppsFlyer, %@ must not be null", argumentName); - } - return value; -} - -- (void)enableTCFDataCollection:(FlutterMethodCall*)call result:(FlutterResult)result { - BOOL shouldCollect = [call.arguments[@"shouldCollect"] boolValue]; - [[AppsFlyerLib shared] enableTCFDataCollection:shouldCollect]; - result(nil); -} - -- (void)addPushNotificationDeepLinkPath:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSArray* deeplinkPath = call.arguments; - if(deeplinkPath != nil){ - [[AppsFlyerLib shared] addPushNotificationDeepLinkPath:deeplinkPath]; - } - result(nil); -} - -- (void)setResolveDeepLinkURLs:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSArray* urlsArr = call.arguments; - if(urlsArr != nil){ - [[AppsFlyerLib shared] setResolveDeepLinkURLs:urlsArr]; - } - result(nil); -} - -- (void)setPartnerData:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* partnerId = call.arguments[@"partnerId"]; - NSDictionary* partnersData = call.arguments[@"partnersData"]; - if(partnersData == [NSNull null]){ - partnersData = nil; - }; - [[AppsFlyerLib shared] setPartnerDataWithPartnerId:partnerId partnerInfo:partnersData]; - result(nil); -} - -- (void)setDisableAdvertisingIdentifiers:(FlutterMethodCall*)call result:(FlutterResult)result{ - id isAdvertiserIdEnabled = call.arguments; - if ([isAdvertiserIdEnabled isKindOfClass:[NSNumber class]]) { - BOOL _isAdvertiserIdEnabled = [isAdvertiserIdEnabled boolValue]; - [[AppsFlyerLib shared] setDisableAdvertisingIdentifier: _isAdvertiserIdEnabled]; - } - result(nil); -} - -- (void)setSharingFilterForPartners:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSArray* partners = call.arguments; - [[AppsFlyerLib shared] setSharingFilterForPartners: partners]; - result(nil); -} - -- (void)setCurrentDeviceLanguage:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* language = call.arguments; - [[AppsFlyerLib shared] setCurrentDeviceLanguage: language]; - result(nil); -} - -- (void)disableSKAdNetwork:(FlutterMethodCall*)call result:(FlutterResult)result{ - id isSKADEnabled = call.arguments; - if ([isSKADEnabled isKindOfClass:[NSNumber class]]) { - _isSKADEnabled = [(NSNumber*)isSKADEnabled boolValue]; - [AppsFlyerLib shared].disableSKAdNetwork = _isSKADEnabled; - } - result(nil); -} - -- (void)useReceiptValidationSandbox:(FlutterMethodCall*)call result:(FlutterResult)result{ - id isSandboxEnabled = call.arguments; - if ([isSandboxEnabled isKindOfClass:[NSNumber class]]) { - _isSandboxEnabled = [(NSNumber*)isSandboxEnabled boolValue]; - [AppsFlyerLib shared].useReceiptValidationSandbox = _isSandboxEnabled; - } - result(nil); -} - -- (void)enableFacebookDeferredApplinks:(FlutterMethodCall*)call result:(FlutterResult)result{ - id isFacebookDeferredApplinksEnabled = call.arguments[@"isFacebookDeferredApplinksEnabled"]; - if ([isFacebookDeferredApplinksEnabled isKindOfClass:[NSNumber class]]) { - if([(NSNumber*)isFacebookDeferredApplinksEnabled boolValue]){ - [[AppsFlyerLib shared] enableFacebookDeferredApplinksWithClass:NSClassFromString(@"FBSDKAppLinkUtility")]; - } - } - result(nil); -} - -- (void)anonymizeUser:(FlutterMethodCall*)call result:(FlutterResult)result { - id shouldAnonymize = call.arguments[@"shouldAnonymize"]; - if ([shouldAnonymize isKindOfClass:[NSNumber class]]) { - [AppsFlyerLib shared].anonymizeUser = [(NSNumber*)shouldAnonymize boolValue]; - } - result(nil); -} - -- (void)setPushNotification:(FlutterMethodCall*)call result:(FlutterResult)result{ - id isPushNotificationEnabled = call.arguments; - if ([isPushNotificationEnabled isKindOfClass:[NSNumber class]]) { - _isPushNotificationEnabled = [(NSNumber*)isPushNotificationEnabled boolValue]; - } - result(nil); -} - -- (void)sendPushNotificationData:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSDictionary* userInfo = call.arguments; - [[AppsFlyerLib shared] handlePushNotification:userInfo]; - result(nil); -} - -- (void)setOneLinkCustomDomain:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSArray* brandDomains = call.arguments; - [[AppsFlyerLib shared] setOneLinkCustomDomains:brandDomains]; - result(nil); -} - -- (void)startListening:(FlutterMethodCall*)call result:(FlutterResult)result{ - // Prepare callback dictionary - if (_callbackById == nil) _callbackById = [NSMutableArray array]; - - NSString* callbackId = call.arguments; - if ([callbackId isEqualToString:afGCDCallback]){ - _gcdCallback = true; - } - if ([callbackId isEqualToString:afOAOACallback]){ - _oaoaCallback = true; - } - if ([callbackId isEqualToString:afUDPCallback]){ - _udpCallback = true; - } - [_callbackById addObject:callbackId]; -} - -- (void)generateInviteLink:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* customerID = call.arguments[@"customerID"]; - NSString* referrerImageUrl = call.arguments[@"referrerImageUrl"]; - NSString* brandDomain = call.arguments[@"brandDomain"]; - NSString* baseDeeplink = call.arguments[@"baseDeeplink"]; - NSString* referrerName = call.arguments[@"referrerName"]; - NSString* channel = call.arguments[@"channel"]; - NSString* campaign = call.arguments[@"campaign"]; - NSDictionary* customParams = call.arguments[@"customParams"]; - - //Explicitly setting the values of the parameters to be nil in case they are initially received as . - if (customerID == [NSNull null]) { - customerID = nil; - } - if (referrerImageUrl == [NSNull null]) { - referrerImageUrl = nil; - } - if (brandDomain == [NSNull null]) { - brandDomain = nil; - } - if (baseDeeplink == [NSNull null]) { - baseDeeplink = nil; - } - if (referrerName == [NSNull null]) { - referrerName = nil; - } - if (channel == [NSNull null]) { - channel = nil; - } - if (campaign == [NSNull null]) { - campaign = nil; - } - if(customParams == [NSNull null]){ - customParams = nil; - }; - - [AppsFlyerShareInviteHelper generateInviteUrlWithLinkGenerator:^AppsFlyerLinkGenerator * _Nonnull(AppsFlyerLinkGenerator * _Nonnull generator) { - [generator setChannel:channel]; - [generator setCampaign:campaign]; - [generator setBrandDomain:brandDomain]; - [generator setBaseDeeplink:baseDeeplink]; - [generator setReferrerName:referrerName]; - [generator setReferrerImageURL:referrerImageUrl]; - [generator setReferrerCustomerId:customerID]; - [generator addParameters:customParams]; - - return generator; - } completionHandler:^(NSURL * _Nullable url) { - NSString * resultURL = url.absoluteString; - NSDictionary* resultURLObject; - if(resultURL != nil){ - resultURLObject = @{ - @"userInviteURL": resultURL - }; - if([_callbackById containsObject:afGenerateInviteLinkSuccess]){ - [_streamHandler sendResponseToFlutter:afGenerateInviteLinkSuccess status:afSuccess data:resultURLObject]; - } - }else{ - resultURLObject = @{ - @"error": @"The URL wasn't generated!" - }; - if([_callbackById containsObject:afGenerateInviteLinkFailure]){ - [_streamHandler sendResponseToFlutter:afGenerateInviteLinkFailure status:afFailure data:resultURLObject]; - } - } - }]; - - result(nil); -} - - - - -- (void)setAppInviteOneLinkID:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* oneLinkID = call.arguments[@"oneLinkID"]; - [AppsFlyerLib shared].appInviteOneLinkID = oneLinkID; - if([_callbackById containsObject:@"setAppInviteOneLinkIDCallback"]){ - NSDictionary* message = @{ - @"status": afSuccess - }; - [_streamHandler sendResponseToFlutter:afAppInviteOneLinkID status:afSuccess data:message]; - } - result(nil); -} - -- (void)logCrossPromotionImpression:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* appId = call.arguments[@"appId"]; - NSString* campaign = call.arguments[@"campaign"]; - NSDictionary* parameters = call.arguments[@"data"]; - - [AppsFlyerCrossPromotionHelper logCrossPromoteImpression:appId campaign:campaign parameters:parameters]; -} - -- (void)logCrossPromotionAndOpenStore:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* campaign = call.arguments[@"campaign"]; - NSDictionary* customParams = call.arguments[@"params"]; - - [AppsFlyerShareInviteHelper generateInviteUrlWithLinkGenerator:^AppsFlyerLinkGenerator * _Nonnull(AppsFlyerLinkGenerator * _Nonnull generator) { - if (campaign != nil && ![campaign isEqualToString:@""]) { - [generator setCampaign:campaign]; - } - if (![customParams isKindOfClass:[NSNull class]]) { - [generator addParameters:customParams]; - } - - return generator; - } completionHandler: ^(NSURL * _Nullable url) { - NSString *appLink = url.absoluteString; - if (@available(iOS 10.0, *)) { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:appLink] options:@{} completionHandler:^(BOOL success) { - }]; - } else { - // Fallback on earlier versions - } - }]; -} - -- (void)setSharingFilter:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSArray* filters = call.arguments; - [[AppsFlyerLib shared] setSharingFilter:filters]; - result(nil); -} - -- (void)setSharingFilterForAllPartners:(FlutterResult)result{ - [[AppsFlyerLib shared] setSharingFilterForAllPartners]; - result(nil); -} - -- (void)getAppsFlyerUID:(FlutterResult)result{ - result([[AppsFlyerLib shared] getAppsFlyerUID]); -} - -- (void)getHostPrefix:(FlutterResult)result{ - result([[AppsFlyerLib shared] hostPrefix]); -} - -- (void)getHostName:(FlutterResult)result{ - result([[AppsFlyerLib shared] host]); -} - -- (void)getSDKVersion:(FlutterResult)result{ - result([[AppsFlyerLib shared] getSDKVersion]); -} - -- (void)setHost:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* hostName = call.arguments[@"hostName"]; - NSString* hostPrefix = call.arguments[@"hostPrefix"]; - [[AppsFlyerLib shared] setHost:hostName withHostPrefix:hostPrefix]; - result(nil); -} - -- (void)validateAndLogInAppPurchase:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* productIdentifier = call.arguments[@"productIdentifier"]; - NSString* price = call.arguments[@"price"]; - NSString* currency = call.arguments[@"currency"]; - NSString* transactionId = call.arguments[@"transactionId"]; - NSDictionary* additionalParameters = call.arguments[@"additionalParameters"]; - - [[AppsFlyerLib shared] validateAndLogInAppPurchase:productIdentifier price:price currency:currency transactionId:transactionId additionalParameters:additionalParameters - success:^(NSDictionary *response) { - NSLog(@"AppsFlyer Debug: validateAndLogInAppIosPurchase Success!"); - [self onValidateSuccess:response]; - } - failure:^(NSError *error, id reponse) { - NSLog(@"AppsFlyer Debug: validateAndLogInAppIosPurchase failed with Error: %@", error); - [self onValidateFail:error]; - }]; - - result(nil); -} - -- (void)validateAndLogInAppPurchaseV2:(FlutterMethodCall*)call result:(FlutterResult)result { - NSDictionary* purchaseDetailsMap = call.arguments[@"purchaseDetails"]; - NSDictionary* additionalParameters = call.arguments[@"additionalParameters"]; - - if (purchaseDetailsMap == nil) { - result([FlutterError errorWithCode:@"INVALID_ARGUMENTS" - message:@"Purchase details cannot be null" - details:nil]); - return; - } - - NSString* purchaseTypeString = purchaseDetailsMap[@"purchaseType"]; - NSString* transactionId = purchaseDetailsMap[@"purchaseToken"]; // purchaseToken maps to transactionId on iOS - NSString* productId = purchaseDetailsMap[@"productId"]; - - if (purchaseTypeString == nil || transactionId == nil || productId == nil) { - result([FlutterError errorWithCode:@"INVALID_ARGUMENTS" - message:@"Purchase details must contain purchaseType, purchaseToken, and productId" - details:nil]); - return; - } - - // Map Dart enum to iOS AFSDKPurchaseType - AFSDKPurchaseType purchaseType = [purchaseTypeString isEqualToString:@"subscription"] - ? AFSDKPurchaseTypeSubscription - : AFSDKPurchaseTypeOneTimePurchase; - - AFSDKPurchaseDetails *purchaseDetails = [[AFSDKPurchaseDetails alloc] initWithProductId:productId - transactionId:transactionId - purchaseType:purchaseType]; - - // Handle NSNull for additionalParameters - NSDictionary* purchaseAdditionalDetails = [additionalParameters isEqual:[NSNull null]] ? nil : additionalParameters; - - [[AppsFlyerLib shared] validateAndLogInAppPurchase:purchaseDetails - purchaseAdditionalDetails:purchaseAdditionalDetails - completion:^(NSDictionary * _Nullable response, NSError * _Nullable error) { - if (error) { - NSLog(@"AppsFlyer Debug: validateAndLogInAppPurchaseV2 failed: %@", error.localizedDescription); - result([FlutterError errorWithCode:@"VALIDATION_ERROR" - message:error.localizedDescription ?: @"Purchase validation failed" - details:@{ - @"error_code": @(error.code), - @"error_domain": error.domain ?: @"Unknown" - }]); - return; - } - - NSLog(@"AppsFlyer Debug: validateAndLogInAppPurchaseV2 Success!"); - result(response); - }]; -} - -- (void)onValidateSuccess: (NSDictionary*) data{ - [_streamHandler sendResponseToFlutter:afValidatePurchase status:afSuccess data:data]; -} - --(void)onValidateFail:(NSError*)error{ - NSDictionary* errorObject = @{ - @"error": @"error" - }; - if(error != nil){ - errorObject = @{ - @"error": error.description - }; - } - - [_streamHandler sendResponseToFlutter:afValidatePurchase status:afFailure data:errorObject]; - [self performSelectorOnMainThread:@selector(handleCallback:) withObject:@[errorObject,afValidatePurchaseChannel] waitUntilDone:NO]; -} - -- (void)setAdditionalData:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSDictionary* data = call.arguments[@"customData"]; - [[AppsFlyerLib shared] setAdditionalData:data]; - result(nil); -} - -- (void)setCustomerUserId:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* userId = call.arguments[@"id"]; - [[AppsFlyerLib shared] setCustomerUserID:userId]; - result(nil); -} - -- (void)setCurrencyCode:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* currencyCode = call.arguments[@"currencyCode"]; - [[AppsFlyerLib shared] setCurrencyCode:currencyCode]; - result(nil); -} - -- (void)stop:(FlutterMethodCall*)call result:(FlutterResult)result{ - BOOL stop = [[call.arguments objectForKey:@"isStopped"] boolValue]; - [AppsFlyerLib shared].isStopped = stop; - result(nil); -} - -- (void)updateServerUninstallToken:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* deviceToken = call.arguments[@"token"]; - deviceToken = [deviceToken stringByReplacingOccurrencesOfString:@" " withString:@""]; - NSMutableData *deviceTokenData= [[NSMutableData alloc] init]; - unsigned char whole_byte; - char byte_chars[3] = {'\0','\0','\0'}; - int i; - for (i=0; i < [deviceToken length]/2; i++) { - byte_chars[0] = [deviceToken characterAtIndex:i*2]; - byte_chars[1] = [deviceToken characterAtIndex:i*2+1]; - whole_byte = strtol(byte_chars, NULL, 16); - [deviceTokenData appendBytes:&whole_byte length:1]; - } - [[AppsFlyerLib shared] registerUninstall:deviceTokenData]; - result(nil); -} - -- (void)waitForCustomerId:(FlutterMethodCall*)call result:(FlutterResult)result{ - result(nil); -} - -- (void)setUserEmails:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSMutableArray *emails = call.arguments[@"emails"]; - NSArray *emaillsArray = [emails copy]; - NSNumber* cryptTypeInt = (id)call.arguments[@"cryptType"]; - - EmailCryptType cryptType = EmailCryptTypeNone; - if(1 == [cryptTypeInt doubleValue]){ - cryptType = EmailCryptTypeSHA256; - } - - [[AppsFlyerLib shared] setUserEmails:emaillsArray withCryptType:cryptType]; - result(nil); -} - -- (void)initSdkWithCall:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString* devKey = nil; - NSString* appId = nil; - NSString* appInviteOneLink = nil; - BOOL manualStart = NO; - BOOL disableCollectASA = NO; - BOOL disableAdvertisingIdentifier = NO; - NSTimeInterval timeToWaitForATTUserAuthorization = 0; - BOOL isDebug = NO; - BOOL isConversionData = NO; - BOOL isUDP = NO; - - id isDebugValue = nil; - id isConversionDataValue = nil; - id isUDPValue = nil; - id isDisableCollectASA = nil; - id isDisableAdvertisingIdentifier = nil; - id isManualStart = nil; - - devKey = call.arguments[afDevKey]; - appId = call.arguments[afAppId]; - timeToWaitForATTUserAuthorization = [(id)call.arguments[afTimeToWaitForATTUserAuthorization] doubleValue]; - - isManualStart = call.arguments[afManualStart]; - if([isManualStart isKindOfClass:[NSNumber class]]){ - manualStart = [(NSNumber*)isManualStart boolValue]; - [self setIsManualStart:manualStart]; - } - - - isDebugValue = call.arguments[afIsDebug]; - if ([isDebugValue isKindOfClass:[NSNumber class]]) { - // isDebug is a boolean that will come through as an NSNumber - isDebug = [(NSNumber*)isDebugValue boolValue]; - } - - [AppsFlyerLib shared].appleAppID = appId; - [AppsFlyerLib shared].appsFlyerDevKey = devKey; - [AppsFlyerLib shared].isDebug = isDebug; - - isConversionDataValue = call.arguments[afConversionData]; - if ([isConversionDataValue isKindOfClass:[NSNumber class]]) { - isConversionData = [(NSNumber*)isConversionDataValue boolValue]; - } - if (isConversionData == YES) { - [[AppsFlyerLib shared] setDelegate:_streamHandler]; - } - - isUDPValue = call.arguments[afUDL]; - if ([isUDPValue isKindOfClass:[NSNumber class]]) { - isUDP = [(NSNumber*)isUDPValue boolValue]; - if(isUDP == YES){ - [AppsFlyerLib shared].deepLinkDelegate = _streamHandler; - } - } - - appInviteOneLink = call.arguments[afInviteOneLink]; - if (appInviteOneLink != nil && appInviteOneLink != [NSNull null]) { - [AppsFlyerLib shared].appInviteOneLinkID = appInviteOneLink; - } - - isDisableCollectASA = call.arguments[afDisableCollectASA]; - if ([isDisableCollectASA isKindOfClass:[NSNumber class]]) { - // isDebug is a boolean that will come through as an NSNumber - disableCollectASA = [(NSNumber*)isDisableCollectASA boolValue]; - } - isDisableAdvertisingIdentifier = call.arguments[afDisableAdvertisingIdentifier]; - if ([isDisableAdvertisingIdentifier isKindOfClass:[NSNumber class]]) { - // isDebug is a boolean that will come through as an NSNumber - disableAdvertisingIdentifier = [(NSNumber*)isDisableAdvertisingIdentifier boolValue]; - } - - - [AppsFlyerLib shared].disableCollectASA = disableCollectASA; - - SEL DisableAdvertisingSel = NSSelectorFromString(@"setDisableAdvertisingIdentifier:"); - id AppsFlyer = [AppsFlyerLib shared]; - if ([AppsFlyer respondsToSelector:DisableAdvertisingSel] && disableAdvertisingIdentifier) { - bypassDisableAdvertisingIdentifier msgSend = (bypassDisableAdvertisingIdentifier)objc_msgSend; - msgSend(AppsFlyer, DisableAdvertisingSel, disableAdvertisingIdentifier); - } - - [[AppsFlyerLib shared] setPluginInfoWith:AFSDKPluginFlutter pluginVersion:kAppsFlyerPluginVersion additionalParams:nil]; - - - // SEL WaitForATTSel = NSSelectorFromString(@"waitForATTUserAuthorizationWithTimeoutInterval:"); - - // if ([AppsFlyer respondsToSelector:WaitForATTSel] && timeToWaitForATTUserAuthorization != 0) { - // bypassWaitForATTUserAuthorization msgSend = (bypassWaitForATTUserAuthorization)objc_msgSend; - // msgSend(AppsFlyer, WaitForATTSel, timeToWaitForATTUserAuthorization); - // } - - if (timeToWaitForATTUserAuthorization != 0) { - [[AppsFlyerLib shared] waitForATTUserAuthorizationWithTimeoutInterval:timeToWaitForATTUserAuthorization]; - } - - if (manualStart == NO){ - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appDidBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil]; - [[AppsFlyerLib shared] start]; - } - - //post notification for the deep link object that the bridge is set and he can handle deep link - [AppsFlyerAttribution shared].isBridgeReady = YES; - [[NSNotificationCenter defaultCenter] postNotificationName:AF_BRIDGE_SET object:self]; - - - result(@{@"status": @"OK"}); -} - --(void)logEventWithCall:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSString *eventName = call.arguments[afEventName]; - NSDictionary *eventValues = call.arguments[afEventValues]; - - // Explicitily setting the values to be nil if call.arguments[afEventValues] returns . - if (eventValues == [NSNull null]) { - eventValues = nil; - } - - [[AppsFlyerLib shared] logEvent:eventName withValues:eventValues]; - //TODO: Add callback handler - result(@YES); -} - -- (void)setMinTimeBetweenSessions:(FlutterMethodCall*)call result:(FlutterResult)result{ - NSInteger seconds = [(id)call.arguments[@"seconds"] integerValue]; - [AppsFlyerLib shared].minTimeBetweenSessions = seconds; - result(nil); -} - -- (void)appDidBecomeActive { - [[AppsFlyerLib shared] start]; - NSLog(@"App Did Become Active"); -} - - -+ (FlutterViewController*) getViewController{ - UIWindow *window = nil; - if (@available(iOS 13.0, *)) { - for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (scene.activationState == UISceneActivationStateForegroundActive) { - window = scene.windows.firstObject; - break; - } - } - } - if (window == nil) { - window = [[[UIApplication sharedApplication] delegate] window]; - } - UIViewController *topMostViewControllerObj = window.rootViewController; - FlutterViewController *flutterViewController = (FlutterViewController *)topMostViewControllerObj; - return flutterViewController; -} - --(void) handleCallback:(NSArray *) objArray{ - NSDictionary* message = [objArray objectAtIndex:0]; - //NSString* channel = [objArray objectAtIndex:1]; - - NSError *error; - NSData *dataFromDict = [NSJSONSerialization dataWithJSONObject:message - options:NSJSONWritingPrettyPrinted - error:&error]; - [[NSNotificationCenter defaultCenter] postNotificationName:@"af-events" object:dataFromDict]; - //if(!error){ - //[flutterViewController sendOnChannel:channel message:dataFromDict binaryReply:^(NSData * _Nullable reply) { - // - //}]; - //} -} - -# pragma mark - handle deep links -// Deep linking -// Open URI-scheme for iOS 9 and above -- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *) options { - [[AppsFlyerAttribution shared] handleOpenUrl:url options:options]; - - // Results of this are ORed and NO doesn't affect other delegate interceptors' result. - return NO; - -} -// Open URI-scheme for iOS 8 and below -- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString*)sourceApplication annotation:(id)annotation { - [[AppsFlyerAttribution shared] handleOpenUrl:url sourceApplication:sourceApplication annotation:annotation]; - - // Results of this are ORed and NO doesn't affect other delegate interceptors' result. - return NO; - -} -// Open Universal Links -- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray * _Nullable))restorationHandler { - [[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:restorationHandler]; - - // Results of this are ORed and NO doesn't affect other delegate interceptors' result. - return NO; -} - -#if __has_include() -#pragma mark - FlutterSceneLifeCycleDelegate - -// UIScene-based URI-scheme deep links (iOS 13+, Flutter 3.41+ UIScene migration) -- (BOOL)scene:(UIScene*)scene openURLContexts:(NSSet*)URLContexts API_AVAILABLE(ios(13.0)) { - for (UIOpenURLContext *context in URLContexts) { - NSDictionary *opts = @{}; - if (context.options.sourceApplication) { - opts = @{UIApplicationOpenURLOptionsSourceApplicationKey: context.options.sourceApplication}; - } - [[AppsFlyerAttribution shared] handleOpenUrl:context.URL options:opts]; - } - return NO; -} - -// Cold-start deep links delivered via UISceneConnectionOptions (iOS 13+) -// Handles both URI-scheme links (URLContexts) and Universal Links (userActivities) -- (BOOL)scene:(UIScene*)scene - willConnectToSession:(UISceneSession*)session - options:(UISceneConnectionOptions*)connectionOptions API_AVAILABLE(ios(13.0)) { - for (UIOpenURLContext *context in connectionOptions.URLContexts) { - NSDictionary *opts = @{}; - if (context.options.sourceApplication) { - opts = @{UIApplicationOpenURLOptionsSourceApplicationKey: context.options.sourceApplication}; - } - [[AppsFlyerAttribution shared] handleOpenUrl:context.URL options:opts]; - } - for (NSUserActivity *activity in connectionOptions.userActivities) { - if ([activity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { - [[AppsFlyerAttribution shared] continueUserActivity:activity restorationHandler:nil]; - } - } - return NO; -} - -// UIScene-based Universal Links (iOS 13+) -- (BOOL)scene:(UIScene*)scene continueUserActivity:(NSUserActivity*)userActivity API_AVAILABLE(ios(13.0)) { - [[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:nil]; - return NO; -} -#endif // __has_include() - - -@end diff --git a/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift new file mode 100644 index 00000000..ceea17f8 --- /dev/null +++ b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift @@ -0,0 +1,618 @@ +// +// AppsflyerSdkPlugin.swift +// appsflyer_sdk +// + +import Foundation +import UIKit +import Flutter + +// Plugin version +private let kAppsFlyerPluginVersion = "7.0.1" + +// Flutter channels +private let afMethodChannel = "af-api" +private let afEventChannel = "af-events" + +// RPC method names that need plugin-side orchestration (everything else is forwarded generically). +private let kRpcInit = "init" +private let kRpcLogAndOpenStore = "logAndOpenStore" +private let kRpcSetPluginInfo = "setPluginInfo" + +/// Upper bound for events buffered while no `af-events` sink is attached. +/// +/// An integration that registers native listeners but never subscribes to the Dart streams — or +/// cancels its subscription for the rest of the session — would otherwise grow the buffer for the +/// lifetime of the engine. A session buffers a handful of events, so the cap only acts as a safety +/// valve. Mirrors `MAX_PENDING_EVENTS` in `AppsFlyerEventBus.kt`. +private let kMaxPendingEvents = 64 + +/// Matches `PLUGIN_DETACHED` / detach messaging in `AppsflyerSdkPlugin.kt`. +private let kPluginDetached = "PLUGIN_DETACHED" +private let kPluginDetachedMessage = "Plugin is not attached to a Flutter engine" + +@objc(AppsflyerSdkPlugin) +public class AppsflyerSdkPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + + /// One entry of the ordered `init` RPC sequence. Built and consumed inside this class only — + /// each entry's `params` is forwarded to the RPC layer as the untouched Foundation payload. + private struct RpcCall { + let method: String + let params: NSDictionary + } + + /// All mutable state below is confined to the main thread. Channel handlers and UIKit callbacks + /// already arrive there, `AFRPCBridge` normalizes RPC completions and events onto it, and + /// `tearDownForEngineDetach()` hops onto it — so none of it needs its own lock. + private var eventChannel: FlutterEventChannel? + private var eventSink: FlutterEventSink? + private var pendingEvents: [String] = [] + private var eventHandlerRegistered = false + /// Set in `tearDownForEngineDetach()` so in-flight `executeJson` completions and nested + /// `DispatchQueue.main.async` work from `logAndOpenStoreFromRpc` do not call `FlutterResult` or + /// `markBridgeReady(markedBy:)` after this engine's channel is gone. + private var isEngineDetached = false + + // ============================================================================ + // Plugin / channel lifecycle + // ============================================================================ + + init(messenger: FlutterBinaryMessenger) { + let channel = FlutterEventChannel(name: afEventChannel, binaryMessenger: messenger) + eventChannel = channel + super.init() + channel.setStreamHandler(self) + // Wire the bridge event handler as early as possible: the RPC layer drops events emitted + // before a handler is attached, so it must be set before start() and listener registration. + registerEventHandler() + } + + @objc(registerWithRegistrar:) + public static func register(with registrar: FlutterPluginRegistrar) { + #if ENABLE_PURCHASE_CONNECTOR + PurchaseConnectorPlugin.register(with: registrar) + #endif + let messenger = registrar.messenger() + let instance = AppsflyerSdkPlugin(messenger: messenger) + // publish: is required so FlutterEngine dealloc invokes detachFromEngineForRegistrar:. + registrar.publish(instance) + let channel = FlutterMethodChannel(name: afMethodChannel, binaryMessenger: messenger) + registrar.addMethodCallDelegate(instance, channel: channel) + registrar.addApplicationDelegate(instance) + addSceneDelegateIfSupported(instance, registrar: registrar) + } + + /// Stands in for the Objective-C `__has_include()` guard, which + /// Swift has no equivalent of: `addSceneDelegate:` only exists in Flutter versions that ship + /// `FlutterSceneLifeCycleDelegate`, so the registrar is probed for it instead. Flutter dispatches + /// every scene callback through `respondsToSelector:` (it never checks protocol conformance), so + /// the `scene:...` methods below participate exactly as the declared conformance used to. + /// The iOS 13.0 availability check the Objective-C code carried is implied by the iOS 13 + /// deployment target of both the podspec and the SPM manifest. + private static func addSceneDelegateIfSupported(_ instance: AppsflyerSdkPlugin, + registrar: FlutterPluginRegistrar) { + let selector = NSSelectorFromString("addSceneDelegate:") + guard registrar.responds(to: selector) else { + return + } + _ = registrar.perform(selector, with: instance) + } + + @objc(detachFromEngineForRegistrar:) + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + #if ENABLE_PURCHASE_CONNECTOR + // The connector is registered from `register(with:)` but publishes no instance of its own, so + // it only reaches a detach callback through this one. Android forwards the same pair of + // lifecycle events to `AppsFlyerPurchaseConnector`. + PurchaseConnectorPlugin.tearDownForEngineDetach(registrar: registrar) + #endif + tearDownForEngineDetach() + } + + /// Engine detach is the one lifecycle callback that can arrive off the main thread — every other + /// writer of this instance's state (`onListen`, `onCancel`, `deliverEvent`, and the RPC + /// completions that read `isEngineDetached`) already runs there. Hopping serializes teardown + /// against them instead of mutating `pendingEvents` and `eventSink` underneath a concurrent + /// `deliverEvent`, mirroring `AppsFlyerAttribution.onMain`. + /// + /// `self` is captured strongly so teardown still completes if the engine releases the plugin + /// first: both the bridge's handler slot and `AppsFlyerAttribution`'s queue are keyed on this + /// instance's identity, and a released owner would leave the bridge holding a handler no + /// detaching instance can claim. + private func tearDownForEngineDetach() { + onMain { [self] in + isEngineDetached = true + eventSink = nil + pendingEvents.removeAll() + eventHandlerRegistered = false + // Ownership-checked: in a multi-engine host this instance may no longer hold the bridge's + // single event-handler slot, and tearing down must not cut events off from the engine that + // does. See `AFRPCBridge.eventHandlerOwner`. + AFRPCBridge.removeEventHandler(owner: self) + eventChannel?.setStreamHandler(nil) + AppsFlyerAttribution.shared().resetBridgeStateIfOwned(by: self) + } + } + + private func onMain(_ body: @escaping () -> Void) { + if Thread.isMainThread { + body() + } else { + DispatchQueue.main.async(execute: body) + } + } + + /// `eventHandlerRegistered` only keeps the second call site (`initFromRpc`) from re-registering + /// what `init(messenger:)` already installed — instance state cannot guard the bridge's global + /// slot, which is what `AFRPCBridge`'s owner tracking is for. + private func registerEventHandler() { + if eventHandlerRegistered { + return + } + eventHandlerRegistered = true + AFRPCBridge.setEventHandler(owner: self) { [weak self] jsonEvent in + self?.deliverEvent(jsonEvent) + } + } + + // MARK: - FlutterStreamHandler (af-events) + + @objc(onListenWithArguments:eventSink:) + public func onListen(withArguments arguments: Any?, + eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + flushPendingEvents() + return nil + } + + @objc(onCancelWithArguments:) + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + // ============================================================================ + // Method channel entry point + // ============================================================================ + + @objc(handleMethodCall:result:) + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if "executeRpc" == call.method { + executeRpc(call, result: result) + } else { + result(FlutterMethodNotImplemented) + } + } + + /// Single RPC entry point. Initialization and the cross-promotion URL side effect require + /// plugin orchestration; every other method is forwarded to AppsFlyerRPC as-is. + private func executeRpc(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard !isEngineDetached else { + deliverFlutterResult(result, FlutterError(code: kPluginDetached, + message: kPluginDetachedMessage, + details: nil)) + return + } + // Internal transport contract (_invokeRpc): {method, params}. Apps must not call this + // channel directly; a malformed envelope is an integration error and is rejected by + // parseEnvelope before dispatch (fail-fast, not FlutterError). + let envelope = parseEnvelope(call) + + if kRpcInit == envelope.method { + initFromRpc(envelope.params, result: result) + } else if kRpcLogAndOpenStore == envelope.method { + logAndOpenStoreFromRpc(envelope.params, result: result) + } else { + dispatchRpc(envelope.method, params: envelope.params, result: result) + } + } + + private struct RpcEnvelope { + let method: String + let params: NSDictionary + } + + private func parseEnvelope(_ call: FlutterMethodCall) -> RpcEnvelope { + guard let arguments = call.arguments as? NSDictionary else { + rpcEnvelopeViolation("arguments must be Map") + } + guard let method = arguments["method"] as? String else { + rpcEnvelopeViolation("method must be String") + } + guard let params = arguments["params"] as? NSDictionary else { + rpcEnvelopeViolation("params must be Map") + } + return RpcEnvelope(method: method, params: params) + } + + private func rpcEnvelopeViolation(_ detail: String) -> Never { + preconditionFailure("RPC envelope contract violation: \(detail)") + } + + // ============================================================================ + // init (SDK 7 session model). start() is forwarded generically via dispatchRpc, + // like logEvent: its result returns on the per-call reply (params.awaitResponse). + // ============================================================================ + + private func initFromRpc(_ params: NSDictionary, result: @escaping FlutterResult) { + registerEventHandler() + + let devKey = stringParam(params, key: "devKey") + let appId = stringParam(params, key: "appId") + + // Ordered RPC sequence: initialize only. `handleLaunchOptions` is forwarded from + // `application:didFinishLaunchingWithOptions:` as soon as launch options arrive — the native + // SDK has no init dependency on that call. Listener registration is explicit in Dart. + let sequence: [RpcCall] = [ + RpcCall(method: "initialize", + params: ["devKey": devKey ?? "", "appId": appId ?? ""]) + ] + + // setPluginInfo runs ahead of the sequence rather than inside it: the plugin name must + // reach the first session payload, but it only labels reporting, so its outcome must not + // abort initialization. + // + // self is captured strongly here and in runSequence: executeJson(forMethod:) already retains + // self for the round trip, and a nil weak self would silently skip the rest of the chain, + // leaving the Flutter result — and the Dart Future awaiting it — unresolved. No cycle is + // possible: the closure is handed to the RPC bridge and never stored on self. + executeJson(forMethod: kRpcSetPluginInfo, + params: ["plugin": "flutter", "pluginVersion": kAppsFlyerPluginVersion]) { _, _ in + self.runSequence(sequence, index: 0) { sequenceError in + guard !self.isEngineDetached else { + return + } + if let sequenceError = sequenceError { + self.deliverFlutterResult(result, sequenceError) + return + } + AppsFlyerAttribution.shared().markBridgeReady(markedBy: self) + self.deliverFlutterResult(result, nil) + } + } + } + + /// Fires the RPC sequence one entry at a time, each in the previous call's completion handler. + private func runSequence(_ sequence: [RpcCall], + index: Int, + completion: @escaping (FlutterError?) -> Void) { + if index >= sequence.count { + completion(nil) + return + } + let entry = sequence[index] + executeJson(forMethod: entry.method, params: entry.params) { _, error in + if let error = error { + completion(error) + return + } + self.runSequence(sequence, index: index + 1, completion: completion) + } + } + + private func logAndOpenStoreFromRpc(_ params: NSDictionary, result: @escaping FlutterResult) { + executeJson(forMethod: kRpcLogAndOpenStore, params: params) { resultObj, error in + guard !self.isEngineDetached else { + return + } + if let error = error { + self.deliverFlutterResult(result, error) + return + } + let data = resultObj?["data"] as? [String: Any] + var url: URL? + if let clickURL = data?["clickURL"] as? String, !clickURL.isEmpty { + url = URL(string: clickURL) + } + if let url = url { + DispatchQueue.main.async { + guard !self.isEngineDetached else { + return + } + UIApplication.shared.open(url, options: [:]) { _ in + self.deliverFlutterResult(result, nil) + } + } + return + } + self.deliverFlutterResult(result, nil) + } + } + + // ============================================================================ + // Generic RPC dispatch + response unwrapping + // ============================================================================ + + private func dispatchRpc(_ method: String, params: NSDictionary, result: @escaping FlutterResult) { + executeJson(forMethod: method, params: params) { resultObj, error in + guard !self.isEngineDetached else { + return + } + if let error = error { + self.deliverFlutterResult(result, error) + return + } + self.deliverFlutterResult(result, self.unwrapValue(forMethod: method, resultObj: resultObj)) + } + } + + /// Invokes `FlutterResult` only while this engine instance is still attached. After detach the + /// isolate may already be gone; skipping is safer than replying on a dead channel. + private func deliverFlutterResult(_ result: @escaping FlutterResult, _ value: Any?) { + guard !isEngineDetached else { + return + } + result(value) + } + + /// Serializes the {id?, method, params} envelope, calls the bridge, and normalizes the JSON string + /// response into either (resultObj, nil) on success or (nil, FlutterError) on a protocol- or + /// SDK-level failure — matching the (value / error) contract the Android dispatcher exposes. + private func executeJson(forMethod method: String, + params: NSDictionary, + completion: @escaping (_ resultObj: [String: Any]?, _ error: FlutterError?) -> Void) { + guard let json = jsonEnvelope(forMethod: method, params: params) else { + completion(nil, FlutterError(code: "SERIALIZATION_ERROR", + message: "Failed to serialize RPC request for \(method)", + details: nil)) + return + } + AFRPCBridge.executeJson(json) { response in + guard !self.isEngineDetached else { + return + } + var parseError: Error? + guard let parsed = self.dictionary(fromJson: response, error: &parseError) else { + completion(nil, FlutterError(code: "RPC_PARSE_ERROR", + message: parseError?.localizedDescription ?? "Failed to parse RPC response", + details: response)) + return + } + // Protocol-level error (bad JSON, unknown method, missing params). + if let envelopeError = parsed["error"] as? [String: Any] { + let code = envelopeError["code"].map { self.objcDescription($0) } ?? "RPC_ERROR" + completion(nil, FlutterError(code: code, + message: envelopeError["message"].map { self.objcDescription($0) }, + details: envelopeError)) + return + } + guard let resultObj = parsed["result"] as? [String: Any] else { + completion([:], nil) + return + } + // Application-level failure is wrapped in the success envelope with success == false. + let success = (resultObj["success"] as? NSNumber)?.boolValue ?? true + if !success { + let message = (resultObj["error"] ?? resultObj["message"]).map { self.objcDescription($0) } + let code = resultObj["errorCode"].map { self.objcDescription($0) } ?? "SDK_ERROR" + completion(nil, FlutterError(code: code, + message: message ?? "RPC operation failed", + details: resultObj)) + return + } + completion(resultObj, nil) + } + } + + /// Extracts the primitive/map value Dart expects from the RPC `result` object. The iOS RPC returns + /// data under nested keys (e.g. {data:{version}}); Android returns the bare value, so we unwrap here + /// to keep the Dart return shape identical across platforms. Setters/void calls return nil. + private func unwrapValue(forMethod method: String, resultObj: [String: Any]?) -> Any? { + let data = resultObj?["data"] as? [String: Any] + switch method { + case "getSdkVersion": + return data?["version"] + case "getAppsFlyerUID": + return data?["uid"] + case "isSessionReady": + return data?["isSessionReady"] + case "validateAndLogInAppPurchase": + return data ?? [:] + case "generateInviteLink": + return data?["url"] + default: + // Void setters have no `data` and correctly return nil. Unlisted getters that return a + // map payload surface it here instead of silently nil — scalar getters with named-key + // nesting still need explicit cases until the RPC envelope matches Android's flat shape. + return data + } + } + + // ============================================================================ + // Event forwarding (bridge -> af-events stream) + // ============================================================================ + + // Forwards the native AppsFlyerRPC envelope to the af-events stream without changing event names + // or payloads, or buffers it until Dart subscribes (onListen). `AFRPCBridge.setEventHandler` + // always enqueues here through `DispatchQueue.main.async` so delivery order matches Android's + // always-post model; this method assumes it is already on the main thread when called. + // + // The buffer keeps the newest `kMaxPendingEvents` events: dropping the oldest bounds worst-case + // memory while still replaying the events a late subscriber is most likely to act on. + private func deliverEvent(_ argsJson: String) { + if let eventSink = eventSink { + eventSink(argsJson) + return + } + pendingEvents.append(argsJson) + if pendingEvents.count > kMaxPendingEvents { + pendingEvents.removeFirst(pendingEvents.count - kMaxPendingEvents) + } + } + + private func flushPendingEvents() { + guard !pendingEvents.isEmpty, let eventSink = eventSink else { + return + } + let pending = pendingEvents + pendingEvents.removeAll() + for args in pending { + eventSink(args) + } + } + + // ============================================================================ + // JSON helpers + // ============================================================================ + + private func jsonEnvelope(forMethod method: String, params: NSDictionary?) -> String? { + return jsonString(from: ["method": method, "params": params ?? NSDictionary()]) + } + + private func jsonString(from object: Any) -> String? { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: []) else { + return nil + } + return String(data: data, encoding: .utf8) + } + + private func dictionary(fromJson json: String, error: inout Error?) -> [String: Any]? { + guard let data = json.data(using: .utf8) else { + return nil + } + do { + return try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } catch let jsonError { + error = jsonError + return nil + } + } + + private func stringParam(_ params: NSDictionary, key: String) -> String? { + return params[key] as? String + } + + /// Reproduces `[NSString stringWithFormat:@"%@", value]` for values coming out of JSON, so that + /// numeric RPC error codes keep the exact spelling the Objective-C implementation produced. + private func objcDescription(_ value: Any) -> String { + if let string = value as? String { + return string + } + return String(describing: value as AnyObject) + } + +} + +// ============================================================================ +// Lifecycle forwarding (AppDelegate + UIScene). AppsFlyerAttribution queues early links and sends +// them through AppsFlyerRPC after initialize completes. +// +// Every selector below is spelled out explicitly: Swift would otherwise derive `application:open:…` +// style selectors from the Swift names, which UIKit and Flutter never call. The parameter types +// stay Objective-C shaped (`[AnyHashable: Any]`, optional where the caller may pass nil) so option +// dictionaries keep their NSString keys and reach AppsFlyerAttribution unchanged. +// ============================================================================ + +extension AppsflyerSdkPlugin { + + @objc(application:didFinishLaunchingWithOptions:) + public func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [AnyHashable: Any]) -> Bool { + guard !launchOptions.isEmpty else { + return false + } + let jsonSafeOptions = NSMutableDictionary() + for (key, value) in launchOptions { + let stringKey = String(describing: key as AnyObject) + if let url = value as? URL { + jsonSafeOptions[stringKey] = url.absoluteString + } else if JSONSerialization.isValidJSONObject([value]) { + jsonSafeOptions[stringKey] = value + } + } + // Fire-and-forget: native `handleLaunchOptions:` only sets a pending-deeplink flag and has + // no dependency on `initialize`. It must run before `registerSessionReadyListener`, which + // Dart registers after `init()` — forwarding here satisfies that earlier than caching did. + executeJson(forMethod: "handleLaunchOptions", + params: ["launchOptions": jsonSafeOptions]) { _, _ in } + return false + } + + // Spelled with the protocol's own Swift name (`open:`) because Objective-C selector + // `application:openURL:options:` may only be provided by the declaration that satisfies the + // `FlutterApplicationLifeCycleDelegate` requirement. The typed option keys are unwrapped back to + // their raw strings so the payload matches the NSDictionary the Objective-C code forwarded. + public func application(_ application: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool { + var rawOptions: [AnyHashable: Any] = [:] + for (key, value) in options { + rawOptions[key.rawValue] = value + } + AppsFlyerAttribution.shared().handleOpenUrl(url, options: rawOptions) + return false + } + + @objc(application:openURL:sourceApplication:annotation:) + public func application(_ application: UIApplication, + openURL url: URL, + sourceApplication: String?, + annotation: Any?) -> Bool { + AppsFlyerAttribution.shared().handleOpenUrl(url, + sourceApplication: sourceApplication, + annotation: annotation) + return false + } + + // UIApplicationDelegate requires restorationHandler; attribution only forwards webpageURL to RPC + // (AppsFlyer SDK ignores the handler too). Handoff/UI restoration stays with the host app. + // Uses the protocol's Swift name (`continue:`) for the same reason as `open:` above. + public func application(_ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([Any]) -> Void) -> Bool { + AppsFlyerAttribution.shared().continueUserActivity(userActivity) + return false + } +} + +// MARK: - FlutterSceneLifeCycleDelegate + +// Registered through `addSceneDelegate:` when the host Flutter version provides it — see +// `addSceneDelegateIfSupported`. Flutter dispatches these by selector, never by conformance. +extension AppsflyerSdkPlugin { + + // UIScene-based URI-scheme deep links (iOS 13+, Flutter 3.41+ UIScene migration) + @available(iOS 13.0, *) + @objc(scene:openURLContexts:) + public func scene(_ scene: UIScene, openURLContexts URLContexts: Set) -> Bool { + for context in URLContexts { + forwardSceneOpenURLContext(context) + } + return false + } + + // Cold-start deep links delivered via UISceneConnectionOptions (iOS 13+) + @available(iOS 13.0, *) + @objc(scene:willConnectToSession:options:) + public func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions?) -> Bool { + for context in connectionOptions?.urlContexts ?? [] { + forwardSceneOpenURLContext(context) + } + for activity in connectionOptions?.userActivities ?? [] { + if activity.activityType == NSUserActivityTypeBrowsingWeb { + AppsFlyerAttribution.shared().continueUserActivity(activity) + } + } + return false + } + + // UIScene-based Universal Links (iOS 13+) + @available(iOS 13.0, *) + @objc(scene:continueUserActivity:) + public func scene(_ scene: UIScene, continueUserActivity userActivity: NSUserActivity) -> Bool { + AppsFlyerAttribution.shared().continueUserActivity(userActivity) + return false + } + + @available(iOS 13.0, *) + private func forwardSceneOpenURLContext(_ context: UIOpenURLContext) { + var opts: [AnyHashable: Any] = [:] + if let sourceApplication = context.options.sourceApplication { + opts = [UIApplication.OpenURLOptionsKey.sourceApplication.rawValue: sourceApplication] + } + AppsFlyerAttribution.shared().handleOpenUrl(context.url, options: opts) + } +} diff --git a/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerAttribution.h b/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerAttribution.h deleted file mode 100644 index e6bed524..00000000 --- a/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerAttribution.h +++ /dev/null @@ -1,30 +0,0 @@ -// -// AppsFlyerAttribution.h -// Pods -// -// Created by Amit Kremer on 11/02/2021. -// - -#ifndef AppsFlyerAttribution_h -#define AppsFlyerAttribution_h -#endif /* AppsFlyerAttribution_h */ - -#import - -@interface AppsFlyerAttribution : NSObject -@property NSUserActivity*_Nullable userActivity; -@property (nonatomic, copy) void (^ _Nullable restorationHandler)(NSArray *_Nullable ); -@property NSURL * _Nullable url; -@property NSDictionary * _Nullable options; -@property NSString* _Nullable sourceApplication; -@property id _Nullable annotation; -@property BOOL isBridgeReady; - -+ (AppsFlyerAttribution *_Nullable)shared; -- (void) continueUserActivity: (NSUserActivity*_Nullable) userActivity restorationHandler: (void (^_Nullable)(NSArray * _Nullable))restorationHandler; -- (void) handleOpenUrl:(NSURL*_Nullable)url options:(NSDictionary*_Nullable) options; -- (void) handleOpenUrl: (NSURL *_Nullable)url sourceApplication:(NSString*_Nullable)sourceApplication annotation:(id _Nullable )annotation; - -@end - -static NSString * _Nullable const AF_BRIDGE_SET = @"bridge is set"; diff --git a/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerStreamHandler.h b/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerStreamHandler.h deleted file mode 100644 index 69eee594..00000000 --- a/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerStreamHandler.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// AppsFlyerStreamHandler.h -// appsflyer_sdk -// -// Created by Shahar Cohen on 05/09/2019. -// - -#import -#import - -// I will change it to seperate file with #defines -#import "AppsflyerSdkPlugin.h" -#import "AppsFlyerAttribution.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface AppsFlyerStreamHandler: NSObject - -- (void)sendResponseToFlutter:(NSString *)responseID status:(NSString *)status data:(NSDictionary *)data; -- (NSString*) getStatusAsString:(AFSDKDeepLinkResultStatus)value; - -@end - -NS_ASSUME_NONNULL_END diff --git a/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h b/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h deleted file mode 100644 index 213d0a81..00000000 --- a/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h +++ /dev/null @@ -1,65 +0,0 @@ -#import -#import "AppsFlyerAttribution.h" -#if __has_include() // from Pod -#import -#else -#import "AppsFlyerLib.h" -#endif - -#if __has_include() -#import -#endif - -#if __has_include() -@interface AppsflyerSdkPlugin: NSObject -#else -@interface AppsflyerSdkPlugin: NSObject -#endif - -@property (readwrite, nonatomic) BOOL isManualStart; - -+ (FlutterMethodChannel*)callbackChannel; -+ (BOOL)gcdCallback; -+ (BOOL)oaoaCallback; -+ (BOOL)udpCallback; - -@end - -// Appsflyer JS objects -#define kAppsFlyerPluginVersion @"6.18.0" -#define afDevKey @"afDevKey" -#define afAppId @"afAppId" -#define afIsDebug @"isDebug" -#define afManualStart @"manualStart" -#define afTimeToWaitForATTUserAuthorization @"timeToWaitForATTUserAuthorization" -#define afEventName @"eventName" -#define afEventValues @"eventValues" -#define afConversionData @"GCD" -#define afUDL @"UDL" -#define afInviteOneLink @"appInviteOneLink" -#define afDisableCollectASA @"disableCollectASA" -#define afDisableAdvertisingIdentifier @"disableAdvertisingIdentifier" - -// Appsflyer native objects -#define afOnInstallConversionData @"onInstallConversionData" -#define afSuccess @"success" -#define afFailure @"failure" -#define afOnAttributionFailure @"onAttributionFailure" -#define afValidatePurchase @"validatePurchase" -#define afOnAppOpenAttribution @"onAppOpenAttribution" -#define afOnDeepLinking @"onDeepLinking" -#define afOnInstallConversionFailure @"onInstallConversionFailure" -#define afOnInstallConversionDataLoaded @"onInstallConversionDataLoaded" -#define afGCDCallback @"onInstallConversionData" -#define afOAOACallback @"onAppOpenAttribution" -#define afUDPCallback @"onDeepLinking" -#define afGenerateInviteLinkSuccess @"generateInviteLinkSuccess" -#define afGenerateInviteLinkFailure @"generateInviteLinkFailure" -#define afAppInviteOneLinkID @"setAppInviteOneLinkIDCallback" - -// Stream Channels -#define afMethodChannel @"af-api" -#define afCallbacksMethodChannel @"callbacks" -#define afEventChannel @"af-events" -#define afValidatePurchaseChannel @"af-validate-purchase" - diff --git a/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/FlutterAppDelegate+AppsFlyerStreamHandler.h b/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/FlutterAppDelegate+AppsFlyerStreamHandler.h deleted file mode 100644 index 7cc93a21..00000000 --- a/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/FlutterAppDelegate+AppsFlyerStreamHandler.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// FlutterAppDelegate+AppsFlyerStreamHandler.h -// appsflyer_sdk -// -// Created by Shahar Cohen on 05/09/2019. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface FlutterAppDelegate () - -@end - -NS_ASSUME_NONNULL_END diff --git a/lib/appsflyer_sdk.dart b/lib/appsflyer_sdk.dart index 99a0af6b..517d6c42 100644 --- a/lib/appsflyer_sdk.dart +++ b/lib/appsflyer_sdk.dart @@ -1,19 +1,22 @@ +// ignore_for_file: unnecessary_library_name + +/// The public API for the AppsFlyer SDK 7 Flutter plugin. +/// +/// Use [AppsFlyerSdk.instance] to initialize the SDK, register native listeners +/// with their callbacks, and call the supported Android and iOS APIs. library appsflyer_sdk; +import 'dart:async'; import 'dart:convert'; -import 'dart:core'; -import 'dart:io'; - -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'src/callbacks.dart'; - part 'src/appsflyer_constants.dart'; +part 'src/appsflyer_event.dart'; +part 'src/appsflyer_listener_registry.dart'; part 'src/appsflyer_invite_link_params.dart'; -part 'src/appsflyer_options.dart'; part 'src/appsflyer_sdk.dart'; part 'src/udl/deep_link_result.dart'; part 'src/udl/deeplink.dart'; @@ -29,8 +32,6 @@ part 'src/purchase_connector/models/subscription_validation_result.dart'; part 'src/purchase_connector/models/validation_failure_data.dart'; part 'src/purchase_connector/models/jvm_throwable.dart'; part 'src/purchase_connector/models/ios_error.dart'; -part 'src/appsflyer_consent.dart'; -part 'src/appsflyer_request_listener.dart'; part 'appsflyer_sdk.g.dart'; -part 'src/appsflyer_ad_revenue_data.dart'; part 'src/af_purchase_details.dart'; +part 'src/appsflyer_exception.dart'; diff --git a/lib/src/af_purchase_details.dart b/lib/src/af_purchase_details.dart index a84c2e04..2d5d8d8d 100644 --- a/lib/src/af_purchase_details.dart +++ b/lib/src/af_purchase_details.dart @@ -1,41 +1,118 @@ part of appsflyer_sdk; -/// Enum representing the type of purchase for AppsFlyer validation. +/// The type of purchase submitted for AppsFlyer validation. enum AFPurchaseType { + /// A one-time in-app purchase. oneTimePurchase, + + /// A recurring subscription purchase. subscription, } -/// Data class representing purchase details for AppsFlyer validation. +/// Purchase details accepted by [AppsFlyerSdk.validateAndLogInAppPurchase]. /// -/// This class encapsulates the essential information needed to validate -/// in-app purchases with AppsFlyer's validation API. +/// Use [AFAndroidPurchaseDetails] for Google Play purchases and +/// [AFIOSPurchaseDetails] for App Store purchases. +@immutable +sealed class AFPurchaseDetails { + /// The kind of purchase being validated. + AFPurchaseType get purchaseType; + + /// The store product identifier. + String get productId; + + /// Builds the platform-specific native validation parameters. + /// + /// This is normally called by [AppsFlyerSdk.validateAndLogInAppPurchase]. + /// Implementations throw [ArgumentError] when used with the wrong [platform]. + Map toRpcMap({ + required TargetPlatform platform, + Map? additionalParameters, + }); +} + +/// Google Play purchase details. @immutable -class AFPurchaseDetails { +final class AFAndroidPurchaseDetails implements AFPurchaseDetails { + @override final AFPurchaseType purchaseType; - final String purchaseToken; + + @override final String productId; - /// Creates an [AFPurchaseDetails] instance. + /// The Google Play purchase token. + final String purchaseToken; + + /// Creates Google Play purchase details. /// - /// All parameters are required: - /// - [purchaseType]: The type of purchase being validated - /// - [purchaseToken]: The token provided by the app store for this purchase - /// - [productId]: The identifier of the product that was purchased - const AFPurchaseDetails({ + /// [productId] and [purchaseToken] must be non-empty. + const AFAndroidPurchaseDetails({ required this.purchaseType, - required this.purchaseToken, required this.productId, + required this.purchaseToken, }); - /// Converts the purchase details to a map for method channel communication. - Map toMap() { + @override + Map toRpcMap({ + required TargetPlatform platform, + Map? additionalParameters, + }) { + if (platform != TargetPlatform.android) { + throw ArgumentError( + 'AFAndroidPurchaseDetails can only be used on Android', + ); + } return { 'purchaseType': purchaseType == AFPurchaseType.oneTimePurchase ? 'one_time_purchase' : 'subscription', 'purchaseToken': purchaseToken, 'productId': productId, + 'additionalParameters': additionalParameters, + }; + } +} + +/// App Store purchase details. +@immutable +final class AFIOSPurchaseDetails implements AFPurchaseDetails { + @override + final AFPurchaseType purchaseType; + + @override + final String productId; + + /// The App Store transaction identifier. + final String transactionId; + + /// Creates App Store purchase details. + /// + /// [productId] and [transactionId] must be non-empty. + const AFIOSPurchaseDetails({ + required this.purchaseType, + required this.productId, + required this.transactionId, + }); + + @override + Map toRpcMap({ + required TargetPlatform platform, + Map? additionalParameters, + }) { + if (platform != TargetPlatform.iOS) { + throw ArgumentError( + 'AFIOSPurchaseDetails can only be used on iOS', + ); + } + return { + 'product': {'productId': productId}, + 'transaction': { + 'transactionId': transactionId, + 'purchaseType': purchaseType == AFPurchaseType.subscription + ? 'subscription' + : 'oneTimePurchase', + }, + 'additionalParameters': additionalParameters, }; } } diff --git a/lib/src/appsflyer_ad_revenue_data.dart b/lib/src/appsflyer_ad_revenue_data.dart deleted file mode 100644 index c759caa0..00000000 --- a/lib/src/appsflyer_ad_revenue_data.dart +++ /dev/null @@ -1,26 +0,0 @@ -part of appsflyer_sdk; - -class AdRevenueData { - final String monetizationNetwork; - final String mediationNetwork; - final String currencyIso4217Code; - final double revenue; - final Map? additionalParameters; - - AdRevenueData( - {required this.monetizationNetwork, - required this.mediationNetwork, - required this.currencyIso4217Code, - required this.revenue, - this.additionalParameters}); - - Map toMap() { - return { - 'monetizationNetwork': monetizationNetwork, - 'mediationNetwork': mediationNetwork, - 'currencyIso4217Code': currencyIso4217Code, - 'revenue': revenue, - 'additionalParameters': additionalParameters - }; - } -} diff --git a/lib/src/appsflyer_consent.dart b/lib/src/appsflyer_consent.dart deleted file mode 100644 index 416ce67d..00000000 --- a/lib/src/appsflyer_consent.dart +++ /dev/null @@ -1,39 +0,0 @@ -part of appsflyer_sdk; - -class AppsFlyerConsent { - final bool isUserSubjectToGDPR; - final bool hasConsentForDataUsage; - final bool hasConsentForAdsPersonalization; - - AppsFlyerConsent._({ - required this.isUserSubjectToGDPR, - required this.hasConsentForDataUsage, - required this.hasConsentForAdsPersonalization, - }); - - // Factory constructors - factory AppsFlyerConsent.forGDPRUser( - {required bool hasConsentForDataUsage, - required bool hasConsentForAdsPersonalization}) { - return AppsFlyerConsent._( - isUserSubjectToGDPR: true, - hasConsentForDataUsage: hasConsentForDataUsage, - hasConsentForAdsPersonalization: hasConsentForAdsPersonalization); - } - - factory AppsFlyerConsent.nonGDPRUser() { - return AppsFlyerConsent._( - isUserSubjectToGDPR: false, - hasConsentForDataUsage: false, - hasConsentForAdsPersonalization: false); - } - - // Converts object to a map - Map toMap() { - return { - 'isUserSubjectToGDPR': isUserSubjectToGDPR, - 'hasConsentForDataUsage': hasConsentForDataUsage, - 'hasConsentForAdsPersonalization': hasConsentForAdsPersonalization, - }; - } -} diff --git a/lib/src/appsflyer_constants.dart b/lib/src/appsflyer_constants.dart index 91f3fdf5..a6008797 100644 --- a/lib/src/appsflyer_constants.dart +++ b/lib/src/appsflyer_constants.dart @@ -1,33 +1,16 @@ part of appsflyer_sdk; -enum EmailCryptType { EmailCryptTypeNone, EmailCryptTypeSHA256 } - -class AppsflyerConstants { - static const String PLUGIN_VERSION = "6.17.9"; - static const String AF_DEV_KEY = "afDevKey"; - static const String AF_APP_Id = "afAppId"; - static const String AF_IS_DEBUG = "isDebug"; - static const String AF_MANUAL_START = "manualStart"; - static const String AF_TIME_TO_WAIT_FOR_ATT_USER_AUTHORIZATION = - "timeToWaitForATTUserAuthorization"; - static const String AF_GCD = "GCD"; - static const String AF_UDL = "UDL"; - static const String AF_SUCCESS = "success"; - static const String AF_FAILURE = "failure"; - static const String AF_GET_CONVERSION_DATA = "onInstallConversionDataLoaded"; - static const String AF_ON_APP_OPEN_ATTRIBUTION = "onAppOpenAttribution"; - static const String AF_ON_DEEP_LINK = "onDeepLinking"; - +class _AppsFlyerConstants { + static const String PLUGIN_VERSION = "7.0.1"; static const String AF_EVENTS_CHANNEL = "af-events"; static const String AF_METHOD_CHANNEL = "af-api"; - static const String AF_CALLBACK_CHANNEL = "callbacks"; - - static const String AF_VALIDATE_PURCHASE = "validatePurchase"; - static const String APP_INVITE_ONE_LINK = "appInviteOneLink"; - static const String DISABLE_COLLECT_ASA = "disableCollectASA"; - static const String DISABLE_ADVERTISING_IDENTIFIER = - "disableAdvertisingIdentifier"; + // Native RPC event names delivered on AF_EVENTS_CHANNEL. + static const String EVENT_CONVERSION_DATA_SUCCESS = "onConversionDataSuccess"; + static const String EVENT_CONVERSION_DATA_FAIL = "onConversionDataFail"; + static const String EVENT_DEEP_LINKING = "onDeepLinking"; + static const String EVENT_DEEP_LINK_RECEIVED = "onDeepLinkReceived"; + static const String EVENT_SESSION_READY = "onSessionReady"; // Purchase Connector constants static const String AF_PURCHASE_CONNECTOR_CHANNEL = "af-purchase-connector"; @@ -40,16 +23,17 @@ class AppsflyerConstants { static const String RESULT = "result"; static const String STORE_KIT_VERSION_KEY = "storeKitVersion"; // Purchase Connector listeners + // These match the exact method names sent by the native Android channel. static const String SUBSCRIPTION_PURCHASE_VALIDATION_RESULT_LISTENER_ON_RESPONSE = - "SubscriptionPurchaseValidationResultListener#onResponse"; + "SubscriptionPurchaseValidationResultListener:onResponse"; static const String SUBSCRIPTION_PURCHASE_VALIDATION_RESULT_LISTENER_ON_FAILURE = - "SubscriptionPurchaseValidationResultListener#onFailure"; + "SubscriptionPurchaseValidationResultListener:onFailure"; static const String IN_APP_VALIDATION_RESULT_LISTENER_ON_RESPONSE = - "InAppValidationResultListener#onResponse"; + "InAppValidationResultListener:onResponse"; static const String IN_APP_VALIDATION_RESULT_LISTENER_ON_FAILURE = - "InAppValidationResultListener#onFailure"; + "InAppValidationResultListener:onFailure"; static const String DID_RECEIVE_PURCHASE_REVENUE_VALIDATION_INFO = "didReceivePurchaseRevenueValidationInfo"; @@ -76,7 +60,7 @@ enum AFMediationNetwork { customMediation, directMonetizationNetwork; - String get value { + String rpcValue({required bool isIOS}) { switch (this) { case AFMediationNetwork.ironSource: return "ironsource"; @@ -103,9 +87,21 @@ enum AFMediationNetwork { case AFMediationNetwork.toponPte: return "topon_pte"; case AFMediationNetwork.customMediation: - return "custom_mediation"; + return isIOS ? "custom" : "custom_mediation"; case AFMediationNetwork.directMonetizationNetwork: - return "direct_monetization_network"; + return isIOS ? "directmonetization" : "direct_monetization_network"; } } } + +/// Android SDK logging levels supported by [AppsFlyerSdk.setLogLevel]. +enum AFLogLevel { + none, + error, + warning, + info, + debug, + verbose; + + String get rpcValue => name.toUpperCase(); +} diff --git a/lib/src/appsflyer_event.dart b/lib/src/appsflyer_event.dart new file mode 100644 index 00000000..11efe23e --- /dev/null +++ b/lib/src/appsflyer_event.dart @@ -0,0 +1,47 @@ +part of appsflyer_sdk; + +/// Internal native RPC event: event name plus normalized payload. +@immutable +class _AppsFlyerEvent { + /// The native event name, such as `onConversionDataSuccess`. + final String name; + + /// The event-specific payload. + /// + /// Events without a map payload use an empty map. + final Map data; + + /// Creates an AppsFlyer event with the supplied name and payload. + const _AppsFlyerEvent({ + required this.name, + required this.data, + }); + + /// Parses a native RPC event JSON envelope. + /// + /// Android and iOS RPC 7.x always emit a JSON object with `event` and `data` + /// (`data` is a map, or JSON `null` for Android `onSessionReady`). The Flutter + /// bridge forwards that payload as a JSON [json]. Malformed input — a non-object + /// envelope, or a missing, empty, or non-string `event` field — throws + /// [FormatException] and is dropped by the plugin stream transformer. + factory _AppsFlyerEvent.fromNative(String json) { + final decoded = jsonDecode(json); + if (decoded is! Map) { + throw const FormatException('AppsFlyer event must be a JSON object'); + } + final envelope = Map.from(decoded); + final rawEvent = envelope['event']; + if (rawEvent is! String || rawEvent.isEmpty) { + throw const FormatException( + 'AppsFlyer event must include a non-empty event name', + ); + } + final rawData = envelope['data']; + return _AppsFlyerEvent( + name: rawEvent, + data: rawData is Map + ? Map.from(rawData) + : {}, + ); + } +} diff --git a/lib/src/appsflyer_exception.dart b/lib/src/appsflyer_exception.dart new file mode 100644 index 00000000..98ad32b3 --- /dev/null +++ b/lib/src/appsflyer_exception.dart @@ -0,0 +1,31 @@ +part of appsflyer_sdk; + +/// An error reported by an AppsFlyer SDK operation. +class AppsFlyerException implements Exception { + /// The numeric error code reported by the native SDK, when available. + /// + /// Native RPC failures use HTTP-style codes (`400`, `422`, `500`, …). When the + /// platform supplies a non-numeric code, [code] is `null` and [message] + /// carries the failure text. + final int? code; + + /// The human-readable error message. + final String message; + + /// Creates an AppsFlyer SDK exception. + const AppsFlyerException({ + this.code, + required this.message, + }); + + /// Converts a Flutter [PlatformException] from the native bridge. + factory AppsFlyerException.fromPlatformException(PlatformException error) { + return AppsFlyerException( + code: int.tryParse(error.code), + message: error.message ?? 'Native request failed', + ); + } + + @override + String toString() => 'AppsFlyerException: [$code] $message'; +} diff --git a/lib/src/appsflyer_invite_link_params.dart b/lib/src/appsflyer_invite_link_params.dart index 43d8e32b..1827cc9c 100644 --- a/lib/src/appsflyer_invite_link_params.dart +++ b/lib/src/appsflyer_invite_link_params.dart @@ -1,26 +1,59 @@ part of appsflyer_sdk; /// This class represents parameters that are used to generate a user invite link. +@immutable class AppsFlyerInviteLinkParams { + /// The channel through which the invite is shared. final String? channel; + + /// The campaign associated with the invite link. final String? campaign; + + /// The name of the user who generated the invite. final String? referrerName; + + /// The URL of the referrer's image. final String? referrerImageUrl; - final String? customerID; + + /// The customer ID of the user who generated the invite. + final String? referrerCustomerId; + + /// The deep-link path opened by the invite link. final String? baseDeepLink; + + /// The branded domain used for the invite link. final String? brandDomain; - final Map? customParams; + + /// Additional parameters to include in the invite link. + final Map? userParams; /// Creates an [AppsFlyerInviteLinkParams] instance. + /// /// All parameters are optional, allowing greater flexibility when /// invoking the constructor. - AppsFlyerInviteLinkParams( - {this.campaign, - this.channel, - this.referrerName, - this.baseDeepLink, - this.brandDomain, - this.customerID, - this.referrerImageUrl, - this.customParams}); + const AppsFlyerInviteLinkParams({ + this.channel, + this.campaign, + this.referrerName, + this.referrerImageUrl, + this.referrerCustomerId, + this.baseDeepLink, + this.brandDomain, + this.userParams, + }); + + /// Converts these parameters to the platform-specific request map. + /// + /// Set [isIOS] to `true` for the iOS request format. Android and iOS use + /// different request keys for [referrerCustomerId]. + Map toRpcMap({required bool isIOS}) => { + 'channel': channel, + 'campaign': campaign, + 'referrerName': referrerName, + 'referrerImageUrl': referrerImageUrl, + isIOS ? 'referrerCustomerId' : 'customerId': referrerCustomerId, + 'baseDeepLink': baseDeepLink, + 'brandDomain': brandDomain, + 'userParams': userParams, + }; } diff --git a/lib/src/appsflyer_listener_registry.dart b/lib/src/appsflyer_listener_registry.dart new file mode 100644 index 00000000..d3aaf647 --- /dev/null +++ b/lib/src/appsflyer_listener_registry.dart @@ -0,0 +1,108 @@ +part of appsflyer_sdk; + +/// Routes native RPC events to the single callback registered for their event +/// name. +/// +/// One callback slot per event name, replaced on re-registration — the same +/// contract as the native SDKs, which hold one listener reference per event +/// type (`AppsFlyerLib.registerConversionListener` on Android, +/// `deepLinkDelegate`/`setSessionReadyListener:` on iOS). +/// +/// Internal to [AppsFlyerSdk]. The plugin exposes no stream or sink a host app +/// can reach, so one native event can never fan out to several app callbacks. +/// +/// An event that arrives before its listener has ever been registered is held +/// and replayed on registration. Both platforms flush their whole native buffer +/// as soon as Dart attaches to `af-events`, which happens on the first +/// `register*Listener` call — without this buffer, every replayed event for a +/// listener registered later in the sequence would be delivered to nothing and +/// lost for good. +/// +/// Holding covers that startup window only. Once a listener has been registered +/// the app has seen the event stream, so an event arriving after it unregisters +/// is dropped rather than replayed the next time it registers. +class _AppsFlyerListenerRegistry { + /// Caps the held events, matching the native buffers + /// (`AppsFlyerEventBus.MAX_PENDING_EVENTS` on Android, `kMaxPendingEvents` on + /// iOS). Held events are the replay of those buffers, so the Dart side never + /// needs to hold more than one native buffer's worth. + static const int _maxPendingEvents = 64; + + final Map _callbacks = + {}; + + /// Events awaiting a callback, in arrival order across all event names. + final List<_AppsFlyerEvent> _pending = <_AppsFlyerEvent>[]; + + /// Event names the app has registered a callback for at least once. + final Set _everRegistered = {}; + + /// Registers [callback] for [eventName], replacing any previous callback, and + /// replays any events held for that name. + /// + /// The replay runs in a microtask so the caller finishes registering before + /// its callback fires — [AppsFlyerSdk.registerConversionListener] fills two + /// slots per call, and a synchronous replay would invoke the app's callback + /// midway through that. + void on(String eventName, void Function(_AppsFlyerEvent) callback) { + _callbacks[eventName] = callback; + _everRegistered.add(eventName); + if (_pending.any((event) => event.name == eventName)) { + scheduleMicrotask(() => _replay(eventName)); + } + } + + /// Drops the callback registered for [eventName], if any, along with any + /// events held for it. + void off(String eventName) { + _callbacks.remove(eventName); + _pending.removeWhere((event) => event.name == eventName); + } + + /// Delivers [event] to its registered callback, holding it for replay if that + /// listener has never been registered. + void dispatch(_AppsFlyerEvent event) { + final callback = _callbacks[event.name]; + if (callback != null) { + callback(event); + return; + } + if (_everRegistered.contains(event.name)) { + debugPrint( + 'AppsFlyer: no listener registered for ${event.name}; event dropped.', + ); + return; + } + _hold(event); + } + + void _hold(_AppsFlyerEvent event) { + if (_pending.length >= _maxPendingEvents) { + final dropped = _pending.removeAt(0); + debugPrint( + 'AppsFlyer: pending event buffer full ($_maxPendingEvents); ' + 'dropped the oldest held ${dropped.name}.', + ); + } + _pending.add(event); + } + + void _replay(String eventName) { + final replayed = <_AppsFlyerEvent>[]; + _pending.removeWhere((event) { + if (event.name != eventName) { + return false; + } + replayed.add(event); + return true; + }); + for (final event in replayed) { + // Re-read per event: a replayed callback may unregister or replace itself. + final callback = _callbacks[eventName]; + if (callback == null) { + return; + } + callback(event); + } + } +} diff --git a/lib/src/appsflyer_options.dart b/lib/src/appsflyer_options.dart deleted file mode 100644 index 240f5556..00000000 --- a/lib/src/appsflyer_options.dart +++ /dev/null @@ -1,29 +0,0 @@ -part of appsflyer_sdk; - -/// The options used to configure the AppsFlyer SDK. -class AppsFlyerOptions { - final String afDevKey; - final bool showDebug; - final String appId; - final double? timeToWaitForATTUserAuthorization; - final String? appInviteOneLink; - final bool? disableAdvertisingIdentifier; - final bool? disableCollectASA; - final bool? manualStart; - - /// Creates an [AppsFlyerOptions] instance. - /// Requires [afDevKey] and [appId] as mandatory Named parameters. - /// All other parameters are optional, it's allows greater flexibility - /// when invoking the constructor. - /// When [manualStart] is true the startSDK method must be called - AppsFlyerOptions({ - required this.afDevKey, - this.showDebug = false, - this.appId = "", - this.timeToWaitForATTUserAuthorization, - this.appInviteOneLink, - this.disableAdvertisingIdentifier, - this.disableCollectASA, - this.manualStart = false, - }); -} diff --git a/lib/src/appsflyer_request_listener.dart b/lib/src/appsflyer_request_listener.dart deleted file mode 100644 index 45e6af6c..00000000 --- a/lib/src/appsflyer_request_listener.dart +++ /dev/null @@ -1,11 +0,0 @@ -part of appsflyer_sdk; - -class AppsFlyerRequestListener { - RequestSuccessListener onSuccess; - RequestErrorListener onError; - - AppsFlyerRequestListener({ - required this.onSuccess, - required this.onError, - }); -} diff --git a/lib/src/appsflyer_sdk.dart b/lib/src/appsflyer_sdk.dart index 14fa7f72..7303d7b2 100644 --- a/lib/src/appsflyer_sdk.dart +++ b/lib/src/appsflyer_sdk.dart @@ -1,656 +1,1035 @@ part of appsflyer_sdk; -class AppsflyerSdk { - // ignore: unused_field - EventChannel _eventChannel; - static AppsflyerSdk? _instance; - final MethodChannel _methodChannel; - bool _isSdkStarted = false; - - AppsFlyerOptions? afOptions; - Map? mapOptions; - - /// Returns the [AppsflyerSdk] instance, initialized with a custom options - /// provided by the user - factory AppsflyerSdk(options) { - if (_instance == null) { - MethodChannel methodChannel = - const MethodChannel(AppsflyerConstants.AF_METHOD_CHANNEL); - - EventChannel eventChannel = - EventChannel(AppsflyerConstants.AF_EVENTS_CHANNEL); - - //check if the option variable is AFOptions type or map type - assert(options is AppsFlyerOptions || options is Map); - if (options is AppsFlyerOptions) { - _instance = AppsflyerSdk.private(methodChannel, eventChannel, - afOptions: options); - } else if (options is Map) { - _instance = AppsflyerSdk.private(methodChannel, eventChannel, - mapOptions: options); - } - } - return _instance!; - } +/// Called with install and attribution conversion data (GCD). +typedef OnConversionDataSuccess = void Function(Map data); + +/// Called when the native SDK fails to retrieve conversion data. +/// +/// The payload shape differs by platform: Android reports `{"error": String}` +/// with no error code; iOS reports `{"error": String, "code": int}`. +typedef OnConversionDataFailure = void Function(Map error); + +/// Called with a Unified Deep Linking result. +typedef OnDeepLinkReceived = void Function(DeepLinkResult result); + +/// Called once per foreground cycle when the SDK is ready to send a session. +typedef OnSessionReady = void Function(); + +/// The AppsFlyer SDK entry point. +/// +/// Use the shared [instance] to configure and initialize the SDK. Each event +/// takes its callback as an argument to its `register*Listener` method; the +/// plugin holds one callback per event and replaces it on re-registration, the +/// same contract as the native SDKs. +/// +/// **Multi-engine hosts:** the native SDK and plugin transport are +/// process-scoped. When more than one Flutter engine is alive, only the engine +/// whose `af-events` subscription attached most recently receives native events, +/// and the last `register*Listener()` from any engine wins at the native layer. +/// Integrate from one primary engine. See `doc/getting-started.md#multi-engine`. +/// +/// Initialization does not send a session. Register the session-ready listener +/// and call [start] from its callback: +/// +/// ```dart +/// final appsFlyer = AppsFlyerSdk.instance; +/// +/// await appsFlyer.init( +/// devKey: 'YOUR_DEV_KEY', +/// appId: 'YOUR_APP_ID', +/// ); +/// await appsFlyer.registerSessionReadyListener(() async { +/// await appsFlyer.start(); +/// }); +/// ``` +class AppsFlyerSdk { + /// Returns the shared [AppsFlyerSdk] instance. + static final AppsFlyerSdk instance = AppsFlyerSdk.private( + const MethodChannel(_AppsFlyerConstants.AF_METHOD_CHANNEL), + const EventChannel(_AppsFlyerConstants.AF_EVENTS_CHANNEL), + ); @visibleForTesting - AppsflyerSdk.private(this._methodChannel, this._eventChannel, - {this.afOptions, this.mapOptions}); + AppsFlyerSdk.private( + this._methodChannel, + this._eventChannel, { + TargetPlatform? platform, + }) : _platform = platform ?? defaultTargetPlatform; - /// Validates [AppsFlyerOptions] and converts them to a map acceptable for the AppsFlyer SDK. - Map _validateAFOptions(AppsFlyerOptions options) { - Map validatedOptions = {}; + final TargetPlatform _platform; - bool? manualStart = options.manualStart; - if (manualStart != null) { - validatedOptions[AppsflyerConstants.AF_MANUAL_START] = manualStart; - } + bool get _isIOS => _platform == TargetPlatform.iOS; - //validations - dynamic devKey = options.afDevKey; - assert(devKey != null); - assert(devKey is String); + bool get _isAndroid => _platform == TargetPlatform.android; - validatedOptions[AppsflyerConstants.AF_DEV_KEY] = devKey; + /// Returns the Flutter plugin version. + String get pluginVersion => _AppsFlyerConstants.PLUGIN_VERSION; - dynamic appInviteOneLink = options.appInviteOneLink; - if (appInviteOneLink != null) { - assert(appInviteOneLink is String); - } - - validatedOptions[AppsflyerConstants.APP_INVITE_ONE_LINK] = appInviteOneLink; - - if (options.disableCollectASA != null) { - validatedOptions[AppsflyerConstants.DISABLE_COLLECT_ASA] = - options.disableCollectASA; - } - - if (options.disableAdvertisingIdentifier != null) { - validatedOptions[AppsflyerConstants.DISABLE_ADVERTISING_IDENTIFIER] = - options.disableAdvertisingIdentifier; - } else { - validatedOptions[AppsflyerConstants.DISABLE_ADVERTISING_IDENTIFIER] = - false; - } + final MethodChannel _methodChannel; + final EventChannel _eventChannel; + final _AppsFlyerListenerRegistry _listeners = _AppsFlyerListenerRegistry(); + StreamSubscription? _eventSubscription; - if (Platform.isIOS) { - if (options.timeToWaitForATTUserAuthorization != null) { - dynamic timeToWaitForATTUserAuthorization = - options.timeToWaitForATTUserAuthorization; - assert(timeToWaitForATTUserAuthorization is double); + /// Attaches the plugin's single `af-events` subscription on first use. + /// + /// Deferred until the first registration so nothing is read from the native + /// buffers before the app has asked for events. Both platforms replay their + /// whole buffer on attach, including events for listeners registered later in + /// the sequence; [_AppsFlyerListenerRegistry] holds those until their + /// callback arrives. + void _ensureEventsSubscribed() { + _eventSubscription ??= _eventChannel.receiveBroadcastStream().listen( + _handleNativeEvent, + onError: (Object error, StackTrace stackTrace) { + debugPrint('AppsFlyer: af-events stream error: $error'); + }, + ); + } - validatedOptions[ - AppsflyerConstants.AF_TIME_TO_WAIT_FOR_ATT_USER_AUTHORIZATION] = - timeToWaitForATTUserAuthorization; + void _handleNativeEvent(dynamic value) { + final _AppsFlyerEvent event; + try { + if (value is! String) { + throw FormatException( + 'AppsFlyer event must be a JSON string, got ${value.runtimeType}', + ); } - dynamic appID = options.appId; - assert(appID != null, "appleAppId is required for iOS apps"); - assert(appID is String); - RegExp exp = RegExp(r'^\d{8,11}$'); - assert(exp.hasMatch(appID)); - validatedOptions[AppsflyerConstants.AF_APP_Id] = appID; + event = _AppsFlyerEvent.fromNative(value); + } catch (error) { + debugPrint('AppsFlyer: dropped malformed native event: $error'); + return; } + _listeners.dispatch(event); + } - validatedOptions[AppsflyerConstants.AF_IS_DEBUG] = - // ignore: unnecessary_null_comparison - (options.showDebug != null) ? options.showDebug : false; + /// Initializes the SDK with [devKey] and, on iOS, [appId]. + /// + /// Does not send a session. + /// + /// If your app handles deep links, call [registerDeepLinkListener] before + /// this method; the other `register*Listener` methods are called after it. + /// + /// [appId] is required by the native iOS SDK and is not sent to Android. + /// Input validation is performed by the native RPC layer. + Future init({ + required String devKey, + String? appId, + }) { + return _invokeVoidRpc( + 'init', + _isIOS ? {'devKey': devKey, 'appId': appId} : {'devKey': devKey}, + ); + } - return validatedOptions; + /// Registers the install and attribution conversion-data listener. + /// + /// [onSuccess] receives the conversion data (GCD). [onFailure] receives + /// retrieval failures: this registration can succeed while the native SDK + /// still fails to retrieve conversion data. + /// + /// Calling this again replaces both callbacks. + Future registerConversionListener({ + required OnConversionDataSuccess onSuccess, + OnConversionDataFailure? onFailure, + }) { + _ensureEventsSubscribed(); + _listeners.on( + _AppsFlyerConstants.EVENT_CONVERSION_DATA_SUCCESS, + (event) => onSuccess(event.data), + ); + _listeners.on( + _AppsFlyerConstants.EVENT_CONVERSION_DATA_FAIL, + (event) => onFailure?.call(event.data), + ); + return _invokeVoidRpc('registerConversionListener'); } - /// Validates a map of option values, checking their types and presence. - Map _validateMapOptions(Map options) { - Map afOptions = {}; - //validations - dynamic devKey = options[AppsflyerConstants.AF_DEV_KEY]; - assert(devKey != null); - assert(devKey is String); + /// Unregisters the native Android conversion-data listener and drops the + /// callbacks passed to [registerConversionListener]. + /// + /// This API is available only on Android. Call [registerConversionListener] + /// again to resume receiving conversion-data events. + Future unregisterConversionListener() async { + _listeners.off(_AppsFlyerConstants.EVENT_CONVERSION_DATA_SUCCESS); + _listeners.off(_AppsFlyerConstants.EVENT_CONVERSION_DATA_FAIL); + return _invokeVoidRpc('unregisterConversionListener'); + } - afOptions[AppsflyerConstants.AF_DEV_KEY] = devKey; + /// Registers the Unified Deep Linking listener. + /// + /// [onDeepLink] receives every resolved deep link, deferred or direct. + /// Calling this again replaces the callback. + /// + /// Call this **before** [init]. On Android, [init] hands the launch intent to + /// the native SDK, which decides once per install whether to send the deferred + /// deep-link resolution request; registering afterwards means that request is + /// never sent for that install, and the skipped state persists across + /// launches. Direct links are unaffected. Registration before [init] is + /// supported on both platforms. + Future registerDeepLinkListener(OnDeepLinkReceived onDeepLink) { + _ensureEventsSubscribed(); + void dispatch(_AppsFlyerEvent event) => + onDeepLink(DeepLinkResult._fromEvent(event, platform: _platform)); + // Android emits onDeepLinking, iOS emits onDeepLinkReceived. + _listeners.on(_AppsFlyerConstants.EVENT_DEEP_LINKING, dispatch); + _listeners.on(_AppsFlyerConstants.EVENT_DEEP_LINK_RECEIVED, dispatch); + return _invokeVoidRpc( + _isAndroid ? 'subscribeForDeepLink' : 'registerDeeplinkListener', + ); + } - dynamic appInviteOneLink = options[AppsflyerConstants.APP_INVITE_ONE_LINK]; - if (appInviteOneLink != null) { - assert(appInviteOneLink is String); - } + /// Requests removal of the Unified Deep Linking listener on Android and drops + /// the callback passed to [registerDeepLinkListener]. + Future unregisterDeeplinkListener() async { + _listeners.off(_AppsFlyerConstants.EVENT_DEEP_LINKING); + _listeners.off(_AppsFlyerConstants.EVENT_DEEP_LINK_RECEIVED); + return _invokeVoidRpc('unsubscribeForDeepLink'); + } - if (options[AppsflyerConstants.AF_MANUAL_START] != null) { - afOptions[AppsflyerConstants.AF_MANUAL_START] = - options[AppsflyerConstants.AF_MANUAL_START]; - } else { - afOptions[AppsflyerConstants.AF_MANUAL_START] = false; - } + /// Registers the session-ready listener. + /// + /// The SDK invokes [onReady] once per foreground cycle when it is ready to + /// send a session. Call [start] from that callback: + /// + /// ```dart + /// await appsFlyer.registerSessionReadyListener(() => appsFlyer.start()); + /// ``` + /// + /// Calling this again replaces the callback, so [start] is never issued twice + /// for one readiness event. + Future registerSessionReadyListener(OnSessionReady onReady) { + _ensureEventsSubscribed(); + _listeners.on( + _AppsFlyerConstants.EVENT_SESSION_READY, + (_) => onReady(), + ); + return _invokeVoidRpc('registerSessionReadyListener'); + } - afOptions[AppsflyerConstants.APP_INVITE_ONE_LINK] = appInviteOneLink; + /// Removes the listener registered by [registerSessionReadyListener] and + /// drops its callback. + Future unregisterSessionReadyListener() { + _listeners.off(_AppsFlyerConstants.EVENT_SESSION_READY); + return _invokeVoidRpc('unregisterSessionReadyListener'); + } - if (options[AppsflyerConstants.DISABLE_COLLECT_ASA] != null) { - afOptions[AppsflyerConstants.DISABLE_COLLECT_ASA] = - options[AppsflyerConstants.DISABLE_COLLECT_ASA]; - } + /// Whether all session-readiness conditions are currently met. + /// + /// Use this when [registerSessionReadyListener] was registered after the SDK + /// became ready. + Future isSessionReady() { + return _invokeRpc('isSessionReady'); + } - if (options[AppsflyerConstants.DISABLE_ADVERTISING_IDENTIFIER] != null) { - afOptions[AppsflyerConstants.DISABLE_ADVERTISING_IDENTIFIER] = - options[AppsflyerConstants.DISABLE_ADVERTISING_IDENTIFIER]; - } else { - afOptions[AppsflyerConstants.DISABLE_ADVERTISING_IDENTIFIER] = false; - } + /// Sends a session ("Launch"). + /// + /// Call once for each [registerSessionReadyListener] callback invocation. + /// Defer this call when the first session must wait for consent or another + /// application condition. + /// + /// When [awaitResponse] is `false` (the default), the returned [Future] + /// completes when the native SDK accepts the request. Delivery success or + /// failure is not reported. + /// + /// When [awaitResponse] is `true`, the [Future] completes when the native + /// request succeeds and throws [AppsFlyerException] when it fails. A timeout + /// does not cancel the native request, which may still succeed later. + Future start({bool awaitResponse = false}) { + return _invokeVoidRpc('start', {'awaitResponse': awaitResponse}); + } - if (Platform.isIOS) { - if (options[ - AppsflyerConstants.AF_TIME_TO_WAIT_FOR_ATT_USER_AUTHORIZATION] != - null) { - dynamic timeToWaitForATTUserAuthorization = options[ - AppsflyerConstants.AF_TIME_TO_WAIT_FOR_ATT_USER_AUTHORIZATION]; - assert(timeToWaitForATTUserAuthorization is double); - - afOptions[ - AppsflyerConstants.AF_TIME_TO_WAIT_FOR_ATT_USER_AUTHORIZATION] = - timeToWaitForATTUserAuthorization; - } + /// Enables or disables SDK debug logging. + /// + /// May be called before [init]. Call before [start] so the first session + /// uses the selected setting. + Future enableDebug(bool enabled) { + return _invokeVoidRpc('isDebug', {'isDebug': enabled}); + } - dynamic appID = options[AppsflyerConstants.AF_APP_Id]; - assert(appID != null, "appleAppId is required for iOS apps"); - assert(appID is String); - RegExp exp = RegExp(r'^\d{8,11}$'); - assert(exp.hasMatch(appID)); - afOptions[AppsflyerConstants.AF_APP_Id] = appID; - } + /// Sets the Android SDK logging level. + /// + /// This API is available only on Android. Use [enableDebug] to enable or + /// disable debug logging on both Android and iOS. + /// + /// ```dart + /// await AppsFlyerSdk.instance.setLogLevel(AFLogLevel.debug); + /// ``` + Future setLogLevel(AFLogLevel logLevel) async { + return _invokeVoidRpc( + 'setLogLevel', + {'logLevel': logLevel.rpcValue}, + ); + } - afOptions[AppsflyerConstants.AF_IS_DEBUG] = - options.containsKey(AppsflyerConstants.AF_IS_DEBUG) - ? options[AppsflyerConstants.AF_IS_DEBUG] - : false; - - return afOptions; - } - - ///initialize the SDK, using the options initialized from the constructor| - Future initSdk( - {bool registerConversionDataCallback = false, - bool registerOnAppOpenAttributionCallback = false, - bool registerOnDeepLinkingCallback = false}) async { - return Future.delayed(Duration(seconds: 0)).then((_) { - Map? validatedOptions; - if (mapOptions != null) { - validatedOptions = _validateMapOptions(mapOptions!); - } else if (afOptions != null) { - validatedOptions = _validateAFOptions(afOptions!); - } + /// Sends an in-app event. + /// + /// [eventName] identifies the event. [eventValues] contains optional event + /// parameters. + /// + /// When [awaitResponse] is `false` (the default), the returned [Future] + /// completes when the native SDK accepts the request. Delivery success or + /// failure is not reported. + /// + /// When [awaitResponse] is `true`, the [Future] completes when the native + /// request succeeds and throws [AppsFlyerException] when it fails. A timeout + /// does not cancel the native request, which may still succeed later. + /// + /// ```dart + /// await AppsFlyerSdk.instance.logEvent( + /// 'af_purchase', + /// eventValues: {'af_revenue': 9.99, 'af_currency': 'USD'}, + /// ); + /// ``` + Future logEvent( + String eventName, { + Map? eventValues, + bool awaitResponse = false, + }) { + return _invokeVoidRpc('logEvent', { + 'eventName': eventName, + 'eventValues': eventValues, + 'awaitResponse': awaitResponse, + }); + } - validatedOptions?[AppsflyerConstants.AF_GCD] = - registerConversionDataCallback || - registerOnAppOpenAttributionCallback; - validatedOptions?[AppsflyerConstants.AF_UDL] = - registerOnDeepLinkingCallback; - //Means that we automatically starting the SDK - if (validatedOptions?[AppsflyerConstants.AF_MANUAL_START] == false) { - _isSdkStarted = true; - } - return _methodChannel.invokeMethod("initSdk", validatedOptions); + /// Logs ad revenue for a monetization or mediation network. + /// + /// [monetizationNetwork] identifies the source network. + /// [mediationNetwork] identifies the mediation platform. + /// [currencyIso4217Code] is the ISO 4217 currency code. + /// [revenue] is the ad-revenue amount. + /// [additionalParameters] contains optional ad-revenue values. + /// + /// The returned [Future] completes when the native SDK accepts the request. + /// Throws [AppsFlyerException] on failure. + Future logAdRevenue({ + required String monetizationNetwork, + required AFMediationNetwork mediationNetwork, + required String currencyIso4217Code, + required double revenue, + Map? additionalParameters, + }) { + return _invokeVoidRpc('logAdRevenue', { + 'monetizationNetwork': monetizationNetwork, + 'mediationNetwork': mediationNetwork.rpcValue(isIOS: _isIOS), + 'currencyIso4217Code': currencyIso4217Code, + 'revenue': revenue, + 'additionalParameters': additionalParameters, }); } - /// Initializes the SDK and sets up a method call handler to listen for native callbacks. - /// Guards against multiple initializations with `_isSdkStarted` - void startSDK({ - RequestSuccessListener? onSuccess, - RequestErrorListener? onError, + /// Manually logs the user's location. + /// + /// [latitude] must be in the range -90 through 90 and [longitude] in the + /// range -180 through 180. Values outside these ranges are rejected with an + /// [AppsFlyerException]. + Future logLocation({ + required double latitude, + required double longitude, }) { - if (_isSdkStarted) { - return; - } - _isSdkStarted = true; - if (onSuccess != null || onError != null) { - _methodChannel.setMethodCallHandler((call) async { - switch (call.method) { - case 'onSuccess': - onSuccess?.call(); - _methodChannel.setMethodCallHandler(null); - break; - case 'onError': - final int errorCode = call.arguments['errorCode']; - final String errorMessage = call.arguments['errorMessage']; - onError?.call(errorCode, errorMessage); - _methodChannel.setMethodCallHandler(null); - break; - default: - print('Unknown method called from the native side.'); - _isSdkStarted = false; - _methodChannel.setMethodCallHandler(null); - break; - } - }); - _methodChannel.invokeMethod('startSDKwithHandler'); - } else { - _methodChannel.invokeMethod('startSDK'); - } + return _invokeVoidRpc('logLocation', { + 'latitude': latitude, + 'longitude': longitude, + }); } - /// Retrieves the current SDK version. - Future getSDKVersion() async { - return _methodChannel.invokeMethod("getSDKVersion"); + /// Manually logs a session on Android. + /// + /// Android only. For typical Flutter apps, call [start] from the + /// [registerSessionReadyListener] callback instead. + Future logSession() async { + return _invokeVoidRpc('logSession'); } - ///These in-app events help you to log how loyal users discover your app, and attribute them to specific - ///campaigns/media-sources. Please take the time define the event/s you want to measure to allow you - ///to send ROI (Return on Investment) and LTV (Lifetime Value). - ///- The `logEvent` method allows you to send in-app events to AppsFlyer analytics. This method allows you to add events dynamically by adding them directly to the application code. - Future logEvent(String eventName, Map? eventValues) async { - return await _methodChannel.invokeMethod( - "logEvent", {'eventName': eventName, 'eventValues': eventValues}); + /// Sets your own customer user ID to cross-reference with the AppsFlyer ID. + Future setCustomerUserId(String customerId) { + return _invokeVoidRpc('setCustomerUserId', {'customerId': customerId}); } - /// Log ad revenue API. - void logAdRevenue(AdRevenueData adRevenueData) { - _methodChannel.invokeMethod("logAdRevenue", adRevenueData.toMap()); + /// Sets the user's email. + /// + /// The SDK hashes the value with SHA-256 before sending it. + Future setUserEmail(String email) { + return _invokeVoidRpc('setUserEmail', {'email': email}); } - /// Sets the host name and the host prefix. - /// This is only relevant if you need to switch between HTTPS environments. - void setHost(String hostPrefix, String hostName) { - _methodChannel.invokeMethod( - "setHost", {'hostPrefix': hostPrefix, 'hostName': hostName}); + /// Sets the user's phone number. + /// + /// [countryCode] is the dialing country code and [phoneNumber] is the local + /// number. The SDK hashes the value with SHA-256 before sending it. + Future setUserPhone(String countryCode, String phoneNumber) { + return _invokeVoidRpc('setUserPhone', { + 'countryCode': countryCode, + 'phoneNumber': phoneNumber, + }); } - /// Opt-out of collection of IMEI. - /// If the app does NOT contain Google Play Services, device IMEI is collected by the SDK. - /// However, apps with Google play services should avoid IMEI collection as this is in violation of the Google Play policy. - void setCollectIMEI(bool isCollect) { - _methodChannel.invokeMethod("setCollectIMEI", {'isCollect': isCollect}); + /// Sets the user's first name. + /// + /// The SDK hashes the value with SHA-256 before sending it. + Future setUserFirstName(String firstName) { + return _invokeVoidRpc('setUserFirstName', {'firstName': firstName}); } - /// Opt-out of collection of Android ID. - /// If the app does NOT contain Google Play Services, Android ID is collected by the SDK. - /// However, apps with Google play services should avoid Android ID collection as this is in violation of the Google Play policy. - void setCollectAndroidId(bool isCollect) { - _methodChannel - .invokeMethod("setCollectAndroidId", {'isCollect': isCollect}); + /// Sets the user's last name. + /// + /// The SDK hashes the value with SHA-256 before sending it. + Future setUserLastName(String lastName) { + return _invokeVoidRpc('setUserLastName', {'lastName': lastName}); } - /// Retrieves the host name. - Future getHostName() async { - return await _methodChannel.invokeMethod("getHostName"); + /// Sets the user's Facebook login ID (App-Scoped ID) for network sharing. + /// + /// Unlike the other `setUser*` methods, this value is not hashed. Pass `0` + /// to clear the ID. + Future setUserFbLoginId(int fbLoginId) { + return _invokeVoidRpc('setUserFbLoginId', {'fbLoginId': fbLoginId}); } - /// Retrieves the host prefix. - Future getHostPrefix() async { - return await _methodChannel.invokeMethod("getHostPrefix"); + /// Clears all PII set through the `setUser*` methods. + Future clearUserPii() { + return _invokeVoidRpc('clearUserPii'); } - /// Sets Android ID. - void setAndroidIdData(String androidId) { - _methodChannel.invokeMethod("setAndroidIdData", {'androidId': androidId}); + /// Sets the currency used for in-app purchase revenue. + /// + /// Use a three-letter ISO 4217 currency code. The default is USD. + Future setCurrencyCode(String currencyCode) { + return _invokeVoidRpc( + 'setCurrencyCode', + {'currencyCode': currencyCode}, + ); + } + + /// Sets the minimum time between two launches for them to count as separate + /// sessions. + /// + /// [seconds] is the minimum interval in seconds. + Future setMinTimeBetweenSessions(int seconds) { + return _invokeVoidRpc( + 'setMinTimeBetweenSessions', + {'seconds': seconds}, + ); } - /// Set the minimum time between sessions. - /// Any app launches that happen within this minimum threshold will be - /// attributed to the current session. Launches that occur after - /// this threshold has been crossed will be counted as a separate session. - void setMinTimeBetweenSessions(int seconds) { - assert(seconds >= 0, "the minimum timeout must be a positive number"); - _methodChannel - .invokeMethod("setMinTimeBetweenSessions", {'seconds': seconds}); + /// Sets a custom host name and prefix. + /// + /// Use this only when instructed by AppsFlyer support. + /// iOS requires both values to be non-empty. Android requires a non-empty + /// [hostName] and permits an empty [hostPrefixName]. + Future setHost(String hostPrefixName, String hostName) { + return _invokeVoidRpc('setHost', { + 'hostPrefixName': hostPrefixName, + 'hostName': hostName, + }); } - /// Sets the IMEI for the device. - void setImeiData(String imei) { - _methodChannel.invokeMethod("setImeiData", {'imei': imei}); + /// Returns the configured host name. + /// + /// Android only. + Future getHostName() { + return _invokeRpc('getHostName'); } - /// Setting user local currency code for in-app purchases. - /// The currency code should be a 3 character ISO 4217 code. (default is USD). - /// You can set the currency code for all events by calling the following method. - void setCurrencyCode(String currencyCode) { - _methodChannel - .invokeMethod("setCurrencyCode", {'currencyCode': currencyCode}); + /// Returns the configured host prefix. + /// + /// Android only. + Future getHostPrefix() { + return _invokeRpc('getHostPrefix'); } - /// Setting whether the SDK should collect tcf data automatically from SharedPreferences/UserDefaults - void enableTCFDataCollection(bool shouldCollect) { - _methodChannel.invokeListMethod( - "enableTCFDataCollection", {'shouldCollect': shouldCollect}); + /// Sets additional custom data sent to AppsFlyer. + /// + /// Pass an empty map to clear previously supplied data. + Future setAdditionalData(Map customData) { + return _invokeVoidRpc( + 'setAdditionalData', + {'customData': customData}, + ); } - @Deprecated('Use setConsentDataV2 instead') - void setConsentData(AppsFlyerConsent consentData) { - _methodChannel.invokeMethod('setConsentData', - {'consentData': consentData.toMap()}); + /// Sets the OneLink ID used as the base for links from [generateInviteLink]. + Future setAppInviteOneLink(String oneLinkId) { + return _invokeVoidRpc( + 'setAppInviteOneLink', + {'oneLinkId': oneLinkId}, + ); } - /// Sets the user consent data. + /// Sets partner-specific data. /// - /// [isUserSubjectToGDPR] - Indicates whether the user is subject to GDPR regulations. - /// [consentForDataUsage] - Indicates whether the user consents to data usage by AppsFlyer. - /// [consentForAdsPersonalization] - Indicates whether the user consents to ad personalization. - /// [hasConsentForAdStorage] - Indicates whether the user consents to ad storage. - void setConsentDataV2( - {bool? isUserSubjectToGDPR, - bool? consentForDataUsage, - bool? consentForAdsPersonalization, - bool? hasConsentForAdStorage}) { - _methodChannel.invokeMethod('setConsentDataV2', { - 'isUserSubjectToGDPR': isUserSubjectToGDPR, - 'consentForDataUsage': consentForDataUsage, - 'consentForAdsPersonalization': consentForAdsPersonalization, - 'hasConsentForAdStorage': hasConsentForAdStorage, + /// [partnerId] identifies the partner and [data] contains the data + /// supplied to that partner. + Future setPartnerData( + String partnerId, + Map data, + ) { + return _invokeVoidRpc('setPartnerData', { + 'partnerId': partnerId, + 'data': data, }); } - /// Opt-out logging for specific user - void anonymizeUser(bool shouldAnonymize) { - _methodChannel - .invokeMethod("anonymizeUser", {'shouldAnonymize': shouldAnonymize}); + /// Blocks sharing of S2S events through postback or API with the specified + /// partners. + /// + /// Pass `null` or an empty list to clear the filter. The plugin normalizes + /// an empty list to `null` before sending the RPC request. + Future setSharingFilterForPartners(List? partners) { + return _invokeVoidRpc( + 'setSharingFilterForPartners', + {'partners': partners != null && partners.isEmpty ? null : partners}, + ); } - /// Opt-out logging for specific user - void performOnDeepLinking() { - _methodChannel.invokeMethod("performOnDeepLinking"); + /// Sets the out-of-store install source. + /// + /// Android only. + Future setOutOfStore(String sourceName) async { + return _invokeVoidRpc('setOutOfStore', {'sourceName': sourceName}); } - /// Setting your own customer ID enables you to cross-reference your own unique ID with AppsFlyer's unique ID and the other devices' IDs. - /// This ID is available in AppsFlyer CSV reports along with Postback APIs for cross-referencing with your internal IDs. - void setCustomerUserId(String id) { - _methodChannel.invokeMethod("setCustomerUserId", {'id': id}); + /// Returns the out-of-store install source. + /// + /// Android only. + Future getOutOfStore() { + return _invokeNullableRpc('getOutOfStore'); } - void setIsUpdate(bool isUpdate) { - _methodChannel.invokeMethod("setIsUpdate", {'isUpdate': isUpdate}); + /// Manually marks the app as updated. + /// + /// Android only. + Future setIsUpdate(bool isUpdate) async { + return _invokeVoidRpc('setIsUpdate', {'isUpdate': isUpdate}); } - /// Once this API is invoked, our SDK no longer communicates with our servers and stops functioning. - /// In some extreme cases you might want to shut down all SDK activity due to legal and privacy compliance. - /// This can be achieved with the stop API. - void stop(bool isStopped) { - _methodChannel.invokeMethod("stop", {'isStopped': isStopped}); + /// Overrides the device language reported to the SDK. + /// + /// iOS only. + Future setCurrentDeviceLanguage(String language) async { + return _invokeVoidRpc( + 'setCurrentDeviceLanguage', + {'language': language}, + ); } - ///Please use updateServerUninstallToken instead (deprecated) - @Deprecated("use updateServerUninstallToken instead") - void enableUninstallTracking(String senderId) { - print("Please use updateServerUninstallToken instead"); + /// Sets a custom install ID to correlate the install with your own ID. + /// + /// On iOS, call before [init]; on Android, call after [init]. + /// + /// Both platforms silently ignore the call unless the opt-in flag is set: + /// `AppsFlyerAllowCustomInstallId = YES` in `Info.plist` (iOS) or + /// `APPSFLYER_ALLOW_CUSTOM_INSTALL_ID = true` in `AndroidManifest.xml` + /// (Android). + Future setInstallId(String installId) { + return _invokeVoidRpc('setInstallId', {'installId': installId}); } - ///Manually pass the Firebase / GCM Device Token for Uninstall measurement. - void updateServerUninstallToken(String token) { - _methodChannel.invokeMethod("updateServerUninstallToken", {'token': token}); + /// Attributes the install to an OEM or manufacturer preinstall campaign. + /// + /// Android only. Call before [start]. [mediaSource] is required; [campaign] + /// and [siteId] are optional. + Future setPreinstallAttribution( + String mediaSource, { + String campaign = '', + String siteId = '', + }) async { + return _invokeVoidRpc('setPreinstallAttribution', { + 'mediaSource': mediaSource, + 'campaign': campaign, + 'siteId': siteId, + }); } - ///Set the user emails and encrypt them. - void setUserEmails(List emails, [EmailCryptType? cryptType]) { - int cryptTypeInt = 0; - if (cryptType != null) { - cryptTypeInt = EmailCryptType.values.indexOf(cryptType); - } - _methodChannel.invokeMethod( - "setUserEmails", {'emails': emails, 'cryptType': cryptTypeInt}); + /// Overrides the app ID reported to AppsFlyer. + /// + /// Android only. Call before [start]. Throws [AppsFlyerException] when + /// [appId] is empty. + Future setAppId(String appId) async { + return _invokeVoidRpc('setAppId', {'appId': appId}); } - ///Get AppsFlyer's unique device ID is created for every new install of an app. - Future getAppsFlyerUID() async { - return await _methodChannel.invokeMethod("getAppsFlyerUID"); + /// Sets GDPR and DMA consent data. + /// + /// Provide the current consent on every app start before [start]. Consent + /// values are not persisted across sessions. Validation is performed by the + /// native RPC layer where enforced. [hasConsentForAdStorage] is optional. + Future setConsentData({ + required bool isUserSubjectToGDPR, + bool? hasConsentForDataUsage, + bool? hasConsentForAdsPersonalization, + bool? hasConsentForAdStorage, + }) { + return _invokeVoidRpc('setConsentData', { + 'isUserSubjectToGDPR': isUserSubjectToGDPR, + 'hasConsentForDataUsage': hasConsentForDataUsage, + 'hasConsentForAdsPersonalization': hasConsentForAdsPersonalization, + 'hasConsentForAdStorage': hasConsentForAdStorage, + }); } - ///Set customer user ID and unlock the wait for customer user id. Use with waitForCustomerUserId - void setCustomerIdAndLogSession(String id) { - _methodChannel.invokeMethod("setCustomerIdAndLogSession", {'id': id}); + /// Enables or disables automatic collection of IAB TCF consent data. + Future enableTCFDataCollection(bool shouldCollect) { + return _invokeVoidRpc( + 'enableTCFDataCollection', + {'shouldCollect': shouldCollect}, + ); } - ///Set to true if you want to delay sdk init until CUID is set - void waitForCustomerUserId(bool wait) { - _methodChannel.invokeMethod("waitForCustomerUserId", {'wait': wait}); + /// Anonymizes user data. + Future anonymizeUser(bool shouldAnonymize) { + return _invokeVoidRpc( + 'anonymizeUser', + {'shouldAnonymize': shouldAnonymize}, + ); } - ///Adds array of keys, which are used to compose key path to resolve deeplink from push notification payload. - void addPushNotificationDeepLinkPath(List deeplinkPath) { - _methodChannel.invokeMethod( - "addPushNotificationDeepLinkPath", deeplinkPath); + /// Stops or resumes all SDK activity and communication with AppsFlyer + /// servers. + /// + /// Pass `true` to stop the SDK and `false` to resume it. + Future stop(bool shouldStop) { + return _invokeVoidRpc('stop', {'shouldStop': shouldStop}); } - /// Validate and log the In-App Purchase for Android on AppsFlyer's dashboard. + /// Whether the SDK is currently stopped. /// - /// @Deprecated Use [validateAndLogInAppPurchaseV2] instead. This API will be removed in a future version. - @Deprecated( - 'Use validateAndLogInAppPurchaseV2 instead for cross-platform purchase validation') - Future validateAndLogInAppAndroidPurchase( - String publicKey, - String signature, - String purchaseData, - String price, - String currency, - Map? additionalParameters) { - return _methodChannel.invokeMethod("validateAndLogInAppAndroidPurchase", { - 'publicKey': publicKey, - 'signature': signature, - 'purchaseData': purchaseData, - 'price': price, - 'currency': currency, - 'additionalParameters': additionalParameters - }); + /// Android only. + Future isStopped() async { + return _invokeRpc('isStopped'); } - /// Accessing AppsFlyer purchase validation data for iOS. - /// - /// @Deprecated Use [validateAndLogInAppPurchaseV2] instead. This API will be removed in a future version. - @Deprecated( - 'Use validateAndLogInAppPurchaseV2 instead for cross-platform purchase validation') - Future validateAndLogInAppIosPurchase( - String productIdentifier, - String price, - String currency, - String transactionId, - Map additionalParameters) async { - return await _methodChannel.invokeMethod("validateAndLogInAppIosPurchase", { - 'productIdentifier': productIdentifier, - 'price': price, - 'currency': currency, - 'transactionId': transactionId, - 'additionalParameters': additionalParameters - }); + /// Disables collection of advertising identifiers. + /// + /// Pass `true` to disable collection of identifiers such as GAID, IDFA, and + /// OAID. Collection is enabled by default. + Future setDisableAdvertisingIdentifiers(bool disable) { + return _invokeVoidRpc( + 'setDisableAdvertisingIdentifiers', + _isIOS ? {'disable': disable} : {'isDisable': disable}, + ); } - /// Validates and logs in-app purchases using the new AppsFlyer validation API (V2). - /// This method validates a purchase using the [AFPurchaseDetails] object. + /// Disables Apple Search Ads attribution collection. /// - /// [purchaseDetails] - The purchase details containing type, token, and product ID - /// [additionalParameters] - Optional additional parameters to send with the validation request + /// iOS only. Call before [start]. + Future setDisableCollectASA(bool disable) async { + return _invokeVoidRpc( + 'setDisableCollectASA', + {'disable': disable}, + ); + } + + /// Enables or disables Android ID collection. /// - /// Returns a Future that completes with the validation result or throws an error if validation fails. - Future> validateAndLogInAppPurchaseV2( - AFPurchaseDetails purchaseDetails, - {Map? additionalParameters}) async { - final arguments = { - 'purchaseDetails': purchaseDetails.toMap(), - 'additionalParameters': additionalParameters, - }; + /// Android only. Apps distributed through Google Play should follow Google + /// Play policy when configuring this value. + Future setCollectAndroidID(bool isCollect) async { + return _invokeVoidRpc('setCollectAndroidID', {'isCollect': isCollect}); + } - final result = await _methodChannel.invokeMethod( - "validateAndLogInAppPurchaseV2", arguments); - return Map.from(result); + /// Disables collection of the network carrier and SIM operator names. + /// + /// Android only. + Future setDisableNetworkData(bool isDisable) async { + return _invokeVoidRpc( + 'setDisableNetworkData', + {'isDisable': isDisable}, + ); } - /// set sandbox for iOS purchase validation - void useReceiptValidationSandbox(bool isSandboxEnabled) { - _methodChannel.invokeMethod( - "useReceiptValidationSandbox", isSandboxEnabled); + /// Disables App Set ID collection. + /// + /// Android only. + Future disableAppSetId() async { + return _invokeVoidRpc('disableAppSetId'); } - /// Set additional data to be sent to AppsFlyer. - void setAdditionalData(Map? customData) { - _methodChannel - .invokeMethod("setAdditionalData", {'customData': customData}); + /// Disables SKAdNetwork attribution. + /// + /// iOS only. Pass `true` to disable SKAdNetwork. + Future setDisableSKAdNetwork(bool disable) async { + return _invokeVoidRpc( + 'setDisableSKAdNetwork', + {'disable': disable}, + ); } - /// Generates an invite link using the specified parameters, aka User Invite feature - void generateInviteLink( - AppsFlyerInviteLinkParams? parameters, - Function success, - Function error, - ) { - Map? paramsMap; - if (parameters != null) { - paramsMap = _translateInviteLinkParamsToMap(parameters); + /// Disables Apple Ads attribution. + /// + /// iOS only. Call before [start]. + Future setDisableAppleAdsAttribution(bool disable) async { + return _invokeVoidRpc( + 'setDisableAppleAdsAttribution', + {'disable': disable}, + ); + } + + /// Disables collection of the Identifier for Vendor (IDFV). + /// + /// iOS only. Call before [start]. + Future setDisableIDFVCollection(bool disable) async { + return _invokeVoidRpc( + 'setDisableIDFVCollection', + {'disable': disable}, + ); + } + + /// Enables device-name collection. + /// + /// iOS only. Collection is disabled by default. Call before [start], and + /// enable it only when your privacy policy covers collection of the device + /// name. + Future setShouldCollectDeviceName(bool collect) async { + return _invokeVoidRpc( + 'setShouldCollectDeviceName', + {'collect': collect}, + ); + } + + /// Validates and logs an in-app purchase. + /// + /// [purchase] is an [AFAndroidPurchaseDetails] or [AFIOSPurchaseDetails] + /// instance for the current platform. + /// [additionalParameters] contains optional values to include with the + /// validation request. + /// By default, Android waits for the native validation result. Set + /// [awaitResponse] to `false` to start validation without a result callback + /// and return an empty map. On iOS, [awaitResponse] is ignored and validation + /// always completes before the [Future] resolves. + /// + /// When the native result is awaited, completes with the validation result + /// or throws [AppsFlyerException] when validation fails. On Android with + /// `awaitResponse: false`, native validation failures are not reported. + Future> validateAndLogInAppPurchase( + AFPurchaseDetails purchase, { + Map? additionalParameters, + bool awaitResponse = true, + }) async { + final params = purchase.toRpcMap( + platform: _platform, + additionalParameters: additionalParameters, + ); + if (_isAndroid) { + params['awaitResponse'] = awaitResponse; } - startListening( - success as void Function(dynamic), "generateInviteLinkSuccess"); - startListening( - error as void Function(dynamic), "generateInviteLinkFailure"); - _methodChannel.invokeMethod("generateInviteLink", paramsMap); - } - - /// Translates invite link parameters into a map for sending over the method channel. - Map _translateInviteLinkParamsToMap( - AppsFlyerInviteLinkParams params) { - Map inviteLinkParamsMap = {}; - inviteLinkParamsMap['customParams'] = params.customParams; - inviteLinkParamsMap['referrerImageUrl'] = params.referrerImageUrl; - inviteLinkParamsMap['customerID'] = params.customerID; - inviteLinkParamsMap['brandDomain'] = params.brandDomain; - inviteLinkParamsMap['baseDeeplink'] = params.baseDeepLink; - inviteLinkParamsMap['referrerName'] = params.referrerName; - inviteLinkParamsMap['channel'] = params.channel; - inviteLinkParamsMap['campaign'] = params.campaign; - - return inviteLinkParamsMap; - } - - ///Set the OneLink ID that should be used for User-Invite-API. - ///The link that is generated for the user invite will use this OneLink ID as the base link ID - Future setAppInviteOneLinkID( - String oneLinkID, Function callback) async { - startListening( - callback as void Function(dynamic), "setAppInviteOneLinkIDCallback"); - await _methodChannel.invokeMethod("setAppInviteOneLinkID", { - 'oneLinkID': oneLinkID, - }); + final result = await _invokeNullableRpc?>( + 'validateAndLogInAppPurchase', + params, + ); + return result == null + ? {} + : Map.from(result); + } + + /// Enables sandbox mode for App Store receipt validation. + /// + /// iOS only. + Future setUseReceiptValidationSandbox(bool sandbox) { + return _invokeVoidRpc( + 'setUseReceiptValidationSandbox', + {'sandbox': sandbox}, + ); } - ///To attribute an impression use the following API call. - ///Make sure to use the promoted App ID as it appears within the AppsFlyer dashboard. - void logCrossPromotionImpression(String appId, String campaign, Map? data) { - _methodChannel.invokeMethod("logCrossPromotionImpression", - {'appId': appId, 'campaign': campaign, 'data': data}); + /// Enables sandbox mode for uninstall-measurement validation. + /// + /// iOS only. This is the uninstall-measurement companion to + /// [setUseReceiptValidationSandbox]. + Future setUseUninstallSandbox(bool sandbox) { + return _invokeVoidRpc( + 'setUseUninstallSandbox', + {'sandbox': sandbox}, + ); } - ///Use the following API to attribute the click and launch the app store's app page. - void logCrossPromotionAndOpenStore( - String appId, String campaign, Map? params) { - _methodChannel.invokeMethod("logCrossPromotionAndOpenStore", { + /// Logs a cross-promotion impression. + /// + /// Use the promoted app ID as shown in the AppsFlyer dashboard. + /// [campaign] and [userParams] are optional. + Future logCrossPromoteImpression( + String appId, { + String campaign = '', + Map? userParams, + }) { + return _invokeVoidRpc('logCrossPromoteImpression', { 'appId': appId, 'campaign': campaign, - 'params': params, + 'userParams': userParams, }); } - /// Sets custom domain for OneLink aka Branded Domains - void setOneLinkCustomDomain(List brandDomains) { - _methodChannel.invokeMethod("setOneLinkCustomDomain", brandDomains); + /// Logs a cross-promotion click and opens the promoted app's store page. + /// + /// [promotedAppId] is the app ID shown in the AppsFlyer dashboard. + /// [campaign] and [userParams] are optional. + Future logAndOpenStore( + String promotedAppId, { + String campaign = '', + Map? userParams, + }) { + return _invokeVoidRpc('logAndOpenStore', { + 'promotedAppId': promotedAppId, + 'campaign': campaign, + 'userParams': userParams, + }); } - /// Is push notification enabled or not (deprecated) - @Deprecated("use sendPushNotificationData instead") - void setPushNotification(bool isEnabled) { - _methodChannel.invokeMethod("setPushNotification", isEnabled); + /// Generates a OneLink user-invite URL. + /// + /// Configure the OneLink template with [setAppInviteOneLink] before + /// generating a link. [parameters] contains optional channel, campaign, + /// referrer, deep-link, branded-domain, and custom values. + /// By default, Android waits for its asynchronous link-generation callback. + /// Set [awaitResponse] to `false` to return the synchronously generated long + /// link instead. On iOS, [awaitResponse] is ignored and link generation is + /// always asynchronous. + /// + /// Completes with the generated URL, or throws [AppsFlyerException] on + /// failure. + /// + /// ```dart + /// final url = await AppsFlyerSdk.instance.generateInviteLink( + /// parameters: AppsFlyerInviteLinkParams(channel: 'whatsapp'), + /// ); + /// ``` + Future generateInviteLink({ + AppsFlyerInviteLinkParams? parameters, + bool awaitResponse = true, + }) async { + final params = (parameters ?? const AppsFlyerInviteLinkParams()).toRpcMap( + isIOS: _isIOS, + ); + if (_isAndroid) { + params['awaitResponse'] = awaitResponse; + } + return _invokeRpc('generateInviteLink', params); } - /// Sends push notification data. - void sendPushNotificationData(Map? userInfo) { - _methodChannel.invokeMethod("sendPushNotificationData", userInfo); + /// Logs the `af_invite` event when a user shares an invite. + /// + /// [channel] is the sharing channel, such as `"facebook"`. + /// [eventParameters] contains optional additional event values. + Future logInvite( + String channel, [ + Map? eventParameters, + ]) { + return _invokeVoidRpc('logInvite', { + 'channel': channel, + 'eventParameters': eventParameters, + }); } - /// Enables or disables Facebook deferred deep links. - void enableFacebookDeferredApplinks(bool isEnabled) { - _methodChannel.invokeMethod("enableFacebookDeferredApplinks", - {'isFacebookDeferredApplinksEnabled': isEnabled}); + /// Resolves [url] and delivers the result to the [registerDeepLinkListener] + /// callback. + /// + /// The URL can be a full URL, OneLink, or Android intent-data string. + /// On Android, set [shouldTriggerSession] to `true` to also enqueue a Launch + /// for re-engagement. iOS ignores [shouldTriggerSession]. + Future performDeepLinking( + String url, { + bool shouldTriggerSession = false, + }) { + return _invokeVoidRpc( + _isAndroid ? 'performDeepLinking' : 'performOnAppAttributionWithURL', + _isAndroid + ? { + 'url': url, + 'shouldTriggerSession': shouldTriggerSession, + } + : {'url': url}, + ); } - /// Disables SKAdNetwork (iOS 14 attribution framework). - void disableSKAdNetwork(bool isEnabled) { - _methodChannel.invokeMethod("disableSKAdNetwork", isEnabled); + /// Resolves OneLink URLs wrapped inside another Universal Link or App Link + /// domain that you own. + /// + /// For example, pass `["click.example.com"]`. [urls] must be non-empty. + Future setResolveDeepLinkURLs(List urls) { + return _invokeVoidRpc('setResolveDeepLinkURLs', {'urls': urls}); } - /// Disables tracking of advertising identifiers. - void setDisableAdvertisingIdentifiers(bool isEnabled) { - _methodChannel.invokeMethod("setDisableAdvertisingIdentifiers", isEnabled); + /// Registers custom or branded OneLink domains. + /// + /// For example, pass `["click.greatapp.com"]`. [domains] must be non-empty. + Future setOneLinkCustomDomain(List domains) { + return _invokeVoidRpc( + 'setOneLinkCustomDomain', + {'domains': domains}, + ); } - /// Listens for conversion data following app install. - void onInstallConversionData(Function callback) async { - startListening( - callback as void Function(dynamic), "onInstallConversionData"); + /// Sets the deep-link resolution timeout in milliseconds. + /// + /// Call before [init]. When this method is not called, the default is 3000 ms + /// on Android and 60000 ms on iOS. Android requires a positive value; iOS + /// accepts zero, but use a positive value for consistent behavior. + Future setDeepLinkTimeout(int timeout) { + return _invokeVoidRpc( + 'setDeepLinkTimeout', + {'timeout': timeout}, + ); } - /// Listens for attribution data following app open. - void onAppOpenAttribution(Function callback) async { - startListening(callback as void Function(dynamic), "onAppOpenAttribution"); + /// Registers the ordered JSON key path of a OneLink nested in a push payload. + /// + /// For example, `["deeply", "nested", "link"]`. [deepLinkPath] must be + /// non-empty. Call before [init]. + /// On iOS, also forward the notification payload with + /// [handlePushNotification]. + Future addPushNotificationDeepLinkPath(List deepLinkPath) { + return _invokeVoidRpc( + 'addPushNotificationDeepLinkPath', + {'deepLinkPath': deepLinkPath}, + ); } - /// Handles deep link result. - void onDeepLinking(Function(DeepLinkResult) callback) async { - startListeningToUDL(callback, "onDeepLinking"); + /// Enables or disables Facebook deferred app-link resolution. + /// + /// On iOS, the Facebook SDK must be linked for this integration. + Future enableFacebookDeferredApplinks(bool isEnabled) { + return _invokeVoidRpc( + 'enableFacebookDeferredApplinks', + {'isEnabled': isEnabled}, + ); } - /// Validates purchase. - void onPurchaseValidation(Function callback) async { - startListening(callback as void Function(dynamic), "validatePurchase"); + /// Appends [parameters] to deep-link URLs containing [contains] before the SDK + /// resolves them. + /// + /// [contains] must be non-empty. iOS also requires [parameters] to be + /// non-empty. + Future appendParametersToDeepLinkingURL( + String contains, + Map parameters, + ) { + return _invokeVoidRpc('appendParametersToDeepLinkingURL', { + 'contains': contains, + 'parameters': parameters, + }); } - /// Sets the current device language. - void setCurrentDeviceLanguage(String language) async { - _methodChannel.invokeMethod("setCurrentDeviceLanguage", language); + /// Sets or clears the Facebook deferred app-link URL directly. + /// + /// iOS only. Pass `null` to clear the current URL. Use this when the + /// application already holds the deferred link. Invalid URLs and dangerous + /// schemes such as `javascript:` are rejected with an + /// [AppsFlyerException]. + Future setFacebookDeferredAppLink(String? url) async { + return _invokeVoidRpc('setFacebookDeferredAppLink', {'url': url}); } - /// Sets sharing filter for specific partners (deprecated). - @Deprecated("use setSharingFilterForPartners instead") - void setSharingFilter(List partners) { - setSharingFilterForPartners(partners); + /// Measures an Android push-notification campaign. + /// + /// Android only. [campaign] and [pid] are required by the native SDK. + Future sendPushNotificationData({ + required String campaign, + required String pid, + bool isRetargeting = false, + Map? additionalParameters, + }) async { + return _invokeVoidRpc('sendPushNotificationData', { + 'campaign': campaign, + 'pid': pid, + 'isRetargeting': isRetargeting, + 'additionalParameters': additionalParameters, + }); } - /// Sets sharing filter for all partners (deprecated). - @Deprecated("use setSharingFilterForPartners instead") - void setSharingFilterForAllPartners() { - setSharingFilterForPartners(["all"]); + /// Passes an APNs push-notification payload to the iOS SDK. + /// + /// iOS only. Pass the complete notification `userInfo` dictionary. + Future handlePushNotification( + Map pushPayload, + ) async { + return _invokeVoidRpc( + 'handlePushNotification', + {'pushPayload': pushPayload}, + ); } - ///The sharing filter blocks the sharing of S2S events via postbacks/API with integrated partners and other third-party integrations. - ///Use the filter to fulfill regulatory requirements like GDPR and CCPA, to comply with user opt-out mechanisms, and for other business logic reasons. - void setSharingFilterForPartners(List partners) async { - _methodChannel.invokeMethod("setSharingFilterForPartners", partners); + /// Passes the device token to AppsFlyer for uninstall measurement. + /// + /// On Android, pass the FCM registration token. On iOS, pass the APNs device + /// token as an even-length hexadecimal string. The same [token] parameter is + /// used on both platforms. + Future updateServerUninstallToken(String token) { + return _isAndroid + ? _invokeVoidRpc('updateServerUninstallToken', {'token': token}) + : _invokeVoidRpc('registerUninstall', {'deviceToken': token}); } - /// Sets out of store app install source. - void setOutOfStore(String sourceName) async { - _methodChannel.invokeMethod("setOutOfStore", sourceName); + /// Returns the native AppsFlyer SDK version. + Future getSdkVersion() { + return _invokeRpc('getSdkVersion'); } - /// Gets out of store app install source. - Future getOutOfStore() async { - return await _methodChannel.invokeMethod("getOutOfStore"); + /// Returns the AppsFlyer unique device ID created for this install. + Future getAppsFlyerUID() { + return _invokeNullableRpc('getAppsFlyerUID'); } - /// Sets the partner-specific data. - void setPartnerData(String partnerId, Map partnerData) async { - _methodChannel.invokeMethod("setPartnerData", - {'partnerId': partnerId, 'partnersData': partnerData}); + /// Whether the app was installed as an OEM or manufacturer preinstall. + /// + /// Android only. + Future isPreInstalledApp() { + return _invokeRpc('isPreInstalledApp'); } - /// Sets URLs to deep link into the app when the app is first installed. - void setResolveDeepLinkURLs(List urls) async { - _methodChannel.invokeMethod("setResolveDeepLinkURLs", urls); + /// Returns the Facebook attribution ID, if available. + /// + /// Android only. + Future getAttributionId() { + return _invokeNullableRpc('getAttributionId'); } - /// Disables transfer of user-specific data over the network. - void setDisableNetworkData(bool disable) { - _methodChannel.invokeMethod("setDisableNetworkData", disable); + Future _invokeVoidRpc( + String method, [ + Map? params, + ]) async { + await _invokeNullableRpc(method, params); } - /// Disables AppSet ID collection (Android only). - /// Starting with v6.17.0, the SDK can automatically collect the AppSet ID. - /// Use this method to opt-out of AppSet ID collection. - void disableAppSetId() { - _methodChannel.invokeMethod("disableAppSetId"); + /// Calls [method] via the RPC channel and casts the result to [T]. + /// + /// [T] may be nullable — pass e.g. `String?` when the native side may + /// legitimately return no value. + Future _invokeNullableRpc( + String method, [ + Map? params, + ]) async { + try { + final dynamic result = await _methodChannel.invokeMethod( + 'executeRpc', + { + 'method': method, + 'params': params ?? {}, + }, + ); + if (result != null && result is! T) { + throw AppsFlyerException( + message: + 'Unexpected RPC result type for $method: ${result.runtimeType}', + ); + } + return result as T; + } on PlatformException catch (error) { + throw AppsFlyerException.fromPlatformException(error); + } } - /// Retrieves the current plugin version. - String getVersionNumber() { - return AppsflyerConstants.PLUGIN_VERSION; + /// Calls [method] via the RPC channel and requires a non-null result. + /// + /// Throws [AppsFlyerException] if the native side unexpectedly returns no + /// value. + Future _invokeRpc( + String method, [ + Map? params, + ]) async { + final result = await _invokeNullableRpc(method, params); + if (result == null) { + throw AppsFlyerException(message: '$method returned no value'); + } + return result; } } diff --git a/lib/src/callbacks.dart b/lib/src/callbacks.dart deleted file mode 100644 index 0d0804ac..00000000 --- a/lib/src/callbacks.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/services.dart'; - -import '../appsflyer_sdk.dart'; - -const _channel = MethodChannel(AppsflyerConstants.AF_CALLBACK_CHANNEL); - -typedef MultiUseCallback = void Function(dynamic msg); -typedef UDLCallback = void Function(DeepLinkResult deepLinkResult); -typedef CancelListening = void Function(); -typedef RequestSuccessListener = void Function(); -typedef RequestErrorListener = void Function( - int errorCode, String errorMessage); - -Map _callbacksById = - {}; -UDLCallback? _udlCallback; - -Future _methodCallHandler(MethodCall call) async { - switch (call.method) { - case 'callListener': - try { - dynamic callMap = jsonDecode(call.arguments); - switch (callMap["id"]) { - case "onAppOpenAttribution": - case "onInstallConversionData": - case "validatePurchase": - case "generateInviteLinkSuccess": - String data = callMap["data"]; - Map? decodedData = jsonDecode(data); - Map fullResponse = { - "status": callMap['status'], - "payload": decodedData - }; - _callbacksById[callMap["id"]]!(fullResponse); - break; - case "onDeepLinking": - Error? error = - (callMap["deepLinkError"] as String?)?.errorFromString(); - Status? status = - (callMap["deepLinkStatus"] as String?)?.statusFromString() ?? - Status.PARSE_ERROR; - Map? map = - callMap["deepLinkObj"] as Map?; - DeepLink? deepLink = map != null ? DeepLink(map) : null; - var dp = DeepLinkResult(error, deepLink, status); - if (_udlCallback != null) { - _udlCallback!(dp); - } - break; - default: - _callbacksById[callMap["id"]]!(callMap["data"]); - break; - } - } on Exception catch (e) { - print("Exception $e"); - } - break; - default: - print('Ignoring invoke from native. This normally shouldn\'t happen.'); - } -} - -Future startListening( - MultiUseCallback callback, String callbackName) async { - _channel.setMethodCallHandler(_methodCallHandler); - - _callbacksById[callbackName] = callback; - - await _channel.invokeMethod("startListening", callbackName); - - return () { - _channel.invokeMethod("cancelListening", callbackName); - _callbacksById.remove(callbackName); - }; -} - -Future startListeningToUDL( - UDLCallback callback, String callbackName) async { - _channel.setMethodCallHandler(_methodCallHandler); - - _udlCallback = callback; - - await _channel.invokeMethod("startListening", callbackName); - - return () { - _channel.invokeMethod("cancelListening", callbackName); - _callbacksById.remove(callbackName); - }; -} diff --git a/lib/src/purchase_connector/missing_configuration_exception.dart b/lib/src/purchase_connector/missing_configuration_exception.dart index 8d104585..5c64ba58 100644 --- a/lib/src/purchase_connector/missing_configuration_exception.dart +++ b/lib/src/purchase_connector/missing_configuration_exception.dart @@ -5,7 +5,7 @@ class MissingConfigurationException implements Exception { final String message; MissingConfigurationException( - {this.message = AppsflyerConstants.MISSING_CONFIGURATION_EXCEPTION_MSG}); + {this.message = _AppsFlyerConstants.MISSING_CONFIGURATION_EXCEPTION_MSG}); @override String toString() => 'ConfigurationException: $message'; diff --git a/lib/src/purchase_connector/purchase_connector.dart b/lib/src/purchase_connector/purchase_connector.dart index ee740d8b..b347f8c8 100644 --- a/lib/src/purchase_connector/purchase_connector.dart +++ b/lib/src/purchase_connector/purchase_connector.dart @@ -71,17 +71,13 @@ class _PurchaseConnectorImpl implements PurchaseConnector { _methodChannel.setMethodCallHandler(_methodCallHandler); final configMap = { - AppsflyerConstants.LOG_SUBS_KEY: config.logSubscriptions, - AppsflyerConstants.LOG_IN_APP_KEY: config.logInApps, - AppsflyerConstants.SANDBOX_KEY: config.sandbox, - AppsflyerConstants.STORE_KIT_VERSION_KEY: config.storeKitVersion.value, + _AppsFlyerConstants.LOG_SUBS_KEY: config.logSubscriptions, + _AppsFlyerConstants.LOG_IN_APP_KEY: config.logInApps, + _AppsFlyerConstants.SANDBOX_KEY: config.sandbox, + _AppsFlyerConstants.STORE_KIT_VERSION_KEY: config.storeKitVersion.value, }; - print("[AppsFlyer_PC_Debug] Sending config to native: $configMap"); - print( - "[AppsFlyer_PC_Debug] Keys being sent: ${AppsflyerConstants.LOG_SUBS_KEY}, ${AppsflyerConstants.LOG_IN_APP_KEY}, ${AppsflyerConstants.SANDBOX_KEY}"); - - _methodChannel.invokeMethod(AppsflyerConstants.CONFIGURE_KEY, configMap); + _methodChannel.invokeMethod(_AppsFlyerConstants.CONFIGURE_KEY, configMap); } /// Factory constructor. @@ -100,11 +96,11 @@ class _PurchaseConnectorImpl implements PurchaseConnector { throw MissingConfigurationException(); } else if (_instance == null && config != null) { // no existing instance. Create new instance and apply config - MethodChannel methodChannel = - const MethodChannel(AppsflyerConstants.AF_PURCHASE_CONNECTOR_CHANNEL); + MethodChannel methodChannel = const MethodChannel( + _AppsFlyerConstants.AF_PURCHASE_CONNECTOR_CHANNEL); _instance = _PurchaseConnectorImpl._internal(methodChannel, config); } else if (_instance != null && config != null) { - debugPrint(AppsflyerConstants.RE_CONFIGURE_ERROR_MSG); + debugPrint(_AppsFlyerConstants.RE_CONFIGURE_ERROR_MSG); } return _instance!; @@ -155,28 +151,32 @@ class _PurchaseConnectorImpl implements PurchaseConnector { /// Method call handler for different operations. Called by the _methodChannel. Future _methodCallHandler(MethodCall call) async { - dynamic callMap = jsonDecode(call.arguments); + // Native may send either a JSON string or an already-decoded Map; handle both. + final dynamic rawArgs = call.arguments; + final dynamic callMap = rawArgs is String ? jsonDecode(rawArgs) : rawArgs; switch (call.method) { - case AppsflyerConstants - .SUBSCRIPTION_PURCHASE_VALIDATION_RESULT_LISTENER_ON_RESPONSE: + case _AppsFlyerConstants + .SUBSCRIPTION_PURCHASE_VALIDATION_RESULT_LISTENER_ON_RESPONSE: _handleSubscriptionPurchaseValidationResultListenerOnResponse(callMap); break; - case AppsflyerConstants - .SUBSCRIPTION_PURCHASE_VALIDATION_RESULT_LISTENER_ON_FAILURE: + case _AppsFlyerConstants + .SUBSCRIPTION_PURCHASE_VALIDATION_RESULT_LISTENER_ON_FAILURE: _handleSubscriptionPurchaseValidationResultListenerOnFailure(callMap); break; - case AppsflyerConstants.IN_APP_VALIDATION_RESULT_LISTENER_ON_RESPONSE: + case _AppsFlyerConstants.IN_APP_VALIDATION_RESULT_LISTENER_ON_RESPONSE: _handleInAppValidationResultListenerOnResponse(callMap); break; - case AppsflyerConstants.IN_APP_VALIDATION_RESULT_LISTENER_ON_FAILURE: + case _AppsFlyerConstants.IN_APP_VALIDATION_RESULT_LISTENER_ON_FAILURE: _handleInAppValidationResultListenerOnFailure(callMap); break; - case AppsflyerConstants.DID_RECEIVE_PURCHASE_REVENUE_VALIDATION_INFO: + case _AppsFlyerConstants.DID_RECEIVE_PURCHASE_REVENUE_VALIDATION_INFO: _handleDidReceivePurchaseRevenueValidationInfo(callMap); break; default: - throw ArgumentError("Method not found: ${call.method}"); + // Unknown callback name — log instead of throwing inside a platform + // message handler (an uncaught throw here becomes an unhandled async error). + debugPrint("PurchaseConnector: unknown method ${call.method}"); } } @@ -222,10 +222,10 @@ class _PurchaseConnectorImpl implements PurchaseConnector { /// /// [callbackData] is the callback data expected in the form of a map. void _handleDidReceivePurchaseRevenueValidationInfo(dynamic callbackData) { - var validationInfo = callbackData[AppsflyerConstants.VALIDATION_INFO] + var validationInfo = callbackData[_AppsFlyerConstants.VALIDATION_INFO] as Map?; var errorMap = - callbackData[AppsflyerConstants.ERROR] as Map?; + callbackData[_AppsFlyerConstants.ERROR] as Map?; var error = errorMap != null ? IosError.fromJson(errorMap) : null; if (_didReceivePurchaseRevenueValidationInfo != null) { @@ -243,7 +243,7 @@ class _PurchaseConnectorImpl implements PurchaseConnector { Map? res = converter(callbackData); if (onResponse != null) { onResponse(res); - } else {} + } } /// Handles failure for a validation result listener. @@ -252,9 +252,9 @@ class _PurchaseConnectorImpl implements PurchaseConnector { /// [onFailureCallback] is a function to be called on failure. void _handleValidationResultListenerOnFailure( dynamic callbackData, OnFailure? onFailureCallback) { - var resultMsg = callbackData[AppsflyerConstants.RESULT] as String; + var resultMsg = callbackData[_AppsFlyerConstants.RESULT] as String; var errorMap = - callbackData[AppsflyerConstants.ERROR] as Map?; + callbackData[_AppsFlyerConstants.ERROR] as Map?; var error = errorMap != null ? JVMThrowable.fromJson(errorMap) : null; if (onFailureCallback != null) { onFailureCallback(resultMsg, error); diff --git a/lib/src/udl/deep_link_result.dart b/lib/src/udl/deep_link_result.dart index 9cfcefd4..82ecfc11 100644 --- a/lib/src/udl/deep_link_result.dart +++ b/lib/src/udl/deep_link_result.dart @@ -1,69 +1,98 @@ part of appsflyer_sdk; -class DeepLinkResult { - final Error? _error; - final DeepLink? _deepLink; - final Status _status; - - DeepLinkResult(this._error, this._deepLink, this._status); - - Error? get error => _error; - - DeepLink? get deepLink => _deepLink; +/// The outcome of native Unified Deep Linking resolution. +enum DeepLinkStatus { + found, + notFound, + error, + unknown, +} - Status get status => _status; +/// Platform-specific deep-link failure information. +/// +/// Android reports a stable error type such as `NETWORK`; iOS reports a +/// localized error message. The fields intentionally remain optional so the +/// platform distinction is not hidden. +@immutable +class DeepLinkFailure { + final String? type; + final String? message; - DeepLinkResult.fromJson(Map json) - : _error = json['error'], - _status = json['status'], - _deepLink = json['deepLink']; + const DeepLinkFailure({this.type, this.message}); Map toJson() => { - 'status': _status.toShortString(), - 'error': _error?.toShortString(), - 'deepLink': _deepLink?.clickEvent, + 'type': type, + 'message': message, }; - - @override - String toString() { - return "DeepLinkResult:${jsonEncode(toJson())}"; - } } -enum Error { TIMEOUT, NETWORK, HTTP_STATUS_CODE, UNEXPECTED, DEVELOPER_ERROR } +@immutable +class DeepLinkResult { + final DeepLinkStatus status; + final DeepLink? deepLink; + final DeepLinkFailure? error; -enum Status { FOUND, NOT_FOUND, ERROR, PARSE_ERROR } + const DeepLinkResult({ + required this.status, + this.deepLink, + this.error, + }); -extension ParseStatusToString on Status { - String toShortString() { - return toString().split('.').last; - } -} + factory DeepLinkResult._fromEvent( + _AppsFlyerEvent event, { + required TargetPlatform platform, + }) { + final data = event.data; + final rawStatus = data['status']?.toString(); + final normalizedStatus = rawStatus?.replaceAll('_', '').toLowerCase(); + final DeepLinkStatus status; + if (normalizedStatus == 'found') { + status = DeepLinkStatus.found; + } else if (normalizedStatus == 'notfound') { + status = DeepLinkStatus.notFound; + } else if (normalizedStatus == 'error' || normalizedStatus == 'failure') { + status = DeepLinkStatus.error; + } else { + status = DeepLinkStatus.unknown; + } -extension ParseErrorToString on Error { - String toShortString() { - return toString().split('.').last; - } -} + final rawDeepLink = data['deepLink']; + final deepLinkMap = _decodeDeepLink(rawDeepLink); + final rawError = data['error']; + final error = rawError == null + ? null + : platform == TargetPlatform.android + ? DeepLinkFailure(type: rawError.toString()) + : DeepLinkFailure(message: rawError.toString()); -extension ParseEnumFromString on String { - Status? statusFromString() { - return Status.values - .firstWhere((s) => _describeEnum(s) == this, orElse: null); + return DeepLinkResult( + status: status, + deepLink: deepLinkMap == null ? null : DeepLink(deepLinkMap), + error: error, + ); } - Error? errorFromString() { - return Error.values - .firstWhere((e) => _describeEnum(e) == this, orElse: null); + static Map? _decodeDeepLink(dynamic value) { + if (value is Map) { + return Map.from(value); + } + if (value is! String || value.isEmpty) { + return null; + } + try { + final decoded = jsonDecode(value); + return decoded is Map ? Map.from(decoded) : null; + } on FormatException { + return null; + } } - String _describeEnum(Object enumEntry) { - final String description = enumEntry.toString(); - final int indexOfDot = description.indexOf('.'); - assert( - indexOfDot != -1 && indexOfDot < description.length - 1, - 'The provided object "$enumEntry" is not an enum.', - ); - return description.substring(indexOfDot + 1); - } + Map toJson() => { + 'status': status.name, + 'error': error?.toJson(), + 'deepLink': deepLink?.clickEvent, + }; + + @override + String toString() => 'DeepLinkResult: ${jsonEncode(toJson())}'; } diff --git a/lib/src/udl/deeplink.dart b/lib/src/udl/deeplink.dart index 40efe063..8570510d 100644 --- a/lib/src/udl/deeplink.dart +++ b/lib/src/udl/deeplink.dart @@ -1,40 +1,55 @@ part of appsflyer_sdk; +@immutable class DeepLink { final Map _clickEvent; - DeepLink(this._clickEvent); + const DeepLink(this._clickEvent); Map get clickEvent => _clickEvent; - String? getStringValue(String key) { - return _clickEvent[key] as String?; - } + String? getStringValue(String key) => _clickEvent[key]?.toString(); - String? get deepLinkValue => _clickEvent["deep_link_value"] as String?; + String? get deepLinkValue => getStringValue('deep_link_value'); - String? get matchType => _clickEvent["match_type"] as String?; + String? get matchType => getStringValue('match_type'); - String? get clickHttpReferrer => - _clickEvent["click_http_referrer"] as String?; + String? get clickHttpReferrer => getStringValue('click_http_referrer'); - String? get mediaSource => _clickEvent["media_source"] as String?; + String? get mediaSource => getStringValue('media_source'); - String? get campaign => _clickEvent["campaign"] as String?; + String? get campaign => getStringValue('campaign'); - String? get campaignId => _clickEvent["campaign_id"] as String?; + String? get campaignId => getStringValue('campaign_id'); - String? get afSub1 => _clickEvent["af_sub1"] as String?; + String? get afSub1 => getStringValue('af_sub1'); - String? get afSub2 => _clickEvent["af_sub2"] as String?; + String? get afSub2 => getStringValue('af_sub2'); - String? get afSub3 => _clickEvent["af_sub3"] as String?; + String? get afSub3 => getStringValue('af_sub3'); - String? get afSub4 => _clickEvent["af_sub4"] as String?; + String? get afSub4 => getStringValue('af_sub4'); - String? get afSub5 => _clickEvent["af_sub5"] as String?; + String? get afSub5 => getStringValue('af_sub5'); - bool? get isDeferred => _clickEvent["is_deferred"] as bool?; + /// Whether this link came from deferred deep linking (fresh install) vs. a + /// direct click while already installed. + /// + /// Reliable on Android. **Always `null` on iOS**: the native click event + /// doesn't carry an `is_deferred` key. + bool? get isDeferred { + final value = _clickEvent['is_deferred']; + if (value is bool) { + return value; + } + if (value?.toString().toLowerCase() == 'true') { + return true; + } + if (value?.toString().toLowerCase() == 'false') { + return false; + } + return null; + } @override String toString() { diff --git a/pubspec.yaml b/pubspec.yaml index 8457c3d3..dc969465 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,12 @@ name: appsflyer_sdk description: A Flutter plugin for AppsFlyer SDK. Supports iOS and Android. -version: 6.18.1 +version: 7.0.1 homepage: https://github.com/AppsFlyerSDK/flutter_appsflyer_sdk environment: - sdk: '>=2.17.0 <4.0.0' - flutter: ">=1.10.0" + sdk: '>=3.5.0 <4.0.0' + flutter: ">=3.24.0" dependencies: flutter: diff --git a/test/appsflyer_sdk_test.dart b/test/appsflyer_sdk_test.dart index 80f3262f..c399f506 100644 --- a/test/appsflyer_sdk_test.dart +++ b/test/appsflyer_sdk_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:appsflyer_sdk/appsflyer_sdk.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -5,364 +8,1570 @@ import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - late AppsflyerSdk instance; - String selectedMethod = ""; - dynamic capturedArguments; - const MethodChannel methodChannel = MethodChannel('af-api'); - const MethodChannel callbacksChannel = MethodChannel('callbacks'); - const EventChannel eventChannel = EventChannel('af-events'); - const MethodChannel eventMethodChannel = MethodChannel('af-events'); + const methodChannel = MethodChannel('af-api'); + const eventChannel = EventChannel('af-events'); + const eventMethodChannel = MethodChannel('af-events'); + + late String? rpcMethod; + late Map? rpcParams; + late Object? rpcResult; + late List eventChannelCalls; + late AppsFlyerSdk androidSdk; + late AppsFlyerSdk iosSdk; setUp(() { - //test map options way - instance = AppsflyerSdk.private(methodChannel, eventChannel, - mapOptions: {'afDevKey': 'sdfhj2342cx'}); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (methodCall) async { - String method = methodCall.method; - switch (method) { - case 'initSdk': - case 'setOneLinkCustomDomain': - case 'logCrossPromotionAndOpenStore': - case 'logCrossPromotionImpression': - case 'setAppInviteOneLinkID': - case 'generateInviteLink': - case 'setSharingFilterForAllPartners': - case 'setSharingFilter': - case 'getSDKVersion': - case 'getAppsFlyerUID': - case 'validateAndLogInAppAndroidPurchase': - case 'setMinTimeBetweenSessions': - case 'getHostPrefix': - case 'getHostName': - case 'setCollectIMEI': - case 'setCollectAndroidId': - case 'setUserEmails': - case 'setAdditionalData': - case 'waitForCustomerUserId': - case 'setCustomerUserId': - case 'setAndroidIdData': - case 'setImeiData': - case 'updateServerUninstallToken': - case 'stop': - case 'setIsUpdate': - case 'setCurrencyCode': - case 'setHost': - case 'logEvent': - case 'setOutOfStore': - case 'getOutOfStore': - case 'logAdRevenue': - case 'setConsentData': - case 'enableTCFDataCollection': - case 'setDisableNetworkData': - case 'disableAppSetId': - case 'setPartnerData': - case 'setResolveDeepLinkURLs': - case 'setPushNotification': - case 'sendPushNotificationData': - case 'enableFacebookDeferredApplinks': - case 'disableSKAdNetwork': - case 'setDisableAdvertisingIdentifiers': - selectedMethod = method; - capturedArguments = methodCall.arguments; - break; - } - return null; + rpcMethod = null; + rpcParams = null; + rpcResult = null; + eventChannelCalls = []; + + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(methodChannel, (call) async { + expect(call.method, 'executeRpc'); + final args = Map.from(call.arguments as Map); + rpcMethod = args['method'] as String; + rpcParams = Map.from(args['params'] as Map); + return rpcResult; }); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(eventMethodChannel, (methodCall) async { - String method = methodCall.method; - if (method == 'listen') { - selectedMethod = method; - capturedArguments = methodCall.arguments; - } + messenger.setMockMethodCallHandler(eventMethodChannel, (call) async { + eventChannelCalls.add(call.method); return null; }); - // Mock handler for callbacks channel to avoid MissingPluginException during startListening - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(callbacksChannel, (methodCall) async { - selectedMethod = methodCall.method; - capturedArguments = methodCall.arguments; - return null; - }); + androidSdk = AppsFlyerSdk.private( + methodChannel, + eventChannel, + platform: TargetPlatform.android, + ); + iosSdk = AppsFlyerSdk.private( + methodChannel, + eventChannel, + platform: TargetPlatform.iOS, + ); }); - test('check initSdk call', () async { - await instance.initSdk( - registerConversionDataCallback: true, - registerOnAppOpenAttributionCallback: true, - registerOnDeepLinkingCallback: false); - - expect('initSdk', selectedMethod); + tearDown(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(methodChannel, null); + messenger.setMockMethodCallHandler(eventMethodChannel, null); }); - group('AppsFlyerSdk', () { - setUp(() { - selectedMethod = ""; - capturedArguments = null; + group('lifecycle', () { + test('init sends the iOS initialization parameters', () async { + await iosSdk.init( + devKey: 'ios-dev-key', + appId: '123456789', + ); + + expect(rpcMethod, 'init'); + expect(rpcParams, { + 'devKey': 'ios-dev-key', + 'appId': '123456789', + }); }); - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, null); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(callbacksChannel, null); + test('init does not send appId to Android', () async { + await androidSdk.init( + devKey: 'android-dev-key', + appId: 'ignored-on-android', + ); + + expect(rpcMethod, 'init'); + expect(rpcParams, {'devKey': 'android-dev-key'}); }); - test('check logEvent call', () async { - await instance.logEvent("eventName", {"key": "val"}); + test('init allows Android without appId', () async { + await androidSdk.init(devKey: 'android-dev-key'); - expect(selectedMethod, 'logEvent'); + expect(rpcMethod, 'init'); + expect(rpcParams, {'devKey': 'android-dev-key'}); }); - test('check setHost call', () async { - instance.setHost("prefix", "hostname"); + test('init forwards invalid values to the native RPC layer', () async { + await androidSdk.init(devKey: ''); + expect(rpcMethod, 'init'); + expect(rpcParams, {'devKey': ''}); + + await iosSdk.init(devKey: 'ios-dev-key'); + expect(rpcMethod, 'init'); + expect(rpcParams, {'devKey': 'ios-dev-key', 'appId': null}); - expect(selectedMethod, 'setHost'); - expect(capturedArguments['hostPrefix'], 'prefix'); - expect(capturedArguments['hostName'], 'hostname'); + await iosSdk.init(devKey: 'ios-dev-key', appId: ''); + expect(rpcMethod, 'init'); + expect(rpcParams, {'devKey': 'ios-dev-key', 'appId': ''}); }); - test('check setCurrencyCode call', () async { - instance.setCurrencyCode("USD"); + test('the public SDK entry point is a singleton', () { + expect(AppsFlyerSdk.instance, same(AppsFlyerSdk.instance)); + }); - expect(selectedMethod, 'setCurrencyCode'); - expect(capturedArguments['currencyCode'], 'USD'); + test('pluginVersion exposes the compiled plugin version constant', () { + expect(androidSdk.pluginVersion, '7.0.1'); + expect(iosSdk.pluginVersion, '7.0.1'); }); - test('check setIsUpdate call', () async { - instance.setIsUpdate(true); + test('listeners are registered explicitly', () async { + await androidSdk.registerConversionListener(onSuccess: (_) {}); + expect(rpcMethod, 'registerConversionListener'); - expect(selectedMethod, 'setIsUpdate'); - expect(capturedArguments['isUpdate'], true); - }); + await androidSdk.registerDeepLinkListener((_) {}); + expect(rpcMethod, 'subscribeForDeepLink'); - test('check stop call', () async { - instance.stop(true); + await iosSdk.registerDeepLinkListener((_) {}); + expect(rpcMethod, 'registerDeeplinkListener'); - expect(selectedMethod, 'stop'); - expect(capturedArguments['isStopped'], true); + await iosSdk.registerSessionReadyListener(() {}); + expect(rpcMethod, 'registerSessionReadyListener'); }); - test('check updateServerUninstallToken call', () async { - instance.updateServerUninstallToken("token123"); - - expect(selectedMethod, 'updateServerUninstallToken'); - expect(capturedArguments['token'], 'token123'); + test('start is fire-and-forget by default', () async { + await androidSdk.start(); + expect(rpcMethod, 'start'); + expect(rpcParams, {'awaitResponse': false}); }); - test('check setOneLinkCustomDomain call', () async { - instance.setOneLinkCustomDomain(["brandDomains"]); + test('start can wait for the native request callback', () async { + await androidSdk.start(awaitResponse: true); + expect(rpcMethod, 'start'); + expect(rpcParams, {'awaitResponse': true}); + }); - expect(selectedMethod, 'setOneLinkCustomDomain'); - expect(capturedArguments, isA()); - expect(capturedArguments, contains('brandDomains')); + test('start with awaitResponse throws AppsFlyerException on RPC failure', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (_) async { + throw PlatformException( + code: '500', + message: 'Session launch failed', + ); + }); + + await expectLater( + androidSdk.start(awaitResponse: true), + throwsA( + isA() + .having((error) => error.code, 'code', 500) + .having( + (error) => error.message, + 'message', + 'Session launch failed', + ), + ), + ); }); - test('check logCrossPromotionAndOpenStore call', () async { - instance.logCrossPromotionAndOpenStore("appId123", "campaignA", null); + test('enableDebug maps to the isDebug RPC method', () async { + await iosSdk.enableDebug(true); + expect(rpcMethod, 'isDebug'); + expect(rpcParams, {'isDebug': true}); + }); - expect(selectedMethod, 'logCrossPromotionAndOpenStore'); - expect(capturedArguments['appId'], 'appId123'); - expect(capturedArguments['campaign'], 'campaignA'); - expect(capturedArguments['params'], null); + test('isSessionReady returns the native result', () async { + rpcResult = true; + expect(await iosSdk.isSessionReady(), isTrue); + expect(rpcMethod, 'isSessionReady'); }); + }); - test('check logCrossPromotionImpression call', () async { - instance.logCrossPromotionImpression("appId", "campaign", null); + group('requests and errors', () { + test('void RPC calls always send an empty params map', () async { + await androidSdk.disableAppSetId(); - expect(selectedMethod, 'logCrossPromotionImpression'); + expect(rpcMethod, 'disableAppSetId'); + expect(rpcParams, isNotNull); + expect(rpcParams, isEmpty); }); - test('check setAppInviteOneLinkID call', () async { - instance.setAppInviteOneLinkID("oneLinkID", (msg) {}); + test('logEvent is fire-and-forget by default', () async { + await androidSdk.logEvent( + 'af_purchase', + eventValues: {'revenue': 4.2}, + ); + + expect(rpcMethod, 'logEvent'); + expect(rpcParams, { + 'eventName': 'af_purchase', + 'eventValues': {'revenue': 4.2}, + 'awaitResponse': false, + }); + }); - expect(selectedMethod, 'setAppInviteOneLinkID'); + test('logEvent can wait for the native request callback', () async { + await androidSdk.logEvent( + 'af_purchase', + eventValues: {'revenue': 4.2}, + awaitResponse: true, + ); + + expect(rpcMethod, 'logEvent'); + expect(rpcParams, { + 'eventName': 'af_purchase', + 'eventValues': {'revenue': 4.2}, + 'awaitResponse': true, + }); }); - test('check generateInviteLink call', () async { - instance.generateInviteLink(null, (msg) {}, (err) {}); + test( + 'PlatformException with a numeric RPC code becomes ' + 'AppsFlyerException', () async { + // Matches the real native shape: RpcResponse.Error/AFRPCError report a + // numeric, HTTP-style code as a string, with no details map. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (_) async { + throw PlatformException( + code: '422', + message: 'devKey cannot be empty', + ); + }); + + await expectLater( + androidSdk.logEvent('', eventValues: null), + throwsA( + isA() + .having((error) => error.code, 'code', 422) + .having((error) => error.message, 'message', + 'devKey cannot be empty'), + ), + ); + }); - expect(selectedMethod, 'generateInviteLink'); + test( + 'PlatformException with a non-numeric plugin-guard code leaves ' + 'code null', () async { + // Matches guards that fail before reaching the RPC layer (a malformed + // channel call, a JSON parse failure) — these report a non-numeric + // code, e.g. "INVALID_PARAMETERS", which has no HTTP-style equivalent. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (_) async { + throw PlatformException( + code: 'INVALID_PARAMETERS', + message: "executeRpc requires a 'method'", + ); + }); + + await expectLater( + androidSdk.logEvent('', eventValues: null), + throwsA( + isA() + .having((error) => error.code, 'code', isNull) + .having( + (error) => error.message, + 'message', + "executeRpc requires a 'method'", + ), + ), + ); }); - test('check getSDKVersion call', () async { - instance.getSDKVersion(); + test('MissingPluginException is not converted to AppsFlyerException', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (_) async { + throw MissingPluginException('No implementation found'); + }); + + await expectLater( + androidSdk.logEvent('', eventValues: null), + throwsA(isA()), + ); + }); - expect(selectedMethod, 'getSDKVersion'); + test('unexpected RPC result type becomes AppsFlyerException', () async { + rpcResult = 1; + + await expectLater( + androidSdk.isSessionReady(), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Unexpected RPC result type for isSessionReady: int', + ), + ), + ); + expect(rpcMethod, 'isSessionReady'); }); - test('check getAppsFlyerUID call', () async { - instance.getAppsFlyerUID(); + test('unexpected null RPC result throws AppsFlyerException', () async { + rpcResult = null; + + for (final call in Function()>[ + iosSdk.isSessionReady, + androidSdk.isStopped, + androidSdk.isPreInstalledApp, + ]) { + await expectLater( + call(), + throwsA( + isA().having( + (error) => error.message, + 'message', + '$rpcMethod returned no value', + ), + ), + ); + } + }); - expect(selectedMethod, 'getAppsFlyerUID'); + test( + 'platform-only calls are forwarded to the native RPC instead of being ' + 'swallowed in Dart', () async { + // Which platform implements which method is the RPC contract's to know. + // Mirroring that list in Dart would silently go stale the moment a + // native SDK adds support, so every call is forwarded and the native + // layer decides. + final forwarded = Function()>{ + 'setCollectAndroidID': () => iosSdk.setCollectAndroidID(true), + 'setLogLevel': () => iosSdk.setLogLevel(AFLogLevel.debug), + 'logSession': () => iosSdk.logSession(), + 'setOutOfStore': () => iosSdk.setOutOfStore('source'), + 'setIsUpdate': () => iosSdk.setIsUpdate(true), + 'setAppId': () => iosSdk.setAppId('123'), + 'disableAppSetId': () => iosSdk.disableAppSetId(), + 'setDisableSKAdNetwork': () => androidSdk.setDisableSKAdNetwork(true), + 'setDisableCollectASA': () => androidSdk.setDisableCollectASA(true), + 'setCurrentDeviceLanguage': () => + androidSdk.setCurrentDeviceLanguage('en'), + 'handlePushNotification': () => + androidSdk.handlePushNotification({'aps': {}}), + }; + + for (final entry in forwarded.entries) { + rpcMethod = null; + await entry.value(); + expect(rpcMethod, entry.key); + } }); - test('check validateAndLogInAppPurchase call', () async { - instance.validateAndLogInAppAndroidPurchase( - "publicKey", "signature", "purchaseData", "9.99", "EUR", null); + test('an off-platform getter forwards rather than fabricating a value', + () async { + // Returning a plausible `false` here would be indistinguishable from a + // genuine "not stopped" answer. + rpcResult = true; - expect(selectedMethod, 'validateAndLogInAppAndroidPurchase'); - expect(capturedArguments['publicKey'], 'publicKey'); - expect(capturedArguments['price'], '9.99'); - expect(capturedArguments['currency'], 'EUR'); + expect(await iosSdk.isStopped(), isTrue); + expect(rpcMethod, 'isStopped'); }); - test('check setMinTimeBetweenSessions call', () async { - instance.setMinTimeBetweenSessions(1); + test('platform-only getters surface the native method-not-found error', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + final args = Map.from(call.arguments as Map); + rpcMethod = args['method'] as String; + throw PlatformException( + code: '404', + message: 'Method not found: $rpcMethod', + ); + }); + + for (final getter in Function()>[ + iosSdk.getHostName, + iosSdk.getHostPrefix, + iosSdk.getOutOfStore, + iosSdk.getAttributionId, + () => iosSdk.isPreInstalledApp(), + () => iosSdk.isStopped(), + ]) { + await expectLater( + getter(), + throwsA( + isA() + .having((error) => error.code, 'code', 404) + .having( + (error) => error.message, + 'message', + 'Method not found: $rpcMethod', + ), + ), + ); + } + }); - expect(selectedMethod, 'setMinTimeBetweenSessions'); + test('platform-only setters surface the native error', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + final args = Map.from(call.arguments as Map); + rpcMethod = args['method'] as String; + throw PlatformException( + code: '422', + message: 'Unknown RPC method: $rpcMethod', + ); + }); + + for (final setter in Function()>[ + () => androidSdk.setUseReceiptValidationSandbox(true), + () => androidSdk.setUseUninstallSandbox(true), + () => androidSdk.setDisableIDFVCollection(true), + () => iosSdk.setCollectAndroidID(true), + ]) { + await expectLater( + setter(), + throwsA( + isA() + .having((error) => error.code, 'code', 422) + .having( + (error) => error.message, + 'message', + 'Unknown RPC method: $rpcMethod', + ), + ), + ); + } }); - test('check getHostPrefix call', () async { - instance.getHostPrefix(); + test('iOS ASA collection is configured through an explicit setter', + () async { + await iosSdk.setDisableCollectASA(true); + expect(rpcMethod, 'setDisableCollectASA'); + expect(rpcParams, {'disable': true}); + }); - expect(selectedMethod, 'getHostPrefix'); + test('Android clear requests reach the native RPC layer', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + final args = Map.from(call.arguments as Map); + rpcMethod = args['method'] as String; + rpcParams = Map.from(args['params'] as Map); + throw PlatformException( + code: '422', + message: 'partners must not be empty', + ); + }); + + for (final partners in ?>[null, []]) { + await expectLater( + androidSdk.setSharingFilterForPartners(partners), + throwsA( + isA() + .having((error) => error.code, 'code', 422) + .having( + (error) => error.message, + 'message', + 'partners must not be empty', + ), + ), + ); + expect(rpcMethod, 'setSharingFilterForPartners'); + expect(rpcParams, {'partners': null}); + } }); + }); - test('check getHostName call', () async { - instance.getHostName(); + group('models and platform payloads', () { + test('setConsentData forwards incomplete GDPR payloads to native RPC', + () async { + await androidSdk.setConsentData(isUserSubjectToGDPR: true); + + expect(rpcMethod, 'setConsentData'); + expect(rpcParams, { + 'isUserSubjectToGDPR': true, + 'hasConsentForDataUsage': null, + 'hasConsentForAdsPersonalization': null, + 'hasConsentForAdStorage': null, + }); + }); - expect(selectedMethod, 'getHostName'); + test('maps every mediation network to the native RPC string', () async { + const androidMediationNetworks = { + ..._sharedMediationNetworkRpcValues, + AFMediationNetwork.customMediation: 'custom_mediation', + AFMediationNetwork.directMonetizationNetwork: + 'direct_monetization_network', + }; + const iosMediationNetworks = { + ..._sharedMediationNetworkRpcValues, + AFMediationNetwork.customMediation: 'custom', + AFMediationNetwork.directMonetizationNetwork: 'directmonetization', + }; + + await _expectLogAdRevenueMediationNetworks( + androidSdk, + androidMediationNetworks, + () => rpcParams, + ); + await _expectLogAdRevenueMediationNetworks( + iosSdk, + iosMediationNetworks, + () => rpcParams, + ); }); - test('check setCollectIMEI call', () async { - instance.setCollectIMEI(true); + test('purchase validation sends the Android contract', () async { + rpcResult = {'status': 'verified'}; + const purchase = AFAndroidPurchaseDetails( + purchaseType: AFPurchaseType.oneTimePurchase, + productId: 'sku', + purchaseToken: 'token', + ); + + expect( + await androidSdk.validateAndLogInAppPurchase(purchase), + {'status': 'verified'}, + ); + expect(rpcParams, { + 'purchaseType': 'one_time_purchase', + 'purchaseToken': 'token', + 'productId': 'sku', + 'additionalParameters': null, + 'awaitResponse': true, + }); + }); - expect(selectedMethod, 'setCollectIMEI'); + test('purchase validation sends the iOS contract', () async { + rpcResult = {}; + const purchase = AFIOSPurchaseDetails( + purchaseType: AFPurchaseType.subscription, + productId: 'sku', + transactionId: 'transaction', + ); + + await iosSdk.validateAndLogInAppPurchase(purchase); + expect(rpcParams, { + 'product': {'productId': 'sku'}, + 'transaction': { + 'transactionId': 'transaction', + 'purchaseType': 'subscription', + }, + 'additionalParameters': null, + }); }); - test('check setCollectAndroidId call', () async { - instance.setCollectAndroidId(true); + test('purchase validation returns an empty map when native result is null', + () async { + rpcResult = null; + const purchase = AFAndroidPurchaseDetails( + purchaseType: AFPurchaseType.oneTimePurchase, + productId: 'sku', + purchaseToken: 'token', + ); + + expect( + await androidSdk.validateAndLogInAppPurchase( + purchase, + awaitResponse: false, + ), + isEmpty, + ); + }); - expect(selectedMethod, 'setCollectAndroidId'); + test('purchase validation forwards awaitResponse only to Android', + () async { + rpcResult = {}; + + await androidSdk.validateAndLogInAppPurchase( + const AFAndroidPurchaseDetails( + purchaseType: AFPurchaseType.oneTimePurchase, + productId: 'android-sku', + purchaseToken: 'token', + ), + awaitResponse: false, + ); + expect(rpcParams!['awaitResponse'], isFalse); + + await iosSdk.validateAndLogInAppPurchase( + const AFIOSPurchaseDetails( + purchaseType: AFPurchaseType.subscription, + productId: 'ios-sku', + transactionId: 'transaction', + ), + awaitResponse: false, + ); + expect(rpcParams, isNot(contains('awaitResponse'))); }); - test('check setUserEmails call', () async { - instance.setUserEmails( - ["user@example.com"], EmailCryptType.EmailCryptTypeSHA256); + test('purchase details reject the wrong platform', () async { + const androidPurchase = AFAndroidPurchaseDetails( + purchaseType: AFPurchaseType.oneTimePurchase, + productId: 'android-sku', + purchaseToken: 'token', + ); + const iosPurchase = AFIOSPurchaseDetails( + purchaseType: AFPurchaseType.subscription, + productId: 'ios-sku', + transactionId: 'transaction', + ); + + await expectLater( + iosSdk.validateAndLogInAppPurchase(androidPurchase), + throwsArgumentError, + ); + await expectLater( + androidSdk.validateAndLogInAppPurchase(iosPurchase), + throwsArgumentError, + ); + + // Neither details type is usable on a platform the plugin does not ship + // a native bridge for. + expect( + () => androidPurchase.toRpcMap(platform: TargetPlatform.macOS), + throwsArgumentError, + ); + expect( + () => iosPurchase.toRpcMap(platform: TargetPlatform.windows), + throwsArgumentError, + ); + }); - expect(selectedMethod, 'setUserEmails'); - expect(capturedArguments['emails'], contains('user@example.com')); - expect(capturedArguments['cryptType'], - EmailCryptType.values.indexOf(EmailCryptType.EmailCryptTypeSHA256)); + test('generateInviteLink returns its per-call result', () async { + rpcResult = 'https://example.onelink.me/invite'; + + final link = await androidSdk.generateInviteLink( + parameters: const AppsFlyerInviteLinkParams( + referrerCustomerId: 'customer', + userParams: {'source': 'share'}, + ), + ); + + expect(link, 'https://example.onelink.me/invite'); + expect(rpcMethod, 'generateInviteLink'); + expect(rpcParams!['customerId'], 'customer'); + expect(rpcParams!['userParams'], {'source': 'share'}); + expect(rpcParams!['awaitResponse'], isTrue); }); - test('check setAdditionalData call', () async { - instance.setAdditionalData(null); + test('generateInviteLink forwards awaitResponse only to Android', () async { + rpcResult = 'https://example.onelink.me/invite'; - expect(selectedMethod, 'setAdditionalData'); - }); + await androidSdk.generateInviteLink(awaitResponse: false); + expect(rpcParams!['awaitResponse'], isFalse); - test('check waitForCustomerUserId call', () async { - instance.waitForCustomerUserId(false); + await iosSdk.generateInviteLink(awaitResponse: false); + expect(rpcParams, isNot(contains('awaitResponse'))); + }); - expect(selectedMethod, 'waitForCustomerUserId'); + test( + 'generateInviteLink throws AppsFlyerException when native returns null', + () async { + rpcResult = null; + + expect( + () => androidSdk.generateInviteLink(), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'generateInviteLink returned no value', + ), + ), + ); }); + }); - test('check setCustomerUserId call', () async { - instance.setCustomerUserId("id"); + group('complete RPC mapping', () { + Future expectVoidRpc( + Future Function() invoke, + String method, + Map params, + ) async { + rpcMethod = null; + rpcParams = null; + await invoke(); + expect(rpcMethod, method); + expect(rpcParams, params); + } + + test('maps cross-platform configuration and identity APIs', () async { + await expectVoidRpc( + () => androidSdk.setCustomerUserId('customer'), + 'setCustomerUserId', + {'customerId': 'customer'}, + ); + await expectVoidRpc( + () => androidSdk.setUserEmail('hash-me@example.com'), + 'setUserEmail', + {'email': 'hash-me@example.com'}, + ); + await expectVoidRpc( + () => androidSdk.setUserPhone('+1', '5551234'), + 'setUserPhone', + {'countryCode': '+1', 'phoneNumber': '5551234'}, + ); + await expectVoidRpc( + () => androidSdk.setUserFirstName('Ada'), + 'setUserFirstName', + {'firstName': 'Ada'}, + ); + await expectVoidRpc( + () => androidSdk.setUserLastName('Lovelace'), + 'setUserLastName', + {'lastName': 'Lovelace'}, + ); + await expectVoidRpc( + () => androidSdk.setUserFbLoginId(42), + 'setUserFbLoginId', + {'fbLoginId': 42}, + ); + await expectVoidRpc( + androidSdk.clearUserPii, + 'clearUserPii', + {}, + ); + await expectVoidRpc( + () => androidSdk.setCurrencyCode('USD'), + 'setCurrencyCode', + {'currencyCode': 'USD'}, + ); + await expectVoidRpc( + () => androidSdk.setMinTimeBetweenSessions(15), + 'setMinTimeBetweenSessions', + {'seconds': 15}, + ); + await expectVoidRpc( + () => androidSdk.setHost('prefix', 'example.com'), + 'setHost', + {'hostPrefixName': 'prefix', 'hostName': 'example.com'}, + ); + await expectVoidRpc( + () => androidSdk.setAdditionalData({'source': 'flutter'}), + 'setAdditionalData', + { + 'customData': {'source': 'flutter'}, + }, + ); + await expectVoidRpc( + () => androidSdk.setAppInviteOneLink('one-link'), + 'setAppInviteOneLink', + {'oneLinkId': 'one-link'}, + ); + await expectVoidRpc( + () => androidSdk.setPartnerData('partner', {'key': 'value'}), + 'setPartnerData', + { + 'partnerId': 'partner', + 'data': {'key': 'value'}, + }, + ); + await expectVoidRpc( + () => androidSdk.setSharingFilterForPartners(['partner']), + 'setSharingFilterForPartners', + { + 'partners': ['partner'], + }, + ); + // An empty list means "clear", which the iOS RPC only honors for null. + await expectVoidRpc( + () => iosSdk.setSharingFilterForPartners([]), + 'setSharingFilterForPartners', + {'partners': null}, + ); + await expectVoidRpc( + () => iosSdk.setSharingFilterForPartners(null), + 'setSharingFilterForPartners', + {'partners': null}, + ); + await expectVoidRpc( + () => androidSdk.setInstallId('install-id'), + 'setInstallId', + {'installId': 'install-id'}, + ); + await expectVoidRpc( + () => androidSdk.setConsentData( + isUserSubjectToGDPR: true, + hasConsentForDataUsage: true, + hasConsentForAdsPersonalization: false, + hasConsentForAdStorage: true, + ), + 'setConsentData', + { + 'isUserSubjectToGDPR': true, + 'hasConsentForDataUsage': true, + 'hasConsentForAdsPersonalization': false, + 'hasConsentForAdStorage': true, + }, + ); + await expectVoidRpc( + () => androidSdk.enableTCFDataCollection(true), + 'enableTCFDataCollection', + {'shouldCollect': true}, + ); + await expectVoidRpc( + () => androidSdk.anonymizeUser(true), + 'anonymizeUser', + {'shouldAnonymize': true}, + ); + await expectVoidRpc( + () => androidSdk.stop(true), + 'stop', + {'shouldStop': true}, + ); + await expectVoidRpc( + () => androidSdk.setDisableAdvertisingIdentifiers(true), + 'setDisableAdvertisingIdentifiers', + {'isDisable': true}, + ); + await expectVoidRpc( + () => iosSdk.setDisableAdvertisingIdentifiers(true), + 'setDisableAdvertisingIdentifiers', + {'disable': true}, + ); + }); - expect(selectedMethod, 'setCustomerUserId'); + test('maps deep-link, sharing, push, and uninstall APIs', () async { + await expectVoidRpc( + () => androidSdk.logLocation( + latitude: 1.5, + longitude: -2.5, + ), + 'logLocation', + {'latitude': 1.5, 'longitude': -2.5}, + ); + await expectVoidRpc( + () => androidSdk.logCrossPromoteImpression( + 'promoted', + campaign: 'campaign', + userParams: {'key': 'value'}, + ), + 'logCrossPromoteImpression', + { + 'appId': 'promoted', + 'campaign': 'campaign', + 'userParams': {'key': 'value'}, + }, + ); + await expectVoidRpc( + () => iosSdk.logAndOpenStore( + 'promoted', + campaign: 'campaign', + userParams: {'key': 'value'}, + ), + 'logAndOpenStore', + { + 'promotedAppId': 'promoted', + 'campaign': 'campaign', + 'userParams': {'key': 'value'}, + }, + ); + await expectVoidRpc( + () => androidSdk.logInvite('email', {'key': 'value'}), + 'logInvite', + { + 'channel': 'email', + 'eventParameters': {'key': 'value'}, + }, + ); + await expectVoidRpc( + () => androidSdk.performDeepLinking( + 'https://example.com/path', + shouldTriggerSession: true, + ), + 'performDeepLinking', + { + 'url': 'https://example.com/path', + 'shouldTriggerSession': true, + }, + ); + await expectVoidRpc( + () => iosSdk.performDeepLinking('https://example.com/path'), + 'performOnAppAttributionWithURL', + {'url': 'https://example.com/path'}, + ); + await expectVoidRpc( + () => androidSdk.setResolveDeepLinkURLs(['example.com']), + 'setResolveDeepLinkURLs', + { + 'urls': ['example.com'], + }, + ); + await expectVoidRpc( + () => androidSdk.setOneLinkCustomDomain(['links.example.com']), + 'setOneLinkCustomDomain', + { + 'domains': ['links.example.com'], + }, + ); + await expectVoidRpc( + () => androidSdk.setDeepLinkTimeout(3000), + 'setDeepLinkTimeout', + {'timeout': 3000}, + ); + await expectVoidRpc( + () => androidSdk.addPushNotificationDeepLinkPath(['data', 'link']), + 'addPushNotificationDeepLinkPath', + { + 'deepLinkPath': ['data', 'link'], + }, + ); + await expectVoidRpc( + () => androidSdk.enableFacebookDeferredApplinks(true), + 'enableFacebookDeferredApplinks', + {'isEnabled': true}, + ); + await expectVoidRpc( + () => androidSdk.appendParametersToDeepLinkingURL( + 'example.com', + {'key': 'value'}, + ), + 'appendParametersToDeepLinkingURL', + { + 'contains': 'example.com', + 'parameters': {'key': 'value'}, + }, + ); + await expectVoidRpc( + () => iosSdk.setFacebookDeferredAppLink(null), + 'setFacebookDeferredAppLink', + {'url': null}, + ); + await expectVoidRpc( + () => androidSdk.sendPushNotificationData( + campaign: 'campaign', + pid: 'media-source', + isRetargeting: true, + additionalParameters: {'key': 'value'}, + ), + 'sendPushNotificationData', + { + 'campaign': 'campaign', + 'pid': 'media-source', + 'isRetargeting': true, + 'additionalParameters': {'key': 'value'}, + }, + ); + await expectVoidRpc( + () => iosSdk.handlePushNotification({'aps': {}}), + 'handlePushNotification', + { + 'pushPayload': {'aps': {}}, + }, + ); + await expectVoidRpc( + () => androidSdk.updateServerUninstallToken('fcm-token'), + 'updateServerUninstallToken', + {'token': 'fcm-token'}, + ); + await expectVoidRpc( + () => iosSdk.updateServerUninstallToken('0123456789abcdef'), + 'registerUninstall', + {'deviceToken': '0123456789abcdef'}, + ); }); - test('check setImeiData call', () async { - instance.setImeiData("imei"); + test('maps every Android-only API', () async { + const logLevels = { + AFLogLevel.none: 'NONE', + AFLogLevel.error: 'ERROR', + AFLogLevel.warning: 'WARNING', + AFLogLevel.info: 'INFO', + AFLogLevel.debug: 'DEBUG', + AFLogLevel.verbose: 'VERBOSE', + }; + + for (final entry in logLevels.entries) { + await expectVoidRpc( + () => androidSdk.setLogLevel(entry.key), + 'setLogLevel', + {'logLevel': entry.value}, + ); + } - expect(selectedMethod, 'setImeiData'); + await expectVoidRpc( + androidSdk.unregisterDeeplinkListener, + 'unsubscribeForDeepLink', + {}, + ); + await expectVoidRpc( + androidSdk.unregisterConversionListener, + 'unregisterConversionListener', + {}, + ); + + await expectVoidRpc(androidSdk.logSession, 'logSession', {}); + await expectVoidRpc( + () => androidSdk.setOutOfStore('amazon'), + 'setOutOfStore', + {'sourceName': 'amazon'}, + ); + await expectVoidRpc( + () => androidSdk.setIsUpdate(true), + 'setIsUpdate', + {'isUpdate': true}, + ); + await expectVoidRpc( + () => androidSdk.setPreinstallAttribution( + 'media', + campaign: 'campaign', + siteId: 'site', + ), + 'setPreinstallAttribution', + { + 'mediaSource': 'media', + 'campaign': 'campaign', + 'siteId': 'site', + }, + ); + await expectVoidRpc( + () => androidSdk.setAppId('com.example.app'), + 'setAppId', + {'appId': 'com.example.app'}, + ); + await expectVoidRpc( + () => androidSdk.setCollectAndroidID(true), + 'setCollectAndroidID', + {'isCollect': true}, + ); + await expectVoidRpc( + () => androidSdk.setDisableNetworkData(true), + 'setDisableNetworkData', + {'isDisable': true}, + ); + await expectVoidRpc( + androidSdk.disableAppSetId, + 'disableAppSetId', + {}, + ); }); - test('check setAndroidIdData call', () async { - instance.setAndroidIdData("androidId"); - - expect(selectedMethod, 'setAndroidIdData'); + test('maps every iOS-only API', () async { + await expectVoidRpc( + () => iosSdk.setCurrentDeviceLanguage('en'), + 'setCurrentDeviceLanguage', + {'language': 'en'}, + ); + await expectVoidRpc( + () => iosSdk.setDisableCollectASA(true), + 'setDisableCollectASA', + {'disable': true}, + ); + await expectVoidRpc( + () => iosSdk.setDisableSKAdNetwork(true), + 'setDisableSKAdNetwork', + {'disable': true}, + ); + await expectVoidRpc( + () => iosSdk.setDisableAppleAdsAttribution(true), + 'setDisableAppleAdsAttribution', + {'disable': true}, + ); + await expectVoidRpc( + () => iosSdk.setDisableIDFVCollection(true), + 'setDisableIDFVCollection', + {'disable': true}, + ); + await expectVoidRpc( + () => iosSdk.setShouldCollectDeviceName(true), + 'setShouldCollectDeviceName', + {'collect': true}, + ); + await expectVoidRpc( + () => iosSdk.setUseReceiptValidationSandbox(true), + 'setUseReceiptValidationSandbox', + {'sandbox': true}, + ); + await expectVoidRpc( + () => iosSdk.setUseUninstallSandbox(true), + 'setUseUninstallSandbox', + {'sandbox': true}, + ); }); - test('check getOutOfStore call', () async { - instance.getOutOfStore(); + test('maps getters and native return values', () async { + rpcResult = 'host.example.com'; + expect(await androidSdk.getHostName(), 'host.example.com'); + expect(rpcMethod, 'getHostName'); + expect(rpcParams, isEmpty); + + rpcResult = 'prefix'; + expect(await androidSdk.getHostPrefix(), 'prefix'); + expect(rpcMethod, 'getHostPrefix'); + + rpcResult = 'amazon'; + expect(await androidSdk.getOutOfStore(), 'amazon'); + expect(rpcMethod, 'getOutOfStore'); + + rpcResult = true; + expect(await androidSdk.isStopped(), isTrue); + expect(rpcMethod, 'isStopped'); + + rpcResult = '7.0.1'; + expect(await iosSdk.getSdkVersion(), '7.0.1'); + expect(rpcMethod, 'getSdkVersion'); + expect(rpcParams, isEmpty); + + rpcResult = null; + expect( + () => iosSdk.getSdkVersion(), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'getSdkVersion returned no value', + ), + ), + ); + + rpcResult = 'uid'; + expect(await iosSdk.getAppsFlyerUID(), 'uid'); + expect(rpcMethod, 'getAppsFlyerUID'); + + rpcResult = true; + expect(await androidSdk.isPreInstalledApp(), isTrue); + expect(rpcMethod, 'isPreInstalledApp'); + + rpcResult = 'attribution'; + expect(await androidSdk.getAttributionId(), 'attribution'); + expect(rpcMethod, 'getAttributionId'); + }); - expect(selectedMethod, 'getOutOfStore'); + test('maps listener removal and iOS invite payload/result', () async { + await expectVoidRpc( + iosSdk.unregisterSessionReadyListener, + 'unregisterSessionReadyListener', + {}, + ); + + rpcResult = 'https://example.onelink.me/invite'; + final link = await iosSdk.generateInviteLink( + parameters: const AppsFlyerInviteLinkParams( + referrerCustomerId: 'customer', + userParams: {'source': 'share'}, + ), + ); + expect(link, 'https://example.onelink.me/invite'); + expect(rpcMethod, 'generateInviteLink'); + expect(rpcParams!['referrerCustomerId'], 'customer'); + expect(rpcParams!['userParams'], {'source': 'share'}); + expect(rpcParams, isNot(contains('awaitResponse'))); }); + }); - test('check setOutOfStore call', () async { - instance.setOutOfStore("source"); + group('event routing', () { + test('subscribes to af-events only when the first listener is registered', + () async { + expect(eventChannelCalls, isEmpty); - expect(selectedMethod, 'setOutOfStore'); - }); + await androidSdk.registerSessionReadyListener(() {}); + await pumpEventQueue(); - test('check logAdRevenue call', () async { - final adRevenueData = AdRevenueData( - monetizationNetwork: 'Applovin', - mediationNetwork: AFMediationNetwork.applovinMax.value, - currencyIso4217Code: 'USD', - revenue: 0.99); - instance.logAdRevenue(adRevenueData); + expect(eventChannelCalls, ['listen']); - expect(selectedMethod, 'logAdRevenue'); - expect(capturedArguments['mediationNetwork'], 'applovin_max'); + await androidSdk.registerConversionListener(onSuccess: (_) {}); + await pumpEventQueue(); + + // One transport subscription for the whole plugin, not one per listener. + expect(eventChannelCalls, ['listen']); }); - test('check enableTCFDataCollection call', () async { - instance.enableTCFDataCollection(true); + test('delivers conversion data to the registered success callback', + () async { + final received = >[]; + await androidSdk.registerConversionListener(onSuccess: received.add); + await _emitEvent({ + 'event': 'onConversionDataSuccess', + 'data': {'media_source': 'organic'}, + 'timestamp': 123, + 'origin': 'android', + }); + await pumpEventQueue(); + + expect(received.single, {'media_source': 'organic'}); + }); - expect(selectedMethod, 'enableTCFDataCollection'); + test( + 'the failure callback passes through the raw native payload ' + '(no synthesized RPC exception)', () async { + final failures = >[]; + await androidSdk.registerConversionListener( + onSuccess: (_) {}, + onFailure: failures.add, + ); + await _emitEvent({ + 'event': 'onConversionDataFail', + 'data': {'error': 'Network unavailable'}, + 'timestamp': 123, + 'origin': 'android', + }); + await pumpEventQueue(); + + // Android never reports a numeric code — the payload is passed through + // as-is rather than backfilled with a synthesized default. + expect(failures.single, {'error': 'Network unavailable'}); }); - test('check setDisableNetworkData call', () async { - instance.setDisableNetworkData(true); + test('a conversion failure without a failure callback is not an error', + () async { + await androidSdk.registerConversionListener(onSuccess: (_) {}); + await _emitEvent({ + 'event': 'onConversionDataFail', + 'data': {'error': 'Network unavailable'}, + }); + await pumpEventQueue(); + }); - expect(selectedMethod, 'setDisableNetworkData'); + test('ignores transport-only envelope fields on conversion events', + () async { + final received = >[]; + await androidSdk.registerConversionListener(onSuccess: received.add); + await _emitEvent({ + 'event': 'onConversionDataSuccess', + 'data': {'media_source': 'organic'}, + 'timestamp': 999, + 'origin': 'android', + }); + await pumpEventQueue(); + + expect(received.single, {'media_source': 'organic'}); }); - test('check setPartnerData call', () async { - instance.setPartnerData('partnerId', {'key': 'value'}); + test('re-registering replaces the callback instead of adding a second one', + () async { + final first = >[]; + final second = >[]; + await androidSdk.registerConversionListener(onSuccess: first.add); + await androidSdk.registerConversionListener(onSuccess: second.add); + await _emitEvent({ + 'event': 'onConversionDataSuccess', + 'data': {'media_source': 'organic'}, + }); + await pumpEventQueue(); + + expect(first, isEmpty); + expect(second.single, {'media_source': 'organic'}); + }); - expect(selectedMethod, 'setPartnerData'); - expect(capturedArguments['partnerId'], 'partnerId'); - expect(capturedArguments['partnersData']['key'], 'value'); + test('a session-ready event reaches the callback exactly once', () async { + var readyCount = 0; + await androidSdk.registerSessionReadyListener(() => readyCount++); + await _emitEvent({ + 'event': 'onSessionReady', + 'data': {}, + 'timestamp': 123.9, + 'origin': 'ios', + }); + await pumpEventQueue(); + + expect(readyCount, 1); }); - test('check setResolveDeepLinkURLs call', () async { - instance.setResolveDeepLinkURLs(['https://example.com']); + test('re-registering the session-ready listener cannot double-start', + () async { + var startCount = 0; + await androidSdk.registerSessionReadyListener(() => startCount++); + await androidSdk.registerSessionReadyListener(() => startCount++); + await _emitEvent({ + 'event': 'onSessionReady', + 'data': {}, + }); + await pumpEventQueue(); + + expect(startCount, 1); + }); - expect(selectedMethod, 'setResolveDeepLinkURLs'); - expect(capturedArguments, contains('https://example.com')); + test('unregistering the session-ready listener drops its callback', + () async { + var readyCount = 0; + await androidSdk.registerSessionReadyListener(() => readyCount++); + await androidSdk.unregisterSessionReadyListener(); + await _emitEvent({ + 'event': 'onSessionReady', + 'data': {}, + }); + await pumpEventQueue(); + + expect(readyCount, 0); }); - test('check sendPushNotificationData call', () async { - instance.sendPushNotificationData({'key': 'value'}); + test( + 'an event replayed before its listener is registered is delivered ' + 'once that listener registers', () async { + // Both platforms flush their whole native buffer as soon as Dart + // attaches, which the first register*Listener call triggers. The + // documented startup order registers the deep-link listener first, so + // every other buffered event arrives before its callback exists. + await androidSdk.registerDeepLinkListener((_) {}); + await _emitEvent({ + 'event': 'onConversionDataSuccess', + 'data': {'media_source': 'organic'}, + }); + await pumpEventQueue(); + + final received = >[]; + await androidSdk.registerConversionListener(onSuccess: received.add); + await pumpEventQueue(); + + expect(received.single, {'media_source': 'organic'}); + }); - expect(selectedMethod, 'sendPushNotificationData'); - expect(capturedArguments['key'], 'value'); + test('held events replay in arrival order', () async { + await androidSdk.registerDeepLinkListener((_) {}); + for (var i = 0; i < 3; i++) { + await _emitEvent({ + 'event': 'onConversionDataSuccess', + 'data': {'index': i}, + }); + } + await pumpEventQueue(); + + final received = >[]; + await androidSdk.registerConversionListener(onSuccess: received.add); + await pumpEventQueue(); + + expect(received.map((data) => data['index']), [0, 1, 2]); }); - test('check enableFacebookDeferredApplinks call', () async { - instance.enableFacebookDeferredApplinks(true); + test('a held event is replayed before a live event that follows it', + () async { + await androidSdk.registerDeepLinkListener((_) {}); + await _emitEvent({ + 'event': 'onConversionDataSuccess', + 'data': {'index': 0}, + }); + await pumpEventQueue(); + + final received = >[]; + // Not awaited: the live event is emitted while the replay is still + // pending, so this pins the replay ahead of it. + unawaited(androidSdk.registerConversionListener(onSuccess: received.add)); + await _emitEvent({ + 'event': 'onConversionDataSuccess', + 'data': {'index': 1}, + }); + await pumpEventQueue(); + + expect(received.map((data) => data['index']), [0, 1]); + }); - expect(selectedMethod, 'enableFacebookDeferredApplinks'); - expect(capturedArguments['isFacebookDeferredApplinksEnabled'], true); + test('re-registering before the replay runs does not deliver twice', + () async { + await androidSdk.registerDeepLinkListener((_) {}); + await _emitEvent({ + 'event': 'onConversionDataSuccess', + 'data': {'media_source': 'organic'}, + }); + await pumpEventQueue(); + + final received = >[]; + unawaited(androidSdk.registerConversionListener(onSuccess: received.add)); + unawaited(androidSdk.registerConversionListener(onSuccess: received.add)); + await pumpEventQueue(); + + expect(received.single, {'media_source': 'organic'}); }); - test('check disableSKAdNetwork call', () async { - instance.disableSKAdNetwork(true); + test('unregistering discards events held for that listener', () async { + var readyCount = 0; + await androidSdk.registerSessionReadyListener(() => readyCount++); + await androidSdk.unregisterSessionReadyListener(); + await _emitEvent({ + 'event': 'onSessionReady', + 'data': {}, + }); + await pumpEventQueue(); + + // Re-registering resumes future events; it does not resurrect events + // that arrived while the app had explicitly unregistered. + await androidSdk.registerSessionReadyListener(() => readyCount++); + await pumpEventQueue(); + + expect(readyCount, 0); + }); + + test('holding is bounded and drops the oldest event first', () async { + await androidSdk.registerDeepLinkListener((_) {}); + for (var i = 0; i < 65; i++) { + await _emitEvent({ + 'event': 'onConversionDataSuccess', + 'data': {'index': i}, + }); + } + await pumpEventQueue(); + + final received = >[]; + await androidSdk.registerConversionListener(onSuccess: received.add); + await pumpEventQueue(); + + expect(received.length, 64); + expect(received.first['index'], 1); + expect(received.last['index'], 64); + }); - expect(selectedMethod, 'disableSKAdNetwork'); + test('routes deep-link events with an object data payload', () async { + DeepLinkResult? result; + await iosSdk.registerDeepLinkListener((value) => result = value); + await _emitEvent({ + 'event': 'onDeepLinkReceived', + 'data': { + 'status': 'found', + 'deepLink': {'deep_link_value': 'home'}, + }, + }); + await pumpEventQueue(); + + expect(result!.status, DeepLinkStatus.found); + expect(result!.deepLink!.deepLinkValue, 'home'); }); - test('check setDisableAdvertisingIdentifiers call', () async { - instance.setDisableAdvertisingIdentifiers(true); + test('drops malformed native events instead of surfacing an error', + () async { + final received = >[]; + await androidSdk.registerConversionListener(onSuccess: received.add); + + await _emitRaw('not-json-at-all'); + await _emitRaw(jsonEncode(['not', 'an', 'object'])); + await _emitEvent({ + 'event': null, + 'data': {'media_source': 'organic'}, + }); + await _emitEvent({ + 'event': '', + 'data': {'media_source': 'organic'}, + }); + await _emitEvent({ + 'event': 123, + 'data': {'media_source': 'organic'}, + }); + await pumpEventQueue(); + + expect(received, isEmpty); + + await _emitEvent({ + 'event': 'onConversionDataSuccess', + 'data': {'media_source': 'organic'}, + }); + await pumpEventQueue(); + + expect(received.single, {'media_source': 'organic'}); + }); - expect(selectedMethod, 'setDisableAdvertisingIdentifiers'); - expect(capturedArguments, true); + test('maps every deep-link status to DeepLinkStatus', () async { + final cases = { + 'found': DeepLinkStatus.found, + 'FOUND': DeepLinkStatus.found, + 'not_found': DeepLinkStatus.notFound, + 'NOT_FOUND': DeepLinkStatus.notFound, + 'error': DeepLinkStatus.error, + 'failure': DeepLinkStatus.error, + 'unexpected': DeepLinkStatus.unknown, + }; + + DeepLinkResult? result; + await androidSdk.registerDeepLinkListener((value) => result = value); + + for (final entry in cases.entries) { + await _emitEvent({ + 'event': 'onDeepLinking', + 'data': {'status': entry.key}, + }); + await pumpEventQueue(); + expect(result!.status, entry.value); + } }); - test('check disableAppSetId call', () async { - instance.disableAppSetId(); + // Both SDK instances share the 'af-events' channel name, so the most + // recently subscribed instance owns the test messenger's handler. Register + // and assert one platform at a time. + test('deep-link errors use platform-specific failure fields', () async { + DeepLinkResult? android; + await androidSdk.registerDeepLinkListener((value) => android = value); + await _emitEvent({ + 'event': 'onDeepLinking', + 'data': {'status': 'error', 'error': 'NETWORK'}, + }); + await pumpEventQueue(); + expect(android!.status, DeepLinkStatus.error); + expect(android!.error!.type, 'NETWORK'); + expect(android!.error!.message, isNull); + + DeepLinkResult? ios; + await iosSdk.registerDeepLinkListener((value) => ios = value); + await _emitEvent({ + 'event': 'onDeepLinkReceived', + 'data': {'status': 'error', 'error': 'Timed out'}, + }); + await pumpEventQueue(); + expect(ios!.status, DeepLinkStatus.error); + expect(ios!.error!.message, 'Timed out'); + expect(ios!.error!.type, isNull); + }); - expect(selectedMethod, 'disableAppSetId'); + test('normalizes Android and iOS deep-link status without hiding errors', + () async { + DeepLinkResult? androidResult; + await androidSdk.registerDeepLinkListener( + (value) => androidResult = value, + ); + await _emitEvent({ + 'event': 'onDeepLinking', + 'data': { + 'status': 'FOUND', + 'deepLink': '{"deep_link_value":"home","is_deferred":false}', + }, + }); + await pumpEventQueue(); + final android = androidResult!; + + DeepLinkResult? iosResult; + await iosSdk.registerDeepLinkListener((value) => iosResult = value); + await _emitEvent({ + 'event': 'onDeepLinkReceived', + 'data': {'status': 'failure', 'error': 'Network unavailable'}, + }); + await pumpEventQueue(); + final ios = iosResult!; + + expect(android.status, DeepLinkStatus.found); + expect(android.deepLink!.deepLinkValue, 'home'); + expect(android.deepLink!.isDeferred, isFalse); + expect(ios.status, DeepLinkStatus.error); + expect(ios.error!.message, 'Network unavailable'); + expect(ios.error!.type, isNull); }); }); } + +const _sharedMediationNetworkRpcValues = { + AFMediationNetwork.ironSource: 'ironsource', + AFMediationNetwork.applovinMax: 'applovin_max', + AFMediationNetwork.googleAdMob: 'google_admob', + AFMediationNetwork.fyber: 'fyber', + AFMediationNetwork.appodeal: 'appodeal', + AFMediationNetwork.admost: 'admost', + AFMediationNetwork.topon: 'topon', + AFMediationNetwork.tradplus: 'tradplus', + AFMediationNetwork.yandex: 'yandex', + AFMediationNetwork.chartboost: 'chartboost', + AFMediationNetwork.unity: 'unity', + AFMediationNetwork.toponPte: 'topon_pte', +}; + +Future _expectLogAdRevenueMediationNetworks( + AppsFlyerSdk sdk, + Map expectedNetworks, + Map? Function() readRpcParams, +) async { + for (final entry in expectedNetworks.entries) { + final additionalParameters = entry.key == AFMediationNetwork.customMediation + ? {'placement': 'banner'} + : null; + await sdk.logAdRevenue( + monetizationNetwork: 'network', + mediationNetwork: entry.key, + currencyIso4217Code: 'USD', + revenue: 1.0, + additionalParameters: additionalParameters, + ); + expect( + readRpcParams(), + { + 'monetizationNetwork': 'network', + 'mediationNetwork': entry.value, + 'currencyIso4217Code': 'USD', + 'revenue': 1.0, + 'additionalParameters': additionalParameters, + }, + ); + } +} + +Future _emitEvent(Map event) => + _emitRaw(jsonEncode(event)); + +Future _emitRaw(String payload) async { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final ByteData data = + const StandardMethodCodec().encodeSuccessEnvelope(payload); + await messenger.handlePlatformMessage('af-events', data, (_) {}); +}