Skip to content

[DELIVERY-125863] Migrate Flutter plugin to AppsFlyer SDK 7 RPC architecture. - #466

Open
af-dudka wants to merge 51 commits into
developmentfrom
dev/DELIVERY-125863/flutter-rpc-sdk7
Open

[DELIVERY-125863] Migrate Flutter plugin to AppsFlyer SDK 7 RPC architecture.#466
af-dudka wants to merge 51 commits into
developmentfrom
dev/DELIVERY-125863/flutter-rpc-sdk7

Conversation

@af-dudka

Copy link
Copy Markdown

Migrate Flutter plugin to AppsFlyer SDK 7 RPC architecture.
Route core APIs through af-api/af-events RPC bridges on Android and iOS, adopt the SDK 7 init + session-ready start model, remove SDK 6 APIs that no longer exist natively, bump native dependencies to 7.0.1, raise iOS minimum to 13.0, and reorganize public documentation.

…tecture.

Route core APIs through af-api/af-events RPC bridges on Android and iOS,
adopt the SDK 7 init + session-ready start model, remove SDK 6 APIs that no
longer exist natively, bump native dependencies to 7.0.1, raise iOS minimum
to 13.0, and reorganize public documentation.
af-dudka added 2 commits July 29, 2026 16:34
Replace legacy INSTALL_REFERRER receiver instructions with Install
Referrer library guidance; format appsflyer_sdk.dart and test file for CI.
@tlozovyi-af

Copy link
Copy Markdown

Mostly reviewed Android side - looks nice, great job, Andrii!

@Suppress("UNCHECKED_CAST")
private fun executeRpc(call: MethodCall, result: Result) {
val arguments = call.arguments as Map<String, Any?>?
val method = arguments!!["method"] as String?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

executeRpc is the single entry point for the whole plugin, yet it force-unwraps the channel
payload with arguments!!["method"] before the surrounding try/catch that is meant to
turn failures into a clean result.error(...). If Dart ever calls executeRpc with null
arguments, a non-Map payload, or a method value that isn't a String, this throws an
uncaught NullPointerException/ClassCastException straight out of onMethodCall — a crash
risk on the platform thread rather than a graceful RPC error. It's also inconsistent with the
iOS counterpart (AppsflyerSdkPlugin.swift), which safely casts with as? and returns a
FlutterError instead of crashing, so the two platforms handle identical malformed input
differently.

// Before
val arguments = call.arguments as Map<String, Any?>?
val method = arguments!!["method"] as String?

// After
val arguments = call.arguments as? Map<String, Any?>
val method = arguments?.get("method") as? String
if (arguments == null || method == null) {
    result.error("UNEXPECTED_ERROR", "executeRpc requires a 'method' argument", null)
    return
}

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was done intentionally: executeRpc is internal-only (_invokeRpc is the sole caller and always sends {method: String, params: Map}). Malformed payloads are programmer errors — Android fails fast; iOS has as? only because the iOS MethodChannel can't safely surface NSException. The try/catch covers RPC dispatch failures, not envelope validation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iOS executeRpc envelope parsing is now aligned with Android.
Swift now enforces the same internal transport contract as Kotlin: {method: String, params: Map} from _invokeRpc, parsed with force casts outside the dispatch error boundary. A malformed envelope is treated as an integration error

}

@Synchronized
private fun getOrCreateRpcHandler(): AppsFlyerRpcHandler {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getOrCreateRpcHandler() lazily builds AppsFlyerRpcHandler once, capturing whichever
Context (activity or applicationContext) is available at the time of the first RPC
call, then caches it in rpcHandler for the plugin's lifetime. That cache is only cleared in
onDetachedFromEngine — never in onDetachedFromActivityForConfigChanges /
onReattachedToActivityForConfigChanges (lines 113–120). On an ordinary configuration change
(screen rotation), Flutter destroys the old Activity and later attaches a new one, but every
RPC call after that keeps using the handler built around the original, now-destroyed
Activity — a static-field Activity leak, and it silently breaks the deep-link replay this
code's own comment says needs the current Activity. Compounding this, activity and
applicationContext are plain (non-@Volatile) fields written on the main thread but read
here from the background rpcExecutor thread (line 167, rpcContext!!); @Synchronized on
this method only serializes calls to itself, it does not establish a happens-before edge with
those unsynchronized main-thread writes, so the worker thread can observe a stale value and
throw an NPE on the !!.

// Invalidate the cached handler so it's rebuilt against the current Activity
override fun onDetachedFromActivityForConfigChanges() {
    this.activity = null
    rpcHandler = null
}

override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
    activity = binding.activity
    binding.addOnNewIntentListener(onNewIntentListener)
    rpcHandler = null
}

override fun onDetachedFromActivity() {
    activity = null
    rpcHandler = null
}

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: cached rpcHandler now always uses applicationContext; Activity is passed only to a one-off handler for init (not cached). @volatile + synchronized(rpcHandlerLock) fix cross-thread visibility. Rotation no longer leaks or pins a destroyed Activity.

}

