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); + }); +}