Skip to content

Fix iOS callback ownership across Flutter engines#456

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

Fix iOS callback ownership across Flutter engines#456
TeddyYeung wants to merge 1 commit into
AppsFlyerSDK:masterfrom
TeddyYeung:codex/ios-multi-engine-callbacks

Conversation

@TeddyYeung

@TeddyYeung TeddyYeung commented Jul 1, 2026

Copy link
Copy Markdown

Summary

Fixes iOS callback delivery so registering appsflyer_sdk on a secondary FlutterEngine does not rebind durable AppsFlyer callbacks away from the engine that owns the active listener.

Background

Flutter apps can register plugins more than once in the same iOS process when they create an additional FlutterEngine, for example a background engine that reuses GeneratedPluginRegistrant.

Before this change, the iOS plugin kept Flutter callback channels in static/global channel state. That meant a later plugin registration could overwrite the callback channel used by callbacks such as conversion data, app-open attribution, and UDL/deep-linking. In that state, a callback could be invoked on the most recently registered engine instead of the engine that called startListening.

This PR keeps the native AppsFlyer SDK state process-global, but separates Flutter callback delivery by callback id:

  • AS IS: durable callback delivery could depend on plugin registration order.
  • TO BE: durable listener callbacks keep their first owning engine until cancel or plugin deallocation.
  • Request-scoped callbacks can still move to the latest listener, so one-shot flows such as invite-link callbacks are not pinned to the first engine forever.

Changes

  • Keep method and callback channels instance-owned per plugin registration instead of rebinding static channel globals from every registrar messenger.
  • Track callback delivery channels by callback id when Dart calls startListening.
  • Route callback responses through the channel registered for each callback id.
  • Keep first-owner-wins behavior only for durable install-conversion, app-open attribution, and UDL/deep-link callbacks.
  • Allow request-scoped callback ids to replace ownership with the latest listener.
  • Release callback ownership when the owning plugin cancels listening or is deallocated.
  • Keep new routing helpers out of the public Objective-C header; the existing callbackChannel getter remains for legacy native callers.
  • Add iOS XCTest coverage for secondary-engine registration, attempted durable listener stealing, non-owner cancel, owner deallocation, GCD routing, and request-scoped replacement.

Compatibility

  • Dart API is unchanged.
  • Channel names and callback payload shapes are unchanged.
  • Single-engine apps continue to use the same integration path.
  • Native AppsFlyer SDK state remains process-global; this change only separates Flutter callback channel ownership from plugin registration order.

Validation

Native regression coverage:

  • AppsflyerSdkPluginMultiEngineTests creates separate fake FlutterBinaryMessenger objects to model a main engine and a secondary engine in the same process.
  • testDeepLinkCallbackOwnerSurvivesSecondaryEngineRegistration verifies that registering a secondary engine does not move an existing deep-link callback owner.
  • testSecondaryEngineCannotStealExistingDeepLinkListener verifies that a secondary engine calling startListening for an already-owned durable callback id does not steal delivery.
  • testSecondaryEngineCancelDoesNotRemoveDeepLinkListenerOwner verifies that non-owner cancellation does not clear the real owner or callback flag.
  • testCallbackOwnerCanMoveAfterOwnerCancelsListening verifies ownership transfer after explicit owner cancellation.
  • testCallbackOwnerCanMoveAfterOwnerIsDeallocated verifies ownership cleanup when the owning plugin instance is deallocated without Dart-side cancellation.
  • testInstallConversionCallbackUsesOwningEngine verifies the same durable ownership behavior for install conversion callbacks.
  • testRequestScopedCallbackUsesLatestEngine verifies request-scoped callbacks can move to the latest engine.

Commands run:

  • git diff --check: passed.
  • dart analyze --no-fatal-warnings: exit 0 with existing warnings/info.
  • flutter test: passed.
  • xcodebuild test -workspace Runner.xcworkspace -scheme Runner -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.4' -quiet: passed, including all AppsflyerSdkPluginMultiEngineTests.
  • flutter build ios --simulator from example: passed.

Local iOS validation used a temporary ignored example/.env file for the example app asset requirement. The file is not part of this PR.

Notes

This addresses the multi-engine registration class of issues where apps reuse GeneratedPluginRegistrant for a background engine. It does not change AppsFlyer's native SDK singleton behavior or introduce any Dart-side API changes.

@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
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.

1 participant