Engineering

A platform bridge is an interface with a lifetime problem

Permissions, biometrics and share sheets all need something shared code does not have: a host that outlives the call.

MobileByteSensei 8 min read
kmparchitectureplatform

TL;DR

A platform bridge looks like a one-method interface: suspend fun requestCamera(): Boolean. The signature hides the thing that actually matters — the implementation needs a host (an Activity, a UIViewController), and that host can be destroyed while the call is in flight.

A suspended call whose host dies does not throw. It does not return. The coroutine stays parked forever, and whatever was awaiting it never runs — so the user sees a spinner that never stops rather than a crash you would get a report about.

The fix is to make the host an explicit, revocable registration, and to make the suspension cancellable when it goes away.

The case this post is about

An onboarding screen that asks for camera permission before opening a scanner. The shared code:

// commonMain
interface PermissionRequester {
    suspend fun request(permission: Permission): Boolean
}
// androidMain — the obvious version
class AndroidPermissionRequester(
    private val activity: ComponentActivity          // ← held for the object's lifetime
) : PermissionRequester {

    override suspend fun request(permission: Permission): Boolean =
        suspendCancellableCoroutine { cont ->
            val launcher = activity.registerForActivityResult(RequestPermission()) { granted ->
                cont.resume(granted)
            }
            launcher.launch(permission.manifestName)
        }
}

Registered in DI as a single, injected into the ViewModel, called from viewModelScope.launch. It works every time you test it, because you test it by tapping the button and granting the permission.

The symptom

A permanent spinner, reported as "the scanner never opens". Reproducible on Android with one setting:

Developer options → Don't keep activities = ON

tap "Enable camera"  → system dialog appears
background the app   → activity destroyed
foreground the app   → dialog still there, tap Allow
                     → spinner. forever. no log, no crash, no ANR.

The logcat line that should have been the clue is a warning nobody reads:

W/ActivityResultRegistry: Dropping pending result for request …:
   the activity was destroyed before the result could be delivered

On iOS the same shape appears when the presenting view controller is dismissed while a biometric prompt is up: the Kotlin continuation is never resumed, the async in Swift never completes, and nothing is logged at all.

What makes this expensive is the failure mode. A crash gets reported, aggregated, prioritised. A coroutine parked forever produces one support ticket that says "it hangs sometimes", and nothing in your telemetry disagrees.

Why the obvious fix failed

"Hold a WeakReference to the Activity." Removes the leak and does not touch the hang — cont.resume is never called either way. A weak reference converts "leaked and stuck" into "stuck", which is progress for memory and not for the user.

"Add a timeout around the call." withTimeout(30_000) does end the spinner, and it was the first thing I shipped. It is a guess about human reaction time: a user reading a system permission rationale can easily exceed thirty seconds, so the timeout either fires on slow-but-correct behaviour or is too long to help. A timeout is the right tool for a network call and the wrong tool for a dialog waiting on a person.

"Make it a single bound to the Activity's lifecycle scope." Correct direction, and it surfaces the real tension: the requester wants application scope (shared code injects it once), while the host has activity scope. Binding the whole object to the activity means re-injecting the shared consumer every recreation, which pushes platform lifetime up into common code.

"Use the result API's SavedStateRegistry so the result survives recreation." This is the genuinely correct Android answer for the result, and it does not help the continuation. The Activity Result API can restore a pending request after recreation; it cannot restore the in-memory coroutine continuation that was waiting on it. Two different pieces of state with two different lifetimes.

The actual mechanism

Three lifetimes are involved, and the interface mentions none of them.

The consumer's lifetime is the ViewModel's — it survives configuration change and activity recreation. The requester's lifetime is whatever DI gave it, usually application. The host's lifetime is the Activity's, which is the shortest of the three and the only one the implementation cannot do without.

A suspending call creates a fourth thing: an in-memory continuation held by whoever is going to resume it. When the host dies, the platform drops the callback that owned the resume. Nothing owns the continuation any more, and suspendCancellableCoroutine has no notion that this has happened — it is waiting to be resumed or cancelled, and neither is going to arrive.

Three lifetimes stacked against one interface signature: the ViewModel consumer survives recreation, the injected requester is application-scoped, and the Activity host is the shortest-lived of the three. When the host dies mid-call the platform drops its callback, leaving a coroutine continuation nobody owns, parked rather than cancelled.

The general statement:

A platform bridge is not a function, it is a conversation with a host that can leave. Any API that suspends across a platform callback needs an answer to "what happens if the thing holding the callback dies" — and the default answer is nothing happens, forever.

This generalises past permissions. Biometric prompts, share sheets, document pickers, in-app review, billing flows, camera intents — every one of them is a suspension across a host that the OS is allowed to destroy.

The fix