private fun dispatchRpc(method: String?, params: JSONObject?, result: Result, voidValue: Any?) {
rpcExecutor!!.execute {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dispatchRpc submits work to rpcExecutor!! with no try/catch around the submitted body and
no null-check on the executor itself. If getOrCreateRpcHandler().execute(...) throws any
Throwable other than JSONException (which executeRpcSync already catches), or if
rpcExecutor has already been nulled by onDetachedFromEngine/shutdown(), the exception is
either swallowed by the executor thread's default uncaught-exception handling or thrown as an
NPE/RejectedExecutionException. Either way, uiThreadHandler.post { deliverRpcResult(...) }
is never reached, so the Flutter Result callback is never invoked and the Dart-side await
on that RPC call hangs forever with no error surfaced — a silent deadlock from the app's
perspective.

// Before
private fun dispatchRpc(method: String?, params: JSONObject?, result: Result, voidValue: Any?) {
    rpcExecutor!!.execute {
        val response = executeRpcSync(method, params)
        uiThreadHandler.post { deliverRpcResult(response, result, voidValue) }
    }
}

// After
private fun dispatchRpc(method: String?, params: JSONObject?, result: Result, voidValue: Any?) {
    val executor = rpcExecutor
    if (executor == null) {
        result.error("PLUGIN_DETACHED", "RPC executor unavailable", null)
        return
    }
    executor.execute {
        try {
            val response = executeRpcSync(method, params)
            uiThreadHandler.post { deliverRpcResult(response, result, voidValue) }
        } catch (t: Throwable) {
            Log.e(AF_PLUGIN_TAG, "dispatchRpc('$method') failed: ${t.message}", t)
            uiThreadHandler.post { result.error("UNEXPECTED_ERROR", t.message, null) }
        }
    }
}

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — dispatchRpc no longer leaves Dart awaits hanging

Replaced rpcExecutor!!.execute { ... } with runOnBlockingRpcExecutor:

Null-check blockingRpcExecutor before submit → PLUGIN_DETACHED instead of NPE after engine detach.
Catch RejectedExecutionException when the executor is shut down.
Wrap the worker body in try/catch (Throwable) and post result.error(...) on the main thread so every RPC path completes the Flutter Result.
Fast RPCs use runRpc() inline on the platform thread with the same try/catch guarantee. Blocking awaited RPCs still use the dedicated single-thread executor.


private fun onAttachedToEngine(applicationContext: Context, messenger: BinaryMessenger) {
this.applicationContext = applicationContext
this.rpcExecutor = Executors.newSingleThreadExecutor()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every RPC call — cheap setters/getters and long-awaited callbacks alike — is serialized on one
Executors.newSingleThreadExecutor(). Any call whose native handler blocks for a while
(network-bound purchase validation, waitForCustomerUserId, host resolution, etc.) head-of-line
blocks every unrelated call queued behind it, including hot calls like logEvent or
getAppsFlyerUID. In the previous Java implementation there was no shared FIFO queue across
every API method. Consider a small bounded pool (or a dedicated second executor for
awaited-callback methods) so a slow call can't stall unrelated fast ones, reserving strict FIFO
ordering only for the calls that genuinely need it (e.g. the init sequence).

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — slow awaited RPCs no longer head-of-line block fast calls

Replaced the single shared executor for every RPC with a split dispatch model:

Fast path (runRpc): setters, getters, and fire-and-forget start / logEvent run inline on the platform thread.
Blocking path (runOnBlockingRpcExecutor): only RPCs that block on a native callback latch use the dedicated single-thread executor — start / logEvent when awaitResponse: true, and validateAndLogInAppPurchase / generateInviteLink (default-await, matching JsonRpcRequestParser).
init stays synchronous on the platform thread to preserve setPluginInfo → init ordering without queuing behind unrelated awaited calls.

* @param binding The binding that was provided in [onAttachedToEngine].
*/
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) = Unit
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR replaces a no-op onDetachedFromEngine with real teardown logic, and that new logic
introduces a cross-engine state-corruption bug. Before this PR, onDetachedFromEngine was
= Unit — a complete no-op. A no-op can leak state, but it cannot corrupt a different
engine's state, since nothing was ever nulled on detach:

// Before this PR
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) = Unit

// After this PR (new code — introduces the bug below)
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
    runCatching { connectorWrapper?.stopObservingTransactions() }
    methodChannel?.setMethodCallHandler(null)
    methodChannel = null
    contextRef?.clear()
    contextRef = null
    connectorWrapper = null
}

The pre-existing root cause this new code exposes: AppsFlyerPurchaseConnector is a Kotlin
object (process-wide singleton) with a single shared methodChannel/contextRef/
connectorWrapper, not keyed per FlutterPluginBinding — that shape predates this PR and is
otherwise harmless as long as detach is a no-op. Once detach actually tears state down, the
singleton shape turns it into cross-engine corruption. Concretely, with two engines E1 and E2
both attached (add-to-app, FlutterEngineGroup): E2's onAttachedToEngine (line 82) overwrites
the shared methodChannel var with channel_E2, so arsListener/viapListener (lines 35, 42,
54, 58) — which push validation results out via that same shared var — now deliver any
in-flight result to E2's channel even if the request came from E1. Then if E1 detaches,
methodChannel?.setMethodCallHandler(null) (line 100) clears E2's channel (methodChannel
currently points to channel_E2, not E1's own), and connectorWrapper = null /
contextRef = null wipe the one shared connector entirely — E1 detaching silently kills E2's
still-active purchase observation
, even though E2's engine never detached. This only
manifests with more than one live engine, but the new teardown code is what makes it reachable.

Given the scale of this migration PR, the safest path is to revert onDetachedFromEngine
back to its pre-PR no-op (= Unit)
for now — that removes the newly-introduced cross-engine
corruption without blocking this PR, at the cost of reinstating the original (pre-existing,
lower-severity) per-detach leak of methodChannel/contextRef/connectorWrapper. Track the
real fix — keying the channel/context/connector per attached FlutterPluginBinding (e.g. a
Map<FlutterPluginBinding, ...>) so both the leak and the multi-engine corruption are fixed
together — in a follow-up Jira ticket rather than in this PR.

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — Purchase Connector teardown is now per-engine, not process-wide

Agreed that real onDetachedFromEngine teardown exposed cross-engine corruption while AppsFlyerPurchaseConnector was a singleton with shared methodChannel / connectorWrapper.

Instead of reverting detach to a no-op, we keyed state by FlutterPlugin.FlutterPluginBinding:

Map<FlutterPluginBinding, EngineAttachment> — each engine gets its own channel, ConnectorWrapper, and validation listeners.
onDetachedFromEngine(binding) removes and disposes only that binding's attachment.
Re-attach guard: attachments.remove(binding)?.dispose() before put.
E1 detach no longer clears E2's channel or connector. The object remains the registry; per-engine isolation lives in the map.


// RD-65582: buffer events that arrive before Dart subscribes (onListen), then replay on
// attach. Main-thread only.
private val pendingEvents: MutableList<String> = ArrayList()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pendingEvents (and its iOS twin, AppsflyerSdkPlugin.swift:38) is an unbounded in-memory
queue of event JSON strings, only drained when Dart calls onListen. An integration that
registers native listeners but never subscribes to the Dart streams — or cancels its
subscription for the rest of the session — accumulates every conversion/UDL/session-ready
payload for the engine's lifetime with no upper bound and no back-pressure. A small
fixed-capacity ring buffer preserves the "replay the first events" goal while making worst-case
memory deterministic.

private const val MAX_PENDING_EVENTS = 64

private fun deliverEvent(callListenerArgs: String) {
    val sink = eventSink
    if (sink != null) {
        sink.success(callListenerArgs)
        return
    }
    if (pendingEvents.size >= MAX_PENDING_EVENTS) {
        Log.w(AF_PLUGIN_TAG, "Dropping oldest buffered event; no Dart listener attached")
        pendingEvents.removeAt(0)
    }
    pendingEvents.add(callListenerArgs)
}

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — event buffer is now capped at 64 with oldest-drop overflow

Added MAX_PENDING_EVENTS = 64 on both platforms:

Android: AppsFlyerEventBus drops the oldest queued event when the process-scoped buffer exceeds the cap before af-events attaches.
iOS: deliverEvent applies the same bound to pendingEvents (mirrors AppsFlyerEventBus.kt).
Replay-on-onListen behavior is unchanged; worst-case memory is now deterministic for integrations that register native listeners but never subscribe (or cancel) the Dart streams. Covered by AppsFlyerEventBusTest overflow cases.


@implementation AFFlutterRPCBridge

+ (void)executeJson:(NSString *)jsonRequest completion:(void (^)(NSString *response))completion {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file bundles two unrelated jobs, and neither one needs to stay in Objective-C — the plugin
should drop the ObjC shim entirely and call AppsFlyerRPCBridge.shared directly from Swift, the
same way AppsFlyer's own React Native plugin already does against this identical dependency, with
no ObjC involved at all (ios/RNAppsFlyerImpl.swift):

// appsflyer-react-native-plugin/ios/RNAppsFlyerImpl.swift — pure Swift, no ObjC shim
// "AppsFlyerRPCBridge.shared is @MainActor-isolated; hop via Task, which preserves ordering
// relative to dispatchToNative's own Task hop below since both enqueue FIFO on the main actor."
Task { @MainActor in
    AppsFlyerRPCBridge.shared.setEventHandler { [weak self] jsonEvent in
        self?.eventEmitter(jsonEvent)
    }
}

AFFlutterRPCBridge's pass-through methods exist to let "nonisolated contexts" (Flutter channel
handlers, UIApplication/UIScene delegate callbacks) reach the @MainActor-isolated
AppsFlyerRPCBridge "without isolation checking" (per the shim header) — but Objective-C doesn't
enforce actor isolation at all, so this doesn't relax the check, it removes it entirely with no
runtime equivalent (no dispatchPrecondition, no forced hop, no assertion). nonisolated(unsafe)
would not fix this either — same "trust me, no checking" escape hatch, spelled in Swift instead
of ObjC. The two HIGH findings elsewhere in this review (AppsflyerSdkPlugin.swift:205's
unguarded initFromRpc completion, and handleBridgeEvent needing to hand-roll its own
Thread.isMainThread check) are the isolation bypass already causing real inconsistency, not a
theoretical risk. Replace it the same way RNAppsFlyerImpl.swift's dispatchToNative does:

// Current: ObjC pass-through bypasses actor isolation checking entirely
AFFlutterRPCBridge.executeJson(json) { response in ... }

// Recommended: explicit hop, checked by the compiler, no isolation bypass needed
Task { @MainActor in
    AppsFlyerRPCBridge.shared.executeJson(json) { response in ... }
}

