diff --git a/ios/Classes/AdAttributionManager.swift b/ios/Classes/AdAttributionManager.swift new file mode 100644 index 0000000..7ec833e --- /dev/null +++ b/ios/Classes/AdAttributionManager.swift @@ -0,0 +1,351 @@ +import Flutter +import Foundation +import UIKit +import AdAttributionKit +import StoreKit + +final class AdAttributionManager { + static let shared = 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? { + get { appImpressionBox as? AppImpression } + set { appImpressionBox = newValue } + } + + @available(iOS 17.4, *) + private var attributionView: UIEventAttributionView? { + 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) + 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)) + } + } + } + + /// 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) + } + + func handleTap(url: String?, completion: @escaping (Any) -> Void) { + 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)) + } + } + return + } + + 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.handleTap() + completion(true) + } catch { + completion(FlutterError(code: "HANDLE_TAP_FAILED", message: "Failed to handle tap: \(error)", details: nil)) + } + } + } + + 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)) + } + } + } + + /// Required keys: + /// - advertisedAppStoreItemIdentifier: Int / NSNumber + /// - adNetworkIdentifier: String + /// - adCampaignIdentifier: Int / NSNumber (0..99) + /// - adImpressionIdentifier: String + /// - timestamp: Double/Int/NSNumber (unix seconds) + /// - signature: String + /// - 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) { + 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 + } + + 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) { + 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 + + 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 + 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 new file mode 100644 index 0000000..6e80d10 --- /dev/null +++ b/ios/Classes/AdAttributionPlugin.swift @@ -0,0 +1,81 @@ +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) + } + 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) + } + } + case "handleTap": + let url = (call.arguments as? [String: Any])?["url"] as? String + 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) + } + 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) + } + case "skanEndImpression": + AdAttributionManager.shared.skanEndImpression { success in + result(success) + } + case "dispose": + AdAttributionManager.shared.dispose { success in + result(success) + } + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/ios/Classes/KontextSdkPlugin.swift b/ios/Classes/KontextSdkPlugin.swift index 44d1f8c..f895d3e 100644 --- a/ios/Classes/KontextSdkPlugin.swift +++ b/ios/Classes/KontextSdkPlugin.swift @@ -11,5 +11,6 @@ public class KontextSdkPlugin: NSObject, FlutterPlugin { OperationSystemPlugin.register(with: registrar) TrackingAuthorizationPlugin.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..f4b6a86 --- /dev/null +++ b/lib/src/services/ad_attribution_kit.dart @@ -0,0 +1,102 @@ +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 const MethodChannel _channel = MethodChannel('kontext_flutter_sdk/ad_attribution'); + + 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}); + 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 || !_initialized) return false; + + try { + final result = await _channel.invokeMethod('setAttributionFrame', { + 'x': rect.left, + 'y': rect.top, + 'width': rect.width, + 'height': rect.height, + }); + 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 || !_initialized || !_attributionFrameSet) return; + + 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 { + if (!Platform.isIOS || !_initialized || !_attributionFrameSet) return; + + 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 || !_initialized || !_attributionFrameSet) return; + + 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 { + 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 aa0ec0a..ac25c9b 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'; @@ -67,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, @@ -76,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; @@ -100,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, }, }; @@ -148,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; } @@ -168,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); @@ -179,6 +201,7 @@ class AdFormat extends HookWidget { BuildContext context, { required String messageType, Json? data, + required GlobalKey adSlotKey, required bool Function() isDisposed, required String adServerUrl, required Uri inlineUri, @@ -192,6 +215,7 @@ class AdFormat extends HookWidget { switch (messageType) { case 'init-iframe': iframeLoaded.value = true; + _handleAdAttributionJws(data); break; case 'show-iframe': showIframe.value = true; @@ -222,6 +246,7 @@ class AdFormat extends HookWidget { _handleOpenComponentIframe( context, + adSlotKey: adSlotKey, adServerUrl: adServerUrl, inlineUri: inlineUri, bidId: bidId, @@ -239,6 +264,7 @@ class AdFormat extends HookWidget { void _handleOpenComponentIframe( BuildContext context, { + required GlobalKey adSlotKey, required String adServerUrl, required Uri inlineUri, required String bidId, @@ -268,6 +294,7 @@ class AdFormat extends HookWidget { data: data, ), onEventIframe: (data) => _handleEventIframe( + adSlotKey: adSlotKey, adServerUrl: adServerUrl, onEvent: onEvent, data: data, @@ -277,9 +304,32 @@ class AdFormat extends HookWidget { } } + 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; + } + await AdAttributionKit.initImpression(jws); + } + + Future _setAttributionFrame(GlobalKey key) async { + final adContainer = _slotRectInWindow(key); + if (adContainer == null) return false; + + return AdAttributionKit.setAttributionFrame(adContainer); + } + + Future _cleanupAdResources() async { + await AdAttributionKit.endView(); + await AdAttributionKit.dispose(); + } + @override Widget build(BuildContext context) { - final slotKey = useMemoized(() => GlobalKey(), const []); + final adSlotKey = useMemoized(() => GlobalKey(), const []); final ticker = useRef(null); final delayedTicker = useRef(null); @@ -325,6 +375,10 @@ class AdFormat extends HookWidget { final adServerUrl = adsProviderData.adServerUrl; final otherParams = adsProviderData.otherParams; + useEffect(() { + return () => _cleanupAdResources(); + }, const []); + useEffect(() { return () => setActive(false); }, const []); @@ -356,7 +410,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, @@ -404,6 +458,7 @@ class AdFormat extends HookWidget { }, [iframeLoaded.value, webViewController.value, otherParamsHash]); void resetIframe() { + _cleanupAdResources(); iframeLoaded.value = false; showIframe.value = false; height.value = 0.0; @@ -431,7 +486,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, @@ -440,6 +495,7 @@ class AdFormat extends HookWidget { uri: inlineUri, allowedOrigins: [adServerUrl], onEventIframe: (data) => _handleEventIframe( + adSlotKey: adSlotKey, adServerUrl: adServerUrl, onEvent: adsProviderData.onEvent, data: data, @@ -451,6 +507,7 @@ class AdFormat extends HookWidget { messageType: messageType, isDisposed: () => disposed.value, data: data, + adSlotKey: adSlotKey, adServerUrl: adServerUrl, inlineUri: inlineUri!, bidId: bidId,