On this page
Imagine a notes app that summarises a long meeting note while the user is offline on a flight, without sending a single word to a server and without costing you anything per request. That's what on-device generative AI on Android makes possible, on supported devices and within some important limits.
This article explains how Gemini Nano, AICore and the ML Kit GenAI APIs fit together, walks through a complete Kotlin implementation of on-device summarisation with feature checks, model download and streaming, and covers the constraints (device support, foreground-only use, quotas, input limits) that determine whether this approach fits your feature.
Google's documentation lists the ML Kit GenAI APIs as beta: Summarization, Proofreading, Rewriting, Image Description and Prompt. Speech Recognition is alpha. Beta APIs carry no SLA and can change, so check the official pages before relying on the exact names below.
How on-device generative AI works on Android
- Gemini Nano is Google's on-device foundation model.
- AICore is the Android system service that distributes and updates Gemini Nano and uses device hardware for fast inference. According to Android's documentation, it's isolated from most packages, has no direct internet access, and doesn't store input data or outputs after processing.
- ML Kit GenAI APIs are the developer-facing layer. The task-specific APIs (summarise, proofread, rewrite, describe images) come with tuned prompts and built-in quality work. The Prompt API accepts your own prompts for custom tasks.
The ML Kit GenAI APIs at a glance
| API | What it does | Good fit |
|---|---|---|
| Summarization | Summarises articles or conversations as bullet points | Long notes, chat threads, articles |
| Proofreading | Fixes grammar and spelling in short text | Message composers, comments |
| Rewriting | Rewrites short text in a different tone or style | "Make it more formal", "shorten" |
| Image Description | Generates a short description of an image | Alt text, accessibility, cataloguing |
| Speech Recognition | Transcribes spoken audio (alpha) | Voice notes, dictation |
| Prompt | Custom text or multimodal prompts to Gemini Nano | Classification, extraction, custom short generation |
Implementation: on-device summarisation in Kotlin
The flow is the same for every task-specific API: create a client โ check feature status โ download if needed โ run inference โ close the client.
1. Add the dependency
dependencies {
// Beta at the time of writing: check the ML Kit release notes for the current version.
implementation("com.google.mlkit:genai-summarization:1.0.0-beta1")
// await() for ListenableFuture, used by the GenAI APIs
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:<latest>")
}2. Wrap the API behind your own interface
Wrapping keeps beta API details in one file, makes the ViewModel testable with a fake, and lets you add a cloud fallback later without touching the UI.
import android.content.Context
import com.google.mlkit.genai.common.DownloadCallback
import com.google.mlkit.genai.common.FeatureStatus
import com.google.mlkit.genai.common.GenAiException
import com.google.mlkit.genai.summarization.Summarization
import com.google.mlkit.genai.summarization.SummarizationRequest
import com.google.mlkit.genai.summarization.SummarizerOptions
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.guava.await
sealed interface ModelState {
data object Unavailable : ModelState
data object NeedsDownload : ModelState
data class Downloading(val downloadedBytes: Long, val totalBytes: Long) : ModelState
data object Ready : ModelState
}
class OnDeviceSummarizer(context: Context) : AutoCloseable {
private val summarizer = Summarization.getClient(
SummarizerOptions.builder(context)
.setInputType(SummarizerOptions.InputType.ARTICLE)
.setOutputType(SummarizerOptions.OutputType.THREE_BULLETS)
.setLanguage(SummarizerOptions.Language.ENGLISH)
.build()
)
suspend fun status(): ModelState = when (summarizer.checkFeatureStatus().await()) {
FeatureStatus.AVAILABLE -> ModelState.Ready
FeatureStatus.DOWNLOADABLE -> ModelState.NeedsDownload
FeatureStatus.DOWNLOADING -> ModelState.Downloading(0, 0)
else -> ModelState.Unavailable
}
/** Emits download progress, then Ready. Fails the flow if the download fails. */
fun download(): Flow<ModelState> = callbackFlow {
var total = 0L
summarizer.downloadFeature(object : DownloadCallback {
override fun onDownloadStarted(bytesToDownload: Long) {
total = bytesToDownload
trySend(ModelState.Downloading(0, total))
}
override fun onDownloadProgress(totalBytesDownloaded: Long) {
trySend(ModelState.Downloading(totalBytesDownloaded, total))
}
override fun onDownloadCompleted() {
trySend(ModelState.Ready)
close()
}
override fun onDownloadFailed(e: GenAiException) {
close(e)
}
})
awaitClose { }
}
/** Streams partial text through [onPartial] and returns the final summary. */
suspend fun summarize(text: String, onPartial: (String) -> Unit): String {
val request = SummarizationRequest.builder(text).build()
val result = summarizer.runInference(request) { newText -> onPartial(newText) }.await()
return result.summary
}
override fun close() = summarizer.close()
}3. Drive it from a ViewModel
data class SummaryUiState(
val model: ModelState = ModelState.Unavailable,
val summary: String = "",
val isSummarizing: Boolean = false,
val error: String? = null,
)
class SummaryViewModel(private val summarizer: OnDeviceSummarizer) : ViewModel() {
private val _uiState = MutableStateFlow(SummaryUiState())
val uiState = _uiState.asStateFlow()
init {
viewModelScope.launch { _uiState.update { it.copy(model = summarizer.status()) } }
}
fun downloadModel() {
viewModelScope.launch {
summarizer.download()
.catch { _uiState.update { s -> s.copy(error = "Download failed. Check storage and connection.") } }
.collect { state -> _uiState.update { it.copy(model = state) } }
}
}
fun summarize(note: String) {
// The API documents input limits (roughly 4,000 tokens); very short inputs give poor summaries.
if (note.length < 400) {
_uiState.update { it.copy(error = "Write a little more before summarising.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isSummarizing = true, summary = "", error = null) }
try {
val final = summarizer.summarize(note) { partial ->
_uiState.update { it.copy(summary = it.summary + partial) }
}
_uiState.update { it.copy(isSummarizing = false, summary = final) }
} catch (e: GenAiException) {
_uiState.update { it.copy(isSummarizing = false, error = "Couldn't summarise right now. Please try again.") }
}
}
}
override fun onCleared() = summarizer.close()
}In the Compose screen, show the summarise button only when model is Ready, a "Download (one-time)" button with a progress bar for NeedsDownload and Downloading, and nothing at all, or a cloud option if the user has consented, for Unavailable.
The limits that shape your design
| Constraint | What the docs say | Design response |
|---|---|---|
| Device support | Specific devices from several manufacturers, with plans to expand | Runtime checks; fallback or hide the feature |
| Foreground only | Inference allowed only when your app is the top foreground app; background use is blocked | Trigger from user actions, not from WorkManager or services |
| Quotas | Per-app inference quota (BUSY); long-duration battery quota | Exponential backoff; avoid per-keystroke calls |
| Input size (Summarization) | Under ~4,000 tokens; article input over 400 characters | Validate length; chunk or truncate long text |
| Languages (Summarization) | English, Japanese and Korean at the time of writing | Detect language first, for example with ML Kit Language ID |
| Device state | Not supported on devices with an unlocked bootloader | Handle as UNAVAILABLE |
| Output variation | Different Gemini Nano versions may return different output | Don't depend on exact wording; test on several devices |
When to use the Prompt API instead
The task-specific APIs are the easiest and most predictable option. Choose the Prompt API when your task doesn't match them, for example classifying a support message into categories, extracting a date and amount from a text, or generating a short custom label. Keep prompts short and specific, test across devices, and remember that Prompt API device support is grouped by Gemini Nano version. Official sample code lives in the ML Kit GenAI samples repository.
Real-world use cases
Meeting notes summary
- Problem
- Long notes, sensitive content, often offline.
- Solution
- Summarization API, three bullets.
- Why
- Private, free per request, works without signal.
- Trade-off
- Supported devices and languages only.
Polish before sending
- Problem
- Typos in quick messages.
- Solution
- Proofreading API on demand, shown as a suggestion to accept.
- Why
- Private messages never leave the phone.
- Trade-off
- Short text only; user must review.
Alt text for gallery images
- Problem
- Images lack accessible descriptions.
- Solution
- Image Description API generates a draft the user can edit.
- Why
- Personal photos stay local.
- Trade-off
- Short, generic descriptions; foreground only.
Troubleshooting
- Always UNAVAILABLE on your test device. Check the published device list, update Google Play services and AICore, and make sure the bootloader isn't unlocked. Emulators generally won't have Gemini Nano.
- Download never completes. Model downloads can be large and may wait for suitable network conditions. Show clear progress and let users continue using the app.
BACKGROUND_USE_BLOCKEDmeans the call happened while your app wasn't the top foreground app. Only trigger inference from visible UI.BUSYmeans you hit the per-app quota. Retry with exponential backoff and reduce request frequency.- Poor summaries of short text. The model performs best on longer input (the docs suggest 300+ words for articles), so don't offer summaries for tiny notes.
Frequently asked questions
Does Gemini Nano increase my APK size?
No. AICore downloads and manages the model at the system level, and your app only includes the ML Kit client library.
Can I fine-tune Gemini Nano or bring my own model to AICore?
The ML Kit GenAI APIs expose Google's managed model. To run your own model on-device, use LiteRT and the related Google AI Edge tooling instead.
Can I run on-device GenAI in a background job?
No. The documentation states inference is only permitted when your app is the top foreground app, and background use, including from a foreground service, is blocked.
How do I support users on unsupported devices?
Either hide the feature or offer a cloud path with clear consent. Firebase AI Logic's experimental hybrid inference can route between on-device and cloud automatically for single-turn requests.
Conclusion
Gemini Nano with the ML Kit GenAI APIs gives Android apps private, offline, no-per-request-cost generative features, managed by the system rather than bundled in your APK. The price is limited device reach, beta APIs, foreground-only execution and quotas, so design every feature with a status check and a fallback.
Next step: add the OnDeviceSummarizer wrapper to a notes screen and handle all four feature states. Then combine it with the cloud path from Gemini API Integration in Android, following the patterns in Cloud AI vs On-Device AI.
Official references
๐ฑ Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
Ready to build real Android applications?
Every AI feature still runs inside an Android app that has to be fast, reliable and well structured. Strengthen the Kotlin, Jetpack Compose and architecture skills that AI integrations depend on, then apply these AI concepts with confidence.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.