Separate the two scopes properly: the bridge is application-scoped and holds a registration that the host attaches and detaches, and the suspension is cancelled when that registration goes away.

// androidMain — application-scoped, host attaches itself
class AndroidPermissionRequester : PermissionRequester {

    private var host: Host? = null

    class Host(val launch: (String) -> Unit, val detach: () -> Unit)

    fun attach(activity: ComponentActivity) {
        val launcher = activity.registerForActivityResult(RequestPermission()) { granted ->
            pending?.let { it.resume(granted); pending = null }
        }
        host = Host(launch = { launcher.launch(it) }, detach = { host = null })

        activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
            override fun onDestroy(owner: LifecycleOwner) {
                // The host is gone. End the suspension instead of abandoning it.
                pending?.cancel(HostGoneException()); pending = null
                host = null
            }
        })
    }

    private var pending: CancellableContinuation<Boolean>? = null

    override suspend fun request(permission: Permission): Boolean {
        val h = host ?: throw HostGoneException()      // fail fast, don't park
        return suspendCancellableCoroutine { cont ->
            pending = cont
            cont.invokeOnCancellation { pending = null }
            h.launch(permission.manifestName)
        }
    }
}

Two properties this buys, and one it does not.

A dying host now ends the call. pending?.cancel(...) turns an infinite park into a CancellationException the caller can handle. The spinner stops because the coroutine actually completes.

Calling with no host fails immediately rather than registering a request nobody can deliver. That distinction — no host yet versus host left mid-call — is worth keeping separate, because the first is a wiring bug and the second is ordinary OS behaviour.

It does not resume the user's intent. The permission dialog did appear; after recreation the answer is lost. If you want to continue rather than restart, the ViewModel has to re-ask when it is re-attached — which is a product decision, and the point is that it is now a decision rather than a hang.

The common side declares the failure rather than hiding it:

// commonMain
class HostGoneException : CancellationException("platform host destroyed mid-request")

interface PermissionRequester {
    /** Throws [HostGoneException] if no host is attached, or if the host dies mid-call. */
    suspend fun request(permission: Permission): Boolean
}

And the ViewModel handles the case the old signature pretended did not exist:

viewModelScope.launch {
    val granted = try {
        requester.request(Permission.Camera)
    } catch (e: HostGoneException) {
        state.update { it.copy(loading = false, retryPermission = true) }
        return@launch
    }
    …
}

iOS needs the same shape with different nouns — attach(viewController), cancel the pending continuation in viewDidDisappear — and the shared interface is unchanged, which is the sign the abstraction is now in the right place.

How we proved it

The bug is a non-event, so the test has to assert that something completes rather than that something is correct. That took two attempts.

My first test tapped the button and destroyed the activity, then asserted the spinner was gone — which passed before the fix, because the test's own teardown tore down the screen. A test whose cleanup produces the condition it is checking proves nothing.

The real test asserts on the continuation:

@Test
fun hostDestroyedMidRequest_cancelsRatherThanParks() = runTest {
    val requester = AndroidPermissionRequester()
    requester.attach(activity)

    val job = launch { requester.request(Permission.Camera) }
    advanceUntilIdle()
    assertTrue(job.isActive)                     // suspended, as expected

    activity.moveToState(Lifecycle.State.DESTROYED)
    advanceUntilIdle()

    assertTrue(job.isCompleted)                  // ← the whole fix, in one assertion
    assertIs<HostGoneException>(job.getCancellationException().cause)
}

Before the fix, job.isCompleted is false and stays false — the assertion fails by never becoming true, which is exactly the production symptom expressed as a test.

On device, with Don't keep activities on, the manual run matches: dialog, background, foreground, and the screen now shows a retry affordance within a frame instead of spinning.

One limit. This covers host destruction, which is the common case. It does not cover a host that is alive but not presenting — an Activity in the background whose launcher will deliver the result later — and that case is legitimately different: the call should keep waiting. Telling those apart is why the fix hooks onDestroy specifically rather than onStop.

What this generalizes to

Every suspension across a platform callback has a host, and the host can leave. Ask it of every bridge you write: who owns the resume, and what is their lifetime relative to the caller's? If the answer is "shorter", you need a cancellation path, not a timeout.

A parked coroutine is an invisible failure. Crashes get reported; silent non-completion produces a vague support ticket and clean dashboards. When you build an async abstraction, make "never completes" an outcome the type system or the error path acknowledges — it is the failure mode nobody instruments.

Scope mismatch is the usual cause, and the fix is a registration. Application-scoped object plus activity-scoped capability is a recurring KMP shape. Attach/detach with cancellation on detach is the pattern; holding the host in the constructor is the bug.

A test for a non-event must assert completion, not state. "The spinner is gone" can pass for reasons unrelated to the fix. "The job completed with this cause" cannot. When the symptom is absence, the assertion has to be about something becoming true.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS