Skip to content

Fix iOS callback ownership across Flutter engines - #456

Open
TeddyYeung wants to merge 3 commits into
AppsFlyerSDK:masterfrom
TeddyYeung:codex/ios-multi-engine-callbacks
Open

TeddyYeung wants to merge 3 commits into
AppsFlyerSDK:masterfrom
TeddyYeung:codex/ios-multi-engine-callbacks

Conversation

@TeddyYeung

@TeddyYeung TeddyYeung commented Jul 1, 2026 •

Copy link
Copy Markdown

Summary

On iOS, registering appsflyer_sdk on a second FlutterEngine moves attribution and deep-link events away from the engine that integrates the SDK. This PR changes who holds the bridge's single event-handler slot:

  • Plugin registration takes the slot only when the slot is free.
  • Dart init claims the slot, so the engine that integrates the SDK receives its events.

Dart API, channel names, and payloads do not change. Android code does not change.

This PR was first opened against the 6.x Objective-C plugin. After the 7.x Swift migration, I rewrote it against AFRPCBridge. This description replaces the earlier one.

Problem

AFRPCBridge installs one event handler per process through AppsFlyerRPCBridge.shared.setEventHandler. Flutter creates one AppsflyerSdkPlugin instance per engine.

On master (7.0.4):

  1. AppsflyerSdkPlugin.init(messenger:) calls registerEventHandler(), which calls AFRPCBridge.setEventHandler(owner: self).
  2. Every engine that registers the plugin replaces the handler. The engine that registered last receives all events.
  3. Dart init does not take the slot back. registerEventHandler() returns early because eventHandlerRegistered is already true from registration.

A common trigger is a background engine that reuses GeneratedPluginRegistrant. For example, the iOS setup of flutter_foreground_task registers all plugins on its background engine. That engine registers after the UI engine, takes the slot, and has no Dart listener. Conversion data and UDL deep-link results then go to the background engine and are dropped.

We hit this in a production app. On iOS, deep links stopped opening the target screen while a foreground task was running. The 6.x plugin had the same problem through the static _callbackChannel.

Changes

  • AFRPCBridge.setEventHandlerIfUnowned(owner:_:) (new): installs the handler only when no live instance owns the slot.
  • AppsflyerSdkPlugin.init(messenger:): calls setEventHandlerIfUnowned. A later engine's registration cannot take an owned slot.
  • AppsflyerSdkPlugin.initFromRpc: calls setEventHandler. The engine that calls Dart init becomes the owner, even if another engine registered first.
  • Removes eventHandlerRegistered and registerEventHandler(). The owner tracking in AFRPCBridge replaces them.
  • AFRPCBridge.eventHandlerOwner is now private(set) so that tests can read it.
Event Before (master, 7.0.4) After
Another engine registers the plugin Takes the slot Takes the slot only if the slot is free
An engine calls Dart init No change. The engine that registered last keeps the slot Takes the slot. This engine becomes the owner

Behavior notes

  • If more than one engine calls Dart init, the engine that called init last receives events.
  • In a single-engine app, the handler is installed at registration and installed again at init for the same instance. The same engine receives events.
  • An engine that does not call init receives events only while it owns the slot.

Tests

example/ios/RunnerTests/MultiEngineEventHandlerTests.swift models several engines in one process with separate FakeBinaryMessenger instances.

Test Checks
testFirstRegistrationTakesFreeSlot The first registration takes a free slot
testSecondaryEngineRegistrationDoesNotStealSlot A later registration does not take an owned slot
testInitClaimsSlotFromEarlierRegistration init takes the slot from an engine that registered earlier
testSecondaryEngineDetachDoesNotReleaseOwnersSlot A non-owner detach does not free the owner's slot
testRegistrationTakesSlotAfterOwnerIsReleased After the owner is released, the next registration takes the slot

The tests check the owner in AFRPCBridge. They do not deliver a native event end to end.

lint-test-build.yml does not run XCTest today. Its comment says to add XCTest to CI once tests exercise AppsflyerSdkPlugin or AFRPCBridge directly. These tests use @testable import appsflyer_sdk and assert on AFRPCBridge state. I can add the CI step in this PR or in a follow-up PR.

Validation

