Starling is a location-sharing app for families and small groups where the server only ever sees ciphertext. On Android it is a small Kotlin wrapper around a web app. The page does all the cryptography, so your position is encrypted and posted from JavaScript inside a WebView. The native side hands the page location fixes and runs a foreground service so Android lets a share keep going with the screen off.
Issue #6 started as "sharing doesn't survive closing the app" and became the longest thread the project has. Two people on Pixels kept testing each new release. Each release fixed something real: the app lock ending a share, a phone lying still never passing the distance filter, a crashed renderer taking the app down, a page call queued on a view that had no window. And each time the report came back the same. Lock the phone, put it down, and a few minutes later your dot on everyone else's map goes grey.
Every test passed
My checks for those fixes locked an emulator, fed it a moving position and counted what reached the relay. They all passed. They were also all shorter than five minutes, and that turned out to be the whole problem.
The run that found it just waited longer: app in the background, screen off, Doze forced on, still moving. At 300 seconds the posts stopped. Fixes kept reaching the page, because the wrapper pushes each one in with evaluateJavascript and synchronous JavaScript still runs. Everything after that waited: the IndexedDB read, the WebCrypto seal and signature, the fetch, the timers. When the app came back to the front it all flushed at once, stamped with the time of the flush.
That is Chromium's background freezing. A page that has been hidden for a while gets frozen, the same as a background tab. On the WebView in that emulator the delay was 300 seconds, and newer Chromium builds shorten it, which fits Pixels going grey within minutes. I had asked the people testing to try the usual suspects, the battery setting and their VPN, and they did, patiently. It changed nothing, because the cause was in the app.
The fix
I found no WebView setting that turns freezing off. What thaws a frozen page is visibility, so during a share the wrapper makes the page visible to Chromium for one second:
v.dispatchWindowVisibilityChanged(View.VISIBLE)
main.postDelayed({
nudging = false
if (webView === v && !windowShown) v.dispatchWindowVisibilityChanged(View.GONE)
}, NUDGE_MS)
Nothing is drawn, since the real window is still hidden, but the page thaws and its freeze clock starts over. The nudge fires on the page's own freeze event, and a watchdog backs that up. Every fix pushed in is answered from a page task, which a frozen page never runs, so a push nobody answers means the page is frozen. A share that keeps going after the app is swiped away is harder, because a WebView with no window can never be made visible. That page moves into a window on a private virtual display that the app owns and never draws.
One side effect needed care. During a nudge, document.visibilityState says "visible" while nobody is looking. Everything in the page that meant "the person is here" now asks the wrapper instead: notifications, the app lock's timer, the poll rate, the screen wake lock.
Measured this time
The check ran longer than the failure: eleven minutes in the background, screen off, deep Doze, moving. The page froze at 301 and 602 seconds and was woken both times. 139 posts went out and all 139 were accepted, never more than nine seconds apart.
A few hours after 0.13.5 shipped, someone on Pixels running GrapheneOS wrote that it was working much better. That was the first word from real phones. The two Pixels from the original report haven't checked in yet.
What I'm taking from it
A test shorter than the failure is not a test. Every one of those earlier checks was honest and passing, and none of them could have failed, because none lasted long enough for the thing that was actually wrong. A debug build on an emulator can shorten Chromium's delay, which makes the freeze cheap to hit on purpose. Every background test I write now does that, or runs past the default.