diff --git a/CHANGELOG.md b/CHANGELOG.md index e7b89d62..ad2b9319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2.2.4 +* Activate SKAdNetwork (SKAN) install attribution for directly-served campaigns (iOS). The SDK now reports a version the ad server accepts for SKAN, receives signed `skan` payloads, and registers view-through (fidelity-0) impressions via `SKAdImpression`. +* Clicks on bids with fidelity-1 SKAN data now open the App Store product sheet (`SKStoreProductViewController`) with full attribution parameters, falling back to the browser if it fails to load. +* Support the `impressionTrigger` bid field (`immediate` — fire on ad done, `component` — fire on component open; defaults to `immediate`). +* Pass the complete signed SKAN payload to the native SKOverlay/SKStoreProduct layers (previously only a bare `appStoreId`, which could not carry attribution). +* Remove the message-driven `open-/close-skstoreproduct-iframe` component; StoreKit presentation is now SDK-driven from the click. +* SKOverlay now requires iOS 16.0+ and fidelity-1 SKAN data — attribution is mandatory, so on iOS 14–15 `present` returns `UNSUPPORTED_IOS` instead of showing an unattributable overlay (previous behavior). +* Harden the native `SKAdImpression` construction: resolve attribution fields upfront (top-level, else fidelity-0 entry), validate all required fields, and fail cleanly with `MISSING_ARGUMENTS` instead of building an empty impression. +* Declare the `StoreKit` framework in the podspec. +* Document the required host-app `SKAdNetworkItems` entry (`mp7rpxwdrx.skadnetwork`) in the README. + ## 2.2.1 * Add `revenue` to `AdEvent.adViewed` events. diff --git a/README.md b/README.md index 0041fe1e..2f92dc38 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,25 @@ A lightweight Flutter SDK for integrating Kontext's AI-powered ads into your iOS and Android apps. 📚 Full documentation: [Kontext Flutter SDK](https://docs.kontext.so/sdk/flutter) + +## iOS setup: SKAdNetwork attribution (required) + +For install attribution (SKAdNetwork) to work, your **host app** must list Kontext's ad network +identifier in its `Info.plist`. Without this entry, ads still serve and render normally, but +Apple will not attribute installs — there is no error or warning; attribution silently never fires. + +Add to your app's `ios/Runner/Info.plist` (or your app target's `Info.plist`): + +```xml +SKAdNetworkItems + + + SKAdNetworkIdentifier + mp7rpxwdrx.skadnetwork + + +``` + +If your app already has an `SKAdNetworkItems` array (most apps with ads do), just append the +`mp7rpxwdrx.skadnetwork` entry to it. See the [example app's Info.plist](example/ios/Runner/Info.plist) +for a working reference. Shipping this change requires a regular App Store release of your app. diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 11921247..1ec1c68b 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -47,5 +47,12 @@ UIApplicationSupportsIndirectInputEvents + SKAdNetworkItems + + + SKAdNetworkIdentifier + mp7rpxwdrx.skadnetwork + + diff --git a/example/lib/main.dart b/example/lib/main.dart index b95b03c4..35ccf455 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -116,6 +116,20 @@ class _HomePageState extends State { enabledPlacementCodes: const [kPlacementCode], otherParams: {'theme': theme}, logLevel: LogLevel.info, + onEvent: (event) { + debugPrint( + '📢 [KONTEXT] ${event.type.value}' + '${event.code != null ? ' code=${event.code}' : ''}' + '${event.format != null ? ' format=${event.format}' : ''}' + '${event.messageId != null ? ' messageId=${event.messageId}' : ''}' + '${event.id != null ? ' id=${event.id}' : ''}' + '${event.revenue != null ? ' revenue=${event.revenue}' : ''}' + '${event.skipCode != null ? ' skipCode=${event.skipCode}' : ''}' + '${event.url != null ? ' url=${event.url}' : ''}' + '${event.errCode != null ? ' errCode=${event.errCode}' : ''}' + '${event.message != null ? ' message=${event.message}' : ''}', + ); + }, child: Column( children: [ Expanded( diff --git a/ios/Classes/SKAdNetworkManager.swift b/ios/Classes/SKAdNetworkManager.swift index 6b8009e1..48269ea9 100644 --- a/ios/Classes/SKAdNetworkManager.swift +++ b/ios/Classes/SKAdNetworkManager.swift @@ -19,14 +19,15 @@ final class SKAdNetworkManager { /// - version: String /// - network: String (adNetworkIdentifier) /// - itunesItem: String/Int (advertisedAppStoreItemIdentifier) - /// - sourceApp: String/Int (sourceAppStoreItemIdentifier, 0 if no App Store ID) + /// Attribution (resolved from the top-level keys, else the fidelity-0 entry): + /// - nonce: String (adImpressionIdentifier) + /// - timestamp: String/Int + /// - signature: String /// Optional keys: + /// - sourceApp: String/Int (sourceAppStoreItemIdentifier; defaults to 0 — "no App Store ID known") + /// - campaign: String/Int (adCampaignIdentifier; defaults to 0) /// - sourceIdentifier: String/Int (SKAdNetwork 4.0, iOS 16.1+) - /// - campaign: String/Int (adCampaignIdentifier) - /// - fidelities: Array (iOS 16.1+; each entry may contain nonce, timestamp, signature) - /// - nonce: String (adImpressionIdentifier; required if no fidelities) - /// - timestamp: String/Int (required if no fidelities) - /// - signature: String (required if no fidelities) + /// - fidelities: Array (fidelity-0 entry fills missing top-level nonce/timestamp/signature) func initImpression(params: [String: Any], completion: @escaping (Any) -> Void) { guard #available(iOS 14.5, *) else { completeOnMain(completion, false) @@ -44,7 +45,7 @@ final class SKAdNetworkManager { return nil } - // Required + // Identity let version = params["version"] as? String let networkId = params["network"] as? String let itunesItem = num(params["itunesItem"]) @@ -53,17 +54,13 @@ final class SKAdNetworkManager { // Optional let campaign = num(params["campaign"]) let sourceIdentifier = num(params["sourceIdentifier"]) - let nonce = params["nonce"] as? String - let timestamp = num(params["timestamp"]) - let signature = params["signature"] as? String - let fidelities = params["fidelities"] as? [[String: Any]] - let hasFidelities: Bool = { - if #available(iOS 16.1, *) { - return !(fidelities?.isEmpty ?? true) - } - return false - }() + // Attribution: prefer top-level fields, fall back to the fidelity-0 entry. + // (No fallback to fidelity-1 — those values are signed with a different formula.) + let f0 = Self.fidelity0Values(from: params) + let nonce = (params["nonce"] as? String) ?? f0?.nonce + let timestamp = num(params["timestamp"]) ?? f0?.timestamp + let signature = (params["signature"] as? String) ?? f0?.signature // Validate that required strings are non-empty after trimming whitespace func isBlank(_ s: String?) -> Bool { @@ -74,25 +71,21 @@ final class SKAdNetworkManager { if isBlank(version) { missing.append("version") } if isBlank(networkId) { missing.append("network") } if itunesItem == nil { missing.append("itunesItem") } - if !hasFidelities { - if isBlank(nonce) { missing.append("nonce") } - if timestamp == nil { missing.append("timestamp") } - if isBlank(signature) { missing.append("signature") } - } - - // When fidelities were provided but ignored due to OS version, - // include a clear hint in the error so the caller understands why the - // top-level nonce/timestamp/signature are still being required. - guard missing.isEmpty else { - let hint: String? = (fidelities != nil && !hasFidelities) - ? "Note: fidelities array was provided but is only supported on iOS 16.1+. " + - "Top-level nonce/timestamp/signature are required on this OS version." - : nil - + if isBlank(nonce) { missing.append("nonce") } + if timestamp == nil { missing.append("timestamp") } + if isBlank(signature) { missing.append("signature") } + + guard missing.isEmpty, + let version = version, + let networkId = networkId, + let itunesItem = itunesItem, + let nonce = nonce, + let timestamp = timestamp, + let signature = signature + else { completeOnMain(completion, FlutterError( code: "MISSING_ARGUMENTS", - message: "Missing required arguments: \(missing.joined(separator: ", "))" + - (hint.map { " \($0)" } ?? ""), + message: "Missing required arguments: \(missing.joined(separator: ", "))", details: ["provided_keys": Array(params.keys)] )) return @@ -101,49 +94,35 @@ final class SKAdNetworkManager { let previousImpression = isStarted ? skImpression : nil isStarted = false - // Collapsed the iOS 16.1 and iOS 16.0 branches into one, since they - // called the identical memberwise initializer. The 4.0-specific extras - // (sourceIdentifier, fidelities) are applied conditionally inside the same branch, - // making the version boundaries explicit and removing the duplicated init call. + // The 16.0 memberwise initializer and the pre-16.0 property setters build + // the identical impression; sourceIdentifier (SKAN 4.0) is applied only on 16.1+. if #available(iOS 16.0, *) { let imp = SKAdImpression( sourceAppStoreItemIdentifier: sourceApp, - advertisedAppStoreItemIdentifier: itunesItem!, - adNetworkIdentifier: networkId!, - // Comment clarifying that on SKAN 4.0 this field is vestigial, - // sourceIdentifier replaces it. We still populate it for API completeness - // and backwards compatibility with older postback versions. + advertisedAppStoreItemIdentifier: itunesItem, + adNetworkIdentifier: networkId, + // Vestigial on SKAN 4.0 (sourceIdentifier replaces it); still populated + // for API completeness and older postback versions. adCampaignIdentifier: campaign ?? NSNumber(value: 0), - adImpressionIdentifier: nonce ?? "", - timestamp: timestamp ?? NSNumber(value: 0), - signature: signature ?? "", - version: version! + adImpressionIdentifier: nonce, + timestamp: timestamp, + signature: signature, + version: version ) - - if #available(iOS 16.1, *) { - // SKAN 4.0: hierarchical source identifier replaces adCampaignIdentifier - if let sourceIdentifier = sourceIdentifier { - imp.sourceIdentifier = sourceIdentifier - } - // SKAN 2.2 fidelity-type: 0 = view-through, 1 = StoreKit-rendered - if hasFidelities, let fidelities = fidelities { - parseFidelities(fidelities, into: imp) - } + if #available(iOS 16.1, *), let sourceIdentifier = sourceIdentifier { + imp.sourceIdentifier = sourceIdentifier } - skImpression = imp - } else { - // iOS 14.5–15.x: memberwise initializer not available, use property-based init let imp = SKAdImpression() imp.sourceAppStoreItemIdentifier = sourceApp - imp.advertisedAppStoreItemIdentifier = itunesItem! - imp.adNetworkIdentifier = networkId! + imp.advertisedAppStoreItemIdentifier = itunesItem + imp.adNetworkIdentifier = networkId imp.adCampaignIdentifier = campaign ?? NSNumber(value: 0) - imp.adImpressionIdentifier = nonce ?? "" - imp.timestamp = timestamp ?? NSNumber(value: 0) - imp.signature = signature ?? "" - imp.version = version! + imp.adImpressionIdentifier = nonce + imp.timestamp = timestamp + imp.signature = signature + imp.version = version skImpression = imp } @@ -245,22 +224,22 @@ final class SKAdNetworkManager { // MARK: - Private - /// Fills nonce/timestamp/signature on the impression from fidelity entries, - /// only if those fields weren't already set at the top level. - @available(iOS 16.1, *) - private func parseFidelities(_ fidelities: [[String: Any]], into imp: SKAdImpression) { - for f in fidelities { - if imp.adImpressionIdentifier.isEmpty, let nonce = f["nonce"] as? String { - imp.adImpressionIdentifier = nonce - } - if imp.timestamp == NSNumber(value: 0) { - if let n = f["timestamp"] as? NSNumber { imp.timestamp = n } - else if let s = f["timestamp"] as? String, let i = Int(s) { imp.timestamp = NSNumber(value: i) } - } - if imp.signature.isEmpty, let sig = f["signature"] as? String { - imp.signature = sig - } - } + /// Resolves nonce/timestamp/signature from the fidelity-0 (view-through) entry. + /// Returns nil if there is no valid fidelity-0 entry — no fallback to fidelity-1, + /// whose values are signed with a different formula. + private static func fidelity0Values(from params: [String: Any]) -> (nonce: String, timestamp: NSNumber, signature: String)? { + guard let fidelities = params["fidelities"] as? [[String: Any]], + let f0 = fidelities.first(where: { ($0["fidelity"] as? Int) == 0 }), + let nonce = f0["nonce"] as? String, !nonce.isEmpty, + let signature = f0["signature"] as? String, !signature.isEmpty + else { return nil } + + let timestamp: NSNumber + if let n = f0["timestamp"] as? NSNumber { timestamp = n } + else if let s = f0["timestamp"] as? String, let i = Int(s) { timestamp = NSNumber(value: i) } + else { return nil } + + return (nonce, timestamp, signature) } private func completeOnMain(_ completion: @escaping (Any) -> Void, _ value: Any) { @@ -270,4 +249,4 @@ final class SKAdNetworkManager { DispatchQueue.main.async { completion(value) } } } -} \ No newline at end of file +} diff --git a/ios/Classes/SKOverlayManager.swift b/ios/Classes/SKOverlayManager.swift index 4f07baa9..078c0820 100644 --- a/ios/Classes/SKOverlayManager.swift +++ b/ios/Classes/SKOverlayManager.swift @@ -7,7 +7,7 @@ final class SKOverlayManager: NSObject { private override init() {} static let shared = SKOverlayManager() - @available(iOS 14.0, *) + @available(iOS 16.0, *) private var overlay: SKOverlay? { get { _overlay as? SKOverlay } set { _overlay = newValue } @@ -17,14 +17,14 @@ final class SKOverlayManager: NSObject { private var pendingPresentCompletion: ((Any) -> Void)? private var pendingDismissCompletion: ((Bool) -> Void)? - func present(appStoreId: String, position: String, dismissible: Bool, completion: @escaping (Any) -> Void) { + func present(skan: [String: Any], position: String, dismissible: Bool, completion: @escaping (Any) -> Void) { runOnMain { [weak self] in guard let self = self else { return } - guard #available(iOS 14.0, *) else { + guard #available(iOS 16.0, *) else { completion( FlutterError( code: "UNSUPPORTED_IOS", - message: "SKOverlay requires iOS 14.0 or later", + message: "SKOverlay requires iOS 16.0 or later", details: nil ) ) @@ -52,11 +52,26 @@ final class SKOverlayManager: NSObject { completion(FlutterError(code: "NO_ACTIVE_SCENE", message: "No active UIWindowScene found", details: nil)) return } + + guard let itunesItem = skan["itunesItem"] as? String, !itunesItem.isEmpty else { + completion(FlutterError(code: "INVALID_ARGUMENTS", message: "itunesItem is required", details: nil)) + return + } let pos: SKOverlay.Position = (position.lowercased() == "bottomraised") ? .bottomRaised : .bottom - let config = SKOverlay.AppConfiguration(appIdentifier: appStoreId, position: pos) + let config = SKOverlay.AppConfiguration(appIdentifier: itunesItem, position: pos) config.userDismissible = dismissible - + + // Wire up fidelity-1 SKAN attribution if available + guard Self.applyImpression(skan, to: config) else { + completion(FlutterError( + code: "INVALID_ARGUMENTS", + message: "Failed to apply SKAN impression — fidelity-1 data missing or invalid", + details: nil + )) + return + } + let overlay = SKOverlay(configuration: config) overlay.delegate = self @@ -66,6 +81,60 @@ final class SKOverlayManager: NSObject { } } } + + // MARK: - SKAN + @available(iOS 16.0, *) + private static func fidelity1Values(from skan: [String: Any]) -> (nonce: String, timestamp: NSNumber, signature: String)? { + guard let fidelities = skan["fidelities"] as? [[String: Any]], + let f1 = fidelities.first(where: { ($0["fidelity"] as? Int) == 1 }), + let nonce = f1["nonce"] as? String, !nonce.isEmpty, + let signature = f1["signature"] as? String, !signature.isEmpty + else { return nil } + + let timestamp: NSNumber + if let n = f1["timestamp"] as? NSNumber { timestamp = n } + else if let s = f1["timestamp"] as? String, let i = Int(s) { timestamp = NSNumber(value: i) } + else { return nil } + + return (nonce, timestamp, signature) + } + + @available(iOS 16.0, *) + private static func applyImpression(_ skan: [String: Any], to config: SKOverlay.AppConfiguration) -> Bool { + guard #available(iOS 16.0, *) else { return false } + + guard + let version = skan["version"] as? String, !version.isEmpty, + let network = skan["network"] as? String, !network.isEmpty, + let itunesItem = skan["itunesItem"] as? String, + let itemId = Int(itunesItem), + let sourceApp = skan["sourceApp"] as? String, + let f1 = fidelity1Values(from: skan) + else { return false } + + let sourceAppInt = Int(sourceApp) ?? 0 + let campaignInt = (skan["campaign"] as? String).flatMap { Int($0) } ?? 0 + + let imp = SKAdImpression() + imp.version = version + imp.adNetworkIdentifier = network + imp.advertisedAppStoreItemIdentifier = NSNumber(value: itemId) + imp.sourceAppStoreItemIdentifier = NSNumber(value: sourceAppInt) + imp.adCampaignIdentifier = NSNumber(value: campaignInt) + imp.adImpressionIdentifier = f1.nonce + imp.timestamp = f1.timestamp + imp.signature = f1.signature + + if #available(iOS 16.1, *) { + if let sourceIdentifier = skan["sourceIdentifier"] as? String, + let sourceIdentifierInt = Int(sourceIdentifier) { + imp.sourceIdentifier = NSNumber(value: sourceIdentifierInt) + } + } + + config.setAdImpression(imp) + return true + } func dismiss(completion: @escaping (Bool) -> Void) { runOnMain { [weak self] in @@ -73,7 +142,7 @@ final class SKOverlayManager: NSObject { completion(false) return } - guard #available(iOS 14.0, *) else { + guard #available(iOS 16.0, *) else { completion(false) return } @@ -111,7 +180,7 @@ final class SKOverlayManager: NSObject { } } -@available(iOS 14.0, *) +@available(iOS 16.0, *) extension SKOverlayManager: SKOverlayDelegate { func storeOverlayDidFailToLoad(_ overlay: SKOverlay, error: Error) { runOnMain { [weak self] in diff --git a/ios/Classes/SKOverlayPlugin.swift b/ios/Classes/SKOverlayPlugin.swift index 5176678e..f6e3e954 100644 --- a/ios/Classes/SKOverlayPlugin.swift +++ b/ios/Classes/SKOverlayPlugin.swift @@ -14,10 +14,10 @@ public class SKOverlayPlugin: NSObject, FlutterPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "present": - guard let args = call.arguments as? [String: Any], - let appStoreId = args["appStoreId"] as? String, - let position = args["position"] as? String, - let dismissible = args["dismissible"] as? Bool else { + guard let args = call.arguments as? [String: Any], + let skan = args["skan"] as? [String: Any], + let position = args["position"] as? String, + let dismissible = args["dismissible"] as? Bool else { result(FlutterError( code: "INVALID_ARGUMENTS", message: "Invalid or missing arguments", @@ -30,7 +30,7 @@ public class SKOverlayPlugin: NSObject, FlutterPlugin { } DispatchQueue.main.async { SKOverlayManager.shared.present( - appStoreId: appStoreId, + skan: skan, position: position, dismissible: dismissible ) { res in diff --git a/ios/Classes/SKStoreProductManager.swift b/ios/Classes/SKStoreProductManager.swift index 8befd6c2..9973e0eb 100644 --- a/ios/Classes/SKStoreProductManager.swift +++ b/ios/Classes/SKStoreProductManager.swift @@ -9,15 +9,29 @@ final class SKStoreProductManager: NSObject, SKStoreProductViewControllerDelegat private weak var presentedViewController: SKStoreProductViewController? - func present(appStoreId: String, completion: @escaping (Any) -> Void) { - guard let itemId = Int(appStoreId) else { - completion(FlutterError(code: "INVALID_ARGUMENTS", message: "appStoreId must be a valid integer string", details: nil)) + func present(skan: [String: Any], completion: @escaping (Any) -> Void) { + guard let itunesItem = skan["itunesItem"] as? String, + let itemId = Int(itunesItem) else { + completion(FlutterError(code: "INVALID_ARGUMENTS", message: "itunesItem must be a valid integer string", details: nil)) return } - let params: [String : Any] = [ + + var params: [String: Any] = [ SKStoreProductParameterITunesItemIdentifier: NSNumber(value: itemId) ] - + // Fail closed when attribution can't be attached: presenting the sheet without + // SKAN params would open the store page unattributed with no error surfaced. + // Returning an error makes the Dart side fall back to the browser instead. + // Mirrors KontextKit and SKOverlayManager. + guard Self.applySkanParams(skan, into: ¶ms) else { + completion(FlutterError( + code: "ATTRIBUTION_FAILED", + message: "Failed to apply SKAN attribution — missing or invalid fidelity-1 data", + details: nil + )) + return + } + let viewController = SKStoreProductViewController() viewController.delegate = self viewController.loadProduct(withParameters: params) { [weak self] loaded, error in @@ -38,7 +52,7 @@ final class SKStoreProductManager: NSObject, SKStoreProductViewControllerDelegat completion(FlutterError(code: "NO_TOP_VIEW_CONTROLLER", message: "No top view controller found", details: nil)) return } - + top.present(viewController, animated: true) { [weak self] in self?.presentedViewController = viewController completion(true) @@ -47,7 +61,63 @@ final class SKStoreProductManager: NSObject, SKStoreProductViewControllerDelegat } } } - + + // MARK: - SKAN + + /// Picks nonce/timestamp/signature from the fidelity-1 entry only. + /// Returns nil if no fidelity-1 entry exists — no fallback to top-level fields + /// since those are fidelity-0 values signed with a different formula. + private static func fidelity1Values(from skan: [String: Any]) -> (nonce: UUID, timestamp: NSNumber, signature: String)? { + guard let fidelities = skan["fidelities"] as? [[String: Any]], + let f1 = fidelities.first(where: { ($0["fidelity"] as? Int) == 1 }), + let nonceStr = f1["nonce"] as? String, !nonceStr.isEmpty, + let nonce = UUID(uuidString: nonceStr), // validate UUID here + let signature = f1["signature"] as? String, !signature.isEmpty + else { return nil } + + // Validate the timestamp parses — a coerced 0 would produce an invalid + // signature and a silent attribution failure. Mirrors SKOverlayManager. + let timestamp: NSNumber + if let n = f1["timestamp"] as? NSNumber { timestamp = n } + else if let s = f1["timestamp"] as? String, let i = Int(s) { timestamp = NSNumber(value: i) } + else { return nil } + + return (nonce, timestamp, signature) + } + + /// Appends all required SKAN install-validation keys to the SKStoreProduct params dict. + /// Returns false when attribution can't be attached (missing/invalid fields or iOS < 14). + private static func applySkanParams(_ skan: [String: Any], into params: inout [String: Any]) -> Bool { + guard #available(iOS 14.0, *) else { return false } + + guard + let version = skan["version"] as? String, !version.isEmpty, + let network = skan["network"] as? String, !network.isEmpty, + let sourceApp = skan["sourceApp"] as? String, + let f1 = fidelity1Values(from: skan) + else { return false } + + let sourceAppInt = Int(sourceApp) ?? 0 + let campaignInt = (skan["campaign"] as? String).flatMap { Int($0) } ?? 0 + + params[SKStoreProductParameterAdNetworkVersion] = version + params[SKStoreProductParameterAdNetworkIdentifier] = network + params[SKStoreProductParameterAdNetworkSourceAppStoreIdentifier] = NSNumber(value: sourceAppInt) + params[SKStoreProductParameterAdNetworkCampaignIdentifier] = NSNumber(value: campaignInt) + params[SKStoreProductParameterAdNetworkTimestamp] = f1.timestamp + params[SKStoreProductParameterAdNetworkAttributionSignature] = f1.signature + params[SKStoreProductParameterAdNetworkNonce] = f1.nonce + + if #available(iOS 16.1, *) { + if let sourceIdentifier = skan["sourceIdentifier"] as? String, + let sourceIdentifierInt = Int(sourceIdentifier) { + params[SKStoreProductParameterAdNetworkSourceIdentifier] = NSNumber(value: sourceIdentifierInt) + } + } + + return true + } + func dismiss(completion: @escaping (Bool) -> Void) { let run: () -> Void = { [weak self] in guard let self = self else { diff --git a/ios/Classes/SKStoreProductPlugin.swift b/ios/Classes/SKStoreProductPlugin.swift index 64671fab..e4fc1ffe 100644 --- a/ios/Classes/SKStoreProductPlugin.swift +++ b/ios/Classes/SKStoreProductPlugin.swift @@ -14,14 +14,13 @@ public class SKStoreProductPlugin: NSObject, FlutterPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "present": - guard let args = call.arguments as? [String: Any], - let appStoreId = args["appStoreId"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "appStoreId is required", details: nil)) + guard let params = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "arguments must be a map", details: nil)) return } DispatchQueue.main.async { - SKStoreProductManager.shared.present(appStoreId: appStoreId) { success in - result(success) + SKStoreProductManager.shared.present(skan: params) { res in + result(res) } } case "dismiss": diff --git a/ios/kontext_flutter_sdk.podspec b/ios/kontext_flutter_sdk.podspec index 5d243463..39ea7081 100644 --- a/ios/kontext_flutter_sdk.podspec +++ b/ios/kontext_flutter_sdk.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'kontext_flutter_sdk' - s.version = '2.2.1' + s.version = '2.2.4' s.summary = 'Kontext Flutter SDK plugin.' s.description = <<-DESC Kontext Flutter SDK: sound status, app info, hardware, power, network, etc. @@ -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', 'AdSupport', 'AppTrackingTransparency' + s.frameworks = 'AVFoundation', 'SystemConfiguration', 'CoreTelephony', 'WebKit', 'AdSupport', 'AppTrackingTransparency', 'StoreKit' s.resources = ['PrivacyInfo.xcprivacy'] diff --git a/lib/src/models/bid.dart b/lib/src/models/bid.dart index e0ac34f1..dd9a91ce 100644 --- a/lib/src/models/bid.dart +++ b/lib/src/models/bid.dart @@ -1,5 +1,9 @@ +import 'package:flutter/foundation.dart'; + enum AdDisplayPosition { afterAssistantMessage, afterUserMessage } +enum ImpressionTrigger { immediate, component } + class Akk { Akk({required this.jws}); @@ -11,7 +15,7 @@ class Akk { } catch (_) { return null; } -} + } @override bool operator ==(Object other) { @@ -36,6 +40,8 @@ class AttributionFidelity { final int fidelity; final String signature; final String nonce; + + /// Seconds since Unix epoch, as required by Apple's SKAdNetwork spec. final String timestamp; static AttributionFidelity? fromJson(Map json) { @@ -92,6 +98,8 @@ class Skan { final String? campaign; final List? fidelities; final String? nonce; + + /// Seconds since Unix epoch, as required by Apple's SKAdNetwork spec. final String? timestamp; final String? signature; @@ -117,6 +125,29 @@ class Skan { } } + Map toJson() { + return { + 'version': version, + 'network': network, + 'itunesItem': itunesItem, + 'sourceApp': sourceApp, + if (sourceIdentifier != null) 'sourceIdentifier': sourceIdentifier, + if (campaign != null) 'campaign': campaign, + if (nonce != null) 'nonce': nonce, + if (timestamp != null) 'timestamp': timestamp, + if (signature != null) 'signature': signature, + if (fidelities != null) + 'fidelities': fidelities! + .map((f) => { + 'fidelity': f.fidelity, + 'nonce': f.nonce, + 'timestamp': f.timestamp, + 'signature': f.signature, + }) + .toList(), + }; + } + @override bool operator ==(Object other) { return identical(this, other) || @@ -128,6 +159,7 @@ class Skan { sourceIdentifier == other.sourceIdentifier && campaign == other.campaign && nonce == other.nonce && + listEquals(fidelities, other.fidelities) && timestamp == other.timestamp && signature == other.signature; } @@ -140,6 +172,7 @@ class Skan { sourceApp, sourceIdentifier, campaign, + Object.hashAll(fidelities ?? const []), nonce, timestamp, signature, @@ -153,7 +186,6 @@ class Skan { } } - class Bid { Bid({ required this.id, @@ -162,6 +194,7 @@ class Bid { required this.position, this.akk, this.skan, + this.impressionTrigger = ImpressionTrigger.immediate, }); final String id; @@ -170,6 +203,7 @@ class Bid { final AdDisplayPosition position; final Akk? akk; final Skan? skan; + final ImpressionTrigger impressionTrigger; bool get isAfterAssistantMessage => position == AdDisplayPosition.afterAssistantMessage; @@ -186,6 +220,7 @@ class Bid { ), akk: _parseAkk(json['akk']), skan: _parseSkan(json['skan']), + impressionTrigger: _parseImpressionTrigger(json['impressionTrigger']), ); } @@ -207,6 +242,14 @@ class Bid { } } + static ImpressionTrigger _parseImpressionTrigger(Object? value) { + if (value is! String) return ImpressionTrigger.immediate; + return ImpressionTrigger.values.firstWhere( + (t) => t.name == value, + orElse: () => ImpressionTrigger.immediate, + ); + } + static double? _parseRevenue(Object? value) { if (value == null) return null; @@ -233,14 +276,15 @@ class Bid { revenue == other.revenue && position == other.position && akk == other.akk && - skan == other.skan; + skan == other.skan && + impressionTrigger == other.impressionTrigger; } @override - int get hashCode => Object.hash(id, code, revenue, position, akk, skan); + int get hashCode => Object.hash(id, code, revenue, position, akk, skan, impressionTrigger); @override String toString() { - return 'Bid(id: $id, code: $code, revenue: $revenue, position: $position, akk: $akk, skan: $skan)'; + return 'Bid(id: $id, code: $code, revenue: $revenue, position: $position, akk: $akk, skan: $skan, impressionTrigger: $impressionTrigger)'; } } diff --git a/lib/src/services/sk_ad_network_service.dart b/lib/src/services/sk_ad_network_service.dart index 72211524..f02f494f 100644 --- a/lib/src/services/sk_ad_network_service.dart +++ b/lib/src/services/sk_ad_network_service.dart @@ -9,10 +9,12 @@ class SKAdNetwork { static const MethodChannel _channel = MethodChannel('kontext_flutter_sdk/sk_ad_network'); + static bool Function() isIOS = () => Platform.isIOS; + static bool _impressionReady = false; static Future initImpression(Skan skan) async { - if (!Platform.isIOS) return false; + if (!isIOS()) return false; final params = { 'version': skan.version, @@ -56,7 +58,7 @@ class SKAdNetwork { } static Future startImpression() async { - if (!Platform.isIOS || !_impressionReady) return; + if (!isIOS() || !_impressionReady) return; try { final result = await _channel.invokeMethod('startImpression'); @@ -69,7 +71,7 @@ class SKAdNetwork { } static Future endImpression() async { - if (!Platform.isIOS || !_impressionReady) return; + if (!isIOS() || !_impressionReady) return; try { final result = await _channel.invokeMethod('endImpression'); @@ -84,7 +86,7 @@ class SKAdNetwork { static Future dispose() async { _impressionReady = false; - if (!Platform.isIOS) return; + if (!isIOS()) return; try { final result = await _channel.invokeMethod('dispose'); diff --git a/lib/src/services/sk_overlay_service.dart b/lib/src/services/sk_overlay_service.dart index 8520c7ba..56b9f754 100644 --- a/lib/src/services/sk_overlay_service.dart +++ b/lib/src/services/sk_overlay_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/services.dart' show MethodChannel, PlatformException; +import 'package:kontext_flutter_sdk/src/models/bid.dart'; import 'package:kontext_flutter_sdk/src/services/logger.dart' show Logger; enum SKOverlayPosition { bottom, bottomRaised } @@ -11,19 +12,20 @@ abstract final class SKOverlayService { static bool Function() isIOS = () => Platform.isIOS; static Future present({ - required String appStoreId, + required Skan skan, required SKOverlayPosition position, bool dismissible = true, }) async { if (!isIOS()) return false; - if (appStoreId.isEmpty) { + + if (skan.itunesItem.isEmpty) { Logger.error('SKOverlay: appStoreId cannot be empty'); return false; } try { final result = await _channel.invokeMethod('present', { - 'appStoreId': appStoreId, + 'skan': skan.toJson(), 'position': position.name, 'dismissible': dismissible, }); diff --git a/lib/src/services/sk_store_product_service.dart b/lib/src/services/sk_store_product_service.dart index 743ddb3e..d47def2c 100644 --- a/lib/src/services/sk_store_product_service.dart +++ b/lib/src/services/sk_store_product_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/services.dart' show MethodChannel; +import 'package:kontext_flutter_sdk/src/models/bid.dart' show Skan; import 'package:kontext_flutter_sdk/src/services/logger.dart' show Logger; abstract final class SKStoreProductService { @@ -8,11 +9,11 @@ abstract final class SKStoreProductService { static bool Function() isIOS = () => Platform.isIOS; - static Future present({required String appStoreId}) async { + static Future present(Skan skan) async { if (!isIOS()) return false; try { - final result = await _channel.invokeMethod('present', {'appStoreId': appStoreId}); + final result = await _channel.invokeMethod('present', skan.toJson()); Logger.debug('SKStoreProduct presented: $result'); return result == true; } catch (e, stack) { diff --git a/lib/src/utils/constants.dart b/lib/src/utils/constants.dart index 119d5be8..65ba8291 100644 --- a/lib/src/utils/constants.dart +++ b/lib/src/utils/constants.dart @@ -1,3 +1,3 @@ const kDefaultAdServerUrl = 'https://server.megabrain.co'; const kSdkLabel = 'sdk-flutter'; -const kSdkVersion = '2.2.1'; +const kSdkVersion = '2.2.4'; diff --git a/lib/src/utils/types.dart b/lib/src/utils/types.dart index 48149f73..07204f06 100644 --- a/lib/src/utils/types.dart +++ b/lib/src/utils/types.dart @@ -6,8 +6,7 @@ typedef Json = Map; enum OpenIframeComponent { modal({'open-component-iframe', 'close-component-iframe'}), - skoverlay({'open-skoverlay-iframe', 'close-skoverlay-iframe'}), - skstoreproduct({'open-skstoreproduct-iframe', 'close-skstoreproduct-iframe'}); + skoverlay({'open-skoverlay-iframe', 'close-skoverlay-iframe'}); const OpenIframeComponent(this.types); diff --git a/lib/src/widgets/ad_format.dart b/lib/src/widgets/ad_format.dart index 413b9002..3bd829b8 100644 --- a/lib/src/widgets/ad_format.dart +++ b/lib/src/widgets/ad_format.dart @@ -184,18 +184,19 @@ class AdFormat extends HookWidget { } break; case 'click-iframe': - _handleClickIframe(adServerUrl: adServerUrl, controller: controller, data: data); + _handleClickIframe(bid: bid, adServerUrl: adServerUrl, controller: controller, data: data); break; case 'ad-done-iframe': final content = data?['cachedContent'] as String?; if (content != null) { adsProviderData.setCachedContent(bid.id, content); } - unawaited(_handleAttributionBeginView(key, attributionType)); + if (bid.impressionTrigger == ImpressionTrigger.immediate) { + unawaited(_startAttributionImpression(attributionType)); + } break; case 'open-component-iframe': case 'open-skoverlay-iframe': - case 'open-skstoreproduct-iframe': final component = OpenIframeComponent.fromMessageType(messageType); if (component == null) { return; @@ -208,19 +209,19 @@ class AdFormat extends HookWidget { controller: controller, inlineUri: inlineUri, component: component, + attributionType: attributionType, data: data, onEvent: adsProviderData.onEvent, ); break; case 'close-component-iframe': case 'close-skoverlay-iframe': - case 'close-skstoreproduct-iframe': final component = OpenIframeComponent.fromMessageType(messageType); if (component == null) { return; } - _handleCloseComponentIframe(component, adServerUrl: adServerUrl, controller: controller); + _handleCloseComponentIframe(component); break; case 'error-iframe': resetIframe(); @@ -230,50 +231,34 @@ class AdFormat extends HookWidget { } Future _handleClickIframe({ + required Bid bid, required String adServerUrl, required InAppWebViewController controller, Json? data, }) async { try { final path = data?['url']; - final appStoreId = data?['appStoreId']; - final uri = (path is String) ? KontextUrlBuilder(baseUrl: adServerUrl, path: path).buildUri() : null; - /* - // AAK is temporarily disabled - final navigationHandled = await AdAttributionKit.handleTap(uri); - if (appStoreId == null) { - // if (uri != null && !navigationHandled) { - browserOpener.open(uri); - } - return; - } - */ + // Check if bid has fidelity-1 SKAN data for StoreKit-rendered attribution. + // If so, we open SKStoreProductViewController instead of the browser. + final skan = bid.skan; + final hasFidelity1 = skan != null && (skan.fidelities?.any((f) => f.fidelity == 1) ?? false); - if (appStoreId == null) { - if (uri != null) { + if (hasFidelity1) { + final storeProductOpened = await _presentSkStoreProduct(skan); + + // Fall back to browser if StoreKit failed to open. + if (!storeProductOpened && uri != null) { browserOpener.open(uri); } return; } - final storeProductOpened = await _presentSkStoreProduct( - adServerUrl, - controller, - appStoreId, - ); - - /* - // AAK is temporarily disabled - if (!storeProductOpened && uri != null && !navigationHandled) { + if (uri != null) { browserOpener.open(uri); } - */ - if (!storeProductOpened && uri != null) { - browserOpener.open(uri); - } } catch (e, stack) { Logger.exception(e, stack); return; @@ -303,10 +288,18 @@ class AdFormat extends HookWidget { } } - Future _presentSkOverlay(String adServerUrl, InAppWebViewController controller, Json data) async { - final appStoreId = data['appStoreId']; - if (appStoreId is! String || appStoreId.isEmpty) { - Logger.error('App Store ID is required to open SKOverlay. Data: $data'); + Future _presentSkOverlay(Json data, Skan? skan) async { + // SKOverlay requires fidelity-1 SKAN data for attribution. + // Without it there's no point opening the overlay. + final hasFidelity1 = skan != null && (skan.fidelities?.any((f) => f.fidelity == 1) ?? false); + if (!hasFidelity1) { + Logger.error('SKOverlay requires fidelity-1 SKAN data. Skipping.'); + return false; + } + + final appStoreId = skan.itunesItem; + if (appStoreId.isEmpty) { + Logger.error('App Store ID is required to open SKOverlay.'); return false; } @@ -318,49 +311,24 @@ class AdFormat extends HookWidget { final dismissible = data['dismissible']; final success = await SKOverlayService.present( - appStoreId: appStoreId, + skan: skan, position: position, dismissible: dismissible is bool ? dismissible : true, ); - - if (success) { - _postMessageToWebView(adServerUrl, controller, { - 'type': 'update-skoverlay-iframe', - 'data': {'code': code, 'open': true}, - }); - } - return success; } - Future _dismissSkOverlay(String adServerUrl, InAppWebViewController? controller) async { - final success = await SKOverlayService.dismiss(); - if (success && controller != null) { - _postMessageToWebView(adServerUrl, controller, { - 'type': 'update-skoverlay-iframe', - 'data': {'code': code, 'open': false}, - }); - } - return success; + Future _dismissSkOverlay() async { + return await SKOverlayService.dismiss(); } - Future _presentSkStoreProduct( - String adServerUrl, - InAppWebViewController controller, - dynamic appStoreId, - ) async { - if (appStoreId is! String || appStoreId.isEmpty) { - Logger.error('App Store ID is required to open SKStoreProduct. Data: $appStoreId'); + Future _presentSkStoreProduct(Skan skan) async { + if (skan.itunesItem.isEmpty) { + Logger.error('App Store ID is required to open SKStoreProduct. Data: $skan'); return false; } - final success = await SKStoreProductService.present(appStoreId: appStoreId); - if (success) { - _postMessageToWebView(adServerUrl, controller, { - 'type': 'update-skstoreproduct-iframe', - 'data': {'code': code, 'open': true}, - }); - } + final success = await SKStoreProductService.present(skan); return success; } @@ -370,32 +338,19 @@ class AdFormat extends HookWidget { ObjectRef<_AttributionType> attributionType, ) async { if (akk != null) { - /* // AAK is temporarily disabled - final success = await AdAttributionKit.initImpression(akk.jws); - if (success) attributionType.value = _AttributionType.aak; - */ } else if (skan != null) { final success = await SKAdNetwork.initImpression(skan); if (success) attributionType.value = _AttributionType.skan; } } - Future _handleAttributionBeginView( - GlobalKey key, + Future _startAttributionImpression( ObjectRef<_AttributionType> attributionType, ) async { switch (attributionType.value) { case _AttributionType.aak: - /* // AAK is temporarily disabled - WidgetsBinding.instance.addPostFrameCallback((_) async { - final adContainer = _slotRectInWindow(key); - if (adContainer == null) return; - final frameSet = await AdAttributionKit.setAttributionFrame(adContainer); - if (frameSet) await AdAttributionKit.beginView(); - }); - */ break; case _AttributionType.skan: await SKAdNetwork.startImpression(); @@ -410,11 +365,7 @@ class AdFormat extends HookWidget { ) async { switch (attributionType.value) { case _AttributionType.aak: - /* // AAK is temporarily disabled - await AdAttributionKit.endView(); - await AdAttributionKit.dispose(); - */ break; case _AttributionType.skan: await SKAdNetwork.endImpression(); @@ -426,15 +377,8 @@ class AdFormat extends HookWidget { attributionType.value = _AttributionType.none; } - Future _dismissSkStoreProduct(String adServerUrl, InAppWebViewController? controller) async { - final success = await SKStoreProductService.dismiss(); - if (success && controller != null) { - _postMessageToWebView(adServerUrl, controller, { - 'type': 'update-skstoreproduct-iframe', - 'data': {'code': code, 'open': false}, - }); - } - return success; + Future _dismissSkStoreProduct() async { + return await SKStoreProductService.dismiss(); } Future _handleOpenComponentIframe( @@ -444,6 +388,7 @@ class AdFormat extends HookWidget { required InAppWebViewController controller, required Uri inlineUri, required OpenIframeComponent component, + required ObjectRef<_AttributionType> attributionType, Json? data, OnEventCallback? onEvent, }) async { @@ -458,6 +403,9 @@ class AdFormat extends HookWidget { switch (component) { case OpenIframeComponent.modal: + if (bid.impressionTrigger == ImpressionTrigger.component) { + unawaited(_startAttributionImpression(attributionType)); + } final modalUri = inlineUri.replacePath('/api/${component.name}/${bid.id}'); (showInterstitial ?? InterstitialModal.show)( context, @@ -465,6 +413,7 @@ class AdFormat extends HookWidget { uri: modalUri, initTimeout: timeout, onClickIframe: (data) => _handleClickIframe( + bid: bid, adServerUrl: adServerUrl, controller: controller, data: data, @@ -482,38 +431,27 @@ class AdFormat extends HookWidget { controller: controller, inlineUri: inlineUri, component: component, + attributionType: attributionType, data: data, onEvent: onEvent, ), onCloseComponentIframe: (component) => _handleCloseComponentIframe( - component, - adServerUrl: adServerUrl, - controller: controller, + component ), ); break; case OpenIframeComponent.skoverlay: - await _presentSkOverlay(adServerUrl, controller, data); - break; - case OpenIframeComponent.skstoreproduct: - await _presentSkStoreProduct(adServerUrl, controller, data['appStoreId']); + await _presentSkOverlay(data, bid.skan); break; } } - Future _handleCloseComponentIframe( - OpenIframeComponent component, { - required String adServerUrl, - required InAppWebViewController controller, - }) async { + Future _handleCloseComponentIframe(OpenIframeComponent component) async { switch (component) { case OpenIframeComponent.modal: break; // Do nothing, already handled by InterstitialModal case OpenIframeComponent.skoverlay: - await _dismissSkOverlay(adServerUrl, controller); - break; - case OpenIframeComponent.skstoreproduct: - await _dismissSkStoreProduct(adServerUrl, controller); + await _dismissSkOverlay(); break; } } @@ -572,8 +510,8 @@ class AdFormat extends HookWidget { useEffect(() { return () { - _dismissSkOverlay(adServerUrl, webviewController.value); - _dismissSkStoreProduct(adServerUrl, webviewController.value); + _dismissSkOverlay(); + _dismissSkStoreProduct(); }; }, const []); @@ -659,8 +597,8 @@ class AdFormat extends HookWidget { void resetIframe() { unawaited(_cleanupAttributionResources(attributionType)); - _dismissSkOverlay(adServerUrl, webviewController.value); - _dismissSkStoreProduct(adServerUrl, webviewController.value); + _dismissSkOverlay(); + _dismissSkStoreProduct(); iframeLoaded.value = false; showIframe.value = false; diff --git a/lib/src/widgets/interstitial_modal.dart b/lib/src/widgets/interstitial_modal.dart index 23efe276..91de38c3 100644 --- a/lib/src/widgets/interstitial_modal.dart +++ b/lib/src/widgets/interstitial_modal.dart @@ -36,10 +36,8 @@ class InterstitialModal { @visibleForTesting KontextWebviewBuilder? webviewBuilder, }) { closeSKOverlay() => onCloseComponentIframe(OpenIframeComponent.skoverlay); - closeSkStoreProduct() => onCloseComponentIframe(OpenIframeComponent.skstoreproduct); closeAll() { closeSKOverlay(); - closeSkStoreProduct(); closeModal(); } @@ -96,7 +94,6 @@ class InterstitialModal { break; case 'open-component-iframe': case 'open-skoverlay-iframe': - case 'open-skstoreproduct-iframe': final component = OpenIframeComponent.fromMessageType(messageType); if (component == null) { return; @@ -109,9 +106,6 @@ class InterstitialModal { case 'close-skoverlay-iframe': closeSKOverlay(); break; - case 'close-skstoreproduct-iframe': - closeSkStoreProduct(); - break; case 'error-component-iframe': closeAll(); break; diff --git a/pubspec.yaml b/pubspec.yaml index 46134fb0..6dd57ede 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: kontext_flutter_sdk description: Flutter SDK for integrating Kontext.so ads. Monetize text-based & AI apps like chatbots, search or messaging with unique, native ad formats. -version: 2.2.1 +version: 2.2.4 homepage: https://www.kontext.so/publishers repository: https://github.com/kontextso/sdk-flutter issue_tracker: https://github.com/kontextso/sdk-flutter/issues diff --git a/test/src/models/bid_test.dart b/test/src/models/bid_test.dart index aca84a1d..df2c3661 100644 --- a/test/src/models/bid_test.dart +++ b/test/src/models/bid_test.dart @@ -284,4 +284,204 @@ void main() { }); }); }); + + // ===== Added for v2.2.4: coverage for the 2.2.2 SKAN-model additions ===== + // (impressionTrigger, Skan.toJson, fidelities in equality, AttributionFidelity) + + group('Bid.impressionTrigger', () { + Map json(Object? trigger) => { + 'bidId': 'bid-1', + 'code': 'code-1', + 'adDisplayPosition': 'afterAssistantMessage', + if (trigger != null) 'impressionTrigger': trigger, + }; + + test('defaults to immediate when missing', () { + expect(Bid.fromJson(json(null)).impressionTrigger, ImpressionTrigger.immediate); + }); + + test('parses immediate', () { + expect(Bid.fromJson(json('immediate')).impressionTrigger, ImpressionTrigger.immediate); + }); + + test('parses component', () { + expect(Bid.fromJson(json('component')).impressionTrigger, ImpressionTrigger.component); + }); + + test('falls back to immediate for an unknown value', () { + expect(Bid.fromJson(json('whenever')).impressionTrigger, ImpressionTrigger.immediate); + }); + + test('falls back to immediate for a non-string value', () { + expect(Bid.fromJson(json(42)).impressionTrigger, ImpressionTrigger.immediate); + }); + }); + + group('Bid equality (skan & impressionTrigger)', () { + Bid make({String? trigger, Map? skan}) => Bid.fromJson({ + 'bidId': 'bid-1', + 'code': 'code-1', + 'adDisplayPosition': 'afterAssistantMessage', + if (trigger != null) 'impressionTrigger': trigger, + if (skan != null) 'skan': skan, + }); + + test('bids differing only in impressionTrigger are not equal', () { + expect(make(trigger: 'immediate'), isNot(equals(make(trigger: 'component')))); + }); + + test('bids with the same impressionTrigger are equal', () { + final a = make(trigger: 'component'); + final b = make(trigger: 'component'); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('bids differing only in skan are not equal', () { + final s1 = {'version': '4.0', 'network': 'net-a', 'itunesItem': 'i', 'sourceApp': 's'}; + final s2 = {'version': '4.0', 'network': 'net-b', 'itunesItem': 'i', 'sourceApp': 's'}; + expect(make(skan: s1), isNot(equals(make(skan: s2)))); + }); + }); + + group('Skan.toJson', () { + Skan requiredOnly() => Skan.fromJson({ + 'version': '4.0', + 'network': 'network-id', + 'itunesItem': 'itunes-item', + 'sourceApp': 'source-app', + })!; + + test('includes exactly the required keys when optionals are null', () { + expect(requiredOnly().toJson(), { + 'version': '4.0', + 'network': 'network-id', + 'itunesItem': 'itunes-item', + 'sourceApp': 'source-app', + }); + }); + + test('omits null optional fields', () { + final json = requiredOnly().toJson(); + for (final key in ['sourceIdentifier', 'campaign', 'nonce', 'timestamp', 'signature', 'fidelities']) { + expect(json.containsKey(key), isFalse, reason: 'should not contain $key'); + } + }); + + test('includes all optional scalar fields when present', () { + final json = Skan.fromJson({ + 'version': '4.0', + 'network': 'network-id', + 'itunesItem': 'itunes-item', + 'sourceApp': 'source-app', + 'sourceIdentifier': 'src-id', + 'campaign': 'camp-1', + 'nonce': 'nonce-abc', + 'timestamp': '1700000000', + 'signature': 'sig-xyz', + })!.toJson(); + expect(json['sourceIdentifier'], 'src-id'); + expect(json['campaign'], 'camp-1'); + expect(json['nonce'], 'nonce-abc'); + expect(json['timestamp'], '1700000000'); + expect(json['signature'], 'sig-xyz'); + }); + + test('serializes fidelities as a list of maps', () { + final json = Skan.fromJson({ + 'version': '4.0', + 'network': 'network-id', + 'itunesItem': 'itunes-item', + 'sourceApp': 'source-app', + 'fidelities': [ + {'fidelity': 1, 'signature': 'sig-1', 'nonce': 'nonce-1', 'timestamp': 'ts-1'}, + ], + })!.toJson(); + expect(json['fidelities'], [ + {'fidelity': 1, 'nonce': 'nonce-1', 'timestamp': 'ts-1', 'signature': 'sig-1'}, + ]); + }); + }); + + group('Skan equality', () { + Skan skanWith([Map overrides = const {}]) => Skan.fromJson({ + 'version': '4.0', + 'network': 'net', + 'itunesItem': 'i', + 'sourceApp': 's', + ...overrides, + })!; + + test('identical skans are equal with equal hashCodes', () { + expect(skanWith(), equals(skanWith())); + expect(skanWith().hashCode, equals(skanWith().hashCode)); + }); + + test('skans differing in a scalar field are not equal', () { + expect(skanWith({'network': 'a'}), isNot(equals(skanWith({'network': 'b'})))); + }); + + test('skans differing in fidelities are not equal', () { + final withFid = skanWith({ + 'fidelities': [ + {'fidelity': 1, 'signature': 's', 'nonce': 'n', 'timestamp': 't'}, + ], + }); + expect(withFid, isNot(equals(skanWith()))); + }); + + test('skans with equal fidelities are equal', () { + final a = skanWith({ + 'fidelities': [ + {'fidelity': 1, 'signature': 's', 'nonce': 'n', 'timestamp': 't'}, + ], + }); + final b = skanWith({ + 'fidelities': [ + {'fidelity': 1, 'signature': 's', 'nonce': 'n', 'timestamp': 't'}, + ], + }); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); + + group('AttributionFidelity', () { + test('parses a valid entry', () { + final f = AttributionFidelity.fromJson({ + 'fidelity': 1, + 'signature': 'sig', + 'nonce': 'nonce', + 'timestamp': '1700000000', + }); + expect(f, isNotNull); + expect(f!.fidelity, 1); + expect(f.signature, 'sig'); + expect(f.nonce, 'nonce'); + expect(f.timestamp, '1700000000'); + }); + + test('returns null when a required field is missing', () { + expect( + AttributionFidelity.fromJson({'fidelity': 1, 'signature': 'sig', 'nonce': 'n'}), + isNull, + ); + }); + + test('returns null when fidelity has the wrong type', () { + expect( + AttributionFidelity.fromJson({'fidelity': 'x', 'signature': 'sig', 'nonce': 'n', 'timestamp': 't'}), + isNull, + ); + }); + + test('equal entries are equal; different ones are not', () { + final a = AttributionFidelity.fromJson({'fidelity': 1, 'signature': 's', 'nonce': 'n', 'timestamp': 't'}); + final b = AttributionFidelity.fromJson({'fidelity': 1, 'signature': 's', 'nonce': 'n', 'timestamp': 't'}); + final c = AttributionFidelity.fromJson({'fidelity': 0, 'signature': 's', 'nonce': 'n', 'timestamp': 't'}); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + expect(a, isNot(equals(c))); + }); + }); } \ No newline at end of file diff --git a/test/src/services/sk_ad_network_service_test.dart b/test/src/services/sk_ad_network_service_test.dart new file mode 100644 index 00000000..063e51bd --- /dev/null +++ b/test/src/services/sk_ad_network_service_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/bid.dart'; +import 'package:kontext_flutter_sdk/src/services/sk_ad_network_service.dart'; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = binding.defaultBinaryMessenger; + const channel = MethodChannel('kontext_flutter_sdk/sk_ad_network'); + + final calls = []; + Object? mockReturn; + Object? mockThrow; + Duration? mockDelay; + + Skan validSkan() => Skan.fromJson({ + 'version': '4.0', + 'network': 'mp7rpxwdrx.skadnetwork', + 'itunesItem': '335187483', + 'sourceApp': '0', + 'sourceIdentifier': '39', + 'fidelities': [ + {'fidelity': 0, 'nonce': 'n0', 'signature': 's0', 'timestamp': '100'}, + {'fidelity': 1, 'nonce': 'n1', 'signature': 's1', 'timestamp': '101'}, + ], + })!; + + setUp(() async { + calls.clear(); + mockReturn = true; + mockThrow = null; + mockDelay = null; + // Reset the static _impressionReady between tests without a channel call. + SKAdNetwork.isIOS = () => false; + await SKAdNetwork.dispose(); + SKAdNetwork.isIOS = () => true; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (mockDelay != null) await Future.delayed(mockDelay!); + if (mockThrow != null) throw mockThrow!; + return mockReturn; + }); + }); + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + + group('initImpression', () { + test('returns false and makes no channel call when not iOS', () async { + SKAdNetwork.isIOS = () => false; + expect(await SKAdNetwork.initImpression(validSkan()), isFalse); + expect(calls, isEmpty); + }); + + test('passes the full skan payload with correct field names', () async { + await SKAdNetwork.initImpression(validSkan()); + expect(calls.single.method, 'initImpression'); + final args = calls.single.arguments as Map; + expect(args['version'], '4.0'); + expect(args['network'], 'mp7rpxwdrx.skadnetwork'); + expect(args['itunesItem'], '335187483'); + expect(args['sourceApp'], '0'); + expect(args['sourceIdentifier'], '39'); + final fidelities = (args['fidelities'] as List).cast(); + expect(fidelities, hasLength(2)); + expect(fidelities[0], {'fidelity': 0, 'signature': 's0', 'nonce': 'n0', 'timestamp': '100'}); + expect(fidelities[1], {'fidelity': 1, 'signature': 's1', 'nonce': 'n1', 'timestamp': '101'}); + }); + + test('returns true on native success and arms the impression', () async { + expect(await SKAdNetwork.initImpression(validSkan()), isTrue); + await SKAdNetwork.startImpression(); + expect(calls.map((c) => c.method), ['initImpression', 'startImpression']); + }); + + test('returns false when native returns non-true and does not arm', () async { + mockReturn = false; + expect(await SKAdNetwork.initImpression(validSkan()), isFalse); + await SKAdNetwork.startImpression(); + expect(calls.map((c) => c.method), ['initImpression']); // no startImpression + }); + + test('returns false on PlatformException without throwing', () async { + mockThrow = PlatformException(code: 'MISSING_ARGUMENTS'); + expect(await SKAdNetwork.initImpression(validSkan()), isFalse); + await SKAdNetwork.startImpression(); + expect(calls.map((c) => c.method), ['initImpression']); // not armed + }); + + test('resolves correctly even when the native side responds slowly', () async { + // Guards the "Apple callback is slow" scenario: the await simply resolves late. + mockDelay = const Duration(milliseconds: 50); + expect(await SKAdNetwork.initImpression(validSkan()), isTrue); + }); + }); + + group('startImpression', () { + test('is a no-op before a successful initImpression', () async { + await SKAdNetwork.startImpression(); + expect(calls, isEmpty); + }); + + test('is a no-op when not iOS even if armed', () async { + await SKAdNetwork.initImpression(validSkan()); + SKAdNetwork.isIOS = () => false; + await SKAdNetwork.startImpression(); + expect(calls.map((c) => c.method), ['initImpression']); + }); + + test('swallows a native error without throwing', () async { + await SKAdNetwork.initImpression(validSkan()); + mockThrow = PlatformException(code: 'SKAN_START_IMPRESSION_FAILED'); + await SKAdNetwork.startImpression(); // must not throw + expect(calls.map((c) => c.method), ['initImpression', 'startImpression']); + }); + + test('completes even when the native side responds slowly', () async { + await SKAdNetwork.initImpression(validSkan()); + mockDelay = const Duration(milliseconds: 50); + await SKAdNetwork.startImpression().timeout(const Duration(seconds: 1)); + expect(calls.last.method, 'startImpression'); + }); + }); + + group('endImpression', () { + test('is a no-op before a successful initImpression', () async { + await SKAdNetwork.endImpression(); + expect(calls, isEmpty); + }); + + test('invokes endImpression when armed', () async { + await SKAdNetwork.initImpression(validSkan()); + await SKAdNetwork.endImpression(); + expect(calls.map((c) => c.method), ['initImpression', 'endImpression']); + }); + + test('swallows a native error without throwing', () async { + await SKAdNetwork.initImpression(validSkan()); + mockThrow = PlatformException(code: 'SKAN_END_IMPRESSION_FAILED'); + await SKAdNetwork.endImpression(); // must not throw + }); + }); + + group('dispose', () { + test('disarms the impression and calls native dispose', () async { + await SKAdNetwork.initImpression(validSkan()); + await SKAdNetwork.dispose(); + await SKAdNetwork.startImpression(); // disarmed -> no call + expect(calls.map((c) => c.method), ['initImpression', 'dispose']); + }); + }); + + test('full lifecycle calls native in order: init, start, end, dispose', () async { + await SKAdNetwork.initImpression(validSkan()); + await SKAdNetwork.startImpression(); + await SKAdNetwork.endImpression(); + await SKAdNetwork.dispose(); + expect(calls.map((c) => c.method), ['initImpression', 'startImpression', 'endImpression', 'dispose']); + }); +} diff --git a/test/src/services/sk_overlay_service_test.dart b/test/src/services/sk_overlay_service_test.dart new file mode 100644 index 00000000..68feaaa2 --- /dev/null +++ b/test/src/services/sk_overlay_service_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/bid.dart'; +import 'package:kontext_flutter_sdk/src/services/sk_overlay_service.dart'; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = binding.defaultBinaryMessenger; + const channel = MethodChannel('kontext_flutter_sdk/sk_overlay'); + + final calls = []; + Object? mockReturn; + Object? mockThrow; + + Skan skanWith([Map overrides = const {}]) => Skan.fromJson({ + 'version': '4.0', + 'network': 'net.skadnetwork', + 'itunesItem': '123456789', + 'sourceApp': '987', + 'fidelities': [ + {'fidelity': 1, 'nonce': 'n1', 'signature': 's1', 'timestamp': '100'}, + ], + ...overrides, + })!; + + setUp(() { + calls.clear(); + mockReturn = true; + mockThrow = null; + SKOverlayService.isIOS = () => true; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (mockThrow != null) throw mockThrow!; + return mockReturn; + }); + }); + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + + group('present', () { + test('returns false and makes no channel call when not iOS', () async { + SKOverlayService.isIOS = () => false; + final result = await SKOverlayService.present( + skan: skanWith(), + position: SKOverlayPosition.bottom, + ); + expect(result, isFalse); + expect(calls, isEmpty); + }); + + test('returns false without a channel call when itunesItem is empty', () async { + final result = await SKOverlayService.present( + skan: skanWith({'itunesItem': ''}), + position: SKOverlayPosition.bottom, + ); + expect(result, isFalse); + expect(calls, isEmpty); + }); + + test('invokes present with skan/position/dismissible and returns true', () async { + final result = await SKOverlayService.present( + skan: skanWith(), + position: SKOverlayPosition.bottomRaised, + dismissible: false, + ); + expect(result, isTrue); + expect(calls, hasLength(1)); + expect(calls.single.method, 'present'); + final args = calls.single.arguments as Map; + expect(args['position'], 'bottomRaised'); + expect(args['dismissible'], false); + final skan = args['skan'] as Map; + expect(skan['itunesItem'], '123456789'); + expect(skan['network'], 'net.skadnetwork'); + }); + + test('defaults dismissible to true', () async { + await SKOverlayService.present(skan: skanWith(), position: SKOverlayPosition.bottom); + expect((calls.single.arguments as Map)['dismissible'], true); + }); + + test('returns false when the native side returns null', () async { + mockReturn = null; + final result = await SKOverlayService.present(skan: skanWith(), position: SKOverlayPosition.bottom); + expect(result, isFalse); + }); + + test('returns false on an UNSUPPORTED_IOS PlatformException', () async { + mockThrow = PlatformException(code: 'UNSUPPORTED_IOS'); + final result = await SKOverlayService.present(skan: skanWith(), position: SKOverlayPosition.bottom); + expect(result, isFalse); + }); + + test('returns false on any other error', () async { + mockThrow = PlatformException(code: 'BOOM'); + final result = await SKOverlayService.present(skan: skanWith(), position: SKOverlayPosition.bottom); + expect(result, isFalse); + }); + }); + + group('dismiss', () { + test('returns false and makes no channel call when not iOS', () async { + SKOverlayService.isIOS = () => false; + expect(await SKOverlayService.dismiss(), isFalse); + expect(calls, isEmpty); + }); + + test('invokes dismiss and returns true', () async { + final result = await SKOverlayService.dismiss(); + expect(result, isTrue); + expect(calls.single.method, 'dismiss'); + }); + + test('returns false when the native side returns non-true', () async { + mockReturn = false; + expect(await SKOverlayService.dismiss(), isFalse); + }); + }); +} diff --git a/test/src/services/sk_store_product_service_test.dart b/test/src/services/sk_store_product_service_test.dart new file mode 100644 index 00000000..5708bb3d --- /dev/null +++ b/test/src/services/sk_store_product_service_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/bid.dart'; +import 'package:kontext_flutter_sdk/src/services/sk_store_product_service.dart'; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = binding.defaultBinaryMessenger; + const channel = MethodChannel('kontext_flutter_sdk/sk_store_product'); + + final calls = []; + Object? mockReturn; + Object? mockThrow; + + Skan validSkan() => Skan.fromJson({ + 'version': '4.0', + 'network': 'net.skadnetwork', + 'itunesItem': '123456789', + 'sourceApp': '987', + 'fidelities': [ + {'fidelity': 1, 'nonce': 'n1', 'signature': 's1', 'timestamp': '100'}, + ], + })!; + + setUp(() { + calls.clear(); + mockReturn = true; + mockThrow = null; + SKStoreProductService.isIOS = () => true; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (mockThrow != null) throw mockThrow!; + return mockReturn; + }); + }); + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + + group('present', () { + test('returns false and makes no channel call when not iOS', () async { + SKStoreProductService.isIOS = () => false; + expect(await SKStoreProductService.present(validSkan()), isFalse); + expect(calls, isEmpty); + }); + + test('invokes present with skan.toJson() as the arguments and returns true', () async { + final result = await SKStoreProductService.present(validSkan()); + expect(result, isTrue); + expect(calls, hasLength(1)); + expect(calls.single.method, 'present'); + // The arguments ARE the skan map directly (not nested under a "skan" key). + final args = calls.single.arguments as Map; + expect(args.containsKey('skan'), isFalse); + expect(args['itunesItem'], '123456789'); + expect(args['version'], '4.0'); + expect(args['network'], 'net.skadnetwork'); + expect((args['fidelities'] as List).single, isA()); + }); + + test('returns false when the native side returns null', () async { + mockReturn = null; + expect(await SKStoreProductService.present(validSkan()), isFalse); + }); + + test('returns false on error', () async { + mockThrow = PlatformException(code: 'BOOM'); + expect(await SKStoreProductService.present(validSkan()), isFalse); + }); + }); + + group('dismiss', () { + test('returns false and makes no channel call when not iOS', () async { + SKStoreProductService.isIOS = () => false; + expect(await SKStoreProductService.dismiss(), isFalse); + expect(calls, isEmpty); + }); + + test('invokes dismiss and returns true', () async { + expect(await SKStoreProductService.dismiss(), isTrue); + expect(calls.single.method, 'dismiss'); + }); + + test('returns false when the native side returns non-true', () async { + mockReturn = 'nope'; + expect(await SKStoreProductService.dismiss(), isFalse); + }); + }); +} diff --git a/test/src/utils/types_test.dart b/test/src/utils/types_test.dart new file mode 100644 index 00000000..4d93f49a --- /dev/null +++ b/test/src/utils/types_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/utils/types.dart'; + +void main() { + group('OpenIframeComponent.fromMessageType', () { + test('returns modal for open-component-iframe', () { + expect(OpenIframeComponent.fromMessageType('open-component-iframe'), OpenIframeComponent.modal); + }); + + test('returns modal for close-component-iframe', () { + expect(OpenIframeComponent.fromMessageType('close-component-iframe'), OpenIframeComponent.modal); + }); + + test('returns skoverlay for open-skoverlay-iframe', () { + expect(OpenIframeComponent.fromMessageType('open-skoverlay-iframe'), OpenIframeComponent.skoverlay); + }); + + test('returns skoverlay for close-skoverlay-iframe', () { + expect(OpenIframeComponent.fromMessageType('close-skoverlay-iframe'), OpenIframeComponent.skoverlay); + }); + + test('returns null for unknown message type', () { + expect(OpenIframeComponent.fromMessageType('unknown-type'), null); + }); + + test('returns null for non-string input', () { + expect(OpenIframeComponent.fromMessageType(42), null); + expect(OpenIframeComponent.fromMessageType(null), null); + }); + }); +} diff --git a/test/src/widgets/ad_format_test.dart b/test/src/widgets/ad_format_test.dart index e06fc719..c121f193 100644 --- a/test/src/widgets/ad_format_test.dart +++ b/test/src/widgets/ad_format_test.dart @@ -4,7 +4,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:kontext_flutter_sdk/src/models/bid.dart'; import 'package:kontext_flutter_sdk/src/models/ad_event.dart'; import 'package:kontext_flutter_sdk/src/services/sk_overlay_service.dart'; -import 'package:kontext_flutter_sdk/src/services/sk_store_product_service.dart'; import 'package:kontext_flutter_sdk/src/utils/types.dart' show Json, OpenIframeComponent; import 'package:kontext_flutter_sdk/src/widgets/ad_format.dart'; import 'package:kontext_flutter_sdk/src/widgets/interstitial_modal.dart' show InterstitialModal; @@ -1094,6 +1093,27 @@ void main() { await tester.pumpWidget( createDefaultProvider( + bids: [ + Bid( + id: '1', + code: 'test_code', + position: AdDisplayPosition.afterAssistantMessage, + skan: Skan( + version: '4.0', + network: 'test.skadnetwork', + itunesItem: '123456', + sourceApp: '0', + fidelities: [ + AttributionFidelity( + fidelity: 1, + signature: 'test_sig', + nonce: 'test_nonce', + timestamp: '1234567890', + ), + ], + ), + ), + ], child: AdFormat( code: 'test_code', messageId: 'msg_1', @@ -1110,7 +1130,6 @@ void main() { jsCalls.clear(); onMessage(fakeController, 'open-skoverlay-iframe', { - 'appStoreId': '123', 'position': 'bottom', 'dismissible': true, }); @@ -1122,17 +1141,9 @@ void main() { final presentCall = methodCalls.firstWhere((c) => c.method == 'present'); final args = presentCall.arguments as Map; - expect(args['appStoreId'], equals('123')); expect(args['position'], equals('bottom')); expect(args['dismissible'], isTrue); - expect( - jsCalls.any( - (s) => s.contains('update-skoverlay-iframe') && s.contains('"open":true') && s.contains('"code":"test_code"'), - ), - isTrue, - ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); }, ); @@ -1199,172 +1210,6 @@ void main() { expect(methodCalls.any((c) => c.method == 'dismiss'), isTrue); - expect( - jsCalls.any( - (s) => - s.contains('update-skoverlay-iframe') && s.contains('"open":false') && s.contains('"code":"test_code"'), - ), - isTrue, - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); - }, - ); - - testWidgets( - 'open-skstoreproduct-iframe presents SKStoreProduct and posts open=true update', - (WidgetTester tester) async { - late OnMessageReceived onMessage; - - FakeWebview webviewBuilder({ - Key? key, - required Uri uri, - required List allowedOrigins, - required OnEventIframe onEventIframe, - required OnMessageReceived onMessageReceived, - }) { - onMessage = onMessageReceived; - return FakeWebview( - key: key, - onEventIframe: onEventIframe, - onMessageReceived: onMessageReceived, - ); - } - - final methodCalls = []; - final jsCalls = []; - - final originalIsIOS = SKStoreProductService.isIOS; - SKStoreProductService.isIOS = () => true; - addTearDown(() => SKStoreProductService.isIOS = originalIsIOS); - - when(() => fakeController.evaluateJavascript(source: any(named: 'source'))).thenAnswer((invocation) async { - final source = invocation.namedArguments[const Symbol('source')] as String; - jsCalls.add(source); - return null; - }); - - const channel = MethodChannel('kontext_flutter_sdk/sk_store_product'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { - methodCalls.add(call); - return true; - }); - - await tester.pumpWidget( - createDefaultProvider( - child: AdFormat( - code: 'test_code', - messageId: 'msg_1', - onActiveChanged: onActiveChanged, - webviewBuilder: webviewBuilder, - ), - ), - ); - - onMessage(fakeController, 'init-iframe', null); - await tester.pump(); - - methodCalls.clear(); - jsCalls.clear(); - - onMessage(fakeController, 'open-skstoreproduct-iframe', { - 'appStoreId': '123', - }); - - await tester.pumpAndSettle(); - - expect(methodCalls.any((c) => c.method == 'present'), isTrue); - - final presentCall = methodCalls.firstWhere((c) => c.method == 'present'); - final args = presentCall.arguments as Map; - expect(args['appStoreId'], equals('123')); - - expect( - jsCalls.any( - (s) => - s.contains('update-skstoreproduct-iframe') && - s.contains('"open":true') && - s.contains('"code":"test_code"'), - ), - isTrue, - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); - }, - ); - - testWidgets( - 'close-skstoreproduct-iframe dismisses SKStoreProduct and posts open=false update', - (WidgetTester tester) async { - late OnMessageReceived onMessage; - - FakeWebview webviewBuilder({ - Key? key, - required Uri uri, - required List allowedOrigins, - required OnEventIframe onEventIframe, - required OnMessageReceived onMessageReceived, - }) { - onMessage = onMessageReceived; - return FakeWebview( - key: key, - onEventIframe: onEventIframe, - onMessageReceived: onMessageReceived, - ); - } - - final methodCalls = []; - final jsCalls = []; - - final originalIsIOS = SKStoreProductService.isIOS; - SKStoreProductService.isIOS = () => true; - addTearDown(() => SKStoreProductService.isIOS = originalIsIOS); - - when(() => fakeController.evaluateJavascript(source: any(named: 'source'))).thenAnswer((invocation) async { - final source = invocation.namedArguments[const Symbol('source')] as String; - jsCalls.add(source); - return null; - }); - - const channel = MethodChannel('kontext_flutter_sdk/sk_store_product'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { - methodCalls.add(call); - return true; - }); - - await tester.pumpWidget( - createDefaultProvider( - child: AdFormat( - code: 'test_code', - messageId: 'msg_1', - onActiveChanged: onActiveChanged, - webviewBuilder: webviewBuilder, - ), - ), - ); - - onMessage(fakeController, 'init-iframe', null); - await tester.pump(); - - methodCalls.clear(); - jsCalls.clear(); - - onMessage(fakeController, 'close-skstoreproduct-iframe', null); - - await tester.pumpAndSettle(); - - expect(methodCalls.any((c) => c.method == 'dismiss'), isTrue); - - expect( - jsCalls.any( - (s) => - s.contains('update-skstoreproduct-iframe') && - s.contains('"open":false') && - s.contains('"code":"test_code"'), - ), - isTrue, - ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); }, );