Branch head 13ebd54 (upstream master merged at 3251787). Since that merge, master changed only the version string in AppsflyerSdkPlugin.swift and did not change AFRPCBridge.swift.

  • Xcode 27.1 (27A9275), iPhone 16 Simulator (iOS 18.6), Flutter 3.44.9
  • flutter build ios --simulator --debug in example/: passed. The example app needs an example/.env file with DEV_KEY and APP_ID. I used dummy values. The file is git-ignored.
  • xcodebuild test -workspace example/ios/Runner.xcworkspace -scheme Runner -only-testing:RunnerTests: 9 of 9 passed, including all 5 tests above.
  • Regression check: with init(messenger:) reverted to AFRPCBridge.setEventHandler (the behavior before this PR), testSecondaryEngineRegistrationDoesNotStealSlot and testSecondaryEngineDetachDoesNotReleaseOwnersSlot fail.

Workaround for released versions

Until this change ships, an app can exclude AppsflyerSdkPlugin from the background engine's plugin registrant. For example, register a filtered copy of GeneratedPluginRegistrant in the flutter_foreground_task registrant callback.

@TeddyYeung
TeddyYeung marked this pull request as ready for review July 1, 2026 07:48
@TeddyYeung
TeddyYeung force-pushed the codex/ios-multi-engine-callbacks branch from 37537c2 to 33c8645 Compare July 1, 2026 07:59
@TeddyYeung
TeddyYeung force-pushed the codex/ios-multi-engine-callbacks branch from 33c8645 to df45d8f Compare July 1, 2026 08:53
@yanashenyang

Copy link
Copy Markdown

When will SPM be supported?

@yanashenyang

Copy link
Copy Markdown

This project is awful; we can't contact them at all, and registering an account requires filling out a ton of mandatory private information.

@yanashenyang

Copy link
Copy Markdown

Plugin AppsflyerSdkPlugin uses deprecated application lifecycle events. Please contact plugin maintainers and request UIScene lifecycle support. This will be required in a future version of Flutter. See https://docs.flutter.dev/release/breaking-changes/uiscenedelegate#migration-guide-for-flutter-plugins

Upstream 7.x replaced the Objective-C iOS plugin with the Swift RPC
implementation, deleting the files the 6.x fix modified. Take master's
tree; the ownership fix is re-applied to the Swift sources next.
…strations

AFRPCBridge holds a single process-wide event-handler slot, but every
plugin registration installed its own handler, so registering
appsflyer_sdk on a secondary FlutterEngine (a background engine reusing
GeneratedPluginRegistrant, add-to-app) redirected attribution and
deep-link events to that engine. Its af-events stream usually has no Dart
subscriber, so the events were buffered there and never reached the main
engine.

Plugin registration now only takes a free slot, and the engine whose Dart
side calls init claims it explicitly, matching the owner
AppsFlyerAttribution.markBridgeReady(markedBy:) records.

Constraint: AppsFlyerRPCBridge exposes one global event handler
Rejected: Per-callback-id routing from the 6.x fix | 7.x delivers all events on one af-events stream
Confidence: high
Scope-risk: narrow
Directive: A background engine that calls init still takes the slot by design
Tested: RunnerTests on iOS simulator (5 new multi-engine cases; 2 fail against the previous behavior)
Not-tested: Physical device, SPM integration build
@TeddyYeung

Copy link
Copy Markdown
Author

Hi @tlozovyi-af @af-dudka, friendly ping on this PR.

  • It fixes iOS callback ownership when appsflyer_sdk is registered on a secondary FlutterEngine (e.g. a background engine reusing GeneratedPluginRegistrant). Dart API and channel names are unchanged.
  • I merged the latest master into the branch on Sep 28 and it merges cleanly.
  • The "Lint, Test & Build" workflow for that push never ran because it required maintainer approval and expired. Could you approve/re-run it?
  • A review would also be appreciated, since merging requires an approval from someone with write access.

Happy to rebase or adjust anything. Thanks!

@TeddyYeung

Copy link
Copy Markdown
Author

I rewrote the description to match the current 7.x diff and added local validation, including a regression check that fails on the pre-PR behavior. The new XCTests exercise AFRPCBridge directly, which is the condition in lint-test-build.yml for running XCTest in CI. Could you approve the workflow run and review when you have time?

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.

2 participants