Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions packages/core/test/plugins/idfa_race_condition_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> _fetchCompleter;

BuggyIdfaPlugin(this._fetchCompleter) : super(PluginType.enrichment) {
_simulateIdfaFetch();
}

Future<void> _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<void> _fetchCompleter;
late final Future<void> _idfaFuture;

FixedIdfaPlugin(this._fetchCompleter) : super(PluginType.enrichment) {
_idfaFuture = _simulateIdfaFetch();
}

Future<void> _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<RawEvent?> 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<void>();
final plugin = BuggyIdfaPlugin(fetchCompleter);
analytics.addPlugin(plugin);

bool executeReturned = false;
plugin.execute(TrackEvent("Application Opened")).then((_) {
executeReturned = true;
});

await Future<void>.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<void>();
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<void>.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'));
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ public class PluginIdfaPlugin: NSObject, FlutterPlugin, NativeIdfaApi {
func getTrackingAuthorizationStatus(completion: @escaping (Result<NativeIdfaData, Error>) -> 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)
)));
}
Expand Down
16 changes: 14 additions & 2 deletions packages/plugins/plugin_idfa/lib/plugin_idfa.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -15,6 +16,8 @@ class IdfaData {
}

class PluginIdfa extends Plugin {
Future<IdfaData>? _trackingStatusFuture;

PluginIdfa({bool shouldAskPermission = true}) : super(PluginType.enrichment) {
if (kIsWeb) {
return;
Expand All @@ -23,15 +26,24 @@ class PluginIdfa extends Plugin {
return;
}
if (shouldAskPermission) {
getTrackingStatus();
_trackingStatusFuture = getTrackingStatus();
}
}

@override
Future<RawEvent?> 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<bool> requestTrackingPermission() async {
final idfaData = await getTrackingStatus();
_trackingStatusFuture = getTrackingStatus();
final idfaData = await _trackingStatusFuture!;
return idfaData.adTrackingEnabled;
}

Expand Down