AFFlutterRunCatchingNSException wraps executeRpc's body (AppsflyerSdkPlugin.swift:145-161),
and per its own call-site comment the concern is a "malformed call.arguments" from Flutter's
own handleMethodCall: — not AppsFlyerRPCBridge. Checked both halves of that claim against the
actual code and found no currently-reachable NSException path in either:

  • AppsFlyerRPCBridge/AFRPCClient/AFRPCParser (AppsFlyerRPC package): AFRPCClient.execute
    is documented "never throws," fully Codable/throws-based with a catch-all do/catch — no
    NSDictionary/NSJSONSerialization-style unsafe access anywhere in the call graph.
  • The Flutter-side parsing under the guard: call.arguments as? NSDictionary and subscripting are
    safe conditional casts (no throw on mismatch). jsonString(from:) (line 379-385) already guards
    with JSONSerialization.isValidJSONObject(object) before data(withJSONObject:) and uses
    try? — the standard defense against the one well-known Foundation gotcha here (NaN/Infinity
    doubles raising NSInvalidArgumentException instead of a catchable Swift error).

Treat this as no live NSException risk for now: nothing in the currently-wrapped code path
raises one, so the guard is redundant against today's implementation (this doesn't cover every
helper exhaustively — e.g. purchase-detail dictionary construction wasn't fully traced — but
nothing found earns keeping ObjC-only machinery around speculatively). Remove
AFFlutterRunCatchingNSException along with AFFlutterRPCBridge and go fully Swift, matching
RNAppsFlyerImpl.swift. If a real crash surfaces later from something this would have caught,
fix it then with a targeted guard at the actual failure point, rather than keeping a blanket
exception boundary around code that doesn't currently need one.

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — ObjC shim removed entirely, both files plus the appsflyer_sdk_objc target from
Package.swift and the podspec. Two deviations from your suggestion:

assumeIsolated instead of Task. Task defers every call by a main-actor turn, and we depend
on the current timing twice: registerEventHandler() runs in init because the RPC layer drops
events emitted before a handler is attached, and the executeRpc send completes before
handleMethodCall: returns. MainActor.assumeIsolated keeps both synchronous and adds the runtime
precondition that was missing — it's @available(iOS 13.0, *) + @_alwaysEmitIntoClient, so it
back-deploys. Everything now goes through AFRPCBridge.swift; detachFromEngine hops through the
main queue since engine dealloc isn't guaranteed main-thread.

NSException removed, with a test. Confirmed your analysis — double.nan does raise
uncatchably, but isValidJSONObject rejects it (and inf, Float.nan,
NSDecimalNumber.notANumber, non-string keys) before serialization. That's undocumented behavior
and I could only test current Foundation, so RPCPayloadSerializationTests in
example/ios/RunnerTests now pins it.

handleBridgeEvent's hand-rolled check is gone, but inbound events go through the same helper: the
main-actor guarantee is one line in a vendored framework wrapping a @Sendable emitter, and losing
it on a version bump would mean a pendingEvents race plus an off-platform-thread
FlutterEventSink call — silently. Docs updated.

return
}
eventHandlerRegistered = true
AFFlutterRPCBridge.setEventHandler { [weak self] jsonEvent in

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

registerEventHandler writes to a single process-global handler slot on the shared
AppsFlyerRPCBridge (via AFFlutterRPCBridge.setEventHandler), but it's called from every
plugin instance's init and unconditionally cleared in tearDownForEngineDetach() (line 96).
In any host that runs more than one FlutterEngine — add-to-app, FlutterEngineGroup,
multi-window/UIScene apps, or plugin re-registration — the last engine to register silently
steals af-events from earlier engines, and the first engine to detach kills the event stream
for engines that are still alive. The per-instance eventHandlerRegistered flag doesn't guard
against this because it's instance state guarding global state.

// Bad: instance lifecycle drives a process-global single-slot handler
private func registerEventHandler() {
    if eventHandlerRegistered { return }
    eventHandlerRegistered = true
    AFFlutterRPCBridge.setEventHandler { [weak self] json in self?.handleBridgeEvent(json) }
}
private func tearDownForEngineDetach() {
    AFFlutterRPCBridge.removeEventHandler()   // kills events for every other engine too
}

// Good: one global handler, N engine-scoped sinks
final class AFEventFanout {
    static let shared = AFEventFanout()
    private var sinks: [ObjectIdentifier: (String) -> Void] = [:]
    func add(_ owner: AnyObject, _ sink: @escaping (String) -> Void) {
        if sinks.isEmpty {
            AFFlutterRPCBridge.setEventHandler { json in AFEventFanout.shared.emit(json) }
        }
        sinks[ObjectIdentifier(owner)] = sink
    }
    func remove(_ owner: AnyObject) {
        sinks.removeValue(forKey: ObjectIdentifier(owner))
        if sinks.isEmpty { AFFlutterRPCBridge.removeEventHandler() }
    }
    private func emit(_ json: String) { sinks.values.forEach { $0(json) } }
}

References:

@af-dudka af-dudka Aug 12, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. AFRPCBridge now records the registering instance as the owner of the bridge's single handler slot, and removeEventHandler(owner:) no-ops unless it still matches — a detaching engine can no longer cut off events for an engine that registered after it and is still alive. This mirrors the this.sink === sink guard in AppsFlyerEventBus.detach on Android.
The same gap existed in PurchaseConnectorPlugin, which had no teardown at all — it publishes no instance of its own, so it never received a detach callback and kept observing transactions with a delegate pointing at a dead engine's channel. AppsflyerSdkPlugin.detachFromEngineForRegistrar: now forwards to it under the same #if ENABLE_PURCHASE_CONNECTOR guard, with the same ownership check. Both build paths verified: Core-only and Core + PurchaseConnector.
Kept newest-wins for delivery rather than the fanout: the native SDK is a process singleton with a single listener registration, and Android behaves the same way.

result(sequenceError)
return
}
self.pendingLaunchOptions = nil

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initFromRpc's completion (and runSequence/dispatchRpc/logAndOpenStoreFromRpc) mutate
the plugin's stored properties — here, self.pendingLaunchOptions = nil — from inside
executeJson(forMethod:params:completion:)'s completion block, with no check of which thread
that fires on. application(_:didFinishLaunchingWithOptions:) (main thread) writes
pendingLaunchOptions at essentially the same time this completion clears it, with no
lock/queue/actor protecting either side — a genuine data race if the RPC completion is ever
invoked off the main thread (plausible for a network-bound RPC call).

