diff --git a/android/build.gradle b/android/build.gradle
index 2f01859..861a569 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -23,3 +23,7 @@ android {
jvmTarget = "1.8"
}
}
+
+dependencies {
+ implementation "com.google.android.gms:play-services-ads-identifier:18.0.1"
+}
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 7d915e7..85d68c4 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
package="so.kontext.sdk.flutter">
+
diff --git a/android/src/main/kotlin/so/kontext/sdk/flutter/AdvertisingIdPlugin.kt b/android/src/main/kotlin/so/kontext/sdk/flutter/AdvertisingIdPlugin.kt
new file mode 100644
index 0000000..cb2a845
--- /dev/null
+++ b/android/src/main/kotlin/so/kontext/sdk/flutter/AdvertisingIdPlugin.kt
@@ -0,0 +1,47 @@
+package so.kontext.sdk.flutter
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import com.google.android.gms.ads.identifier.AdvertisingIdClient
+import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import java.util.concurrent.Executors
+
+class AdvertisingIdPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
+ private lateinit var channel: MethodChannel
+ private lateinit var context: Context
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val executor = Executors.newSingleThreadExecutor()
+
+ override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ context = binding.applicationContext
+ channel = MethodChannel(binding.binaryMessenger, "kontext_flutter_sdk/advertising_id")
+ channel.setMethodCallHandler(this)
+ }
+
+ override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+ when (call.method) {
+ "getAdvertisingId" -> executor.execute {
+ val id = try {
+ val info = AdvertisingIdClient.getAdvertisingIdInfo(context)
+ if (info.isLimitAdTrackingEnabled) null else info.id
+ } catch (_: Exception) {
+ null
+ }
+
+ mainHandler.post {
+ result.success(id)
+ }
+ }
+
+ else -> result.notImplemented()
+ }
+ }
+
+ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ channel.setMethodCallHandler(null)
+ executor.shutdown()
+ }
+}
diff --git a/android/src/main/kotlin/so/kontext/sdk/flutter/KontextSdkPlugin.kt b/android/src/main/kotlin/so/kontext/sdk/flutter/KontextSdkPlugin.kt
index bbe4c0a..e3d9c5c 100644
--- a/android/src/main/kotlin/so/kontext/sdk/flutter/KontextSdkPlugin.kt
+++ b/android/src/main/kotlin/so/kontext/sdk/flutter/KontextSdkPlugin.kt
@@ -3,31 +3,34 @@ package so.kontext.sdk.flutter
import io.flutter.embedding.engine.plugins.FlutterPlugin
class KontextSdkPlugin : FlutterPlugin {
+ private val advertisingId = AdvertisingIdPlugin()
private val appInfo = AppInfoPlugin()
+ private val audio = DeviceAudioPlugin()
private val hardware = DeviceHardwarePlugin()
+ private val network = DeviceNetworkPlugin()
private val os = OperationSystemPlugin()
private val power = DevicePowerPlugin()
- private val audio = DeviceAudioPlugin()
- private val network = DeviceNetworkPlugin()
private val tcf = TransparencyConsentFramework()
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ advertisingId.onAttachedToEngine(binding)
appInfo.onAttachedToEngine(binding)
+ audio.onAttachedToEngine(binding)
hardware.onAttachedToEngine(binding)
+ network.onAttachedToEngine(binding)
os.onAttachedToEngine(binding)
power.onAttachedToEngine(binding)
- audio.onAttachedToEngine(binding)
- network.onAttachedToEngine(binding)
tcf.onAttachedToEngine(binding)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ advertisingId.onDetachedFromEngine(binding)
appInfo.onDetachedFromEngine(binding)
+ audio.onDetachedFromEngine(binding)
hardware.onDetachedFromEngine(binding)
+ network.onDetachedFromEngine(binding)
os.onDetachedFromEngine(binding)
power.onDetachedFromEngine(binding)
- audio.onDetachedFromEngine(binding)
- network.onDetachedFromEngine(binding)
tcf.onDetachedFromEngine(binding)
}
}
diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist
index 5458fc4..1192124 100644
--- a/example/ios/Runner/Info.plist
+++ b/example/ios/Runner/Info.plist
@@ -22,6 +22,8 @@
????
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
+ NSUserTrackingUsageDescription
+ This identifier will be used to deliver more relevant ads.
LSRequiresIPhoneOS
UILaunchStoryboardName
diff --git a/ios/Classes/AdvertisingIdPlugin.swift b/ios/Classes/AdvertisingIdPlugin.swift
new file mode 100644
index 0000000..77390a0
--- /dev/null
+++ b/ios/Classes/AdvertisingIdPlugin.swift
@@ -0,0 +1,37 @@
+import Flutter
+import UIKit
+import AdSupport
+import AppTrackingTransparency
+
+public class AdvertisingIdPlugin: NSObject, FlutterPlugin {
+ public static func register(with registrar: FlutterPluginRegistrar) {
+ let channel = FlutterMethodChannel(
+ name: "kontext_flutter_sdk/advertising_id",
+ binaryMessenger: registrar.messenger()
+ )
+ let instance = AdvertisingIdPlugin()
+ registrar.addMethodCallDelegate(instance, channel: channel)
+ }
+
+ public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "getAdvertisingId":
+ result(Self.getAdvertisingId())
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ private static func getAdvertisingId() -> String? {
+ let manager = ASIdentifierManager.shared()
+ if #available(iOS 14.0, *) {
+ return ATTrackingManager.trackingAuthorizationStatus == .authorized
+ ? manager.advertisingIdentifier.uuidString
+ : nil
+ }
+
+ return manager.isAdvertisingTrackingEnabled
+ ? manager.advertisingIdentifier.uuidString
+ : nil
+ }
+}
diff --git a/ios/Classes/KontextSdkPlugin.swift b/ios/Classes/KontextSdkPlugin.swift
index 0af1ce6..44d1f8c 100644
--- a/ios/Classes/KontextSdkPlugin.swift
+++ b/ios/Classes/KontextSdkPlugin.swift
@@ -2,12 +2,14 @@ import Flutter
public class KontextSdkPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
+ AdvertisingIdPlugin.register(with: registrar)
AppInfoPlugin.register(with: registrar)
- DeviceHardwarePlugin.register(with: registrar)
- OperationSystemPlugin.register(with: registrar)
- DevicePowerPlugin.register(with: registrar)
DeviceAudioPlugin.register(with: registrar)
+ DeviceHardwarePlugin.register(with: registrar)
DeviceNetworkPlugin.register(with: registrar)
+ DevicePowerPlugin.register(with: registrar)
+ OperationSystemPlugin.register(with: registrar)
+ TrackingAuthorizationPlugin.register(with: registrar)
TransparencyConsentFrameworkPlugin.register(with: registrar)
}
}
diff --git a/ios/Classes/TrackingAuthorizationPlugin.swift b/ios/Classes/TrackingAuthorizationPlugin.swift
new file mode 100644
index 0000000..2ea9ffd
--- /dev/null
+++ b/ios/Classes/TrackingAuthorizationPlugin.swift
@@ -0,0 +1,73 @@
+import Flutter
+import UIKit
+import AppTrackingTransparency
+
+public class TrackingAuthorizationPlugin: NSObject, FlutterPlugin {
+ private var observer: NSObjectProtocol?
+ private static let notSupportedStatus = 4
+
+ deinit {
+ removeObserver()
+ }
+
+ public static func register(with registrar: FlutterPluginRegistrar) {
+ let channel = FlutterMethodChannel(
+ name: "kontext_flutter_sdk/tracking_authorization",
+ binaryMessenger: registrar.messenger()
+ )
+ let instance = TrackingAuthorizationPlugin()
+ registrar.addMethodCallDelegate(instance, channel: channel)
+ }
+
+ public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "getTrackingAuthorizationStatus":
+ getTrackingAuthorizationStatus(result: result)
+ case "requestTrackingAuthorization":
+ requestTrackingAuthorization(result: result)
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ private func getTrackingAuthorizationStatus(result: @escaping FlutterResult) {
+ if #available(iOS 14, *) {
+ result(Int(ATTrackingManager.trackingAuthorizationStatus.rawValue))
+ } else {
+ result(Self.notSupportedStatus)
+ }
+ }
+
+ private func requestTrackingAuthorization(result: @escaping FlutterResult) {
+ if #available(iOS 14, *) {
+ removeObserver()
+ ATTrackingManager.requestTrackingAuthorization { [weak self] status in
+ if status == .denied && ATTrackingManager.trackingAuthorizationStatus == .notDetermined {
+ self?.addObserver(result: result)
+ return
+ }
+ result(Int(status.rawValue))
+ }
+ } else {
+ result(Self.notSupportedStatus)
+ }
+ }
+
+ private func addObserver(result: @escaping FlutterResult) {
+ removeObserver()
+ observer = NotificationCenter.default.addObserver(
+ forName: UIApplication.didBecomeActiveNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.requestTrackingAuthorization(result: result)
+ }
+ }
+
+ private func removeObserver() {
+ if let observer = observer {
+ NotificationCenter.default.removeObserver(observer)
+ self.observer = nil
+ }
+ }
+}
diff --git a/ios/PrivacyInfo.xcprivacy b/ios/PrivacyInfo.xcprivacy
index a355f0a..199446a 100644
--- a/ios/PrivacyInfo.xcprivacy
+++ b/ios/PrivacyInfo.xcprivacy
@@ -3,12 +3,27 @@
- NSPrivacyTracking
+ NSPrivacyTracking
+
NSPrivacyTrackingDomains
-
+
+ megabrain.co
+
NSPrivacyCollectedDataTypes
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypeDeviceID
+ NSPrivacyCollectedDataTypeLinked
+ NSPrivacyCollectedDataTypeTracking
+ NSPrivacyCollectedDataTypePurposes
+
+ NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising
+
+
+
NSPrivacyCollectedDataType
NSPrivacyCollectedDataTypeOtherAppInfo
@@ -30,6 +45,7 @@
NSPrivacyCollectedDataTypePurposeAnalytics
+
NSPrivacyAccessedAPITypes
@@ -42,7 +58,6 @@
C617.1
-
NSPrivacyAccessedAPIType
NSPrivacyAccessedAPICategorySystemBootTime
diff --git a/ios/kontext_flutter_sdk.podspec b/ios/kontext_flutter_sdk.podspec
index 9152441..00b8c5f 100644
--- a/ios/kontext_flutter_sdk.podspec
+++ b/ios/kontext_flutter_sdk.podspec
@@ -16,7 +16,7 @@ Kontext Flutter SDK: sound status, app info, hardware, power, network, etc.
s.platform = :ios, '12.0'
s.swift_version = '5.0'
- s.frameworks = 'AVFoundation', 'SystemConfiguration', 'CoreTelephony', 'WebKit'
+ s.frameworks = 'AVFoundation', 'SystemConfiguration', 'CoreTelephony', 'WebKit', 'AdSupport', 'AppTrackingTransparency'
s.resources = ['PrivacyInfo.xcprivacy']
diff --git a/lib/src/services/advertising_id_service.dart b/lib/src/services/advertising_id_service.dart
new file mode 100644
index 0000000..7640cda
--- /dev/null
+++ b/lib/src/services/advertising_id_service.dart
@@ -0,0 +1,178 @@
+import 'dart:io' show Platform;
+
+import 'package:device_info_plus/device_info_plus.dart';
+import 'package:flutter/foundation.dart' show visibleForTesting;
+import 'package:flutter/services.dart' show MethodChannel;
+import 'package:kontext_flutter_sdk/src/services/logger.dart';
+import 'package:kontext_flutter_sdk/src/services/tracking_authorization_service.dart';
+import 'package:kontext_flutter_sdk/src/utils/extensions.dart';
+
+class AdvertisingIdService {
+ static const _zeroUuid = '00000000-0000-0000-0000-000000000000';
+ static const _ch = MethodChannel('kontext_flutter_sdk/advertising_id');
+
+ static bool _didRunStartupFlow = false;
+ static Future? _startupFlowFuture;
+
+ @visibleForTesting
+ static bool Function() isIOSProvider = _isIOS;
+
+ @visibleForTesting
+ static Future Function() iosVersionProvider = _iOSVersion;
+
+ @visibleForTesting
+ static Future Function() trackingStatusProvider = _trackingStatus;
+
+ @visibleForTesting
+ static Future Function() requestTrackingProvider = _requestTrackingAuthorization;
+
+ @visibleForTesting
+ static Future Function() idfvProvider = _vendorId;
+
+ @visibleForTesting
+ static Future Function() advertisingIdProvider = _advertisingId;
+
+ static Future requestTrackingAuthorization() {
+ if (_didRunStartupFlow) {
+ return _startupFlowFuture ?? Future.value();
+ }
+
+ _didRunStartupFlow = true;
+ _startupFlowFuture = _requestTrackingAuthorizationInternal();
+ return _startupFlowFuture!;
+ }
+
+ static Future getVendorId() async {
+ if (!isIOSProvider()) {
+ return null;
+ }
+
+ try {
+ final vendorId = await idfvProvider();
+ return _normalizeIdentifier(vendorId);
+ } catch (e, stack) {
+ Logger.error('Failed to resolve IDFV: $e', stack);
+ return null;
+ }
+ }
+
+ static Future getAdvertisingId() async {
+ try {
+ final advertisingId = await advertisingIdProvider();
+ return _normalizeIdentifier(advertisingId);
+ } catch (e, stack) {
+ Logger.error('Failed to resolve advertising ID: $e', stack);
+ return null;
+ }
+ }
+
+ static Future<({String? vendorId, String? advertisingId})> resolveIds({
+ String? vendorIdFallback,
+ String? advertisingIdFallback,
+ }) async {
+ final results = await Future.wait([
+ getVendorId(),
+ getAdvertisingId(),
+ ]);
+
+ final vendorId = _normalizeIdentifier(results[0]) ?? _normalizeIdentifier(vendorIdFallback);
+ final advertisingId = _normalizeIdentifier(results[1]) ?? _normalizeIdentifier(advertisingIdFallback);
+
+ return (
+ vendorId: vendorId,
+ advertisingId: advertisingId,
+ );
+ }
+
+ @visibleForTesting
+ static void resetForTesting() {
+ _didRunStartupFlow = false;
+ _startupFlowFuture = null;
+
+ isIOSProvider = _isIOS;
+ iosVersionProvider = _iOSVersion;
+ trackingStatusProvider = _trackingStatus;
+ requestTrackingProvider = _requestTrackingAuthorization;
+ idfvProvider = _vendorId;
+ advertisingIdProvider = _advertisingId;
+ }
+
+ static Future _requestTrackingAuthorizationInternal() async {
+ if (!isIOSProvider()) {
+ return;
+ }
+
+ try {
+ final iosVersion = await iosVersionProvider();
+ if (!_isVersionAtLeast145(iosVersion)) {
+ return;
+ }
+
+ final status = await trackingStatusProvider();
+ if (status != TrackingStatus.notDetermined) {
+ return;
+ }
+
+ await requestTrackingProvider();
+ } catch (e, stack) {
+ Logger.error('Failed to request ATT on startup: $e', stack);
+ }
+ }
+
+ static String? _normalizeIdentifier(String? rawValue) {
+ final normalized = rawValue?.nullIfEmpty;
+ if (normalized == null ) {
+ return null;
+ }
+ if (normalized.toLowerCase() == _zeroUuid) {
+ return null;
+ }
+ return normalized;
+ }
+
+ // On iOS >=14.0 <14.5, tracking authorization request dialog isn't required in order to get IDFA.
+ // In those iOS versions, if we ask for it and the user rejects it we will lose access to IDFA.
+ static bool _isVersionAtLeast145(String version) {
+ final parts = version.split('.');
+ final major = int.tryParse(parts[0]) ?? 0;
+ final minor = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0;
+
+ if (major > 14) {
+ return true;
+ }
+ if (major < 14) {
+ return false;
+ }
+
+ return minor >= 5;
+ }
+
+ static bool _isIOS() => Platform.isIOS;
+
+ static Future _iOSVersion() async {
+ final info = await DeviceInfoPlugin().iosInfo;
+ return info.systemVersion;
+ }
+
+ static Future _trackingStatus() {
+ return TrackingAuthorizationService.trackingAuthorizationStatus;
+ }
+
+ static Future _requestTrackingAuthorization() {
+ return TrackingAuthorizationService.requestTrackingAuthorization();
+ }
+
+ static Future _vendorId() async {
+ final info = await DeviceInfoPlugin().iosInfo;
+ return info.identifierForVendor;
+ }
+
+ static Future _advertisingId() async {
+ try {
+ return _ch.invokeMethod('getAdvertisingId');
+ } catch (e, stack) {
+ Logger.error('Failed to fetch advertising ID: $e', stack);
+ return null;
+ }
+ }
+}
diff --git a/lib/src/services/api.dart b/lib/src/services/api.dart
index ecc0ef6..dac083f 100644
--- a/lib/src/services/api.dart
+++ b/lib/src/services/api.dart
@@ -8,6 +8,7 @@ import 'package:kontext_flutter_sdk/src/services/logger.dart';
import 'package:kontext_flutter_sdk/src/services/http_client.dart';
import 'package:kontext_flutter_sdk/src/models/message.dart';
import 'package:kontext_flutter_sdk/src/models/regulatory.dart';
+import 'package:kontext_flutter_sdk/src/services/advertising_id_service.dart';
import 'package:kontext_flutter_sdk/src/services/transparency_consent_framework_service.dart';
import 'package:kontext_flutter_sdk/src/utils/constants.dart';
import 'package:kontext_flutter_sdk/src/utils/extensions.dart';
@@ -82,8 +83,13 @@ class Api {
try {
final deviceJson = await device.toJsonFresh();
- final vendor = vendorId?.nullIfEmpty;
- final advertising = advertisingId?.nullIfEmpty;
+ final ids = await AdvertisingIdService.resolveIds(
+ vendorIdFallback: vendorId,
+ advertisingIdFallback: advertisingId,
+ );
+
+ final vendor = ids.vendorId?.nullIfEmpty;
+ final advertising = ids.advertisingId?.nullIfEmpty;
final variant = variantId?.nullIfEmpty;
final regulatoryObj = (regulatory ?? const Regulatory()).copyWith(
diff --git a/lib/src/services/tracking_authorization_service.dart b/lib/src/services/tracking_authorization_service.dart
new file mode 100644
index 0000000..a98c527
--- /dev/null
+++ b/lib/src/services/tracking_authorization_service.dart
@@ -0,0 +1,56 @@
+import 'dart:io' show Platform;
+
+import 'package:flutter/services.dart' show MethodChannel;
+import 'package:kontext_flutter_sdk/src/services/logger.dart';
+
+enum TrackingStatus {
+ /// The user has not yet received an authorization request dialog.
+ notDetermined,
+
+ /// The device is restricted and the system cannot show a request dialog.
+ restricted,
+
+ /// The user denied authorization for tracking.
+ denied,
+
+ /// The user authorized tracking.
+ authorized,
+
+ /// The platform does not support ATT.
+ notSupported,
+}
+
+class TrackingAuthorizationService {
+ static const _ch = MethodChannel('kontext_flutter_sdk/tracking_authorization');
+
+ static Future get trackingAuthorizationStatus async {
+ if (!Platform.isIOS) {
+ return TrackingStatus.notSupported;
+ }
+ return _invokeStatusMethod('getTrackingAuthorizationStatus');
+ }
+
+ static Future requestTrackingAuthorization() async {
+ if (!Platform.isIOS) {
+ return TrackingStatus.notSupported;
+ }
+ return _invokeStatusMethod('requestTrackingAuthorization');
+ }
+
+ static Future _invokeStatusMethod(String method) async {
+ try {
+ final rawStatus = await _ch.invokeMethod(method);
+ return _mapRawStatus(rawStatus);
+ } catch (e, stack) {
+ Logger.error('Failed to invoke $method: $e', stack);
+ return TrackingStatus.notSupported;
+ }
+ }
+
+ static TrackingStatus _mapRawStatus(int? rawStatus) {
+ if (rawStatus == null || rawStatus < 0 || rawStatus >= TrackingStatus.values.length) {
+ return TrackingStatus.notSupported;
+ }
+ return TrackingStatus.values[rawStatus];
+ }
+}
diff --git a/lib/src/widgets/ads_provider.dart b/lib/src/widgets/ads_provider.dart
index a86d313..121359a 100644
--- a/lib/src/widgets/ads_provider.dart
+++ b/lib/src/widgets/ads_provider.dart
@@ -1,3 +1,5 @@
+import 'dart:async' show unawaited;
+
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' show HookWidget, useState, useEffect, useRef;
import 'package:flutter_inappwebview/flutter_inappwebview.dart'
@@ -5,6 +7,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'
import 'package:kontext_flutter_sdk/src/models/bid.dart';
import 'package:kontext_flutter_sdk/src/models/regulatory.dart';
import 'package:kontext_flutter_sdk/src/services/api.dart';
+import 'package:kontext_flutter_sdk/src/services/advertising_id_service.dart';
import 'package:kontext_flutter_sdk/src/services/http_client.dart';
import 'package:kontext_flutter_sdk/src/services/logger.dart';
import 'package:kontext_flutter_sdk/src/utils/types.dart' show OnEventCallback;
@@ -69,7 +72,7 @@ class AdsProvider extends HookWidget {
/// The character object used in this conversation.
final Character? character;
- /// Vendor-specific ID.
+ /// Vendor identifier (IDFV on iOS).
final String? vendorId;
/// A variant ID that helps determine which type of ad to render.
@@ -78,7 +81,7 @@ class AdsProvider extends HookWidget {
/// based on an agreement between the publisher and Kontext.so.
final String? variantId;
- /// Device-specific identifier provided by the operating systems (IDFA/GAID)
+ /// Device-specific identifier (IDFA on iOS, GAID on Android).
final String? advertisingId;
/// The log level for the SDK:
@@ -163,6 +166,11 @@ class AdsProvider extends HookWidget {
return null;
}, [logLevel]);
+ useEffect(() {
+ unawaited(AdvertisingIdService.requestTrackingAuthorization());
+ return null;
+ }, const []);
+
usePreloadAds(
context,
publisherToken: publisherToken,
diff --git a/test/src/services/advertising_id_service_test.dart b/test/src/services/advertising_id_service_test.dart
new file mode 100644
index 0000000..a2adc5e
--- /dev/null
+++ b/test/src/services/advertising_id_service_test.dart
@@ -0,0 +1,121 @@
+import 'dart:async';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:kontext_flutter_sdk/src/services/advertising_id_service.dart';
+import 'package:kontext_flutter_sdk/src/services/tracking_authorization_service.dart';
+
+void main() {
+ setUp(() {
+ AdvertisingIdService.resetForTesting();
+ });
+
+ tearDown(() {
+ AdvertisingIdService.resetForTesting();
+ });
+
+ test('requests ATT on iOS 14.5+ when status is not determined', () async {
+ var requestCount = 0;
+
+ AdvertisingIdService.isIOSProvider = () => true;
+ AdvertisingIdService.iosVersionProvider = () async => '16.0';
+ AdvertisingIdService.trackingStatusProvider = () async => TrackingStatus.notDetermined;
+ AdvertisingIdService.requestTrackingProvider = () async {
+ requestCount += 1;
+ return TrackingStatus.authorized;
+ };
+
+ await AdvertisingIdService.requestTrackingAuthorization();
+
+ expect(requestCount, 1);
+ });
+
+ test('does not request ATT on iOS below 14.5', () async {
+ var statusCount = 0;
+ var requestCount = 0;
+
+ AdvertisingIdService.isIOSProvider = () => true;
+ AdvertisingIdService.iosVersionProvider = () async => '14.4';
+ AdvertisingIdService.trackingStatusProvider = () async {
+ statusCount += 1;
+ return TrackingStatus.notDetermined;
+ };
+ AdvertisingIdService.requestTrackingProvider = () async {
+ requestCount += 1;
+ return TrackingStatus.authorized;
+ };
+
+ await AdvertisingIdService.requestTrackingAuthorization();
+
+ expect(statusCount, 0);
+ expect(requestCount, 0);
+ });
+
+ test('does not request ATT on non-iOS', () async {
+ var requestCount = 0;
+
+ AdvertisingIdService.isIOSProvider = () => false;
+ AdvertisingIdService.requestTrackingProvider = () async {
+ requestCount += 1;
+ return TrackingStatus.authorized;
+ };
+
+ await AdvertisingIdService.requestTrackingAuthorization();
+
+ expect(requestCount, 0);
+ });
+
+ test('startup ATT flow runs only once per app run', () async {
+ var statusCount = 0;
+ var requestCount = 0;
+ final completer = Completer();
+
+ AdvertisingIdService.isIOSProvider = () => true;
+ AdvertisingIdService.iosVersionProvider = () async => '17.1';
+ AdvertisingIdService.trackingStatusProvider = () async {
+ statusCount += 1;
+ return TrackingStatus.notDetermined;
+ };
+ AdvertisingIdService.requestTrackingProvider = () {
+ requestCount += 1;
+ return completer.future;
+ };
+
+ final first = AdvertisingIdService.requestTrackingAuthorization();
+ final second = AdvertisingIdService.requestTrackingAuthorization();
+
+ completer.complete(TrackingStatus.authorized);
+ await Future.wait([first, second]);
+ await AdvertisingIdService.requestTrackingAuthorization();
+
+ expect(statusCount, 1);
+ expect(requestCount, 1);
+ });
+
+ test('normalizes identifiers and prefers service values', () async {
+ AdvertisingIdService.isIOSProvider = () => true;
+ AdvertisingIdService.idfvProvider = () async => ' service-idfv ';
+ AdvertisingIdService.advertisingIdProvider = () async => '00000000-0000-0000-0000-000000000000';
+
+ final ids = await AdvertisingIdService.resolveIds(
+ vendorIdFallback: 'fallback-idfv',
+ advertisingIdFallback: 'fallback-idfa',
+ );
+
+ expect(ids.vendorId, 'service-idfv');
+ expect(ids.advertisingId, 'fallback-idfa');
+ });
+
+ test('returns null ids when service and fallback values are empty', () async {
+ AdvertisingIdService.isIOSProvider = () => true;
+ AdvertisingIdService.idfvProvider = () async => '';
+ AdvertisingIdService.advertisingIdProvider = () async => ' ';
+
+ final ids = await AdvertisingIdService.resolveIds(
+ vendorIdFallback: ' ',
+ advertisingIdFallback: '',
+ );
+
+ expect(ids.vendorId, isNull);
+ expect(ids.advertisingId, isNull);
+ });
+}
diff --git a/test/src/services/api_preload_test.dart b/test/src/services/api_preload_test.dart
index f0d4fa4..fc15864 100644
--- a/test/src/services/api_preload_test.dart
+++ b/test/src/services/api_preload_test.dart
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:kontext_flutter_sdk/src/models/bid.dart' show AdDisplayPosition;
import 'package:kontext_flutter_sdk/src/models/regulatory.dart';
import 'package:kontext_flutter_sdk/src/services/api.dart';
+import 'package:kontext_flutter_sdk/src/services/advertising_id_service.dart';
import 'package:kontext_flutter_sdk/src/services/http_client.dart';
import 'package:kontext_flutter_sdk/src/utils/types.dart' show Json;
import 'package:mocktail/mocktail.dart';
@@ -21,6 +22,7 @@ void main() {
HttpClient.resetInstance();
Api.resetInstance();
+ AdvertisingIdService.resetForTesting();
HttpClient(baseUrl: 'https://api.test', client: mock);
api = Api();
@@ -277,4 +279,127 @@ void main() {
),
)).called(1);
});
+
+ test('service ids override fallback ids in preload body', () async {
+ when(() => mock.post(
+ any(),
+ headers: any(named: 'headers'),
+ body: any(named: 'body'),
+ )).thenAnswer((_) async {
+ return http.Response('{"sessionId": "123", "bids": []}', 200);
+ });
+
+ AdvertisingIdService.isIOSProvider = () => true;
+ AdvertisingIdService.idfvProvider = () async => 'service-idfv';
+ AdvertisingIdService.advertisingIdProvider = () async => 'service-idfa';
+
+ await api.preload(
+ publisherToken: 'test-token',
+ userId: 'user-123',
+ conversationId: 'conv-456',
+ messages: [],
+ enabledPlacementCodes: [],
+ vendorId: 'fallback-idfv',
+ advertisingId: 'fallback-idfa',
+ isDisabled: false,
+ );
+
+ verify(() => mock.post(
+ Uri.parse('https://api.test/preload'),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Kontextso-Publisher-Token': 'test-token',
+ 'Kontextso-Is-Disabled': '0',
+ },
+ body: any(
+ named: 'body',
+ that: predicate((raw) {
+ final body = jsonDecode(raw) as Json;
+ return body['vendorId'] == 'service-idfv' && body['advertisingId'] == 'service-idfa';
+ }),
+ ),
+ )).called(1);
+ });
+
+ test('fallback ids are used when service ids are unavailable', () async {
+ when(() => mock.post(
+ any(),
+ headers: any(named: 'headers'),
+ body: any(named: 'body'),
+ )).thenAnswer((_) async {
+ return http.Response('{"sessionId": "123", "bids": []}', 200);
+ });
+
+ AdvertisingIdService.isIOSProvider = () => true;
+ AdvertisingIdService.idfvProvider = () async => '';
+ AdvertisingIdService.advertisingIdProvider = () async => null;
+
+ await api.preload(
+ publisherToken: 'test-token',
+ userId: 'user-123',
+ conversationId: 'conv-456',
+ messages: [],
+ enabledPlacementCodes: [],
+ vendorId: 'fallback-idfv',
+ advertisingId: 'fallback-idfa',
+ isDisabled: false,
+ );
+
+ verify(() => mock.post(
+ Uri.parse('https://api.test/preload'),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Kontextso-Publisher-Token': 'test-token',
+ 'Kontextso-Is-Disabled': '0',
+ },
+ body: any(
+ named: 'body',
+ that: predicate((raw) {
+ final body = jsonDecode(raw) as Json;
+ return body['vendorId'] == 'fallback-idfv' && body['advertisingId'] == 'fallback-idfa';
+ }),
+ ),
+ )).called(1);
+ });
+
+ test('ids are omitted when both service and fallback ids are empty', () async {
+ when(() => mock.post(
+ any(),
+ headers: any(named: 'headers'),
+ body: any(named: 'body'),
+ )).thenAnswer((_) async {
+ return http.Response('{"sessionId": "123", "bids": []}', 200);
+ });
+
+ AdvertisingIdService.isIOSProvider = () => true;
+ AdvertisingIdService.idfvProvider = () async => '00000000-0000-0000-0000-000000000000';
+ AdvertisingIdService.advertisingIdProvider = () async => '';
+
+ await api.preload(
+ publisherToken: 'test-token',
+ userId: 'user-123',
+ conversationId: 'conv-456',
+ messages: [],
+ enabledPlacementCodes: [],
+ vendorId: ' ',
+ advertisingId: '',
+ isDisabled: false,
+ );
+
+ verify(() => mock.post(
+ Uri.parse('https://api.test/preload'),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Kontextso-Publisher-Token': 'test-token',
+ 'Kontextso-Is-Disabled': '0',
+ },
+ body: any(
+ named: 'body',
+ that: predicate((raw) {
+ final body = jsonDecode(raw) as Json;
+ return !body.containsKey('vendorId') && !body.containsKey('advertisingId');
+ }),
+ ),
+ )).called(1);
+ });
}
diff --git a/test/src/widgets/ads_provider_test.dart b/test/src/widgets/ads_provider_test.dart
new file mode 100644
index 0000000..2a9fb46
--- /dev/null
+++ b/test/src/widgets/ads_provider_test.dart
@@ -0,0 +1,91 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:kontext_flutter_sdk/src/models/message.dart';
+import 'package:kontext_flutter_sdk/src/services/advertising_id_service.dart';
+import 'package:kontext_flutter_sdk/src/services/tracking_authorization_service.dart';
+import 'package:kontext_flutter_sdk/src/widgets/ads_provider.dart';
+
+void main() {
+ setUp(() {
+ AdvertisingIdService.resetForTesting();
+ });
+
+ tearDown(() {
+ AdvertisingIdService.resetForTesting();
+ });
+
+ testWidgets('AdsProvider triggers startup ATT flow', (tester) async {
+ var requestCount = 0;
+
+ AdvertisingIdService.isIOSProvider = () => true;
+ AdvertisingIdService.iosVersionProvider = () async => '16.0';
+ AdvertisingIdService.trackingStatusProvider = () async => TrackingStatus.notDetermined;
+ AdvertisingIdService.requestTrackingProvider = () async {
+ requestCount += 1;
+ return TrackingStatus.authorized;
+ };
+
+ await tester.pumpWidget(
+ const Directionality(
+ textDirection: TextDirection.ltr,
+ child: AdsProvider(
+ publisherToken: 'token',
+ userId: 'user',
+ conversationId: 'conv',
+ messages: [],
+ enabledPlacementCodes: ['inlineAd'],
+ child: SizedBox.shrink(),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ expect(requestCount, 1);
+ });
+
+ testWidgets('AdsProvider startup ATT flow runs only once across remounts', (tester) async {
+ var requestCount = 0;
+
+ AdvertisingIdService.isIOSProvider = () => true;
+ AdvertisingIdService.iosVersionProvider = () async => '16.0';
+ AdvertisingIdService.trackingStatusProvider = () async => TrackingStatus.notDetermined;
+ AdvertisingIdService.requestTrackingProvider = () async {
+ requestCount += 1;
+ return TrackingStatus.authorized;
+ };
+
+ await tester.pumpWidget(
+ const Directionality(
+ textDirection: TextDirection.ltr,
+ child: AdsProvider(
+ key: ValueKey('provider-1'),
+ publisherToken: 'token',
+ userId: 'user',
+ conversationId: 'conv',
+ messages: [],
+ enabledPlacementCodes: ['inlineAd'],
+ child: SizedBox.shrink(),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ await tester.pumpWidget(
+ const Directionality(
+ textDirection: TextDirection.ltr,
+ child: AdsProvider(
+ key: ValueKey('provider-2'),
+ publisherToken: 'token',
+ userId: 'user',
+ conversationId: 'conv',
+ messages: [],
+ enabledPlacementCodes: ['inlineAd'],
+ child: SizedBox.shrink(),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ expect(requestCount, 1);
+ });
+}