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..05fb7652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 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. + +## 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. 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/ios/kontext_flutter_sdk.podspec b/ios/kontext_flutter_sdk.podspec index 6e646688..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.2' + 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/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 { diff --git a/lib/src/utils/constants.dart b/lib/src/utils/constants.dart index 92abec0b..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.2'; +const kSdkVersion = '2.2.3-rc.2'; diff --git a/lib/src/widgets/ad_format.dart b/lib/src/widgets/ad_format.dart index 45dfb2c2..deb6809c 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,7 +472,12 @@ class AdFormat extends HookWidget { final ticker = useRef(null); 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(); @@ -496,6 +511,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 +550,64 @@ class AdFormat extends HookWidget { final showIframe = useState(false); final height = useState(.0); + // 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. + // + // 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; + 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; // 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: init-iframe missing after ${attempts}s — ' + 'sending update-iframe (attempt $attempts, code=$code, messageId=$messageId)', + ); + iframeLoaded.value = true; // the piece a lost init-iframe never delivered + _postUpdateIframe( + controller, + adServerUrl: adsProviderData.adServerUrl, + messages: adsProviderData.messages.getLastMessages(), + otherParams: adsProviderData.otherParams, + ); + }); + initFallbackTimer.value = timer; + return () => timer.cancel(); + }, [bidId]); + useEffect(() { // messageId can only become relevant if an ad was shown for that specific messageId if (showIframe.value && adsProviderData.lastAssistantMessageId == messageId) { @@ -556,6 +632,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 +640,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 +674,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 +701,14 @@ 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; + }, ); return Offstage( diff --git a/lib/src/widgets/kontext_webview.dart b/lib/src/widgets/kontext_webview.dart index 9b5657fe..2d6b5a18 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,16 @@ class KontextWebview extends HookWidget { required this.allowedOrigins, required this.onEventIframe, required this.onMessageReceived, + this.onWebViewCreated, + this.onLoadStop, }); final Uri uri; 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}) { if (limiter.shouldSendRemote(message)) { @@ -170,6 +174,17 @@ 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 + // 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; diff --git a/pubspec.yaml b/pubspec.yaml index 356181ac..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.2 +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 c121f193..f16ac279 100644 --- a/test/src/widgets/ad_format_test.dart +++ b/test/src/widgets/ad_format_test.dart @@ -451,6 +451,68 @@ void main() { }, ); + testWidgets( + 'recovers a lost init-iframe even when show-iframe arrives first', + (WidgetTester tester) async { + // 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 = []; + + 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 (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(); + + // The normal path is dead (init-iframe never set iframeLoaded), so nothing yet. + expect(updateIframeCalls, isEmpty); + + // 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 once the ad became visible. + await tester.pumpWidget(const SizedBox()); + }, + ); + testWidgets( 'Timer is cancelled when widget is disposed mid-update', (WidgetTester tester) async {