On-Device AI

Wiring ML Kit GenAI into a Compose screen without blocking it

Wiring an on-device GenAI capability into Compose is mostly state design. Model the availability and failure states before the happy path, not after.

MobileByteSensei 8 min read
A call that is a few lines, and the states around it
on-devicecomposeandroid

The integration code for an on-device GenAI capability is small. Get a client, pass an input, get a result, render it.

Everything that makes it a shippable feature is the surrounding state machine, and most of that exists for one reason: unlike a normal function call, this one can be unavailable — not failed, not slow, unavailable — and that is a state most screens have never had to represent.

The case this post is about

One summarise button, across four real device states:

Pixel 9, model present          -> Button "Summarise"  -> skeleton -> summary text
Pixel 9, model not downloaded   -> "Summarise this article offline (41 MB)"  [Download]
Pixel 9, download at 38%        -> progress 38%, button disabled, no spinner
Pixel 6a, unsupported           -> "Summaries are not available on this device" + [Read full text]

The first row is the integration and it is a few lines. The other three are the feature.

The last row is the one that shipped wrong. Before that state existed, an unsupported device reached the running state — a spinner that never resolved, because the call it was waiting on had already returned unavailable and the state model had nowhere to put that.

This post follows those four rows, because the fix was not a timeout.

The symptom

The happy path gets built first because it is the part you can see working. One suspend call, a loading boolean, a result, a try/catch.

Then reality arrives in the form of states that are not errors:

  • the feature is not supported on this device at all
  • it is supported but the model has not been downloaded yet
  • it is downloading right now, with progress
  • it is ready but the first call will be slow
  • it is ready and fast

A boolean cannot represent that, so the code grows a second boolean, and then a nullable field, and then a combination that cannot actually occur but the type system permits. The screen is now describable only by reading it.

A boolean, and the states it was asked to carry

Why the obvious fix failed

The obvious fix is isLoading plus a try/catch, which is the correct shape for a network call and the wrong shape here.

A network call has two interesting outcomes and this has five, three of which are not failures and need completely different UI. Not supported on this device is a permanent product state that should change what the screen offers. Downloading is temporary and needs progress. Ready but cold is ready — it just needs the interface to be honest about the wait.

Collapsing them into isLoading = true means the user sees a spinner for a thing that will never work on their phone.

The second attempt is to check availability once at screen entry. Better, and it goes stale: the download finishes, and the screen that checked thirty seconds ago still says unavailable.

The actual mechanism

Model availability as a stream, not a question, and make illegal states unrepresentable.

One sealed state type, not a set of flags. The screen is in exactly one of the five states, and the type should say so. The combination that cannot happen then cannot be written.

Availability is observed, not polled. The screen reacts to availability changing underneath it, because it does — a download completing mid-session is normal, not an edge case.

Separate the capability state from the request state. Can this device do it and what is happening with this particular request are two different lifetimes. Flattening them into one enum produces states like "unavailable but also loading".

Keep the call off the composition and the result narrow. The generation runs in the ViewModel scope so it survives recomposition and cancels with the screen, and the streaming value — if it streams — is read by the leaf that renders it rather than passed down through the tree.

Two lifetimes that get flattened into one enum

The fix

The piece I would build first, before the happy path: the unavailable state.

Building it first forces the design decision that is otherwise deferred until it is expensive — what does this feature offer to a user whose device cannot run it? A non-AI alternative, a hidden entry point, a hosted fallback, or an honest message. All four are legitimate; none of them is a spinner.

Doing it last means it gets whatever is cheapest at the end of the sprint, which is usually an error dialog on a path that is not an error.

The state built first, and the state built last

This generalises past GenAI to every capability-gated feature — biometrics, notifications, a camera API, a share sheet. The pattern in this codebase for those is a platform bridge with a narrow declaration that returns a value rather than throwing, precisely so that unavailable is a value the shared code handles instead of an exception somebody has to remember to catch.

Built unavailable-first, the state model is the feature and the happy path is one branch of it:

sealed interface Availability {
    object Ready : Availability
    data class Downloadable(val megabytes: Int) : Availability
    data class Downloading(val percent: Int) : Availability
    data class Unsupported(val reason: String) : Availability
}

sealed interface SummaryState {
    object Idle : SummaryState
    object Running : SummaryState
    data class Done(val text: String) : SummaryState
    data class Failed(val message: String) : SummaryState
    // NOT an error state. The feature is absent, which is a different thing, and
    // conflating them is how an error dialog ends up on a non-error path.
    data class Offered(val alternative: String) : SummaryState
}

