From 2e23add956d627913ffde883265c24d13b44929c Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Wed, 8 Jul 2026 10:31:55 +0200 Subject: [PATCH 01/16] chore(release): start 2.2.4 from v2.2.1 baseline Begin the 2.2.4 line from the known-good production tag v2.2.1 and add a placeholder changelog heading. SKAN code will be re-introduced on top of this, reviewed incrementally. Content to follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7b89d62..df3e6c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 2.2.4 + ## 2.2.1 * Add `revenue` to `AdEvent.adViewed` events. From 6e88ffce844508db0c9260a0c2850d2a5771ec6d Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Wed, 8 Jul 2026 12:36:09 +0200 Subject: [PATCH 02/16] chore(release): bump version to 2.2.4 Cross the server skanSupported gate (Flutter >= 2.2.2) so the ad server sends signed skan payloads. Only the version line changes; the Flutter constraint (>=3.24.0) and flutter_lints (^5.0.0) are intentionally kept at their v2.2.1 values. Co-Authored-By: Claude Opus 4.8 (1M context) --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 8d4a30e48f0b50705f48ce69aa8a5b8c7c44ba1e Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Wed, 8 Jul 2026 12:43:23 +0200 Subject: [PATCH 03/16] fix(skan): report SDK version 2.2.4 to the server kSdkVersion is sent as the reported SDK version in the preload request (api.dart, use_preload_ads.dart). The ad server gates skanSupported on sdk-flutter >= 2.2.2, so this must be >= 2.2.2 for signed skan payloads to be returned. Set to 2.2.4 to match pubspec. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/utils/constants.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'; From 24cad89581894e1517a9a62681bc02584c767dad Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Wed, 8 Jul 2026 12:59:25 +0200 Subject: [PATCH 04/16] feat(skan): declare StoreKit framework + bump podspec to 2.2.4 The SKAN native code (SKAdNetworkManager, SKStoreProductManager, SKOverlayManager) imports StoreKit. Declare it explicitly in s.frameworks so it links reliably now that SKAN is actually activated, rather than relying on Swift implicit auto-linking. Deployment target intentionally kept at iOS 12.0 (SKAN APIs are runtime-gated with @available). Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/kontext_flutter_sdk.podspec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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'] From 58f676d0541958e2d66a92fcd4f9fe4d4a3dc002 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Wed, 8 Jul 2026 13:17:33 +0200 Subject: [PATCH 05/16] fix(privacy): sync PrivacyInfo.xcprivacy with Swift SDK (from 2.2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the 2.2.2 privacy manifest: NSPrivacyTracking=false, clear tracking domains, DeviceID declared as AppFunctionality/non-tracking (matches sdk-swift), and add the UserDefaults required-reason API (CA92.1). SystemBootTime (8FFB.1) is intentionally retained: the Flutter native AppInfoPlugin reads kp_proc.p_starttime, which sdk-swift does not — so the manifests correctly differ on that one entry. Manifest reflects the Flutter binary's actual API usage. Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/PrivacyInfo.xcprivacy | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/ios/PrivacyInfo.xcprivacy b/ios/PrivacyInfo.xcprivacy index 199446a7..1afc70b2 100644 --- a/ios/PrivacyInfo.xcprivacy +++ b/ios/PrivacyInfo.xcprivacy @@ -2,28 +2,21 @@ - - NSPrivacyTracking - + NSPrivacyTracking NSPrivacyTrackingDomains - - megabrain.co - - + NSPrivacyCollectedDataTypes - NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeDeviceID - NSPrivacyCollectedDataTypeLinked - NSPrivacyCollectedDataTypeTracking + NSPrivacyCollectedDataTypeLinked + NSPrivacyCollectedDataTypeTracking NSPrivacyCollectedDataTypePurposes - NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising + NSPrivacyCollectedDataTypePurposeAppFunctionality - NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeOtherAppInfo @@ -34,7 +27,6 @@ NSPrivacyCollectedDataTypePurposeAnalytics - NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeOtherDiagnosticData @@ -45,9 +37,7 @@ NSPrivacyCollectedDataTypePurposeAnalytics - - NSPrivacyAccessedAPITypes @@ -66,7 +56,14 @@ 8FFB.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + - From ae9802eb8b2047fa2a2b24e5315ca28848a51e87 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Wed, 8 Jul 2026 14:04:30 +0200 Subject: [PATCH 06/16] feat(skan): port 2.2.2 bid.dart model + add model tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the tested 2.2.2 Skan/Bid model onto the 2.2.1 base: adds Skan.toJson() (consumed by the SKOverlay/SKStoreProduct services), ImpressionTrigger, and fidelities in Skan equality/hashCode. Additive and non-breaking (superset of the 2.2.1 public API). Extend bid_test.dart with 20 new tests covering the additions: impressionTrigger parsing/defaults, Skan.toJson (required-only, null omission, optionals, fidelities), Skan equality incl. fidelities, and AttributionFidelity parse/equality. Full file: 50 tests, all passing. Note: bid.dart:221 still reads json['akk'] (should be 'aak') — an AAK-only bug, irrelevant to SKAN; left as-is for the SKAN-focused 2.2.4. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/models/bid.dart | 54 ++++++++- test/src/models/bid_test.dart | 200 ++++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 5 deletions(-) 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/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 From 62be34892554069bf4132d338126fe66088deb90 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Wed, 8 Jul 2026 16:01:44 +0200 Subject: [PATCH 07/16] feat(skan): port the 2.2.2 SKAN flow (native + Dart) as one atomic unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the tested 2.2.2 SKAN wiring onto the 2.2.1 base as a single compile-coupled + method-channel-coupled change: - ad_format.dart: click -> fidelity-1 -> SKStoreProduct (else browser), impressionTrigger timing (immediate on ad-done / component on modal), pass full skan to the services, remove the message-driven skstoreproduct path. Excludes the non-SKAN '; null' evaluateJavascript tweak (kept at v2.2.1, consistent with kontext_webview.dart). - types.dart: drop the skstoreproduct OpenIframeComponent value. - interstitial_modal.dart: drop skstoreproduct message handling. - sk_overlay_service / sk_store_product_service: send skan.toJson(). - Native plugins/managers read the skan payload; field mapping matches the production-proven KontextKit (SKOverlay + SKStoreProduct verified). - SKAdNetworkManager: aligned with KontextKit — resolve nonce/timestamp/signature upfront (top-level else fidelity-0), validate all six required fields, guard-let instead of force-unwraps, drop parseFidelities, fail cleanly with MISSING_ARGUMENTS. Flutter SKAN was never live in production, so KontextKit is the source of truth. - Port coupled 2.2.2 tests (ad_format_test, types_test). flutter analyze: clean. Full test suite: green. Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/Classes/SKAdNetworkManager.swift | 145 ++++++------- ios/Classes/SKOverlayManager.swift | 85 +++++++- ios/Classes/SKOverlayPlugin.swift | 10 +- ios/Classes/SKStoreProductManager.swift | 58 +++++- ios/Classes/SKStoreProductPlugin.swift | 9 +- lib/src/services/sk_overlay_service.dart | 8 +- .../services/sk_store_product_service.dart | 5 +- lib/src/utils/types.dart | 3 +- lib/src/widgets/ad_format.dart | 168 +++++---------- lib/src/widgets/interstitial_modal.dart | 6 - test/src/utils/types_test.dart | 31 +++ test/src/widgets/ad_format_test.dart | 197 ++---------------- 12 files changed, 316 insertions(+), 409 deletions(-) create mode 100644 test/src/utils/types_test.dart 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..b6974ce0 100644 --- a/ios/Classes/SKStoreProductManager.swift +++ b/ios/Classes/SKStoreProductManager.swift @@ -9,14 +9,17 @@ 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) ] + Self.applySkanParams(skan, into: ¶ms) let viewController = SKStoreProductViewController() viewController.delegate = self @@ -47,6 +50,53 @@ 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: String, 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 timestamp = f1["timestamp"] as? String, !timestamp.isEmpty, + let signature = f1["signature"] as? String, !signature.isEmpty + else { return nil } + return (nonce, timestamp, signature) + } + + /// Appends all required SKAN install-validation keys to the SKStoreProduct params dict. + private static func applySkanParams(_ skan: [String: Any], into params: inout [String: Any]) { + guard #available(iOS 14.0, *) else { return } + + 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 } + + let sourceAppInt = Int(sourceApp) ?? 0 + let campaignInt = (skan["campaign"] as? String).flatMap { Int($0) } ?? 0 + let timestampInt = Int(f1.timestamp) ?? 0 + + params[SKStoreProductParameterAdNetworkVersion] = version + params[SKStoreProductParameterAdNetworkIdentifier] = network + params[SKStoreProductParameterAdNetworkSourceAppStoreIdentifier] = NSNumber(value: sourceAppInt) + params[SKStoreProductParameterAdNetworkCampaignIdentifier] = NSNumber(value: campaignInt) + params[SKStoreProductParameterAdNetworkTimestamp] = NSNumber(value: timestampInt) + 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) + } + } + } func dismiss(completion: @escaping (Bool) -> Void) { let run: () -> Void = { [weak self] in 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/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/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/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); }, ); From 7053a60bbee80d76dff10883a81f1ea104a1073a Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Wed, 8 Jul 2026 16:18:02 +0200 Subject: [PATCH 08/16] test(skan): add unit tests for SKOverlay + SKStoreProduct services Cover the two previously-untested SKAN services via injectable isIOS + mocked method channels: platform gating (non-iOS -> false, no channel call), correct channel args (skan.toJson(); overlay nests under 'skan' with position/dismissible, store-product sends the skan map directly), return-value handling, the overlay itunesItem.isEmpty guard, and PlatformException/UNSUPPORTED_IOS handling. 17 tests, all passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/services/sk_overlay_service_test.dart | 121 ++++++++++++++++++ .../sk_store_product_service_test.dart | 90 +++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 test/src/services/sk_overlay_service_test.dart create mode 100644 test/src/services/sk_store_product_service_test.dart 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); + }); + }); +} From d66f43ecf5fe51184f01ff185a6a70feb39cea01 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Wed, 8 Jul 2026 16:46:42 +0200 Subject: [PATCH 09/16] docs(example): add SKAdNetworkItems with our ad-network id to the demo app Show publishers the required SKAN setup: our ad-network id (mp7rpxwdrx.skadnetwork) must be present in the host app's Info.plist SKAdNetworkItems for Apple to allow SKAN attribution. Example-only; does not affect the published SDK. Co-Authored-By: Claude Opus 4.8 (1M context) --- example/ios/Runner/Info.plist | 7 +++++++ 1 file changed, 7 insertions(+) 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 + + From 7cc0e0790f455cbb5db4331f3a5a4673014b0c59 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Wed, 8 Jul 2026 17:32:31 +0200 Subject: [PATCH 10/16] docs(example): log ad lifecycle events via onEvent in the demo app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the AdsProvider onEvent callback to print each ad event (filled/no-fill/render-started/completed/viewed/clicked/error) with its key fields — a ready reference for publishers on consuming SDK events. Example-only; does not affect the published SDK. Co-Authored-By: Claude Opus 4.8 (1M context) --- example/lib/main.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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( From 62b5f86f817915c5ff5d940d57970a198a426781 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Thu, 9 Jul 2026 12:05:03 +0200 Subject: [PATCH 11/16] docs(readme): document required SKAdNetworkItems host-app setup Integrators must add mp7rpxwdrx.skadnetwork to their own app's Info.plist SKAdNetworkItems or SKAN attribution silently never fires (ads still serve). Addresses PR #102 review feedback; the example app's plist serves as the working reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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. From cb2900b591017c6557fa08826effe6a97dad458d Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Thu, 9 Jul 2026 12:40:08 +0200 Subject: [PATCH 12/16] docs(changelog): fill in the 2.2.4 release notes Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index df3e6c9e..f32c2ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +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. +* Update the iOS privacy manifest (`NSPrivacyTracking` = false, no tracking domains, add the UserDefaults required-reason API) to match the Swift SDK. +* 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. From 9f748b34e1a35a8a671ddcd9e327a6cea6f6435a Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Thu, 9 Jul 2026 13:38:37 +0200 Subject: [PATCH 13/16] fix(skan): validate fidelity-1 timestamp parses instead of coercing to 0 A non-numeric timestamp previously became 0 via 'Int(...) ?? 0', producing an invalid signature and silent attribution loss. Parse it in fidelity1Values (NSNumber or numeric string, else nil -> SKAN params skipped, sheet still presents), mirroring SKOverlayManager and KontextKit. Unreachable from our server (signs String(Date.now()) and the wire schema enforces strings), but a DSP could theoretically send a non-numeric string. Addresses PR #102 review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/Classes/SKStoreProductManager.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/ios/Classes/SKStoreProductManager.swift b/ios/Classes/SKStoreProductManager.swift index b6974ce0..012cd4e8 100644 --- a/ios/Classes/SKStoreProductManager.swift +++ b/ios/Classes/SKStoreProductManager.swift @@ -20,7 +20,7 @@ final class SKStoreProductManager: NSObject, SKStoreProductViewControllerDelegat SKStoreProductParameterITunesItemIdentifier: NSNumber(value: itemId) ] Self.applySkanParams(skan, into: ¶ms) - + let viewController = SKStoreProductViewController() viewController.delegate = self viewController.loadProduct(withParameters: params) { [weak self] loaded, error in @@ -41,7 +41,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) @@ -56,14 +56,21 @@ final class SKStoreProductManager: NSObject, SKStoreProductViewControllerDelegat /// 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: String, signature: String)? { + 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 timestamp = f1["timestamp"] as? String, !timestamp.isEmpty, 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) } @@ -80,13 +87,12 @@ final class SKStoreProductManager: NSObject, SKStoreProductViewControllerDelegat let sourceAppInt = Int(sourceApp) ?? 0 let campaignInt = (skan["campaign"] as? String).flatMap { Int($0) } ?? 0 - let timestampInt = Int(f1.timestamp) ?? 0 params[SKStoreProductParameterAdNetworkVersion] = version params[SKStoreProductParameterAdNetworkIdentifier] = network params[SKStoreProductParameterAdNetworkSourceAppStoreIdentifier] = NSNumber(value: sourceAppInt) params[SKStoreProductParameterAdNetworkCampaignIdentifier] = NSNumber(value: campaignInt) - params[SKStoreProductParameterAdNetworkTimestamp] = NSNumber(value: timestampInt) + params[SKStoreProductParameterAdNetworkTimestamp] = f1.timestamp params[SKStoreProductParameterAdNetworkAttributionSignature] = f1.signature params[SKStoreProductParameterAdNetworkNonce] = f1.nonce From 468f5740cf039a29b0d674e2e7ad302e726931d0 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Thu, 9 Jul 2026 13:45:04 +0200 Subject: [PATCH 14/16] fix(skan): fail closed when SKAN attribution can't be attached to SKStoreProduct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applySkanParams was void — on any invalid field it silently skipped the SKAN params, the sheet still presented, and completion(true) suppressed the Dart browser fallback: the store page opened unattributed with no error surfaced. It now returns Bool and present() errors with ATTRIBUTION_FAILED, which the existing Dart path turns into a browser fallback — no Dart change needed. Mirrors KontextKit (identical error semantics) and SKOverlayManager. Addresses PR #102 blocking review. Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/Classes/SKStoreProductManager.swift | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/ios/Classes/SKStoreProductManager.swift b/ios/Classes/SKStoreProductManager.swift index 012cd4e8..9973e0eb 100644 --- a/ios/Classes/SKStoreProductManager.swift +++ b/ios/Classes/SKStoreProductManager.swift @@ -19,7 +19,18 @@ final class SKStoreProductManager: NSObject, SKStoreProductViewControllerDelegat var params: [String: Any] = [ SKStoreProductParameterITunesItemIdentifier: NSNumber(value: itemId) ] - Self.applySkanParams(skan, into: ¶ms) + // 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 @@ -75,15 +86,16 @@ final class SKStoreProductManager: NSObject, SKStoreProductViewControllerDelegat } /// Appends all required SKAN install-validation keys to the SKStoreProduct params dict. - private static func applySkanParams(_ skan: [String: Any], into params: inout [String: Any]) { - guard #available(iOS 14.0, *) else { return } + /// 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 } + else { return false } let sourceAppInt = Int(sourceApp) ?? 0 let campaignInt = (skan["campaign"] as? String).flatMap { Int($0) } ?? 0 @@ -102,8 +114,10 @@ final class SKStoreProductManager: NSObject, SKStoreProductViewControllerDelegat params[SKStoreProductParameterAdNetworkSourceIdentifier] = NSNumber(value: sourceIdentifierInt) } } + + return true } - + func dismiss(completion: @escaping (Bool) -> Void) { let run: () -> Void = { [weak self] in guard let self = self else { From f700f2f8fbc4092e2702f8c627a62a416d14d5c7 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Thu, 9 Jul 2026 14:38:09 +0200 Subject: [PATCH 15/16] revert(privacy): keep PrivacyInfo.xcprivacy at the v2.2.1 production state Minimal-delta decision: 2.2.1 is the proven production baseline and the manifest is unrelated to the SKAN activation this release ships. Drops the 2.2.2 manifest sync (and its changelog line) so the file no longer appears in the release diff. The Swift-SDK-aligned manifest can be revisited separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 - ios/PrivacyInfo.xcprivacy | 29 ++++++++++++++++------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f32c2ae9..ad2b9319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ * 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. -* Update the iOS privacy manifest (`NSPrivacyTracking` = false, no tracking domains, add the UserDefaults required-reason API) to match the Swift SDK. * Declare the `StoreKit` framework in the podspec. * Document the required host-app `SKAdNetworkItems` entry (`mp7rpxwdrx.skadnetwork`) in the README. diff --git a/ios/PrivacyInfo.xcprivacy b/ios/PrivacyInfo.xcprivacy index 1afc70b2..199446a7 100644 --- a/ios/PrivacyInfo.xcprivacy +++ b/ios/PrivacyInfo.xcprivacy @@ -2,21 +2,28 @@ - NSPrivacyTracking + + NSPrivacyTracking + NSPrivacyTrackingDomains - + + megabrain.co + + NSPrivacyCollectedDataTypes + NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeDeviceID - NSPrivacyCollectedDataTypeLinked - NSPrivacyCollectedDataTypeTracking + NSPrivacyCollectedDataTypeLinked + NSPrivacyCollectedDataTypeTracking NSPrivacyCollectedDataTypePurposes - NSPrivacyCollectedDataTypePurposeAppFunctionality + NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising + NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeOtherAppInfo @@ -27,6 +34,7 @@ NSPrivacyCollectedDataTypePurposeAnalytics + NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeOtherDiagnosticData @@ -37,7 +45,9 @@ NSPrivacyCollectedDataTypePurposeAnalytics + + NSPrivacyAccessedAPITypes @@ -56,14 +66,7 @@ 8FFB.1 - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryUserDefaults - NSPrivacyAccessedAPITypeReasons - - CA92.1 - - + From f5ac4b1b268699c69555f5c7f39aeeb65ae077b4 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Thu, 9 Jul 2026 14:49:23 +0200 Subject: [PATCH 16/16] test(skan): cover the SKAdNetwork impression state machine (15 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make isIOS injectable (same pattern as the other two SKAN services) and add unit tests for the full impression lifecycle: payload field names, arming only on successful init, start/end gated on readiness, error swallowing (never throws), dispose disarming, call ordering, and slow native responses resolving correctly — the Dart layer is latency-proof regardless of when Apple's async completion returns. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/services/sk_ad_network_service.dart | 10 +- .../services/sk_ad_network_service_test.dart | 161 ++++++++++++++++++ 2 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 test/src/services/sk_ad_network_service_test.dart 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/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']); + }); +}