Skip to content

fix(android): recover from lost init-iframe + handshake logging (2.2.2 debug) - #101

Open
JakubMrozek wants to merge 6 commits into
run-2.2.2from
fix/android-init-iframe-race-and-logging
Open

fix(android): recover from lost init-iframe + handshake logging (2.2.2 debug)#101
JakubMrozek wants to merge 6 commits into
run-2.2.2from
fix/android-init-iframe-race-and-logging

Conversation

@JakubMrozek

Copy link
Copy Markdown
Member

Context

Customer (chai / Saylo-style app) reports: on Android, ads fill but never show on v2.2.2. Server-side we confirmed the exact signature via ClickHouse + OTel logs:

  • bids row exists (fill) ✅
  • impressions_content written (creative HTML served via GET /api/frame/:id) ✅
  • impressions_events viewednever written ❌ (client never fires /impression/:id/view)

So the creative loads in the WebView but the show/view handshake never completes — no error, no forwarded log.

Root cause

Same class of bug the RN SDK already fixed for Saylo (sdk-react-native/src/formats/Format.tsx): the Android WebView onLoad fires 2+ times. The SDK flushes the early-bridge message queue only once in onWebViewCreated; a reload after that re-creates an empty __kontextMsgQueue, so an init-iframe posted on the reload is queued and never delivered. Without init-iframe, iframeLoaded never becomes true → update-iframe is never sent → the stream never starts → the ad never shows.

An exhaustive line-by-line review of the whole v2.2.1→v2.2.2 diff showed nothing in it mechanically breaks the Android show path (the ; null change is a no-op on Android), consistent with this being a latent race present in both versions that timing perturbations (e.g. the Flutter 3.38 WebView runtime) surface more often — which is why "2.2.1 works / 2.2.2 doesn't" and why it's not reliably reproducible.

Changes

  • KontextWebview: re-run the queue flush on every onLoadStop (delivers a re-queued init-iframe); expose optional onLoadStop.
  • AdFormat: 500ms fallback on load — if the handshake hasn't progressed, force iframeLoaded and (re)send update-iframe (≤6 tries, stops on show-iframe). Mirrors the RN workaround.
  • Logging: [Kontext][handshake] info logs across the lifecycle so we can see on-device exactly where it dies and whether the fallback rescues it.
  • ; null removed from the two evaluateJavascript sources — no-op on Android; only silenced a cosmetic iOS log. Restore before release if this ships to iOS.
  • example: prints ad events; uses chai-dev / inlineAd for testing.

Testing

  • flutter analyze clean; flutter test test/src/widgets/ad_format_test.dart → 26/26 pass.
  • Next: run the example on an Android emulator and capture the [Kontext][handshake] trace.

Base is run-2.2.2 (the 2.2.2 line) intentionally — main is left untouched.

🤖 Generated with Claude Code

JakubMrozek and others added 4 commits July 2, 2026 20:08
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) <noreply@anthropic.com>
…gcat

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) <noreply@anthropic.com>
…ceholder

- 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) <noreply@anthropic.com>
…r 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) <noreply@anthropic.com>
….3-rc.1

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant