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