From 6341f86476365ce6098639b5aeb984092b331bec Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Thu, 2 Jul 2026 20:08:45 +0100 Subject: [PATCH 1/6] fix(android): recover from lost init-iframe + add handshake logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: on Android the ad fills (frame served) but never shows, and no `viewed` impression is recorded. Root cause is the same class of bug fixed in the React Native SDK for Saylo: the Android WebView `onLoad` can fire two or more times, and a reload after the one-time `onWebViewCreated` flush re-creates an empty message queue, so an `init-iframe` posted on the reload is queued but never delivered. Without `init-iframe`, `iframeLoaded` never becomes true, `update-iframe` is never sent, the stream never starts, and the ad never shows — silently, with no error. Changes: - KontextWebview: re-run the message-queue flush on every `onLoadStop` (not just once in `onWebViewCreated`) so a re-queued `init-iframe` is delivered; expose an optional `onLoadStop` callback. - AdFormat: arm a 500ms fallback on load that, if the handshake hasn't progressed, forces `iframeLoaded` (to unblock dimension posting) and (re)sends `update-iframe` — mirroring the RN Saylo workaround. - Add `[Kontext][handshake]` info logging across the full lifecycle (active/frame URL, onLoadStop, init/show/resize/ad-done/hide/error, update-iframe normal + fallback, dimension posting) to diagnose on device. - Drop the `; null` appended to the two evaluateJavascript sources. It is a no-op on Android (undefined is coerced to null there); it only suppressed a cosmetic iOS "unsupported type" log. Restore before release if desired. - example: print ad events and use chai-dev/inlineAd for on-device testing. Co-Authored-By: Claude Opus 4.8 (1M context) --- example/lib/constants.dart | 2 +- example/lib/main.dart | 20 +++++++++ lib/src/widgets/ad_format.dart | 64 +++++++++++++++++++++++++++- lib/src/widgets/kontext_webview.dart | 14 +++++- 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/example/lib/constants.dart b/example/lib/constants.dart index f0bff058..599ab4fb 100644 --- a/example/lib/constants.dart +++ b/example/lib/constants.dart @@ -1,2 +1,2 @@ -const String kPublisherToken = 'PUBLISHER_TOKEN'; +const String kPublisherToken = 'chai-dev'; const String kPlacementCode = 'inlineAd'; diff --git a/example/lib/main.dart b/example/lib/main.dart index b95b03c4..a518fb11 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -64,6 +64,25 @@ class _HomePageState extends State { void _append(Message m) => setState(() => _messages.add(m)); + void _onAdEvent(AdEvent event) { + // Surface every ad event (ad.filled, ad.no-fill, ad.viewed, ad.clicked, …) + // in the console so it's visible in `flutter run` logs. + final parts = [ + 'type=${event.type.value}', + if (event.code != null) 'code=${event.code}', + if (event.skipCode != null) 'skipCode=${event.skipCode}', + if (event.id != null) 'id=${event.id}', + if (event.revenue != null) 'revenue=${event.revenue}', + if (event.messageId != null) 'messageId=${event.messageId}', + if (event.format != null) 'format=${event.format}', + if (event.area != null) 'area=${event.area}', + if (event.url != null) 'url=${event.url}', + if (event.message != null) 'message=${event.message}', + if (event.errCode != null) 'errCode=${event.errCode}', + ]; + debugPrint('[KontextAdEvent] ${parts.join(' ')}'); + } + void _onSubmit() { final text = _input.text.trim(); if (text.isEmpty || _isLoading) return; @@ -116,6 +135,7 @@ class _HomePageState extends State { enabledPlacementCodes: const [kPlacementCode], otherParams: {'theme': theme}, logLevel: LogLevel.info, + onEvent: _onAdEvent, child: Column( children: [ Expanded( diff --git a/lib/src/widgets/ad_format.dart b/lib/src/widgets/ad_format.dart index 45dfb2c2..c295ebdc 100644 --- a/lib/src/widgets/ad_format.dart +++ b/lib/src/widgets/ad_format.dart @@ -136,6 +136,7 @@ class AdFormat extends HookWidget { }, }; + Logger.info('[Kontext][handshake] sending update-iframe (code=$code, messageId=$messageId)'); _postMessageToWebView(adServerUrl, controller, payload); } @@ -145,7 +146,7 @@ class AdFormat extends HookWidget { Json payload, ) { controller.evaluateJavascript(source: ''' - window.postMessage(${jsonEncode(payload)}, '$adServerUrl'); null + window.postMessage(${jsonEncode(payload)}, '$adServerUrl'); '''); } @@ -168,25 +169,32 @@ class AdFormat extends HookWidget { }) { switch (messageType) { case 'init-iframe': + Logger.info('[Kontext][handshake] init-iframe received (code=$code, messageId=$messageId, bidId=${bid.id})'); iframeLoaded.value = true; unawaited(_handleAttributionInitialization(bid.akk, bid.skan, attributionType)); break; case 'show-iframe': + Logger.info('[Kontext][handshake] show-iframe received (code=$code, messageId=$messageId, bidId=${bid.id})'); showIframe.value = true; break; case 'hide-iframe': + Logger.info('[Kontext][handshake] hide-iframe received (code=$code, messageId=$messageId)'); showIframe.value = false; break; case 'resize-iframe': final dataHeight = data?['height']; if (dataHeight is num) { + Logger.info('[Kontext][handshake] resize-iframe received height=$dataHeight (code=$code, messageId=$messageId)'); height.value = dataHeight.toDouble(); + } else { + Logger.info('[Kontext][handshake] resize-iframe received with non-numeric height=$dataHeight (code=$code, messageId=$messageId)'); } break; case 'click-iframe': _handleClickIframe(bid: bid, adServerUrl: adServerUrl, controller: controller, data: data); break; case 'ad-done-iframe': + Logger.info('[Kontext][handshake] ad-done-iframe received (code=$code, messageId=$messageId, impressionTrigger=${bid.impressionTrigger.name})'); final content = data?['cachedContent'] as String?; if (content != null) { adsProviderData.setCachedContent(bid.id, content); @@ -224,9 +232,11 @@ class AdFormat extends HookWidget { _handleCloseComponentIframe(component); break; case 'error-iframe': + Logger.info('[Kontext][handshake] error-iframe received — resetting (code=$code, messageId=$messageId, data=$data)'); resetIframe(); break; default: + Logger.info('[Kontext][handshake] unhandled message "$messageType" (code=$code, messageId=$messageId)'); } } @@ -462,11 +472,14 @@ class AdFormat extends HookWidget { final ticker = useRef(null); final delayedTicker = useRef(null); + final initFallbackTimer = useRef(null); void cancelTimers() { delayedTicker.value?.cancel(); delayedTicker.value = null; ticker.value?.cancel(); ticker.value = null; + initFallbackTimer.value?.cancel(); + initFallbackTimer.value = null; } void setActive(bool active) => WidgetsBinding.instance.addPostFrameCallback((_) { @@ -496,6 +509,9 @@ class AdFormat extends HookWidget { final isActive = !disabled && bid != null && inlineUri != null; useEffect(() { + if (isActive) { + Logger.info('[Kontext][handshake] ad active — loading frame (code=$code, messageId=$messageId, bidId=$bidId, uri=$inlineUri)'); + } setActive(isActive); return null; }, [isActive]); @@ -532,6 +548,44 @@ class AdFormat extends HookWidget { final showIframe = useState(false); final height = useState(.0); + // Fallback for a lost `init-iframe` (Android double-onLoad race; see RN Saylo fix). + // Normally the iframe posts `init-iframe`, we set `iframeLoaded` and send `update-iframe`, + // and the server starts the stream. If `init-iframe` never reaches us, that chain never + // starts: the ad fills (frame served) but never shows, with no error. This starts on + // page load and, if the handshake hasn't progressed after a short delay, forces + // `iframeLoaded` (to unblock dimension posting) and (re)sends `update-iframe` directly. + void startInitFallback(InAppWebViewController controller) { + if (initFallbackTimer.value != null) return; // already armed for this load cycle + var attempts = 0; + initFallbackTimer.value = Timer.periodic(const Duration(milliseconds: 500), (timer) { + // Stop once the ad actually starts showing, on dispose, or if init-iframe arrived + // normally before we ever needed to fire. + if (disposed.value || showIframe.value || (iframeLoaded.value && attempts == 0)) { + timer.cancel(); + initFallbackTimer.value = null; + return; + } + attempts++; + Logger.info( + '[Kontext][handshake] init-iframe/show-iframe not received after ' + '${attempts * 500}ms — sending update-iframe fallback ' + '(code=$code, messageId=$messageId, attempt=$attempts)', + ); + iframeLoaded.value = true; // unblock the Offstage + dimension-posting pipeline + _postUpdateIframe( + controller, + adServerUrl: adsProviderData.adServerUrl, + messages: adsProviderData.messages.getLastMessages(), + otherParams: adsProviderData.otherParams, + ); + if (attempts >= 6) { + // ~3s of retries; give up so we don't spin forever on a genuinely empty slot. + timer.cancel(); + initFallbackTimer.value = null; + } + }); + } + useEffect(() { // messageId can only become relevant if an ad was shown for that specific messageId if (showIframe.value && adsProviderData.lastAssistantMessageId == messageId) { @@ -556,6 +610,7 @@ class AdFormat extends HookWidget { ); final shouldRun = iframeLoaded.value && showIframe.value; if (shouldRun && ticker.value == null && delayedTicker.value == null) { + Logger.info('[Kontext][handshake] ad visible (iframeLoaded && showIframe) — starting dimension posting (code=$code, messageId=$messageId)'); // Start after a short delay to allow initial layout to settle delayedTicker.value = Timer(const Duration(milliseconds: 500), () { delayedTicker.value = null; @@ -563,6 +618,7 @@ class AdFormat extends HookWidget { return; } // First call immediately without waiting for the first tick + Logger.info('[Kontext][handshake] posting first update-dimensions-iframe (code=$code, messageId=$messageId)'); postDimensions(); ticker.value = Timer.periodic( const Duration(milliseconds: 300), @@ -596,6 +652,7 @@ class AdFormat extends HookWidget { }, [iframeLoaded.value, webviewController.value, otherParamsHash]); void resetIframe() { + Logger.info('[Kontext][handshake] resetIframe (code=$code, messageId=$messageId)'); unawaited(_cleanupAttributionResources(attributionType)); _dismissSkOverlay(); _dismissSkStoreProduct(); @@ -622,6 +679,11 @@ class AdFormat extends HookWidget { allowedOrigins: allowedOrigins, onEventIframe: onEventIframe, onMessageReceived: onMessageReceived, + onLoadStop: (controller) { + Logger.info('[Kontext][handshake] webview onLoadStop (code=$code, messageId=$messageId)'); + webviewController.value = controller; + startInitFallback(controller); + }, ); return Offstage( diff --git a/lib/src/widgets/kontext_webview.dart b/lib/src/widgets/kontext_webview.dart index 9b5657fe..3bd19b41 100644 --- a/lib/src/widgets/kontext_webview.dart +++ b/lib/src/widgets/kontext_webview.dart @@ -55,7 +55,7 @@ final _flushMsgQueue = ''' } catch (e) { console.error('Error flushing message queue to Flutter: ', e); } - })(); null + })(); '''; typedef OnEventIframe = void Function(InAppWebViewController controller, Json? data); @@ -75,12 +75,14 @@ class KontextWebview extends HookWidget { required this.allowedOrigins, required this.onEventIframe, required this.onMessageReceived, + this.onLoadStop, }); final Uri uri; final List allowedOrigins; final OnEventIframe onEventIframe; final OnMessageReceived onMessageReceived; + final void Function(InAppWebViewController controller)? onLoadStop; void _logError(WebViewConsoleErrorLimiter limiter, {required String message}) { if (limiter.shouldSendRemote(message)) { @@ -171,6 +173,16 @@ class KontextWebview extends HookWidget { controller.evaluateJavascript(source: _flushMsgQueue); }, + onLoadStop: (controller, url) async { + // onWebViewCreated flushes the early-bridge queue exactly once. On Android the + // page's onLoadStop can fire two or more times; a reload after that first flush + // re-creates an empty __kontextMsgQueue, so an `init-iframe` posted on the reload + // is queued but never flushed again — the SDK never learns the iframe is ready and + // never sends `update-iframe`, so the stream never starts and the ad never shows. + // Re-flushing on every load delivers those queued messages. (See RN Saylo fix.) + await controller.evaluateJavascript(source: _flushMsgQueue); + onLoadStop?.call(controller); + }, onConsoleMessage: (controller, consoleMessage) { final level = consoleMessage.messageLevel; final webViewMessage = 'WebView Console $level: ${consoleMessage.message}'; From b6115253a21541241bfedfa81b218ee047ec1254 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Thu, 2 Jul 2026 20:14:55 +0100 Subject: [PATCH 2/6] debug: also print SDK logs to stdout so they show in flutter run / logcat Logger._logLocal only used developer.log(name: 'Kontext'), which surfaces in DevTools but not in `flutter run` stdout or adb logcat. Mirror it to debugPrint (kDebugMode only) so the [Kontext][handshake] trace is visible on device during debugging. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/services/logger.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/src/services/logger.dart b/lib/src/services/logger.dart index 284f231a..c60805fb 100644 --- a/lib/src/services/logger.dart +++ b/lib/src/services/logger.dart @@ -121,6 +121,13 @@ class Logger { error: error, stackTrace: stackTrace, ); + + // developer.log only surfaces in DevTools; also print so logs are visible + // in `flutter run` stdout / logcat during debugging. + if (kDebugMode) { + debugPrint('[Kontext][${level.name}] $message'); + if (error != null) debugPrint('[Kontext][${level.name}] error: $error'); + } } Future _logRemote(LogLevel level, String message) async { From 7478190d3f7af787d4438813279fe3fb7490dcce Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Thu, 2 Jul 2026 20:20:07 +0100 Subject: [PATCH 3/6] chore: bump to 2.2.3-rc.0 (pre-release) and restore example token placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kSdkVersion, pubspec version, and podspec version -> 2.2.3-rc.0 so the RC is identifiable in preload requests (sdk.version) and everywhere else. - example: restore kPublisherToken placeholder ('PUBLISHER_TOKEN'). Not a production release — pre-release identifier for testing the Android init-iframe fix on the customer's device. Co-Authored-By: Claude Opus 4.8 (1M context) --- example/lib/constants.dart | 2 +- ios/kontext_flutter_sdk.podspec | 2 +- lib/src/utils/constants.dart | 2 +- pubspec.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/example/lib/constants.dart b/example/lib/constants.dart index 599ab4fb..f0bff058 100644 --- a/example/lib/constants.dart +++ b/example/lib/constants.dart @@ -1,2 +1,2 @@ -const String kPublisherToken = 'chai-dev'; +const String kPublisherToken = 'PUBLISHER_TOKEN'; const String kPlacementCode = 'inlineAd'; diff --git a/ios/kontext_flutter_sdk.podspec b/ios/kontext_flutter_sdk.podspec index 6e646688..9d9544fb 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.2' + s.version = '2.2.3-rc.0' s.summary = 'Kontext Flutter SDK plugin.' s.description = <<-DESC Kontext Flutter SDK: sound status, app info, hardware, power, network, etc. diff --git a/lib/src/utils/constants.dart b/lib/src/utils/constants.dart index 92abec0b..69ec0c4f 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.2'; +const kSdkVersion = '2.2.3-rc.0'; diff --git a/pubspec.yaml b/pubspec.yaml index 356181ac..e54068f2 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.2 +version: 2.2.3-rc.0 homepage: https://www.kontext.so/publishers repository: https://github.com/kontextso/sdk-flutter issue_tracker: https://github.com/kontextso/sdk-flutter/issues From 3e08a9ab6db652c39f14f3f4faa3ae7531d2c279 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Thu, 2 Jul 2026 20:31:30 +0100 Subject: [PATCH 4/6] ci: allow pre-release tags to publish from an RC branch; changelog for 2.2.3-rc.0 publish.yml previously required every published tag to be an ancestor of origin/main. Exempt pre-release tags (semver `-` suffix, e.g. v2.2.3-rc.0) so an RC can be handed to a customer for testing without merging to main. Stable releases still must be cut from main. main is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 9 ++++++++- CHANGELOG.md | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1a0184b2..564a62e7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,8 +15,15 @@ jobs: with: fetch-depth: 0 - - name: Ensure tag commit is contained in origin/main + - name: Ensure tag commit is contained in origin/main (skipped for pre-release tags) run: | + # Stable releases must be cut from main. Pre-release tags (semver with a + # `-` suffix, e.g. v2.2.3-rc.0) may be published from a release/RC branch + # so we can hand a build to a customer without merging to main. + if [[ "$GITHUB_REF_NAME" == *-* ]]; then + echo "Pre-release tag '$GITHUB_REF_NAME' — skipping on-main ancestry check." + exit 0 + fi git fetch origin main --prune echo "Tag commit: $GITHUB_SHA" git merge-base --is-ancestor "$GITHUB_SHA" "origin/main" diff --git a/CHANGELOG.md b/CHANGELOG.md index 13cfbf12..3c4dd06b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 2.2.3-rc.0 +* Pre-release. Fix an Android race where a lost `init-iframe` (WebView `onLoad` firing multiple times) left the ad filled but never shown: re-flush the message queue on every load and add an `update-iframe` fallback. Adds `[Kontext][handshake]` diagnostic logging. + ## 2.2.2 * BREAKING: Update minimum requirements to Flutter `>=3.38.0` and iOS deployment target `13.0`. * Set NSPrivacyTracking to false and clear tracking domains. From ede80c5d95387b67f922255194ec6c58c5999f1d Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Fri, 3 Jul 2026 12:33:10 +0100 Subject: [PATCH 5/6] fix(android): mount-based init-iframe recovery + repro test; bump 2.2.3-rc.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rc.0 recovery armed its update-iframe fallback in `onLoadStop`, but that event is exactly what the Android double-onLoad reload corrupts — so on the customer device 2 of 4 ads still filled-but-never-showed (frame served, no `viewed`). Rearm on mount instead, independent of any load event, and keep re-sending `update-iframe` until `show-iframe` actually arrives. - ad_format: mount-based recovery nudger (reload-proof); its timer is no longer cancelled by cancelTimers() (that runs before the ad is visible, i.e. exactly when the nudger must keep going). - kontext_webview: expose onWebViewCreated so we get the controller even if zero messages arrive. - test: reproduce the lost-init-iframe failure and assert recovery (fails on the old onLoadStop approach, passes now). Full suite 27/27. - Validated on emulator: 6/6 ads rendered, one benign recovery nudge, no regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 ++ ios/kontext_flutter_sdk.podspec | 2 +- lib/src/utils/constants.dart | 2 +- lib/src/widgets/ad_format.dart | 64 +++++++++++++++----------- lib/src/widgets/kontext_webview.dart | 3 ++ pubspec.yaml | 2 +- test/src/widgets/ad_format_test.dart | 69 ++++++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4dd06b..43c4dd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 2.2.3-rc.1 +* Pre-release. Harden the lost-`init-iframe` recovery: the `update-iframe` fallback is now armed on mount (reload-proof) instead of on `onLoadStop`, and it re-sends until `show-iframe` actually arrives. Adds a widget test reproducing the lost-`init-iframe` case. + ## 2.2.3-rc.0 * Pre-release. Fix an Android race where a lost `init-iframe` (WebView `onLoad` firing multiple times) left the ad filled but never shown: re-flush the message queue on every load and add an `update-iframe` fallback. Adds `[Kontext][handshake]` diagnostic logging. diff --git a/ios/kontext_flutter_sdk.podspec b/ios/kontext_flutter_sdk.podspec index 9d9544fb..6269098b 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.3-rc.0' + s.version = '2.2.3-rc.1' s.summary = 'Kontext Flutter SDK plugin.' s.description = <<-DESC Kontext Flutter SDK: sound status, app info, hardware, power, network, etc. diff --git a/lib/src/utils/constants.dart b/lib/src/utils/constants.dart index 69ec0c4f..f80ec544 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.3-rc.0'; +const kSdkVersion = '2.2.3-rc.1'; diff --git a/lib/src/widgets/ad_format.dart b/lib/src/widgets/ad_format.dart index c295ebdc..a91a0cbd 100644 --- a/lib/src/widgets/ad_format.dart +++ b/lib/src/widgets/ad_format.dart @@ -474,12 +474,14 @@ class AdFormat extends HookWidget { final delayedTicker = useRef(null); final initFallbackTimer = useRef(null); void cancelTimers() { + // Note: initFallbackTimer is intentionally NOT cancelled here. It's the mount-based + // recovery nudger and has its own lifecycle (armed once, stops on show-iframe/dispose); + // this cancelTimers() runs whenever the dimension ticker should stop (i.e. before the + // ad is visible), which is exactly when the nudger still needs to run. delayedTicker.value?.cancel(); delayedTicker.value = null; ticker.value?.cancel(); ticker.value = null; - initFallbackTimer.value?.cancel(); - initFallbackTimer.value = null; } void setActive(bool active) => WidgetsBinding.instance.addPostFrameCallback((_) { @@ -548,28 +550,38 @@ class AdFormat extends HookWidget { final showIframe = useState(false); final height = useState(.0); - // Fallback for a lost `init-iframe` (Android double-onLoad race; see RN Saylo fix). - // Normally the iframe posts `init-iframe`, we set `iframeLoaded` and send `update-iframe`, - // and the server starts the stream. If `init-iframe` never reaches us, that chain never - // starts: the ad fills (frame served) but never shows, with no error. This starts on - // page load and, if the handshake hasn't progressed after a short delay, forces - // `iframeLoaded` (to unblock dimension posting) and (re)sends `update-iframe` directly. - void startInitFallback(InAppWebViewController controller) { - if (initFallbackTimer.value != null) return; // already armed for this load cycle + // Recovery nudger for a lost `init-iframe` (Android double-onLoad race; see RN Saylo fix). + // + // Normal flow: the iframe posts `init-iframe` -> we set `iframeLoaded` -> the + // [iframeLoaded] effect sends `update-iframe` -> the server starts the stream -> + // the iframe posts `show-iframe`. If `init-iframe` is lost (the WebView reloads + // mid-handshake and the message never reaches us), that chain never starts and the + // ad is served but never shown — silently. + // + // This is armed on MOUNT (not on any load/`onLoadStop` event, which the reload itself + // corrupts): once a controller is available it (re)sends `update-iframe` every tick + // until the ad actually shows. It also sets `iframeLoaded` so dimension posting can run + // once `show-iframe` finally lands. Idempotent: an extra `update-iframe` on the happy + // path is harmless, and it stops the instant `show-iframe` arrives. + useEffect(() { var attempts = 0; - initFallbackTimer.value = Timer.periodic(const Duration(milliseconds: 500), (timer) { - // Stop once the ad actually starts showing, on dispose, or if init-iframe arrived - // normally before we ever needed to fire. - if (disposed.value || showIframe.value || (iframeLoaded.value && attempts == 0)) { - timer.cancel(); - initFallbackTimer.value = null; + final timer = Timer.periodic(const Duration(milliseconds: 800), (t) { + if (disposed.value || showIframe.value) { + t.cancel(); + return; + } + final controller = webviewController.value; + if (controller == null) return; // wait until the webview hands us a controller + if (attempts >= 8) { + // ~6.4s of nudging; stop so we don't spin forever on a genuinely empty slot. + t.cancel(); return; } attempts++; Logger.info( - '[Kontext][handshake] init-iframe/show-iframe not received after ' - '${attempts * 500}ms — sending update-iframe fallback ' - '(code=$code, messageId=$messageId, attempt=$attempts)', + '[Kontext][handshake] recovery nudge #$attempts — show-iframe not received; ' + '(re)sending update-iframe (iframeLoaded=${iframeLoaded.value}, ' + 'code=$code, messageId=$messageId)', ); iframeLoaded.value = true; // unblock the Offstage + dimension-posting pipeline _postUpdateIframe( @@ -578,13 +590,10 @@ class AdFormat extends HookWidget { messages: adsProviderData.messages.getLastMessages(), otherParams: adsProviderData.otherParams, ); - if (attempts >= 6) { - // ~3s of retries; give up so we don't spin forever on a genuinely empty slot. - timer.cancel(); - initFallbackTimer.value = null; - } }); - } + initFallbackTimer.value = timer; + return () => timer.cancel(); + }, const []); useEffect(() { // messageId can only become relevant if an ad was shown for that specific messageId @@ -679,10 +688,13 @@ class AdFormat extends HookWidget { allowedOrigins: allowedOrigins, onEventIframe: onEventIframe, onMessageReceived: onMessageReceived, + onWebViewCreated: (controller) { + Logger.info('[Kontext][handshake] webview created (code=$code, messageId=$messageId)'); + webviewController.value = controller; + }, onLoadStop: (controller) { Logger.info('[Kontext][handshake] webview onLoadStop (code=$code, messageId=$messageId)'); webviewController.value = controller; - startInitFallback(controller); }, ); diff --git a/lib/src/widgets/kontext_webview.dart b/lib/src/widgets/kontext_webview.dart index 3bd19b41..2d6b5a18 100644 --- a/lib/src/widgets/kontext_webview.dart +++ b/lib/src/widgets/kontext_webview.dart @@ -75,6 +75,7 @@ class KontextWebview extends HookWidget { required this.allowedOrigins, required this.onEventIframe, required this.onMessageReceived, + this.onWebViewCreated, this.onLoadStop, }); @@ -82,6 +83,7 @@ class KontextWebview extends HookWidget { final List allowedOrigins; final OnEventIframe onEventIframe; final OnMessageReceived onMessageReceived; + final void Function(InAppWebViewController controller)? onWebViewCreated; final void Function(InAppWebViewController controller)? onLoadStop; void _logError(WebViewConsoleErrorLimiter limiter, {required String message}) { @@ -172,6 +174,7 @@ class KontextWebview extends HookWidget { ); controller.evaluateJavascript(source: _flushMsgQueue); + onWebViewCreated?.call(controller); }, onLoadStop: (controller, url) async { // onWebViewCreated flushes the early-bridge queue exactly once. On Android the diff --git a/pubspec.yaml b/pubspec.yaml index e54068f2..d61344e8 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.3-rc.0 +version: 2.2.3-rc.1 homepage: https://www.kontext.so/publishers repository: https://github.com/kontextso/sdk-flutter issue_tracker: https://github.com/kontextso/sdk-flutter/issues diff --git a/test/src/widgets/ad_format_test.dart b/test/src/widgets/ad_format_test.dart index c121f193..75939ca6 100644 --- a/test/src/widgets/ad_format_test.dart +++ b/test/src/widgets/ad_format_test.dart @@ -451,6 +451,75 @@ void main() { }, ); + testWidgets( + 'recovers a lost init-iframe: mount nudger re-sends update-iframe until show-iframe', + (WidgetTester tester) async { + // Reproduces the Android double-onLoad race: the frame loads (so a controller is + // available and a resize can arrive) but `init-iframe` is NEVER delivered — the + // exact "served but never shown" case seen on the customer device. The mount-based + // recovery must send `update-iframe` anyway, and keep doing so until `show-iframe`. + late OnMessageReceived onMessage; + final updateIframeCalls = []; + + when(() => fakeController.evaluateJavascript(source: any(named: 'source'))).thenAnswer((invocation) async { + final source = invocation.namedArguments[const Symbol('source')] as String; + if (source.contains('"type":"update-iframe"')) { + updateIframeCalls.add(source); + } + return null; + }); + + 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); + } + + await tester.pumpWidget( + createDefaultProvider( + child: AdFormat( + code: 'test_code', + messageId: 'msg_1', + onActiveChanged: onActiveChanged, + webviewBuilder: webviewBuilder, + ), + ), + ); + + // Frame loaded and posted a resize, but init-iframe is withheld (lost in the reload). + onMessage(fakeController, 'resize-iframe', {'height': 100}); + await tester.pump(); + + // Nothing sent yet — before the fix, update-iframe was gated on init-iframe, so the + // ad would stay served-but-never-shown here forever. + expect(updateIframeCalls, isEmpty); + + // The mount-based nudger must send update-iframe even though init-iframe never came. + await tester.pump(const Duration(milliseconds: 900)); + expect(updateIframeCalls, isNotEmpty, reason: 'recovery must fire without init-iframe'); + + // ...and keep nudging until the ad actually shows. + final countAfterFirst = updateIframeCalls.length; + await tester.pump(const Duration(milliseconds: 900)); + expect(updateIframeCalls.length, greaterThan(countAfterFirst)); + + // Once show-iframe finally arrives, nudging stops. + onMessage(fakeController, 'show-iframe', null); + await tester.pump(); + final countAtShow = updateIframeCalls.length; + await tester.pump(const Duration(milliseconds: 1600)); + expect(updateIframeCalls.length, countAtShow, reason: 'nudging stops once show-iframe arrives'); + + // Dispose to cancel the dimension ticker started by show-iframe. + await tester.pumpWidget(const SizedBox()); + }, + ); + testWidgets( 'Timer is cancelled when widget is disposed mid-update', (WidgetTester tester) async { From e62646a7f97811544a789a02a72caf634f9e6884 Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Fri, 3 Jul 2026 14:43:32 +0100 Subject: [PATCH 6/6] fix(android): correct + re-arm init-iframe recovery; bump 2.2.3-rc.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rc.1 stopped the recovery on `show-iframe`, but show-iframe can arrive while init-iframe was lost — leaving iframeLoaded false and the ad blank. It also armed once on mount, so a reused ad slot only ever recovered the first ad. - Recovery now keys off the true failure signal (iframeLoaded never set), not show-iframe, and stops only when actually shown (iframeLoaded && showIframe) or when a real init-iframe arrived (never nudges healthy ads). - Re-armed per [bidId] so every ad recovers, not just the first. - Regression test: show-iframe arrives but init-iframe lost -> recovery still fires. Full suite 27/27. - Validated on emulator with init-iframe dropped: 3/3 ads recovered and viewed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 ++ ios/kontext_flutter_sdk.podspec | 2 +- lib/src/utils/constants.dart | 2 +- lib/src/widgets/ad_format.dart | 43 ++++++++++++++++++---------- pubspec.yaml | 2 +- test/src/widgets/ad_format_test.dart | 41 +++++++++++--------------- 6 files changed, 51 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43c4dd48..05fb7652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 2.2.3-rc.2 +* Pre-release. Correct the lost-`init-iframe` recovery: it now keys off the real failure signal (`iframeLoaded` never set — `show-iframe` can arrive while `init-iframe` was lost) and re-arms per ad, so every ad in a reused slot recovers, not just the first. Healthy ads trigger zero recovery traffic. Adds a regression test for the `show-iframe`-first case. + ## 2.2.3-rc.1 * Pre-release. Harden the lost-`init-iframe` recovery: the `update-iframe` fallback is now armed on mount (reload-proof) instead of on `onLoadStop`, and it re-sends until `show-iframe` actually arrives. Adds a widget test reproducing the lost-`init-iframe` case. diff --git a/ios/kontext_flutter_sdk.podspec b/ios/kontext_flutter_sdk.podspec index 6269098b..5d37574d 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.3-rc.1' + s.version = '2.2.3-rc.2' s.summary = 'Kontext Flutter SDK plugin.' s.description = <<-DESC Kontext Flutter SDK: sound status, app info, hardware, power, network, etc. diff --git a/lib/src/utils/constants.dart b/lib/src/utils/constants.dart index f80ec544..1a48148d 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.3-rc.1'; +const kSdkVersion = '2.2.3-rc.2'; diff --git a/lib/src/widgets/ad_format.dart b/lib/src/widgets/ad_format.dart index a91a0cbd..deb6809c 100644 --- a/lib/src/widgets/ad_format.dart +++ b/lib/src/widgets/ad_format.dart @@ -558,32 +558,45 @@ class AdFormat extends HookWidget { // mid-handshake and the message never reaches us), that chain never starts and the // ad is served but never shown — silently. // - // This is armed on MOUNT (not on any load/`onLoadStop` event, which the reload itself - // corrupts): once a controller is available it (re)sends `update-iframe` every tick - // until the ad actually shows. It also sets `iframeLoaded` so dimension posting can run - // once `show-iframe` finally lands. Idempotent: an extra `update-iframe` on the happy - // path is harmless, and it stops the instant `show-iframe` arrives. + // Armed independent of any load/`onLoadStop` event (which the reload itself corrupts), + // and keyed on [bidId] so it RE-ARMS for every ad — a reused slot handling multiple ads + // otherwise only ever recovers the first one. useEffect(() { var attempts = 0; - final timer = Timer.periodic(const Duration(milliseconds: 800), (t) { - if (disposed.value || showIframe.value) { + var recovering = false; + final timer = Timer.periodic(const Duration(milliseconds: 1000), (t) { + if (disposed.value) { + t.cancel(); + return; + } + // Truly shown (BOTH flags) -> success, stop. + if (iframeLoaded.value && showIframe.value) { + t.cancel(); + return; + } + // Healthy path: a real init-iframe set iframeLoaded and we never had to recover. + // Stop and never interfere — this is why well-behaved integrations see ZERO recovery + // traffic. We must NOT key off showIframe here: `show-iframe` can arrive while + // `init-iframe` was lost, and without `iframeLoaded` the ad stays blank — that is + // exactly the served-but-never-shown failure. + if (iframeLoaded.value && !recovering) { t.cancel(); return; } final controller = webviewController.value; - if (controller == null) return; // wait until the webview hands us a controller - if (attempts >= 8) { - // ~6.4s of nudging; stop so we don't spin forever on a genuinely empty slot. + if (controller == null) return; // no webview yet + if (attempts >= 3) { + // Bounded. Give up so we don't spin on a genuinely empty slot. t.cancel(); return; } attempts++; + recovering = true; Logger.info( - '[Kontext][handshake] recovery nudge #$attempts — show-iframe not received; ' - '(re)sending update-iframe (iframeLoaded=${iframeLoaded.value}, ' - 'code=$code, messageId=$messageId)', + '[Kontext][handshake] recovery: init-iframe missing after ${attempts}s — ' + 'sending update-iframe (attempt $attempts, code=$code, messageId=$messageId)', ); - iframeLoaded.value = true; // unblock the Offstage + dimension-posting pipeline + iframeLoaded.value = true; // the piece a lost init-iframe never delivered _postUpdateIframe( controller, adServerUrl: adsProviderData.adServerUrl, @@ -593,7 +606,7 @@ class AdFormat extends HookWidget { }); initFallbackTimer.value = timer; return () => timer.cancel(); - }, const []); + }, [bidId]); useEffect(() { // messageId can only become relevant if an ad was shown for that specific messageId diff --git a/pubspec.yaml b/pubspec.yaml index d61344e8..da4524fc 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.3-rc.1 +version: 2.2.3-rc.2 homepage: https://www.kontext.so/publishers repository: https://github.com/kontextso/sdk-flutter issue_tracker: https://github.com/kontextso/sdk-flutter/issues diff --git a/test/src/widgets/ad_format_test.dart b/test/src/widgets/ad_format_test.dart index 75939ca6..f16ac279 100644 --- a/test/src/widgets/ad_format_test.dart +++ b/test/src/widgets/ad_format_test.dart @@ -452,12 +452,12 @@ void main() { ); testWidgets( - 'recovers a lost init-iframe: mount nudger re-sends update-iframe until show-iframe', + 'recovers a lost init-iframe even when show-iframe arrives first', (WidgetTester tester) async { - // Reproduces the Android double-onLoad race: the frame loads (so a controller is - // available and a resize can arrive) but `init-iframe` is NEVER delivered — the - // exact "served but never shown" case seen on the customer device. The mount-based - // recovery must send `update-iframe` anyway, and keep doing so until `show-iframe`. + // The exact customer failure: init-iframe is lost (WebView reload), but show-iframe + // STILL arrives. Without iframeLoaded the Offstage stays hidden and the ad is blank. + // Regression guard: the recovery must key off the failure signature (no iframeLoaded), + // NOT be fooled into stopping just because show-iframe was received. late OnMessageReceived onMessage; final updateIframeCalls = []; @@ -491,31 +491,24 @@ void main() { ), ); - // Frame loaded and posted a resize, but init-iframe is withheld (lost in the reload). + // Frame loaded (controller arrives via a resize) and show-iframe arrived, but + // init-iframe was LOST — so iframeLoaded is still false and the ad is blank. onMessage(fakeController, 'resize-iframe', {'height': 100}); + onMessage(fakeController, 'show-iframe', null); await tester.pump(); - // Nothing sent yet — before the fix, update-iframe was gated on init-iframe, so the - // ad would stay served-but-never-shown here forever. + // The normal path is dead (init-iframe never set iframeLoaded), so nothing yet. expect(updateIframeCalls, isEmpty); - // The mount-based nudger must send update-iframe even though init-iframe never came. - await tester.pump(const Duration(milliseconds: 900)); - expect(updateIframeCalls, isNotEmpty, reason: 'recovery must fire without init-iframe'); - - // ...and keep nudging until the ad actually shows. - final countAfterFirst = updateIframeCalls.length; - await tester.pump(const Duration(milliseconds: 900)); - expect(updateIframeCalls.length, greaterThan(countAfterFirst)); - - // Once show-iframe finally arrives, nudging stops. - onMessage(fakeController, 'show-iframe', null); - await tester.pump(); - final countAtShow = updateIframeCalls.length; - await tester.pump(const Duration(milliseconds: 1600)); - expect(updateIframeCalls.length, countAtShow, reason: 'nudging stops once show-iframe arrives'); + // Recovery MUST still fire — the old code stopped on show-iframe and left the ad blank. + await tester.pump(const Duration(milliseconds: 1100)); + expect( + updateIframeCalls, + isNotEmpty, + reason: 'recovery must fire even when show-iframe arrived but init-iframe was lost', + ); - // Dispose to cancel the dimension ticker started by show-iframe. + // Dispose to cancel the dimension ticker started once the ad became visible. await tester.pumpWidget(const SizedBox()); }, );