The better fix isn't to guard this race — it's to delete pendingLaunchOptions and the whole
cache-and-sequence-later path it exists for. The only reason didFinishLaunchingWithOptions
doesn't call handleLaunchOptions immediately is a (mistaken) assumption that it needs init to
have run first. Checked the native implementation directly (AppsFlyerLib.m:4147-4166):
handleLaunchOptions: has zero dependency on init state — no check of _appsFlyerDevKey/
_appleAppID, nothing referencing prior configuration. It only extracts the
NSUserActivityTypeBrowsingWeb payload from launch options and sets
_hasPendingDeeplinkForEvaluation = YES under a lock. There's no ordering requirement to
preserve at all.

// Current: cache launch options, wait for a later init() call to sequence handleLaunchOptions
// after initialize — an ordering dependency that doesn't exist on the native side
public func application(_ application: UIApplication,
                        didFinishLaunchingWithOptions launchOptions: [AnyHashable: Any]) -> Bool {
    if !launchOptions.isEmpty {
        pendingLaunchOptions = /* sanitized options */
    }
    return false
}
// ...later, inside initFromRpc's sequence, gated behind initialize completing:
if let pendingLaunchOptions = pendingLaunchOptions {
    sequence.append(RpcCall(method: "handleLaunchOptions", params: ["launchOptions": pendingLaunchOptions]))
}

// Recommended: fire immediately, no caching, no dependency on init() timing, no shared
// mutable state to race on
public func application(_ application: UIApplication,
                        didFinishLaunchingWithOptions launchOptions: [AnyHashable: Any]) -> Bool {
    if !launchOptions.isEmpty {
        let jsonSafeOptions = /* sanitized options, unchanged */
        Task { @MainActor in
            AppsFlyerRPCBridge.shared.executeJson(
                jsonEnvelope(forMethod: "handleLaunchOptions", params: jsonSafeOptions)
            ) { _ in }
        }
    }
    return false
}

This removes pendingLaunchOptions entirely (no more field to race on, no more sequencing logic
in initFromRpc, no more self.pendingLaunchOptions = nil to guard) — the "our bridge should be
stupid" principle applies directly: forward the lifecycle callback as an RPC call the moment it
happens, don't build stateful machinery to defer it based on an ordering assumption the native
SDK doesn't actually have.

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed both points. AFRPCBridge.executeJson now normalizes completions onto the main thread the same way inbound events are, so plugin state mutated from RPC completions does not depend on the vendor's main-actor hop surviving a version bump.

Removed pendingLaunchOptions and the init-time sequencing. didFinishLaunchingWithOptions now forwards handleLaunchOptions immediately through AFRPCBridge.executeJson (fire-and-forget). Your native reading is correct — no init dependency — and the one ordering constraint is relative to registerSessionReadyListener, which Dart registers after init(), so firing at launch satisfies it earlier than caching did.

/// 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? {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unwrapValue(forMethod:) hardcodes a closed switch over five RPC method names and silently
default: return nil for everything else. The two named cases are not incidental, though —
checked against the actual native handlers, and iOS genuinely nests these two results one level
deeper than Android does:

  • iOS AFRPCDataHandler.swift: .success(SDKSuccess(message: ..., data: ["version": version]))
    and data: ["uid": uid] — the value is nested under a named key inside data.
  • Android AppsFlyerRpcHandler.kt (handleGetSdkVersion/handleGetAppsFlyerUID):
    RpcResponse.Success(version)/RpcResponse.Success(uid) — the bare value, no nesting.

So data?["version"]/data?["uid"] in the two listed cases are necessary today, but the
necessity itself is the actual bug, and it doesn't belong to this plugin. Filed as
Cross-Platform Bug: getSdkVersion/getAppsFlyerUID response envelope shape mismatch
— iOS's RPC layer nests these two results under a named key (data.version, data.uid) while
Android returns the bare value directly, an inconsistency in the shared RPC contract, not a
legitimate platform difference to encode per plugin. (RNAppsFlyerImpl.swift's
normalize(iosResponseJson:) unwraps result["data"] generically with no per-method case at
all, which given this same nesting likely returns a {"version": ...} dictionary instead of a
bare string for getSdkVersion on iOS — a live symptom of the same root cause, not a pattern to
copy.)

