DEV Community

YADNYESH RANA
YADNYESH RANA

Posted on

Gemini Nano Isn't on Most of Your Users' Phones. Here's the Feature-Detection Pattern That Doesn't Embarrass You in Production

Gemini Nano Isn't on Most of Your Users' Phones. Here's the Feature-Detection Pattern That Doesn't Embarrass You in Production

On-device AI on Android sounds simple until you actually ship it: "just call Gemini Nano through AICore." Then you test on a real device fleet and discover most of your users don't have a phone that supports it — wrong chipset, OS version too old, AICore not provisioned, or the device just isn't on Google's supported list yet. If your app assumes local inference is always available, it crashes or silently does nothing for a huge chunk of your install base.

The fix isn't "wrap it in a try/catch." It's treating on-device AI capability as a first-class piece of state you check before you build any UI around it, with a real fallback path — not a loading spinner over a dead end.

The wrong way (what most first attempts look like)

// Don't do this
suspend fun generateReply(prompt: String): String {
    val session = GenerativeModel.getInstance(context) // throws on unsupported devices
    return session.generateContent(prompt).text
}
Enter fullscreen mode Exit fullscreen mode

This works on your Pixel 9 Pro. It throws UnsupportedOperationException (or just hangs, depending on the failure mode) on everything else. You find out in a crash report, not in code review.

Model the capability as a sealed state, not a boolean

A plain isSupported: Boolean loses information you actually need — "not supported at all" and "supported but the model isn't downloaded yet" require completely different UI.

sealed interface OnDeviceAiState {
    data object Unavailable : OnDeviceAiState        // hardware/OS doesn't support it, ever
    data object NeedsDownload : OnDeviceAiState        // supported, but model isn't provisioned locally yet
    data object Downloading : OnDeviceAiState
    data class Ready(val session: AiSession) : OnDeviceAiState
    data class Failed(val reason: Throwable) : OnDeviceAiState
}
Enter fullscreen mode Exit fullscreen mode

Feature detection: check before you commit to a UI path

class OnDeviceAiAvailability(private val context: Context) {

    suspend fun check(): OnDeviceAiState {
        val manager = context.getSystemService(AiCoreManager::class.java)
            ?: return OnDeviceAiState.Unavailable // system service doesn't exist on this OS build

        val status = try {
            manager.checkFeatureStatus(GenerativeAiFeature.TEXT_GENERATION)
        } catch (e: SecurityException) {
            // AICore present but this feature isn't whitelisted/enabled on this device
            return OnDeviceAiState.Unavailable
        }

        return when (status) {
            FeatureStatus.UNSUPPORTED -> OnDeviceAiState.Unavailable
            FeatureStatus.DOWNLOADABLE -> OnDeviceAiState.NeedsDownload
            FeatureStatus.DOWNLOADING -> OnDeviceAiState.Downloading
            FeatureStatus.AVAILABLE -> runCatching { AiSession.create(manager) }
                .fold(
                    onSuccess = { OnDeviceAiState::Ready.let { ctor -> OnDeviceAiState.Ready(it) } },
                    onFailure = { OnDeviceAiState.Failed(it) }
                )
            else -> OnDeviceAiState.Unavailable
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The important part isn't the exact API shape (AICore's surface is still shifting release to release) — it's the principle: every branch of FeatureStatus becomes a distinct state your UI can render correctly, instead of one boolean that collapses "will never work" and "works in three seconds once the model downloads" into the same thing.

The fallback that actually matters: cloud, but only when it's worth it

Here's where teams get the tradeoff backwards. The instinct is "local unavailable → always call the cloud API instead." That's correct for the common case, but it silently reintroduces the exact costs on-device inference was supposed to avoid — data leaves the device, latency goes up, and now you're paying per-token for every user whose phone couldn't run it locally.

A better default: only fall back to cloud for requests where the user explicitly asked for a response now (a chat message, a summarize-this-page tap) — not for speculative/background inference (pre-fetching a suggestion, drafting a notification reply nobody asked for yet). Background inference on Unavailable/NeedsDownload should just... not happen, rather than quietly turning into a cloud bill.

class HybridGenerationRouter(
    private val local: OnDeviceAiAvailability,
    private val cloud: CloudGeminiClient,
) {
    suspend fun generate(prompt: String, userInitiated: Boolean): Result<String> {
        return when (val state = local.check()) {
            is OnDeviceAiState.Ready ->
                runCatching { state.session.generate(prompt) }
                    .recoverCatching { if (userInitiated) cloud.generate(prompt) else throw it }

            OnDeviceAiState.Unavailable, OnDeviceAiState.NeedsDownload, OnDeviceAiState.Downloading ->
                if (userInitiated) cloud.generate(prompt)
                else Result.failure(IllegalStateException("on-device unavailable, skipping background inference"))

            is OnDeviceAiState.Failed ->
                if (userInitiated) cloud.generate(prompt) else Result.failure(state.reason)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

recoverCatching on the local path matters too — local inference can fail mid-session (memory pressure, the model getting evicted under load), and you want the same "cloud only if the user is actually waiting" rule to apply there, not just at the initial capability check.

The part that's easy to get wrong: caching the check

checkFeatureStatus isn't free, and DOWNLOADABLEDOWNLOADINGAVAILABLE is a real state transition that can happen while your app is running (the OS can provision the model in the background). Don't cache the result of check() for the lifetime of the app — cache it for the lifetime of a screen, and re-check whenever the app returns to foreground after being backgrounded for more than a few minutes. Otherwise you'll show "AI unavailable" for the rest of the session to a user whose phone finished downloading the model two minutes after they opened your app.


This capability-detection layer is the boring 20% of on-device AI work that determines whether the flashy 80% (actual prompt design, streaming responses, structured output parsing) ships to real users instead of just your test device. If you want the fuller picture — the actual AICore/Gemini Nano client setup, token-budget management for the 2K-token local context window, and the AICore beta-track device whitelisting steps this article deliberately skipped — I wrote it all up in the Android On-Device AI: Gemini Nano & AI Edge SDK Playbook.

Top comments (0)