@Composable
fun SummaryPanel(
    availability: Availability,
    state: SummaryState,
    onSummarise: () -> Unit,
    onRequestDownload: () -> Unit,
) {
    when (availability) {
        // Written FIRST. What a user whose device cannot run this is offered.
        is Availability.Unsupported -> AlternativePanel(
            headline = stringResource(R.string.summary_unavailable_here),
            body = availability.reason,
            action = stringResource(R.string.read_full_text),
        )
        is Availability.Downloadable -> DownloadPrompt(
            sizeMb = availability.megabytes, onConfirm = onRequestDownload)
        is Availability.Downloading -> DownloadProgress(percent = availability.percent)
        Availability.Ready -> when (state) {
            SummaryState.Idle -> Button(onClick = onSummarise) { Text(stringResource(R.string.summarise)) }
            SummaryState.Running -> SummarySkeleton()
            is SummaryState.Done -> SummaryText(state.text)
            is SummaryState.Failed -> RetryRow(state.message, onRetry = onSummarise)
            is SummaryState.Offered -> AlternativePanel(
                headline = stringResource(R.string.summary_unavailable_here),
                body = state.alternative, action = stringResource(R.string.read_full_text))
        }
    }
}

The same screen across four real device states:

Pixel 9, model present          -> Button "Summarise"  -> skeleton -> summary text
Pixel 9, model not downloaded   -> "Summarise this article offline (41 MB)"  [Download]
Pixel 9, download at 38%        -> progress 38%, button disabled, no spinner
Pixel 6a, unsupported           -> "Summaries are not available on this device" + [Read full text]

The last row is the one that gets built last and shipped worst. Before the state existed, that device reached SummaryState.Running — a spinner that never resolved, because the call it was waiting for had already returned unavailable and nothing in the state model had anywhere to put that. The fix was not a timeout; it was a fifth value.

Building the unavailable state first has a second effect that is easy to miss: it decides where the feature's entry point lives. If the answer is "a hosted fallback", the entry point can be unconditional and the routing is internal. If the answer is "a non-AI alternative", the entry point is still unconditional but the screen has two shapes. If the answer is "hide it", the entry point itself becomes conditional — which means navigation, deep links and any analytics counting that screen all now depend on device capability.

That third case is the one that propagates furthest, and it is invisible if you build the happy path first. By the time somebody discovers that a deep link can land a user on a screen their device cannot run, the navigation graph has been written on the assumption that the destination always exists. Deciding it early costs a conversation; deciding it late costs a refactor of the thing that connects every screen to every other one.

How we proved it

Not measured: I have not shipped an ML Kit GenAI feature. I am deliberately not describing the specific API surface, its method names, its availability semantics or its download behaviour, because I have not used it and a confident description of an API I have not called is exactly the kind of plausible-sounding wrong thing this blog exists to avoid.

Measured, and what the structure above comes from: the platform-bridge discipline across this codebase, where capabilities like permissions, biometrics, deep links, share sheets, calendar events and notification scheduling are declared narrowly and return values rather than throwing. The recurring failure there is exactly the one described: a capability treated as always-present, with the unavailable case handled by whatever the exception path happened to do.

Also measured: that a handler which compiles is not a handler that works. The gates here refuse a click handler with an empty body or one that does not touch the target its contract declares, because "the code is there" and "the behaviour is there" turned out to be genuinely different things often enough to need a machine check.

What I would verify first

Three things I would establish before designing anything, by reading the platform documentation and then confirming on a device rather than trusting either alone:

What exactly does availability mean, and can it change at runtime? The answer determines whether availability is a startup check or a subscription, and that is a structural decision.

What is the download, who triggers it, and what does the user see? If the platform manages it, your UI still has to represent it. If you trigger it, you own the whole flow.

What is the first-call cost once available? Ready and fast are not the same state, and if they differ materially the interface needs to know which one it is in.

What this generalizes to

When integrating a capability that can be absent, build the absent case first. It is the state that determines what the feature actually is, and the only one that cannot be fixed later with a spinner.

Model the states as one closed type so the impossible combinations cannot be written, observe availability rather than asking once, and keep can this device separate from what is this request doing. The call itself will be a few lines. It was always going to be.

One post a week, no digest

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

Follow by RSS