This should be fixed in the RPC module (align iOS's envelope to Android's flat shape), and once
it is, unwrapValue's two named cases should be deleted from this plugin entirely — they're a
workaround for an upstream inconsistency, not permanent adaptation logic to maintain. Until that
lands, the immediate bug in this file is narrower than the two cases themselves: it's the
closed default: return nil. Any future nested-result RPC method that isn't added here
silently returns nil instead of failing loudly or falling back sensibly — generalize that part
now, and remove the two named cases later once the RPC-module fix ships:

// Bad: closed switch, unlisted methods silently 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"]
    default: return nil
    }
}

// Better: keep the confirmed-nested cases, generalize only the fallback so an unlisted
// getter returns its payload instead of silently 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"]
    default: return data ?? resultObj
    }
}

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree on the diagnosis. iOS nests getSdkVersion/getAppsFlyerUID under named keys in data while Android returns bare values — that's an RPC-module inconsistency, not a permanent plugin adaptation, and I'll keep the explicit unwrap cases until that upstream fix lands.

On the immediate plugin fix: agreed that default: return nil is the real bug here — any unlisted getter silently loses its return value. I'll generalize the fallback to return data (and keep the five existing explicit cases: getSdkVersion, getAppsFlyerUID, isSessionReady, validateAndLogInAppPurchase, generateInviteLink — all have the same iOS-nested vs Android-flat mismatch, not just the two you named).

Won't remove the named cases in this PR; that belongs with the RPC envelope alignment you filed.


