Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2e23add
chore(release): start 2.2.4 from v2.2.1 baseline
JakubMrozek Jul 8, 2026
6e88ffc
chore(release): bump version to 2.2.4
JakubMrozek Jul 8, 2026
8d4a30e
fix(skan): report SDK version 2.2.4 to the server
JakubMrozek Jul 8, 2026
24cad89
feat(skan): declare StoreKit framework + bump podspec to 2.2.4
JakubMrozek Jul 8, 2026
58f676d
fix(privacy): sync PrivacyInfo.xcprivacy with Swift SDK (from 2.2.2)
JakubMrozek Jul 8, 2026
ae9802e
feat(skan): port 2.2.2 bid.dart model + add model tests
JakubMrozek Jul 8, 2026
62be348
feat(skan): port the 2.2.2 SKAN flow (native + Dart) as one atomic unit
JakubMrozek Jul 8, 2026
7053a60
test(skan): add unit tests for SKOverlay + SKStoreProduct services
JakubMrozek Jul 8, 2026
d66f43e
docs(example): add SKAdNetworkItems with our ad-network id to the dem…
JakubMrozek Jul 8, 2026
7cc0e07
docs(example): log ad lifecycle events via onEvent in the demo app
JakubMrozek Jul 8, 2026
62b5f86
docs(readme): document required SKAdNetworkItems host-app setup
JakubMrozek Jul 9, 2026
cb2900b
docs(changelog): fill in the 2.2.4 release notes
JakubMrozek Jul 9, 2026
9f748b3
fix(skan): validate fidelity-1 timestamp parses instead of coercing to 0
JakubMrozek Jul 9, 2026
468f574
fix(skan): fail closed when SKAN attribution can't be attached to SKS…
JakubMrozek Jul 9, 2026
f700f2f
revert(privacy): keep PrivacyInfo.xcprivacy at the v2.2.1 production …
JakubMrozek Jul 9, 2026
f5ac4b1
test(skan): cover the SKAdNetwork impression state machine (15 tests)
JakubMrozek Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<key>SKAdNetworkItems</key>
<array>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>mp7rpxwdrx.skadnetwork</string>
</dict>
</array>
```

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.
7 changes: 7 additions & 0 deletions example/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,12 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>SKAdNetworkItems</key>
<array>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>mp7rpxwdrx.skadnetwork</string>
Comment thread
JakubMrozek marked this conversation as resolved.
</dict>
</array>
</dict>
</plist>
14 changes: 14 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,20 @@ class _HomePageState extends State<HomePage> {
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(
Expand Down
145 changes: 62 additions & 83 deletions ios/Classes/SKAdNetworkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"])
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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) {
Expand All @@ -270,4 +249,4 @@ final class SKAdNetworkManager {
DispatchQueue.main.async { completion(value) }
}
}
}
}
Loading
Loading