From 4896df471fad87dcf5d565ee6181ef8a3618baca Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Sat, 11 Oct 2025 18:35:19 +0200 Subject: [PATCH 01/12] AdAttributionKit: initImpression --- ios/Classes/AdAttributionManager.swift | 36 +++++++++++++++ ios/Classes/AdAttributionPlugin.swift | 29 ++++++++++++ ios/Classes/KontextSdkPlugin.swift | 1 + lib/src/services/ad_attribution_kit.dart | 58 ++++++++++++++++++++++++ lib/src/widgets/ad_format.dart | 20 ++++++++ 5 files changed, 144 insertions(+) create mode 100644 ios/Classes/AdAttributionManager.swift create mode 100644 ios/Classes/AdAttributionPlugin.swift create mode 100644 lib/src/services/ad_attribution_kit.dart diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift new file mode 100644 index 0000000..f0b730b --- /dev/null +++ b/ios/Classes/AdAttributionManager.swift @@ -0,0 +1,36 @@ +import Flutter +import Foundation +import UIKit +import AdAttributionKit + +final class AdAttributionManager { + static let shared = AdAttributionManager() + + private var appImpressionBox: Any? + + @available(iOS 17.4, *) + private var appImpression: AppImpression? { + get { appImpressionBox as? AppImpression } + set { appImpressionBox = newValue } + } + + func initImpression(jws: String, completion: @escaping (Any) -> Void) { + guard #available(iOS 17.4, *) else { + completion(false) + return + } + Task { [weak self] in + guard let self = self else { + completion(false) + return + } + do { + let imp = try await AppImpression(compactJWS: jws) + self.appImpression = imp + completion(true) + } catch { + completion(FlutterError(code: "INIT_IMPRESSION_FAILED", message: "Failed to initialize AppImpression: \(error)", details: nil)) + } + } + } +} diff --git a/ios/Classes/AdAttributionPlugin.swift b/ios/Classes/AdAttributionPlugin.swift new file mode 100644 index 0000000..55c8103 --- /dev/null +++ b/ios/Classes/AdAttributionPlugin.swift @@ -0,0 +1,29 @@ +import Flutter +import UIKit + +public class AdAttributionPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "kontext_flutter_sdk/ad_attribution", + binaryMessenger: registrar.messenger() + ) + let instance = AdAttributionPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initImpression": + guard let args = call.arguments as? [String: Any], + let jws = args["jws"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "jws is required", details: nil)) + return + } + AdAttributionManager.shared.initImpression(jws: jws) { success in + result(success) + } + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/ios/Classes/KontextSdkPlugin.swift b/ios/Classes/KontextSdkPlugin.swift index 0af1ce6..4b4a940 100644 --- a/ios/Classes/KontextSdkPlugin.swift +++ b/ios/Classes/KontextSdkPlugin.swift @@ -9,5 +9,6 @@ public class KontextSdkPlugin: NSObject, FlutterPlugin { DeviceAudioPlugin.register(with: registrar) DeviceNetworkPlugin.register(with: registrar) TransparencyConsentFrameworkPlugin.register(with: registrar) + AdAttributionPlugin.register(with: registrar) } } diff --git a/lib/src/services/ad_attribution_kit.dart b/lib/src/services/ad_attribution_kit.dart new file mode 100644 index 0000000..04f0fd4 --- /dev/null +++ b/lib/src/services/ad_attribution_kit.dart @@ -0,0 +1,58 @@ +import 'dart:io'; +import 'dart:ui' show Rect; + +import 'package:flutter/services.dart' show MethodChannel; +import 'package:kontext_flutter_sdk/src/services/logger.dart' show Logger; + +class AdAttributionKit { + AdAttributionKit._(); + + static AdAttributionKit? _instance; + + factory AdAttributionKit() { + return _instance ??= AdAttributionKit._(); + } + + static const MethodChannel _channel = MethodChannel('kontext_flutter_sdk/ad_attribution'); + + static Future initImpression(String jws) async { + if (!Platform.isIOS) return; + + try { + final result = await _channel.invokeMethod('initImpression', {'jws': jws}); + Logger.debug('AdAttributionKit impression initialized: $result'); + } catch (e, stack) { + Logger.exception('Error initializing AdAttributionKit impression: $e', stack); + } + } + + static Future setAttributionFrame(Rect rect) async { + if (!Platform.isIOS) return; + + throw UnimplementedError('setAttributionFrame is not implemented yet.'); + } + + static Future handleTap(String? url) async { + if (!Platform.isIOS) return; + + throw UnimplementedError('handleTap is not implemented yet.'); + } + + static Future beginView() async { + if (!Platform.isIOS) return; + + throw UnimplementedError('beginView is not implemented yet.'); + } + + static Future endView() async { + if (!Platform.isIOS) return; + + throw UnimplementedError('endView is not implemented yet.'); + } + + static Future dispose() async { + if (!Platform.isIOS) return; + + throw UnimplementedError('dispose is not implemented yet.'); + } +} diff --git a/lib/src/widgets/ad_format.dart b/lib/src/widgets/ad_format.dart index aa0ec0a..351f0a3 100644 --- a/lib/src/widgets/ad_format.dart +++ b/lib/src/widgets/ad_format.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:kontext_flutter_sdk/src/models/ad_event.dart'; import 'package:kontext_flutter_sdk/src/models/message.dart'; +import 'package:kontext_flutter_sdk/src/services/ad_attribution_kit.dart'; import 'package:kontext_flutter_sdk/src/services/logger.dart'; import 'package:kontext_flutter_sdk/src/utils/browser_opener.dart'; import 'package:kontext_flutter_sdk/src/utils/constants.dart'; @@ -195,6 +196,13 @@ class AdFormat extends HookWidget { break; case 'show-iframe': showIframe.value = true; + final tmpData = { + if (data != null) ...data, + 'jws': jsonEncode({ + 'dummy': 'data', + }) + }; + _handleAdAttributionJws(tmpData); break; case 'hide-iframe': showIframe.value = false; @@ -230,6 +238,9 @@ class AdFormat extends HookWidget { onEvent: adsProviderData.onEvent, ); break; + case "ad-attribution-kit": + _handleAdAttributionJws(data); + break; case 'error-iframe': resetIframe(); break; @@ -277,6 +288,15 @@ class AdFormat extends HookWidget { } } + void _handleAdAttributionJws(Json? data) { + final jws = data?['jws']; + if (jws is! String || jws.isEmpty) { + Logger.error('Ad attribution JWS is missing or invalid. Data: $data'); + return; + } + AdAttributionKit.initImpression(jws); + } + @override Widget build(BuildContext context) { final slotKey = useMemoized(() => GlobalKey(), const []); From ffaadc992af2ec8cd70b96d46811df0e35c13f5c Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Sat, 11 Oct 2025 23:05:41 +0200 Subject: [PATCH 02/12] AdAttributionKit: setAttributionFrame --- ios/Classes/AdAttributionManager.swift | 86 +++++++++++++++++++++++- ios/Classes/AdAttributionPlugin.swift | 19 ++++++ lib/src/services/ad_attribution_kit.dart | 12 +++- lib/src/widgets/ad_format.dart | 10 +++ 4 files changed, 125 insertions(+), 2 deletions(-) diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift index f0b730b..0c23da3 100644 --- a/ios/Classes/AdAttributionManager.swift +++ b/ios/Classes/AdAttributionManager.swift @@ -7,12 +7,20 @@ final class AdAttributionManager { static let shared = AdAttributionManager() private var appImpressionBox: Any? - + private var attributionViewBox: Any? + private weak var hostWindow: UIWindow? + @available(iOS 17.4, *) private var appImpression: AppImpression? { get { appImpressionBox as? AppImpression } set { appImpressionBox = newValue } } + + @available(iOS 17.4, *) + private var attributionView: UIEventAttributionView? { + get { attributionViewBox as? UIEventAttributionView } + set { attributionViewBox = newValue } + } func initImpression(jws: String, completion: @escaping (Any) -> Void) { guard #available(iOS 17.4, *) else { @@ -33,4 +41,80 @@ final class AdAttributionManager { } } } + + /// Places the UIEventAttributionView in window coordinates over the ad. + func setAttributionFrame(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, completion: @escaping (Any) -> Void) { + guard #available(iOS 17.4, *) else { + completion(false) + return + } + + guard width > 0, height > 0 else { + if let view = attributionView { + view.removeFromSuperview() + attributionView = nil + } + hostWindow = nil + completion(false) + return + } + + guard let window = currentKeyWindow() else { + completion(FlutterError(code: "NO_KEY_WINDOW", message: "No key window found", details: nil)) + return + } + + if attributionView == nil { + let view = UIEventAttributionView() + // Ensure the view does not interfere with normal user interaction + view.isUserInteractionEnabled = false + window.addSubview(view) + attributionView = view + } + + attributionView?.frame = CGRect(x: x, y: y, width: width, height: height) + hostWindow = window + completion(true) + } + + private func currentKeyWindow() -> UIWindow? { + // Scan scenes by activation, prefer .foregroundActive, then .foregroundInactive + if #available(iOS 13.0, *) { + let scenes = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + + func pickWindow(in scene: UIWindowScene) -> UIWindow? { + // Key window first + if let key = scene.windows.first(where: { $0.isKeyWindow }) { + return key + } + // Then visible window, normal-level window + return scene.windows.first(where: { !$0.isHidden && $0.windowLevel == .normal }) + } + + + if let scene = scenes.first(where: { $0.activationState == .foregroundActive}), + let window = pickWindow(in: scene) { + return window + } + + if let scene = scenes.first(where: { $0.activationState == .foregroundInactive}), + let window = pickWindow(in: scene) { + return window + } + } else { + if let window = UIApplication.shared.keyWindow { + return window + } + } + + if #available(iOS 13.0, *) { + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first(where: { !$0.isHidden && $0.windowLevel == .normal }) + } + + return nil + } } diff --git a/ios/Classes/AdAttributionPlugin.swift b/ios/Classes/AdAttributionPlugin.swift index 55c8103..dc87af1 100644 --- a/ios/Classes/AdAttributionPlugin.swift +++ b/ios/Classes/AdAttributionPlugin.swift @@ -22,6 +22,25 @@ public class AdAttributionPlugin: NSObject, FlutterPlugin { AdAttributionManager.shared.initImpression(jws: jws) { success in result(success) } + case "setAttributionFrame": + guard let args = call.arguments as? [String: Any], + let x = (args["x"] as? NSNumber)?.doubleValue, + let y = (args["y"] as? NSNumber)?.doubleValue, + let width = (args["width"] as? NSNumber)?.doubleValue, + let height = (args["height"] as? NSNumber)?.doubleValue else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "x, y, width, height are required", details: nil)) + return + } + DispatchQueue.main.async { + AdAttributionManager.shared.setAttributionFrame( + x: CGFloat(x), + y: CGFloat(y), + width: CGFloat(width), + height: CGFloat(height) + ) { success in + result(success) + } + } default: result(FlutterMethodNotImplemented) } diff --git a/lib/src/services/ad_attribution_kit.dart b/lib/src/services/ad_attribution_kit.dart index 04f0fd4..cfb2927 100644 --- a/lib/src/services/ad_attribution_kit.dart +++ b/lib/src/services/ad_attribution_kit.dart @@ -29,7 +29,17 @@ class AdAttributionKit { static Future setAttributionFrame(Rect rect) async { if (!Platform.isIOS) return; - throw UnimplementedError('setAttributionFrame is not implemented yet.'); + try { + final result = await _channel.invokeMethod('setAttributionFrame', { + 'x': rect.left, + 'y': rect.top, + 'width': rect.width, + 'height': rect.height, + }); + Logger.debug('AdAttributionKit attribution frame set: $result'); + } catch (e, stack) { + Logger.exception('Error setting AdAttributionKit attribution frame: $e', stack); + } } static Future handleTap(String? url) async { diff --git a/lib/src/widgets/ad_format.dart b/lib/src/widgets/ad_format.dart index 351f0a3..a3f468b 100644 --- a/lib/src/widgets/ad_format.dart +++ b/lib/src/widgets/ad_format.dart @@ -180,6 +180,7 @@ class AdFormat extends HookWidget { BuildContext context, { required String messageType, Json? data, + required GlobalKey key, required bool Function() isDisposed, required String adServerUrl, required Uri inlineUri, @@ -203,6 +204,7 @@ class AdFormat extends HookWidget { }) }; _handleAdAttributionJws(tmpData); + _setAttributionFrame(key); break; case 'hide-iframe': showIframe.value = false; @@ -297,6 +299,13 @@ class AdFormat extends HookWidget { AdAttributionKit.initImpression(jws); } + void _setAttributionFrame(GlobalKey key) { + final adContainer = _slotRectInWindow(key); + if (adContainer == null) return; + + AdAttributionKit.setAttributionFrame(adContainer); + } + @override Widget build(BuildContext context) { final slotKey = useMemoized(() => GlobalKey(), const []); @@ -471,6 +480,7 @@ class AdFormat extends HookWidget { messageType: messageType, isDisposed: () => disposed.value, data: data, + key: slotKey, adServerUrl: adServerUrl, inlineUri: inlineUri!, bidId: bidId, From 027f48c27ba6a17421dc9da0444a383d0800d584 Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Sun, 12 Oct 2025 13:51:26 +0200 Subject: [PATCH 03/12] AdAttributionKit: rename key parameter to adSlotKey for clarity --- lib/src/widgets/ad_format.dart | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/src/widgets/ad_format.dart b/lib/src/widgets/ad_format.dart index a3f468b..00f3c20 100644 --- a/lib/src/widgets/ad_format.dart +++ b/lib/src/widgets/ad_format.dart @@ -68,7 +68,7 @@ class AdFormat extends HookWidget { void _postDimensions({ required BuildContext context, - required GlobalKey key, + required GlobalKey adSlotKey, InAppWebViewController? controller, required String adServerUrl, required bool Function() disposed, @@ -77,17 +77,17 @@ class AdFormat extends HookWidget { }) { if (disposed()) return; - final slot = _slotRectInWindow(key); - if (slot == null || controller == null) return; + final adSlot = _slotRectInWindow(adSlotKey); + if (adSlot == null || controller == null) return; final viewport = _visibleWindowRect(context); final mq = MediaQueryData.fromView(View.of(context)); final keyboardHeight = mq.viewInsets.bottom; - final containerWidth = slot.width.nullIfNaN; - final containerHeight = slot.height.nullIfNaN; - final containerX = slot.left.nullIfNaN; - final containerY = slot.top.nullIfNaN; + final containerWidth = adSlot.width.nullIfNaN; + final containerHeight = adSlot.height.nullIfNaN; + final containerX = adSlot.left.nullIfNaN; + final containerY = adSlot.top.nullIfNaN; final isAnyNullDimension = containerWidth == null || containerHeight == null || containerX == null || containerY == null; @@ -101,10 +101,10 @@ class AdFormat extends HookWidget { 'data': { 'windowWidth': viewport.width.nullIfNaN, 'windowHeight': viewport.height.nullIfNaN, - 'containerWidth': slot.width.nullIfNaN, - 'containerHeight': slot.height.nullIfNaN, - 'containerX': slot.left.nullIfNaN, - 'containerY': slot.top.nullIfNaN, + 'containerWidth': adSlot.width.nullIfNaN, + 'containerHeight': adSlot.height.nullIfNaN, + 'containerX': adSlot.left.nullIfNaN, + 'containerY': adSlot.top.nullIfNaN, 'keyboardHeight': keyboardHeight.nullIfNaN, }, }; @@ -180,7 +180,7 @@ class AdFormat extends HookWidget { BuildContext context, { required String messageType, Json? data, - required GlobalKey key, + required GlobalKey adSlotKey, required bool Function() isDisposed, required String adServerUrl, required Uri inlineUri, @@ -204,7 +204,7 @@ class AdFormat extends HookWidget { }) }; _handleAdAttributionJws(tmpData); - _setAttributionFrame(key); + _setAttributionFrame(adSlotKey); break; case 'hide-iframe': showIframe.value = false; @@ -308,7 +308,7 @@ class AdFormat extends HookWidget { @override Widget build(BuildContext context) { - final slotKey = useMemoized(() => GlobalKey(), const []); + final adSlotKey = useMemoized(() => GlobalKey(), const []); final ticker = useRef(null); final delayedTicker = useRef(null); @@ -385,7 +385,7 @@ class AdFormat extends HookWidget { void postDimensions() => _postDimensions( context: context, controller: webViewController.value, - key: slotKey, + adSlotKey: adSlotKey, adServerUrl: adServerUrl, disposed: () => disposed.value, isNullDimensions: isNullDimensions.value, @@ -460,7 +460,7 @@ class AdFormat extends HookWidget { return Offstage( offstage: !iframeLoaded.value || !showIframe.value, child: Container( - key: slotKey, + key: adSlotKey, height: height.value, width: double.infinity, color: Colors.transparent, @@ -480,7 +480,7 @@ class AdFormat extends HookWidget { messageType: messageType, isDisposed: () => disposed.value, data: data, - key: slotKey, + adSlotKey: adSlotKey, adServerUrl: adServerUrl, inlineUri: inlineUri!, bidId: bidId, From 796f10615a72e93b5df29d6e7d422856bb0e479e Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Sun, 12 Oct 2025 13:57:34 +0200 Subject: [PATCH 04/12] AdAttributionKit: implement handleTap method and integrate with AdAttributionManager --- ios/Classes/AdAttributionManager.swift | 60 +++++++++++++++++------- ios/Classes/AdAttributionPlugin.swift | 5 ++ lib/src/services/ad_attribution_kit.dart | 9 +++- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift index 0c23da3..e1499f9 100644 --- a/ios/Classes/AdAttributionManager.swift +++ b/ios/Classes/AdAttributionManager.swift @@ -9,13 +9,13 @@ final class AdAttributionManager { private var appImpressionBox: Any? private var attributionViewBox: Any? private weak var hostWindow: UIWindow? - + @available(iOS 17.4, *) private var appImpression: AppImpression? { get { appImpressionBox as? AppImpression } set { appImpressionBox = newValue } } - + @available(iOS 17.4, *) private var attributionView: UIEventAttributionView? { get { attributionViewBox as? UIEventAttributionView } @@ -41,14 +41,14 @@ final class AdAttributionManager { } } } - + /// Places the UIEventAttributionView in window coordinates over the ad. func setAttributionFrame(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, completion: @escaping (Any) -> Void) { guard #available(iOS 17.4, *) else { completion(false) return } - + guard width > 0, height > 0 else { if let view = attributionView { view.removeFromSuperview() @@ -58,12 +58,12 @@ final class AdAttributionManager { completion(false) return } - + guard let window = currentKeyWindow() else { completion(FlutterError(code: "NO_KEY_WINDOW", message: "No key window found", details: nil)) return } - + if attributionView == nil { let view = UIEventAttributionView() // Ensure the view does not interfere with normal user interaction @@ -71,18 +71,46 @@ final class AdAttributionManager { window.addSubview(view) attributionView = view } - + attributionView?.frame = CGRect(x: x, y: y, width: width, height: height) hostWindow = window completion(true) } - + + func handleTap(url: String?, completion: @escaping (Any) -> Void) { + guard #available(iOS 18.0, *) else { + completion(false) + return + } + guard let impression = appImpression else { + completion(FlutterError(code: "NO_IMPRESSION", message: "AppImpression not initialized", details: nil)) + return + } + + Task { [weak self] in + guard let self = self else { + completion(false) + return + } + do { + if let urlString = url, let reengagementURL = URL(string: urlString) { + try await impression.handleTap(reengagementURL: reengagementURL) + } else { + try await impression.handleTap() + } + completion(true) + } catch { + completion(FlutterError(code: "HANDLE_TAP_FAILED", message: "Failed to handle tap: \(error)", details: nil)) + } + } + } + private func currentKeyWindow() -> UIWindow? { // Scan scenes by activation, prefer .foregroundActive, then .foregroundInactive if #available(iOS 13.0, *) { let scenes = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } - + func pickWindow(in scene: UIWindowScene) -> UIWindow? { // Key window first if let key = scene.windows.first(where: { $0.isKeyWindow }) { @@ -91,15 +119,15 @@ final class AdAttributionManager { // Then visible window, normal-level window return scene.windows.first(where: { !$0.isHidden && $0.windowLevel == .normal }) } - - + + if let scene = scenes.first(where: { $0.activationState == .foregroundActive}), - let window = pickWindow(in: scene) { + let window = pickWindow(in: scene) { return window } - + if let scene = scenes.first(where: { $0.activationState == .foregroundInactive}), - let window = pickWindow(in: scene) { + let window = pickWindow(in: scene) { return window } } else { @@ -107,14 +135,14 @@ final class AdAttributionManager { return window } } - + if #available(iOS 13.0, *) { return UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .flatMap { $0.windows } .first(where: { !$0.isHidden && $0.windowLevel == .normal }) } - + return nil } } diff --git a/ios/Classes/AdAttributionPlugin.swift b/ios/Classes/AdAttributionPlugin.swift index dc87af1..b80592d 100644 --- a/ios/Classes/AdAttributionPlugin.swift +++ b/ios/Classes/AdAttributionPlugin.swift @@ -41,6 +41,11 @@ public class AdAttributionPlugin: NSObject, FlutterPlugin { result(success) } } + case "handleTap": + let url = (call.arguments as? [String: Any])?["url"] as? String + AdAttributionManager.shared.handleTap(url: url) { success in + result(success) + } default: result(FlutterMethodNotImplemented) } diff --git a/lib/src/services/ad_attribution_kit.dart b/lib/src/services/ad_attribution_kit.dart index cfb2927..2d2dd28 100644 --- a/lib/src/services/ad_attribution_kit.dart +++ b/lib/src/services/ad_attribution_kit.dart @@ -42,10 +42,15 @@ class AdAttributionKit { } } - static Future handleTap(String? url) async { + static Future handleTap(Uri? uri) async { if (!Platform.isIOS) return; - throw UnimplementedError('handleTap is not implemented yet.'); + try { + final result = await _channel.invokeMethod('handleTap', {'url': uri?.toString()}); + Logger.debug('AdAttributionKit handle tap: $result'); + } catch (e, stack) { + Logger.exception('Error handling AdAttributionKit tap: $e', stack); + } } static Future beginView() async { From 1f2e811505cffe48741873042782920c14c9a518 Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Sun, 12 Oct 2025 14:06:51 +0200 Subject: [PATCH 05/12] AdAttributionKit: update completion handler to return true on successful attribution --- ios/Classes/AdAttributionManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift index e1499f9..7e1f6bb 100644 --- a/ios/Classes/AdAttributionManager.swift +++ b/ios/Classes/AdAttributionManager.swift @@ -55,7 +55,7 @@ final class AdAttributionManager { attributionView = nil } hostWindow = nil - completion(false) + completion(true) return } From 1bdbfdaa22d3637b55f738324cd225d851ccc6c5 Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Sun, 12 Oct 2025 16:37:41 +0200 Subject: [PATCH 06/12] AdAttributionKit: enhance handleTap method to support reengagement URLs and improve error handling --- ios/Classes/AdAttributionManager.swift | 38 ++++++++++++++++++-------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift index 7e1f6bb..3944662 100644 --- a/ios/Classes/AdAttributionManager.swift +++ b/ios/Classes/AdAttributionManager.swift @@ -78,7 +78,31 @@ final class AdAttributionManager { } func handleTap(url: String?, completion: @escaping (Any) -> Void) { - guard #available(iOS 18.0, *) else { + if let urlString = url, !urlString.isEmpty { + guard #available(iOS 18.0, *) else { + completion(FlutterError(code: "UNSUPPORTED_IOS_VERSION", message: "Handling reengagement URL requires iOS 18.0 or later", details: nil)) + return + } + guard let impression = appImpression else { + completion(FlutterError(code: "NO_IMPRESSION", message: "AppImpression not initialized", details: nil)) + return + } + guard let reengagementURL = URL(string: urlString) else { + completion(FlutterError(code: "INVALID_URL", message: "Provided URL is invalid", details: nil)) + return + } + + Task { + do { + try await impression.handleTap(reengagementURL: reengagementURL) + completion(true) + } catch { + completion(FlutterError(code: "HANDLE_TAP_FAILED", message: "Failed to handle tap with URL: \(error)", details: nil)) + } + } + } + + guard #available(iOS 17.4, *) else { completion(false) return } @@ -87,17 +111,9 @@ final class AdAttributionManager { return } - Task { [weak self] in - guard let self = self else { - completion(false) - return - } + Task { do { - if let urlString = url, let reengagementURL = URL(string: urlString) { - try await impression.handleTap(reengagementURL: reengagementURL) - } else { - try await impression.handleTap() - } + try await impression.handleTap() completion(true) } catch { completion(FlutterError(code: "HANDLE_TAP_FAILED", message: "Failed to handle tap: \(error)", details: nil)) From 89aff7156585db3a3b2c6d4e42d76de22e62284e Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Sun, 12 Oct 2025 17:59:24 +0200 Subject: [PATCH 07/12] AdAttributionKit: implement beginView and endView methods --- ios/Classes/AdAttributionManager.swift | 41 ++++++++++++++++++++++++ ios/Classes/AdAttributionPlugin.swift | 8 +++++ lib/src/services/ad_attribution_kit.dart | 14 ++++++-- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift index 3944662..b3ee1d3 100644 --- a/ios/Classes/AdAttributionManager.swift +++ b/ios/Classes/AdAttributionManager.swift @@ -100,6 +100,7 @@ final class AdAttributionManager { completion(FlutterError(code: "HANDLE_TAP_FAILED", message: "Failed to handle tap with URL: \(error)", details: nil)) } } + return } guard #available(iOS 17.4, *) else { @@ -120,6 +121,46 @@ final class AdAttributionManager { } } } + + func beginView(completion: @escaping (Any) -> Void) { + guard #available(iOS 17.4, *) else { + completion(false) + return + } + guard let impression = appImpression else { + completion(FlutterError(code: "NO_IMPRESSION", message: "AppImpression not initialized", details: nil)) + return + } + + Task { + do { + try await impression.beginView() + completion(true) + } catch { + completion(FlutterError(code: "BEGIN_VIEW_FAILED", message: "Failed to begin view: \(error)", details: nil)) + } + } + } + + func endView(completion: @escaping (Any) -> Void) { + guard #available(iOS 17.4, *) else { + completion(false) + return + } + guard let impression = appImpression else { + completion(FlutterError(code: "NO_IMPRESSION", message: "AppImpression not initialized", details: nil)) + return + } + + Task { + do { + try await impression.endView() + completion(true) + } catch { + completion(FlutterError(code: "END_VIEW_FAILED", message: "Failed to end view: \(error)", details: nil)) + } + } + } private func currentKeyWindow() -> UIWindow? { // Scan scenes by activation, prefer .foregroundActive, then .foregroundInactive diff --git a/ios/Classes/AdAttributionPlugin.swift b/ios/Classes/AdAttributionPlugin.swift index b80592d..ba73c08 100644 --- a/ios/Classes/AdAttributionPlugin.swift +++ b/ios/Classes/AdAttributionPlugin.swift @@ -46,6 +46,14 @@ public class AdAttributionPlugin: NSObject, FlutterPlugin { AdAttributionManager.shared.handleTap(url: url) { success in result(success) } + case "beginView": + AdAttributionManager.shared.beginView { success in + result(success) + } + case "endView": + AdAttributionManager.shared.endView { success in + result(success) + } default: result(FlutterMethodNotImplemented) } diff --git a/lib/src/services/ad_attribution_kit.dart b/lib/src/services/ad_attribution_kit.dart index 2d2dd28..f5516b9 100644 --- a/lib/src/services/ad_attribution_kit.dart +++ b/lib/src/services/ad_attribution_kit.dart @@ -56,13 +56,23 @@ class AdAttributionKit { static Future beginView() async { if (!Platform.isIOS) return; - throw UnimplementedError('beginView is not implemented yet.'); + try { + final result = await _channel.invokeMethod('beginView'); + Logger.debug('AdAttributionKit view began: $result'); + } catch (e, stack) { + Logger.exception('Error beginning AdAttributionKit view: $e', stack); + } } static Future endView() async { if (!Platform.isIOS) return; - throw UnimplementedError('endView is not implemented yet.'); + try { + final result = await _channel.invokeMethod('endView'); + Logger.debug('AdAttributionKit view ended: $result'); + } catch (e, stack) { + Logger.exception('Error ending AdAttributionKit view: $e', stack); + } } static Future dispose() async { From c6dbc37e9eed841cd3247a85cad06c92cd855e67 Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Sun, 12 Oct 2025 18:25:11 +0200 Subject: [PATCH 08/12] AdAttributionKit: implement dispose method and cleanup resources --- ios/Classes/AdAttributionManager.swift | 20 ++++++++++++++++++++ ios/Classes/AdAttributionPlugin.swift | 4 ++++ lib/src/services/ad_attribution_kit.dart | 7 ++++++- lib/src/widgets/ad_format.dart | 19 +++++++++++-------- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift index b3ee1d3..36c0017 100644 --- a/ios/Classes/AdAttributionManager.swift +++ b/ios/Classes/AdAttributionManager.swift @@ -161,6 +161,26 @@ final class AdAttributionManager { } } } + + func dispose(completion: @escaping (Any) -> Void) { + appImpressionBox = nil + hostWindow = nil + + let uiCleanup = { [weak self] in + guard let self = self else { return } + if #available(iOS 17.4, *) { + self.attributionView?.removeFromSuperview() + self.attributionView = nil + } + completion(true) + } + + if Thread.isMainThread { + uiCleanup() + } else { + DispatchQueue.main.async { uiCleanup() } + } + } private func currentKeyWindow() -> UIWindow? { // Scan scenes by activation, prefer .foregroundActive, then .foregroundInactive diff --git a/ios/Classes/AdAttributionPlugin.swift b/ios/Classes/AdAttributionPlugin.swift index ba73c08..573cc5e 100644 --- a/ios/Classes/AdAttributionPlugin.swift +++ b/ios/Classes/AdAttributionPlugin.swift @@ -54,6 +54,10 @@ public class AdAttributionPlugin: NSObject, FlutterPlugin { AdAttributionManager.shared.endView { success in result(success) } + case "dispose": + AdAttributionManager.shared.dispose { success in + result(success) + } default: result(FlutterMethodNotImplemented) } diff --git a/lib/src/services/ad_attribution_kit.dart b/lib/src/services/ad_attribution_kit.dart index f5516b9..ca74f38 100644 --- a/lib/src/services/ad_attribution_kit.dart +++ b/lib/src/services/ad_attribution_kit.dart @@ -78,6 +78,11 @@ class AdAttributionKit { static Future dispose() async { if (!Platform.isIOS) return; - throw UnimplementedError('dispose is not implemented yet.'); + try { + final result = await _channel.invokeMethod('dispose'); + Logger.debug('AdAttributionKit disposed: $result'); + } catch (e, stack) { + Logger.exception('Error disposing AdAttributionKit: $e', stack); + } } } diff --git a/lib/src/widgets/ad_format.dart b/lib/src/widgets/ad_format.dart index 00f3c20..906b389 100644 --- a/lib/src/widgets/ad_format.dart +++ b/lib/src/widgets/ad_format.dart @@ -197,14 +197,6 @@ class AdFormat extends HookWidget { break; case 'show-iframe': showIframe.value = true; - final tmpData = { - if (data != null) ...data, - 'jws': jsonEncode({ - 'dummy': 'data', - }) - }; - _handleAdAttributionJws(tmpData); - _setAttributionFrame(adSlotKey); break; case 'hide-iframe': showIframe.value = false; @@ -306,6 +298,12 @@ class AdFormat extends HookWidget { AdAttributionKit.setAttributionFrame(adContainer); } + Future _cleanupAdResources() async { + await Future.wait([ + AdAttributionKit.dispose(), + ]); + } + @override Widget build(BuildContext context) { final adSlotKey = useMemoized(() => GlobalKey(), const []); @@ -354,6 +352,10 @@ class AdFormat extends HookWidget { final adServerUrl = adsProviderData.adServerUrl; final otherParams = adsProviderData.otherParams; + useEffect(() { + return () => _cleanupAdResources(); + }, const []); + useEffect(() { return () => setActive(false); }, const []); @@ -433,6 +435,7 @@ class AdFormat extends HookWidget { }, [iframeLoaded.value, webViewController.value, otherParamsHash]); void resetIframe() { + _cleanupAdResources(); iframeLoaded.value = false; showIframe.value = false; height.value = 0.0; From 7fd3dcc24bc6d08ef8734225a30bf18bc7f88eb6 Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Mon, 1 Dec 2025 13:46:40 +0100 Subject: [PATCH 09/12] AdAttributionKit: enhance initialization and attribution frame handling --- ios/Classes/AdAttributionManager.swift | 2 +- lib/src/services/ad_attribution_kit.dart | 46 ++++++++++++++-------- lib/src/widgets/ad_format.dart | 50 ++++++++++++++++++------ 3 files changed, 68 insertions(+), 30 deletions(-) diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift index 36c0017..083265c 100644 --- a/ios/Classes/AdAttributionManager.swift +++ b/ios/Classes/AdAttributionManager.swift @@ -55,7 +55,7 @@ final class AdAttributionManager { attributionView = nil } hostWindow = nil - completion(true) + completion(false) return } diff --git a/lib/src/services/ad_attribution_kit.dart b/lib/src/services/ad_attribution_kit.dart index ca74f38..f4b6a86 100644 --- a/lib/src/services/ad_attribution_kit.dart +++ b/lib/src/services/ad_attribution_kit.dart @@ -7,27 +7,29 @@ import 'package:kontext_flutter_sdk/src/services/logger.dart' show Logger; class AdAttributionKit { AdAttributionKit._(); - static AdAttributionKit? _instance; - - factory AdAttributionKit() { - return _instance ??= AdAttributionKit._(); - } - static const MethodChannel _channel = MethodChannel('kontext_flutter_sdk/ad_attribution'); - static Future initImpression(String jws) async { - if (!Platform.isIOS) return; + static bool _initialized = false; + static bool _attributionFrameSet = false; + + static Future initImpression(String jws) async { + if (!Platform.isIOS) return false; try { final result = await _channel.invokeMethod('initImpression', {'jws': jws}); - Logger.debug('AdAttributionKit impression initialized: $result'); + final success = result == true; + _initialized = success; + Logger.debug('AdAttributionKit impression initialized: $success'); + return success; } catch (e, stack) { + _initialized = false; Logger.exception('Error initializing AdAttributionKit impression: $e', stack); + return false; } } - static Future setAttributionFrame(Rect rect) async { - if (!Platform.isIOS) return; + static Future setAttributionFrame(Rect rect) async { + if (!Platform.isIOS || !_initialized) return false; try { final result = await _channel.invokeMethod('setAttributionFrame', { @@ -36,14 +38,19 @@ class AdAttributionKit { 'width': rect.width, 'height': rect.height, }); - Logger.debug('AdAttributionKit attribution frame set: $result'); + final success = result == true; + _attributionFrameSet = success; + Logger.debug('AdAttributionKit attribution frame set: $success'); + return success; } catch (e, stack) { + _attributionFrameSet = false; Logger.exception('Error setting AdAttributionKit attribution frame: $e', stack); + return false; } } static Future handleTap(Uri? uri) async { - if (!Platform.isIOS) return; + if (!Platform.isIOS || !_initialized || !_attributionFrameSet) return; try { final result = await _channel.invokeMethod('handleTap', {'url': uri?.toString()}); @@ -54,7 +61,7 @@ class AdAttributionKit { } static Future beginView() async { - if (!Platform.isIOS) return; + if (!Platform.isIOS || !_initialized || !_attributionFrameSet) return; try { final result = await _channel.invokeMethod('beginView'); @@ -65,7 +72,7 @@ class AdAttributionKit { } static Future endView() async { - if (!Platform.isIOS) return; + if (!Platform.isIOS || !_initialized || !_attributionFrameSet) return; try { final result = await _channel.invokeMethod('endView'); @@ -76,13 +83,20 @@ class AdAttributionKit { } static Future dispose() async { - if (!Platform.isIOS) return; + if (!Platform.isIOS || !_initialized) { + _initialized = false; + _attributionFrameSet = false; + return; + } try { final result = await _channel.invokeMethod('dispose'); Logger.debug('AdAttributionKit disposed: $result'); } catch (e, stack) { Logger.exception('Error disposing AdAttributionKit: $e', stack); + } finally { + _initialized = false; + _attributionFrameSet = false; } } } diff --git a/lib/src/widgets/ad_format.dart b/lib/src/widgets/ad_format.dart index 906b389..ac25c9b 100644 --- a/lib/src/widgets/ad_format.dart +++ b/lib/src/widgets/ad_format.dart @@ -149,7 +149,12 @@ class AdFormat extends HookWidget { } } - void _handleEventIframe({required String adServerUrl, OnEventCallback? onEvent, Json? data}) { + Future _handleEventIframe({ + required GlobalKey adSlotKey, + required String adServerUrl, + OnEventCallback? onEvent, + Json? data, + }) async { if (data == null) { return; } @@ -169,6 +174,22 @@ class AdFormat extends HookWidget { }; final adEvent = AdEvent.fromJson(updatedData); + switch (adEvent.type) { + case AdEventType.adRenderCompleted: + final frameSet = await _setAttributionFrame(adSlotKey); + if (frameSet) { + await AdAttributionKit.beginView(); + } + break; + case AdEventType.adClicked: + AdAttributionKit.handleTap(uri); + if (uri != null) { + uri.openInAppBrowser(); + } + break; + default: + } + onEvent?.call(adEvent); } catch (e, stack) { Logger.exception(e, stack); @@ -194,6 +215,7 @@ class AdFormat extends HookWidget { switch (messageType) { case 'init-iframe': iframeLoaded.value = true; + _handleAdAttributionJws(data); break; case 'show-iframe': showIframe.value = true; @@ -224,6 +246,7 @@ class AdFormat extends HookWidget { _handleOpenComponentIframe( context, + adSlotKey: adSlotKey, adServerUrl: adServerUrl, inlineUri: inlineUri, bidId: bidId, @@ -232,9 +255,6 @@ class AdFormat extends HookWidget { onEvent: adsProviderData.onEvent, ); break; - case "ad-attribution-kit": - _handleAdAttributionJws(data); - break; case 'error-iframe': resetIframe(); break; @@ -244,6 +264,7 @@ class AdFormat extends HookWidget { void _handleOpenComponentIframe( BuildContext context, { + required GlobalKey adSlotKey, required String adServerUrl, required Uri inlineUri, required String bidId, @@ -273,6 +294,7 @@ class AdFormat extends HookWidget { data: data, ), onEventIframe: (data) => _handleEventIframe( + adSlotKey: adSlotKey, adServerUrl: adServerUrl, onEvent: onEvent, data: data, @@ -282,26 +304,27 @@ class AdFormat extends HookWidget { } } - void _handleAdAttributionJws(Json? data) { - final jws = data?['jws']; + Future _handleAdAttributionJws(Json? data) async { + final jws = data?['payload']['adAttributionKit']['jws']; + if (jws == null) return; + if (jws is! String || jws.isEmpty) { Logger.error('Ad attribution JWS is missing or invalid. Data: $data'); return; } - AdAttributionKit.initImpression(jws); + await AdAttributionKit.initImpression(jws); } - void _setAttributionFrame(GlobalKey key) { + Future _setAttributionFrame(GlobalKey key) async { final adContainer = _slotRectInWindow(key); - if (adContainer == null) return; + if (adContainer == null) return false; - AdAttributionKit.setAttributionFrame(adContainer); + return AdAttributionKit.setAttributionFrame(adContainer); } Future _cleanupAdResources() async { - await Future.wait([ - AdAttributionKit.dispose(), - ]); + await AdAttributionKit.endView(); + await AdAttributionKit.dispose(); } @override @@ -472,6 +495,7 @@ class AdFormat extends HookWidget { uri: inlineUri, allowedOrigins: [adServerUrl], onEventIframe: (data) => _handleEventIframe( + adSlotKey: adSlotKey, adServerUrl: adServerUrl, onEvent: adsProviderData.onEvent, data: data, From cf4272cabb333dfde963a5d5b585d31cdef443f9 Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Mon, 13 Oct 2025 22:46:00 +0200 Subject: [PATCH 10/12] SKAdNetwork: add SKAdNetwork impression handling methods --- ios/Classes/AdAttributionManager.swift | 53 +++++++++++++++++++++++++- ios/Classes/AdAttributionPlugin.swift | 8 ++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift index 083265c..cb8ced5 100644 --- a/ios/Classes/AdAttributionManager.swift +++ b/ios/Classes/AdAttributionManager.swift @@ -2,6 +2,7 @@ import Flutter import Foundation import UIKit import AdAttributionKit +import StoreKit final class AdAttributionManager { static let shared = AdAttributionManager() @@ -9,6 +10,7 @@ final class AdAttributionManager { private var appImpressionBox: Any? private var attributionViewBox: Any? private weak var hostWindow: UIWindow? + private var skImpressionBox: Any? @available(iOS 17.4, *) private var appImpression: AppImpression? { @@ -21,7 +23,13 @@ final class AdAttributionManager { get { attributionViewBox as? UIEventAttributionView } set { attributionViewBox = newValue } } - + + @available(iOS 14.5, *) + private var skImpression: SKAdImpression? { + get { skImpressionBox as? SKAdImpression } + set { skImpressionBox = newValue } + } + func initImpression(jws: String, completion: @escaping (Any) -> Void) { guard #available(iOS 17.4, *) else { completion(false) @@ -162,6 +170,49 @@ final class AdAttributionManager { } } + + + func skanStartImpression(completion: @escaping (Any) -> Void) { + guard #available(iOS 14.5, *) else { + completion(false) + return + } + + guard let impression = skImpression else { + completion(FlutterError(code: "NO_IMPRESSION", message: "SKAdImpression not initialized", details: nil)) + return + } + + SKAdNetwork.startImpression(impression) { error in + if let error = error { + completion(FlutterError(code: "SKAN_START_IMPRESSION_FAILED", message: "Failed to start SKAdImpression: \(error)", details: nil)) + } else { + completion(true) + } + } + } + + func skanEndImpression(completion: @escaping (Any) -> Void) { + guard #available(iOS 14.5, *) else { + completion(false) + return + } + + guard let impression = skImpression else { + completion(FlutterError(code: "NO_IMPRESSION", message: "SKAdImpression not initialized", details: nil)) + return + } + + SKAdNetwork.endImpression(impression) { error in + if let error = error { + completion(FlutterError(code: "SKAN_END_IMPRESSION_FAILED", message: "Failed to end SKAdImpression: \(error)", details: nil)) + } else { + completion(true) + } + } + } + + func dispose(completion: @escaping (Any) -> Void) { appImpressionBox = nil hostWindow = nil diff --git a/ios/Classes/AdAttributionPlugin.swift b/ios/Classes/AdAttributionPlugin.swift index 573cc5e..dac927b 100644 --- a/ios/Classes/AdAttributionPlugin.swift +++ b/ios/Classes/AdAttributionPlugin.swift @@ -54,6 +54,14 @@ public class AdAttributionPlugin: NSObject, FlutterPlugin { AdAttributionManager.shared.endView { success in result(success) } + case "skanStartImpression": + AdAttributionManager.shared.skanStartImpression { success in + result(success) + } + case "skanEndImpression": + AdAttributionManager.shared.skanEndImpression { success in + result(success) + } case "dispose": AdAttributionManager.shared.dispose { success in result(success) From 34045ce01264d6b5809bd1920dad3a9aaab23e54 Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Mon, 13 Oct 2025 23:22:33 +0200 Subject: [PATCH 11/12] SKAdNetwork: implement skanInitImpression method and update impression handling --- ios/Classes/AdAttributionManager.swift | 92 +++++++++++++++++++++----- ios/Classes/AdAttributionPlugin.swift | 8 +++ 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift index cb8ced5..3837294 100644 --- a/ios/Classes/AdAttributionManager.swift +++ b/ios/Classes/AdAttributionManager.swift @@ -23,13 +23,13 @@ final class AdAttributionManager { get { attributionViewBox as? UIEventAttributionView } set { attributionViewBox = newValue } } - + @available(iOS 14.5, *) private var skImpression: SKAdImpression? { get { skImpressionBox as? SKAdImpression } set { skImpressionBox = newValue } } - + func initImpression(jws: String, completion: @escaping (Any) -> Void) { guard #available(iOS 17.4, *) else { completion(false) @@ -129,7 +129,7 @@ final class AdAttributionManager { } } } - + func beginView(completion: @escaping (Any) -> Void) { guard #available(iOS 17.4, *) else { completion(false) @@ -139,7 +139,7 @@ final class AdAttributionManager { completion(FlutterError(code: "NO_IMPRESSION", message: "AppImpression not initialized", details: nil)) return } - + Task { do { try await impression.beginView() @@ -149,7 +149,7 @@ final class AdAttributionManager { } } } - + func endView(completion: @escaping (Any) -> Void) { guard #available(iOS 17.4, *) else { completion(false) @@ -159,7 +159,7 @@ final class AdAttributionManager { completion(FlutterError(code: "NO_IMPRESSION", message: "AppImpression not initialized", details: nil)) return } - + Task { do { try await impression.endView() @@ -170,19 +170,80 @@ final class AdAttributionManager { } } - - + /// Required keys: + /// - advertisedAppStoreItemIdentifier: Int / NSNumber + /// - adNetworkIdentifier: String + /// - adCampaignIdentifier: Int / NSNumber (0..99) + /// - adImpressionIdentifier: String (UUID recommended) + /// - timestamp: Double/Int/NSNumber (unix seconds) + /// - signature: String + /// - version: String (e.g. "4.0", "3.0", "2.2") + /// Optional: + /// - sourceAppStoreItemIdentifier: Int/NSNumber (0 allowed if publisher app has no App Store ID - for testing) + func skanInitImpression(params: [String: Any], completion: @escaping (Any) -> Void) { + guard #available(iOS 16.0, *) else { + completion(false) + return + } + + func num(_ any: Any?) -> NSNumber? { + if let n = any as? NSNumber { return n } + if let i = any as? Int { return NSNumber(value: i) } + if let d = any as? Double { return NSNumber(value: d) } + if let s = any as? String, let i = Int(s) { return NSNumber(value: i) } + return nil + } + + let advertised = num(params["advertisedAppStoreItemIdentifier"]) + let networkId = params["adNetworkIdentifier"] as? String + let campaign = num(params["adCampaignIdentifier"]) + let impId = params["adImpressionIdentifier"] as? String + let timestamp = num(params["timestamp"]) + let signature = params["signature"] as? String + let version = params["version"] as? String + + let source = num(params["sourceAppStoreItemIdentifier"]) ?? NSNumber(value: 0) + + var missing: [String] = [] + if advertised == nil { missing.append("advertisedAppStoreItemIdentifier") } + if networkId?.isEmpty != false { missing.append("adNetworkIdentifier") } + if campaign == nil { missing.append("adCampaignIdentifier") } + if impId?.isEmpty != false { missing.append("adImpressionIdentifier") } + if timestamp == nil { missing.append("timestamp") } + if signature?.isEmpty != false { missing.append("signature") } + if version?.isEmpty != false { missing.append("version") } + + guard missing.isEmpty else { + completion(FlutterError(code: "MISSING_ARGUMENTS", message: "Missing required arguments: \(missing.joined(separator: ", "))", details: ["provided_keys": Array(params.keys)])) + return + } + + let impression = SKAdImpression( + sourceAppStoreItemIdentifier: source, + advertisedAppStoreItemIdentifier: advertised!, + adNetworkIdentifier: networkId!, + adCampaignIdentifier: campaign!, + adImpressionIdentifier: impId!, + timestamp: timestamp!, + signature: signature!, + version: version! + ) + + self.skImpression = impression + completion(true) + } + func skanStartImpression(completion: @escaping (Any) -> Void) { guard #available(iOS 14.5, *) else { completion(false) return } - + guard let impression = skImpression else { completion(FlutterError(code: "NO_IMPRESSION", message: "SKAdImpression not initialized", details: nil)) return } - + SKAdNetwork.startImpression(impression) { error in if let error = error { completion(FlutterError(code: "SKAN_START_IMPRESSION_FAILED", message: "Failed to start SKAdImpression: \(error)", details: nil)) @@ -191,18 +252,18 @@ final class AdAttributionManager { } } } - + func skanEndImpression(completion: @escaping (Any) -> Void) { guard #available(iOS 14.5, *) else { completion(false) return } - + guard let impression = skImpression else { completion(FlutterError(code: "NO_IMPRESSION", message: "SKAdImpression not initialized", details: nil)) return } - + SKAdNetwork.endImpression(impression) { error in if let error = error { completion(FlutterError(code: "SKAN_END_IMPRESSION_FAILED", message: "Failed to end SKAdImpression: \(error)", details: nil)) @@ -212,11 +273,10 @@ final class AdAttributionManager { } } - func dispose(completion: @escaping (Any) -> Void) { appImpressionBox = nil hostWindow = nil - + let uiCleanup = { [weak self] in guard let self = self else { return } if #available(iOS 17.4, *) { @@ -225,7 +285,7 @@ final class AdAttributionManager { } completion(true) } - + if Thread.isMainThread { uiCleanup() } else { diff --git a/ios/Classes/AdAttributionPlugin.swift b/ios/Classes/AdAttributionPlugin.swift index dac927b..6e80d10 100644 --- a/ios/Classes/AdAttributionPlugin.swift +++ b/ios/Classes/AdAttributionPlugin.swift @@ -54,6 +54,14 @@ public class AdAttributionPlugin: NSObject, FlutterPlugin { AdAttributionManager.shared.endView { success in result(success) } + case "skanInitImpression": + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "expected dictionary", details: nil)) + return + } + AdAttributionManager.shared.skanInitImpression(params: args) { success in + result(success) + } case "skanStartImpression": AdAttributionManager.shared.skanStartImpression { success in result(success) From 59f417de4cf7e0e7fd6970e7d8f5afcc9068ffaf Mon Sep 17 00:00:00 2001 From: Duc Phi Viet Date: Tue, 14 Oct 2025 23:20:55 +0200 Subject: [PATCH 12/12] AdAttributionManager: enhance SKAdImpression initialization for iOS version compatibility --- ios/Classes/AdAttributionManager.swift | 49 +++++++++++++++++--------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift index 3837294..7ec833e 100644 --- a/ios/Classes/AdAttributionManager.swift +++ b/ios/Classes/AdAttributionManager.swift @@ -169,15 +169,15 @@ final class AdAttributionManager { } } } - + /// Required keys: /// - advertisedAppStoreItemIdentifier: Int / NSNumber /// - adNetworkIdentifier: String /// - adCampaignIdentifier: Int / NSNumber (0..99) - /// - adImpressionIdentifier: String (UUID recommended) + /// - adImpressionIdentifier: String /// - timestamp: Double/Int/NSNumber (unix seconds) /// - signature: String - /// - version: String (e.g. "4.0", "3.0", "2.2") + /// - version: String /// Optional: /// - sourceAppStoreItemIdentifier: Int/NSNumber (0 allowed if publisher app has no App Store ID - for testing) func skanInitImpression(params: [String: Any], completion: @escaping (Any) -> Void) { @@ -218,19 +218,34 @@ final class AdAttributionManager { return } - let impression = SKAdImpression( - sourceAppStoreItemIdentifier: source, - advertisedAppStoreItemIdentifier: advertised!, - adNetworkIdentifier: networkId!, - adCampaignIdentifier: campaign!, - adImpressionIdentifier: impId!, - timestamp: timestamp!, - signature: signature!, - version: version! - ) - - self.skImpression = impression - completion(true) + if #available(iOS 16.0, *) { + let imp = SKAdImpression( + sourceAppStoreItemIdentifier: source, + advertisedAppStoreItemIdentifier: advertised!, + adNetworkIdentifier: networkId!, + adCampaignIdentifier: campaign!, + adImpressionIdentifier: impId!, + timestamp: timestamp!, + signature: signature!, + version: version! + ) + self.skImpression = imp + completion(true) + } else if #available(iOS 14.5, *) { + let imp = SKAdImpression() + imp.sourceAppStoreItemIdentifier = source + imp.advertisedAppStoreItemIdentifier = advertised! + imp.adNetworkIdentifier = networkId! + imp.adCampaignIdentifier = campaign! + imp.adImpressionIdentifier = impId! + imp.timestamp = timestamp! + imp.signature = signature! + imp.version = version! + self.skImpression = imp + completion(true) + } else { + completion(false) + } } func skanStartImpression(completion: @escaping (Any) -> Void) { @@ -272,7 +287,7 @@ final class AdAttributionManager { } } } - + func dispose(completion: @escaping (Any) -> Void) { appImpressionBox = nil hostWindow = nil