/// Forwards the native AppsFlyerRPC envelope without changing event names or payloads.
private func handleBridgeEvent(_ jsonEvent: String) {
if Thread.isMainThread {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleBridgeEvent delivers synchronously when it happens to already be called on the main
thread and asynchronously (via DispatchQueue.main.async) otherwise. That mixed dispatch can
reorder events: an event emitted from a background callback is enqueued on the main queue, and
a subsequent event emitted directly on the main thread is delivered ahead of the queued one.
Android's AppsflyerSdkPlugin.kt always posts through uiThreadHandler, so the two platforms
give different ordering guarantees for the same af-events stream, and Dart consumers of
onDeepLinkReceived/onConversionDataSuccess have no way to restore order.

/// Always hops through DispatchQueue.main.async, even when already on the main thread, so every
/// call — regardless of origin thread — is serialized through one FIFO queue in call order. A
/// same-thread fast path would let a main-thread call deliver synchronously ahead of an earlier
/// background-thread call still queued behind it, reordering same-event-type callbacks.
private func handleBridgeEvent(_ jsonEvent: String) {
    DispatchQueue.main.async { self.deliverEvent(jsonEvent) }
}

deliverEvent/flushPendingEvents need no code change, just a comment fix — the current
comment on deliverEvent ("bridge...invoked on the main thread, no extra hop is required") is
the stale rationale for the fast path being removed here; left in place, it's an invitation for
someone to reintroduce this exact bug later.

Worth ruling out explicitly: @MainActor/Task { @MainActor in ... } instead of GCD. It's the
wrong tool for this hop specifically. DispatchQueue.main.async is a synchronous, documented
strict-FIFO enqueue at the point of call — the enqueue itself establishes total order. An
unstructured Task { @MainActor in ... } created from an arbitrary background thread instead
routes through the cooperative thread pool before landing on the MainActor executor, an extra
scheduling indirection with no documented cross-task FIFO guarantee — it would appear to work
today only because MainActor's default executor happens to be backed by the main dispatch queue
on Apple platforms, which is an implementation detail, not a contract. It would also force
deliverEvent to become @MainActor, which drags in FlutterStreamHandler.onListen (a
synchronous Flutter delegate call already guaranteed to run on the main thread) needing its own
Task hop — two independent hopping mechanisms racing for "the main thread," reintroducing the
same class of bug this fix removes.

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleBridgeEvent was removed in 2820349 — events now go AFRPCBridge.setEventHandler → deliverEvent directly. The mixed sync/async dispatch you're describing didn't go away though; it moved into AFRPCBridge.onMainActor, which still fast-paths synchronously on the main thread. Agree that's the wrong model for event delivery and that Android's always-post pattern is the right reference.

I'll apply the unconditional DispatchQueue.main.async hop in AFRPCBridge.setEventHandler only (keeping the synchronous onMainActor path for executeJson / handler registration order). Will also update the comments in AFRPCBridge and deliverEvent — the old "no extra hop required" rationale is gone from the code but the replacement text still implies sync delivery is safe.

}

private func tearDownForEngineDetach() {
eventSink = nil

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tearDownForEngineDetach() clears eventSink, pendingEvents, eventHandlerRegistered, and
removes the bridge's event handler, but doesn't cancel or guard against in-flight
executeJson(forMethod:...) completions started by initFromRpc/runSequence/dispatchRpc/
logAndOpenStoreFromRpc before detach. If the Flutter engine (and this plugin instance) is
torn down while an RPC call is outstanding, the pending completion closure — which strongly
captures self and, in initFromRpc, calls the original FlutterResult — still fires later,
potentially invoking result(...) against a channel whose engine has already been destroyed,
and continuing to mutate pendingLaunchOptions/call AppsFlyerAttribution.shared().markBridgeReady()
after teardown.

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on in-flight RPC completions after engine detach. You're right that tearDownForEngineDetach() previously cleared event state but did not guard executeJson completions from initFromRpc, dispatchRpc, or logAndOpenStoreFromRpc — in particular FlutterResult and the process-scoped markBridgeReady() call could still run after the channel was gone.

One note: the pendingLaunchOptions concern no longer applies; we removed that cache and forward handleLaunchOptions fire-and-forget from didFinishLaunchingWithOptions instead.

We'll set an isEngineDetached flag at the start of teardown and have all RPC completion paths (including the nested UIApplication.open completion in logAndOpenStore) skip FlutterResult and markBridgeReady() once detached. New channel calls after detach will return PLUGIN_DETACHED, consistent with Android. We intentionally keep strong self in the init chain so a detached engine doesn't leave the Dart Future in an ambiguous state on a live engine — the guard makes post-detach completions a no-op instead.


private static let sharedInstance = AppsFlyerAttribution()

private var isBridgeReady = false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppsFlyerAttribution is a plain NSObject singleton (not an actor, not @MainActor) whose
mutable state — isBridgeReady and pendingRequests — is written from UIKit delegate
callbacks (continueUserActivity, handleOpenUrl, expected on the main thread) and from
markBridgeReady() (line 78), invoked from AppsflyerSdkPlugin.initFromRpc's RPC completion
whose thread affinity is not verified (see the related finding on AppsflyerSdkPlugin.swift:205).
executeOrQueue (line 88) reads isBridgeReady and appends to pendingRequests with no
synchronization; a concurrent read/write from two different threads on a plain Array is
undefined behavior in Swift.

// Safer: isolate the mutable state, e.g. with @MainActor.
@MainActor
public class AppsFlyerAttribution: NSObject {
    private var isBridgeReady = false
    private var pendingRequests: [PendingRequest] = []
}

This is a correct interim fix, but see the expanded recommendation on
AppsFlyerAttribution.swift:133 below — a lifecycle-callback wrapper that would absorb this queue
(and the isolation problem with it) is already planned for the RPC module, so treat this fix as a
bridge until that lands, not a permanent home for the state.

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that AppsFlyerAttribution's isBridgeReady / pendingRequests state isn't formally isolated — the ObjC implementation had the same pattern, and the Swift port didn't add synchronization.

For the markBridgeReady() path from initFromRpc, RPC completions are now normalized onto the main thread in AFRPCBridge.executeJson, so that specific cross-thread race is addressed at the call site. UIKit delegate entry points are already main-thread.

We'll still harden AppsFlyerAttribution itself by routing all queue/state mutations through a main-queue serializer (same approach as AFRPCBridge), so the singleton doesn't rely on caller thread assumptions — especially for the public @objc surface. We agree this is an interim bridge until the RPC lifecycle-callback wrapper absorbs this queue.

let json = String(data: data, encoding: .utf8) else {
return
}
AFFlutterRPCBridge.executeJson(json) { _ in }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

execute(method:params:) discards the RPC response entirely (AFFlutterRPCBridge.executeJson(json) { _ in })
and also silently drops the request when JSONSerialization rejects the envelope (line 128) —
so a failed deep-link/Universal-Link forward, arguably the highest-value path in the whole
plugin, produces no log, no event, and no way for the host app to detect it. Compounding this,
isBridgeReady/pendingRequests are never reset on engine detach
(AppsflyerSdkPlugin.tearDownForEngineDetach clears its own state but not this singleton's), so
after an engine is torn down and recreated the queue-until-ready gate stays permanently open
and early URLs from the new engine are forwarded into a bridge whose event handler may have
been removed.

Stepping back further: this whole class shouldn't be living in the Flutter plugin at all, and the
JSON round-trip is the wrong shape for what it's actually doing. continueUserActivity,
handleOpenUrl, and handleOpenURL are already first-class typed requests inside the RPC module
(AFRPCContinueUserActivityRequest, AFRPCHandleOpenURLRequest, AFRPCHandleOpenUrlRequest in
AFRPCDomainRequests.swift, routed by AFRPCDeepLinkHandler straight to
sdk.continueUserActivity(activity) / sdk.handleOpen(...)) — but the piece that's missing there
is exactly what this class is standing in for: a lifecycle-callback wrapper that gates calls until
the bridge is ready and exposes typed entry points directly, with no JSON in between. That wrapper
does not exist in the RPC module yet; it's already planned/in progress upstream, not something to
design from scratch here. This class's entire job today is to take the native
NSUserActivity/URL/[AnyHashable: Any] the app's own AppDelegate/SceneDelegate hands it,
re-encode them into a [String: Any] params dict, guard that dict through JSONSerialization,
stringify it, and hand the string to AFFlutterRPCBridge.executeJson(_:) — which exists purely to
cross the Dart↔native language boundary. There is no language boundary here: this is native Swift
calling into a native Swift RPC bridge, in the same process, on (intendedly) the same thread.
Encoding to JSON and decoding it straight back into the very same typed request struct on the
other side buys nothing, and is the direct cause of the silent-failure bug above — a typed call
has no serialization step to fail at. It's also not a Flutter-specific problem: the Capacitor
plugin's pre-SDK7
AppsFlyerAttribution.swift
hand-rolls the same "queue until bridge ready, then forward continueUserActivity/handleOpenUrl"
glue independently (via NotificationCenter and single-slot pending state instead of an array),
with its own different set of gaps — two plugins solving the identical problem, twice, both
imperfectly, which is the case for doing it once upstream instead of a third time here. Given the
upstream wrapper is already on the roadmap, the actionable ask for this PR is narrower: don't
invest further in hardening this class's own queue/thread-safety beyond the interim fixes below,
and flag this call site (plus AppsflyerSdkPlugin.swift:279) for migration once the RPC module
exposes the typed lifecycle API, so this class can most likely be deleted rather than patched.
Until then, the immediate fix for this file is the response-surfacing and reset-on-detach behavior
described above, and the actor-isolation fix at AppsFlyerAttribution.swift:27 stands as the
correct interim mitigation.

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — agreed on the narrow interim scope until the upstream RPC lifecycle wrapper lands.

Since the review: the accessor is AFRPCBridge, attribution queue state is main-queue serialized, and markBridgeReady() is skipped after detach via isEngineDetached.

For this PR we'll add os_log on serialization/RPC failures (same silent behavior as ObjC; UDL still via af-events only) and reset isBridgeReady / pendingRequests on detach with an owner guard so other live engines aren't affected. Typed AFRPC*Request migration is out of scope here — noted at the call sites for when the upstream wrapper ships.

],
dependencies: [
.package(url: "https://github.com/AppsFlyerSDK/AppsFlyerFramework.git", .exact("6.18.0"))
.package(name: "FlutterFramework", path: "../FlutterFramework"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.package(name: "FlutterFramework", path: "../FlutterFramework") adds a local path dependency
to a directory that does not exist anywhere in this repository (checked: no FlutterFramework
directory at the repo root, under ios/, or anywhere else), and this is not part of Flutter's
documented SwiftPM plugin integration (plugins normally just import Flutter, supplied via
search paths by the flutter tool/Xcode, without declaring it as an SPM package dependency). For
a real SwiftPM consumer resolving this package from its published git tag, ../FlutterFramework
will not exist relative to the checkout location, and swift package resolve/swift build/
opening Package.swift directly in Xcode will fail with "could not find package
'FlutterFramework'". This likely went unnoticed because the example app's SwiftPM path isn't
exercised the same way in CI, contradicting documentation claims that consumers can use SwiftPM
normally.

// Before — dependency on a nonexistent local path
dependencies: [
    .package(name: "FlutterFramework", path: "../FlutterFramework"),
    .package(url: "https://github.com/AppsFlyerSDK/AppsFlyerFramework.git", exact: "7.0.1")
]

// After — Flutter is supplied by the embedding app/toolchain, not SPM
dependencies: [
    .package(url: "https://github.com/AppsFlyerSDK/AppsFlyerFramework.git", exact: "7.0.1")
]

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ../FlutterFramework dependency is required by Flutter's SPM plugin-author guide — Flutter generates it in the consuming app's ephemeral output, so it isn't in this repo and standalone swift package resolve is expected to fail. Removing it would break the Flutter SPM integration path.

Agreed CI only exercises CocoaPods today. The example app disables SPM because local path: ../ hits a SwiftPM identity mismatch with our checkout folder name; pub.dev consumers don't. We've documented this in F-060 / ARCHITECTURE.md. A dedicated SPM CI job is a follow-up (F-060 gap), out of scope here.

@@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This removes the MinimumOSVersion/13.0 key pair entirely rather than keeping it in sync
with the new iOS 13.0 deployment target this PR introduces elsewhere (podspec, Package.swift,
Xcode project). AppFrameworkInfo.plist is what Xcode/Flutter tooling consults when embedding
Flutter.framework; dropping the explicit minimum can cause the embedded framework's declared
minimum OS to silently fall back to whatever Flutter's default template produces. If this key
is meant to be regenerated by flutter create/flutter build going forward, call that out in
the commit; otherwise restore it as 13.0 to stay consistent with the rest of this migration.

References:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the plist change. We removed MinimumOSVersion from AppFrameworkInfo.plist as part of aligning the example app with current Flutter iOS tooling: recent Flutter versions treat the Xcode/Podfile deployment target as the source of truth and stamp MinimumOSVersion into the built App.framework at compile time (see flutter/flutter#178253). The tools migration also strips this key from AppFrameworkInfo.plist on flutter build, so re-adding 13.0 here would likely be removed again on the next build.

iOS 13.0 remains explicit in the example Podfile, Runner deployment targets, and the plugin podspec/Package.swift — that's what drives the embedded framework minimum on a real build.

@af-dudka
af-dudka requested a review from pazlavi August 13, 2026 08:18
af-dudka and others added 15 commits August 13, 2026 11:47
Remove redundant Travis CI, dead example code, and debug Purchase Connector logs; handle af-events stream errors and align iOS isSessionReady unwrapping with RPC 7.0.12.

Co-authored-by: Cursor <cursoragent@cursor.com>
Apply dart format to af-events listen indentation; add migration guide section for removed Core public headers and align sharing-filter platform notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants