Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
20 changes: 20 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ class _HomePageState extends State<HomePage> {

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 = <String>[
'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;
Expand Down Expand Up @@ -116,6 +135,7 @@ class _HomePageState extends State<HomePage> {
enabledPlacementCodes: const [kPlacementCode],
otherParams: {'theme': theme},
logLevel: LogLevel.info,
onEvent: _onAdEvent,
child: Column(
children: [
Expanded(
Expand Down
2 changes: 1 addition & 1 deletion ios/kontext_flutter_sdk.podspec
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
7 changes: 7 additions & 0 deletions lib/src/services/logger.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> _logRemote(LogLevel level, String message) async {
Expand Down
2 changes: 1 addition & 1 deletion lib/src/utils/constants.dart
Original file line number Diff line number Diff line change
@@ -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';
89 changes: 88 additions & 1 deletion lib/src/widgets/ad_format.dart
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ class AdFormat extends HookWidget {
},
};

Logger.info('[Kontext][handshake] sending update-iframe (code=$code, messageId=$messageId)');
_postMessageToWebView(adServerUrl, controller, payload);
}

Expand All @@ -145,7 +146,7 @@ class AdFormat extends HookWidget {
Json payload,
) {
controller.evaluateJavascript(source: '''
window.postMessage(${jsonEncode(payload)}, '$adServerUrl'); null
window.postMessage(${jsonEncode(payload)}, '$adServerUrl');
''');
}

Expand All @@ -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);
Expand Down Expand Up @@ -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)');
}
}

Expand Down Expand Up @@ -462,7 +472,12 @@ class AdFormat extends HookWidget {

final ticker = useRef<Timer?>(null);
final delayedTicker = useRef<Timer?>(null);
final initFallbackTimer = useRef<Timer?>(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();
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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) {
Expand All @@ -556,13 +632,15 @@ 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;
if (!iframeLoaded.value || !showIframe.value || disposed.value) {
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),
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand Down
17 changes: 16 additions & 1 deletion lib/src/widgets/kontext_webview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<String> 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)) {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading