diff --git a/packages/core/test/plugins/idfa_race_condition_test.dart b/packages/core/test/plugins/idfa_race_condition_test.dart new file mode 100644 index 0000000..0d9bf28 --- /dev/null +++ b/packages/core/test/plugins/idfa_race_condition_test.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:segment_analytics/analytics.dart'; +import 'package:segment_analytics/analytics_platform_interface.dart'; +import 'package:segment_analytics/event.dart'; +import 'package:segment_analytics/plugin.dart'; +import 'package:segment_analytics/state.dart'; + +import '../mocks/mocks.dart'; +import '../mocks/mocks.mocks.dart'; + +/// Simulates the BUGGY PluginIdfa behavior (before PR #198): fires off async +/// IDFA fetch in constructor without overriding execute() to await it. +class BuggyIdfaPlugin extends Plugin { + final Completer _fetchCompleter; + + BuggyIdfaPlugin(this._fetchCompleter) : super(PluginType.enrichment) { + _simulateIdfaFetch(); + } + + Future _simulateIdfaFetch() async { + await _fetchCompleter.future; + final context = await analytics?.state.context.state; + if (context != null) { + context.device.advertisingId = 'test-advertising-id'; + context.device.adTrackingEnabled = true; + analytics?.state.context.setState(context); + } + } +} + +/// Simulates the FIXED PluginIdfa behavior (PR #198): stores the future and +/// awaits it in execute(), blocking events until IDFA data is available. +class FixedIdfaPlugin extends Plugin { + final Completer _fetchCompleter; + late final Future _idfaFuture; + + FixedIdfaPlugin(this._fetchCompleter) : super(PluginType.enrichment) { + _idfaFuture = _simulateIdfaFetch(); + } + + Future _simulateIdfaFetch() async { + await _fetchCompleter.future; + final context = await analytics?.state.context.state; + if (context != null) { + context.device.advertisingId = 'test-advertising-id'; + context.device.adTrackingEnabled = true; + analytics?.state.context.setState(context); + } + } + + @override + Future execute(RawEvent event) async { + await _idfaFuture; + return event; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const writeKey = '123'; + final batch = [ + TrackEvent("Event 1"), + TrackEvent("Event 2"), + TrackEvent("Event 3"), + ]; + + group('IDFA plugin - advertisingId on initial events', () { + late Analytics analytics; + late MockHTTPClient httpClient; + + setUp(() async { + AnalyticsPlatform.instance = MockPlatform(); + httpClient = Mocks.httpClient(); + when(httpClient.settingsFor(writeKey)) + .thenAnswer((_) => Future.value(SegmentAPISettings({}))); + when(httpClient.startBatchUpload(writeKey, batch)) + .thenAnswer((_) => Future.value(true)); + analytics = Analytics( + Configuration(writeKey, + trackApplicationLifecycleEvents: false, + token: "test-token"), + Mocks.store(), + httpClient: (_) => httpClient); + await analytics.init(); + }); + + test('regression: without execute() override, events pass through before ' + 'IDFA is ready', () async { + // Demonstrates the bug from before PR #198: an enrichment plugin that + // does async work in constructor but doesn't override execute() lets + // events through immediately. This causes context.device.advertisingId + // to be null when events are serialized to the queue. + final fetchCompleter = Completer(); + final plugin = BuggyIdfaPlugin(fetchCompleter); + analytics.addPlugin(plugin); + + bool executeReturned = false; + plugin.execute(TrackEvent("Application Opened")).then((_) { + executeReturned = true; + }); + + await Future.delayed(Duration.zero); + + // Confirms the buggy behavior: execute() returned without waiting + expect(executeReturned, isTrue, + reason: 'Without execute() override, events pass through immediately'); + + fetchCompleter.complete(); + }); + + test('fix: with execute() override, events are blocked until IDFA resolves', + () async { + // Validates the fix from PR #198: by overriding execute() to await the + // IDFA future, no event can pass through the enrichment phase until + // advertisingId is populated in context. + final fetchCompleter = Completer(); + final plugin = FixedIdfaPlugin(fetchCompleter); + analytics.addPlugin(plugin); + + bool executeReturned = false; + final executeFuture = + plugin.execute(TrackEvent("Application Opened")).then((result) { + executeReturned = true; + return result; + }); + + await Future.delayed(Duration.zero); + + // execute() is still blocked — waiting for IDFA + expect(executeReturned, isFalse, + reason: 'execute() must block until IDFA data is available'); + + // Simulate native ATTrackingManager callback + fetchCompleter.complete(); + await executeFuture; + + // advertisingId is now set before event passes through + expect(executeReturned, isTrue); + final context = await analytics.state.context.state; + expect(context!.device.advertisingId, equals('test-advertising-id')); + }); + }); +} diff --git a/packages/plugins/plugin_idfa/ios/Classes/PluginIdfaPlugin.swift b/packages/plugins/plugin_idfa/ios/Classes/PluginIdfaPlugin.swift index 3ae041b..de9380b 100644 --- a/packages/plugins/plugin_idfa/ios/Classes/PluginIdfaPlugin.swift +++ b/packages/plugins/plugin_idfa/ios/Classes/PluginIdfaPlugin.swift @@ -7,11 +7,11 @@ public class PluginIdfaPlugin: NSObject, FlutterPlugin, NativeIdfaApi { func getTrackingAuthorizationStatus(completion: @escaping (Result) -> Void) { if #available(iOS 14, *) { ATTrackingManager.requestTrackingAuthorization { status in - let idfa = status == .authorized ? ASIdentifierManager.shared().advertisingIdentifier.uuidString : self.fallbackValue + let idfa = status == .authorized ? ASIdentifierManager.shared().advertisingIdentifier.uuidString : "00000000-0000-0000-0000-000000000000" completion(.success(NativeIdfaData( adTrackingEnabled: status == .authorized, - advertisingId: idfa!, + advertisingId: idfa, trackingStatus: status == .authorized ? TrackingStatus.authorized : status == .denied ? TrackingStatus.denied : status == .notDetermined ? TrackingStatus.notDetermined : status == .restricted ? TrackingStatus.restricted : TrackingStatus.unknown //self.statusToString(status) ))); } diff --git a/packages/plugins/plugin_idfa/lib/plugin_idfa.dart b/packages/plugins/plugin_idfa/lib/plugin_idfa.dart index a439330..cb7313a 100644 --- a/packages/plugins/plugin_idfa/lib/plugin_idfa.dart +++ b/packages/plugins/plugin_idfa/lib/plugin_idfa.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:segment_analytics/event.dart'; import 'package:segment_analytics/plugin.dart'; import 'package:segment_analytics_plugin_idfa/native_idfa.dart'; @@ -15,6 +16,8 @@ class IdfaData { } class PluginIdfa extends Plugin { + Future? _trackingStatusFuture; + PluginIdfa({bool shouldAskPermission = true}) : super(PluginType.enrichment) { if (kIsWeb) { return; @@ -23,15 +26,24 @@ class PluginIdfa extends Plugin { return; } if (shouldAskPermission) { - getTrackingStatus(); + _trackingStatusFuture = getTrackingStatus(); } } + @override + Future execute(RawEvent event) async { + // Wait for the initial IDFA fetch to complete before allowing events through, + // so that Application Installed / Application Opened are stamped with IDFA data. + await _trackingStatusFuture; + return event; + } + /// `requestTrackingPermission()` will prompt the user for /// tracking permission and returns a promise you can use to /// make additional tracking decisions based on the user response Future requestTrackingPermission() async { - final idfaData = await getTrackingStatus(); + _trackingStatusFuture = getTrackingStatus(); + final idfaData = await _trackingStatusFuture!; return idfaData.adTrackingEnabled; }