On this page
- How the pieces fit
- Step 1: Set up Firebase and AI Logic
- Step 2: Protect the endpoint with App Check
- Step 3: Your first request
- Step 4: Model the UI state and stream into it
- Step 5: The Compose screen
- Step 6: Sending images (multimodal input)
- Step 7: Handle the errors you'll actually see
- Cost and abuse control
- Use cases this pattern fits
- Frequently asked questions
- Conclusion
Most "Gemini in Android" tutorials stop at one generateContent() call with an API key pasted into BuildConfig. That works in a demo and becomes a security incident in production. This guide builds the feature the way you'd actually ship it.
It's for intermediate Android developers comfortable with Kotlin, coroutines and Compose. You'll integrate Gemini through Firebase AI Logic, Google's recommended client SDK for calling Gemini from mobile apps, protect it with App Check, configure the model, stream output into a Compose screen, send images, and handle errors and cost.
The SDK and Gemini models evolve quickly. The code below follows the Firebase AI Logic documentation at the time of writing, which uses firebase-ai through the Firebase BoM and the gemini-3.8-flash model in its examples. Check the current models page and Firebase release notes before you ship.
How the pieces fit
Step 1: Set up Firebase and AI Logic
- Create or open a Firebase project in the Firebase console and register your Android app with its exact package name.
- Enable Firebase AI Logic in the console and choose a Gemini API provider. The Gemini Developer API has a no-cost tier for getting started. Vertex AI Gemini API suits enterprise and Google Cloud requirements.
- Download
google-services.jsoninto yourapp/directory. - Add the Google services plugin and dependencies as shown below.
plugins {
// ...existing Android/Kotlin plugins
id("com.google.gms.google-services") version "<latest>" apply false
}plugins {
// ...
id("com.google.gms.google-services")
}
dependencies {
implementation(platform("com.google.firebase:firebase-bom:<latest>"))
implementation("com.google.firebase:firebase-ai")
// App Check: Play Integrity for release builds, debug provider for local development
implementation("com.google.firebase:firebase-appcheck-playintegrity")
debugImplementation("com.google.firebase:firebase-appcheck-debug")
}Step 2: Protect the endpoint with App Check
Because the app calls Gemini through Firebase, anyone who copies your Firebase configuration could try to call it too. App Check attaches an attestation token, from Play Integrity on real devices, so Firebase can reject requests that don't come from your genuine app. Firebase's documentation states that App Check is enforced for AI Logic from July 2026, so set it up from day one.
class App : Application() {
override fun onCreate() {
super.onCreate()
Firebase.initialize(this)
Firebase.appCheck.installAppCheckProviderFactory(
if (BuildConfig.DEBUG) DebugAppCheckProviderFactory.getInstance()
else PlayIntegrityAppCheckProviderFactory.getInstance()
)
}
}The debug provider logs a debug token, which you register in the Firebase console. Don't commit it to a public repository, and never ship a debug build or debug provider in production.
Step 3: Your first request
Create the model once, for example in a repository provided by dependency injection, and call it from a coroutine. In Kotlin the SDK methods are suspend functions:
import com.google.firebase.Firebase
import com.google.firebase.ai.ai
import com.google.firebase.ai.type.GenerativeBackend
import com.google.firebase.ai.type.content
import com.google.firebase.ai.type.generationConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapNotNull
class GeminiTextRepository(modelName: String = "gemini-3.8-flash") {
private val model = Firebase.ai(backend = GenerativeBackend.googleAI()).generativeModel(
modelName = modelName,
// Keep answers focused and bounded: lower temperature, capped output length.
generationConfig = generationConfig {
temperature = 0.4f
maxOutputTokens = 800
},
// System instructions shape every response; users can't see or change them.
systemInstruction = content {
text(
"You are a concise assistant inside a study app for Android developers. " +
"Answer in plain English, use short paragraphs, and say when you are unsure."
)
},
)
/** Single response: simplest, but the user waits for the whole answer. */
suspend fun explain(topic: String): String =
model.generateContent("Explain $topic in under 150 words.").text.orEmpty()
/** Streaming response: text appears progressively, which feels much faster. */
fun explainStream(topic: String): Flow<String> =
model.generateContentStream("Explain $topic in under 150 words.")
.mapNotNull { chunk -> chunk.text }
}Step 4: Model the UI state and stream into it
A streamed AI response has more states than a typical network call: idle, waiting for the first token, streaming, complete and failed. Model them explicitly so the UI can show the right affordances, such as a Stop button while streaming.
sealed interface ExplainUiState {
data object Idle : ExplainUiState
data object Waiting : ExplainUiState // request sent, no text yet
data class Streaming(val text: String) : ExplainUiState
data class Done(val text: String) : ExplainUiState
data class Error(val message: String, val partial: String = "") : ExplainUiState
}
class ExplainViewModel(private val repository: GeminiTextRepository) : ViewModel() {
private val _uiState = MutableStateFlow<ExplainUiState>(ExplainUiState.Idle)
val uiState: StateFlow<ExplainUiState> = _uiState.asStateFlow()
private var job: Job? = null
fun explain(topic: String) {
if (topic.isBlank()) return
job?.cancel() // one request at a time
job = viewModelScope.launch {
_uiState.value = ExplainUiState.Waiting
val buffer = StringBuilder()
try {
repository.explainStream(topic.take(500)).collect { chunk -> // cap user input length
buffer.append(chunk)
_uiState.value = ExplainUiState.Streaming(buffer.toString())
}
_uiState.value = ExplainUiState.Done(buffer.toString())
} catch (e: CancellationException) {
_uiState.value = ExplainUiState.Done(buffer.toString()) // user pressed Stop
throw e
} catch (e: Exception) {
_uiState.value = ExplainUiState.Error(e.toUserMessage(), buffer.toString())
}
}
}
fun stop() { job?.cancel() }
}Step 5: The Compose screen
@Composable
fun ExplainScreen(viewModel: ExplainViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var topic by rememberSaveable { mutableStateOf("") }
val busy = state is ExplainUiState.Waiting || state is ExplainUiState.Streaming
Column(Modifier.fillMaxSize().padding(16.dp)) {
OutlinedTextField(
value = topic,
onValueChange = { topic = it },
label = { Text("What should I explain?") },
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
)
Row(Modifier.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.explain(topic) }, enabled = !busy && topic.isNotBlank()) { Text("Explain") }
if (busy) OutlinedButton(onClick = viewModel::stop) { Text("Stop") }
}
when (val s = state) {
ExplainUiState.Idle -> Text("Ask about any Android topic.", style = MaterialTheme.typography.bodyMedium)
ExplainUiState.Waiting -> LinearProgressIndicator(Modifier.fillMaxWidth())
is ExplainUiState.Streaming -> AiText(s.text)
is ExplainUiState.Done -> AiText(s.text)
is ExplainUiState.Error -> {
if (s.partial.isNotEmpty()) AiText(s.partial)
Text(s.message, color = MaterialTheme.colorScheme.error)
TextButton(onClick = { viewModel.explain(topic) }) { Text("Try again") }
}
}
}
}
@Composable
private fun AiText(text: String) {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text("AI-generated ยท may contain mistakes", style = MaterialTheme.typography.labelSmall)
Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 4.dp))
}
}Step 6: Sending images (multimodal input)
Gemini models accept images alongside text. Build a multi-part content block. Downscale large photos first: smaller images upload faster and use fewer input tokens.
suspend fun describeForAltText(photo: Bitmap): String {
val scaled = photo.scaleToMaxSide(1024) // your helper: keep aspect ratio, max 1024 px
val prompt = content {
image(scaled)
text("Write one sentence of alt text for this image for a screen-reader user. No preamble.")
}
return model.generateContent(prompt).text.orEmpty()
}
fun Bitmap.scaleToMaxSide(max: Int): Bitmap {
val ratio = max.toFloat() / maxOf(width, height)
if (ratio >= 1f) return this
return Bitmap.createScaledBitmap(this, (width * ratio).toInt(), (height * ratio).toInt(), true)
}Step 7: Handle the errors you'll actually see
Map SDK exceptions to messages a user can act on, and log the technical details for yourself. The Firebase AI SDK throws subclasses of FirebaseAIException. Check the API reference for the full current list.
fun Throwable.toUserMessage(): String = when (this) {
is java.io.IOException -> "You appear to be offline. Check your connection and try again."
is com.google.firebase.ai.type.QuotaExceededException -> "The assistant is busy right now. Please try again in a minute."
is com.google.firebase.ai.type.PromptBlockedException -> "That request can't be answered. Try rephrasing it."
is com.google.firebase.ai.type.FirebaseAIException -> "The assistant couldn't respond. Please try again."
else -> "Something went wrong. Please try again."
}| Symptom | Likely cause | Fix |
|---|---|---|
| Requests rejected after enabling enforcement | App Check token missing or invalid | Register the debug token for debug builds; use Play Integrity for release; check SHA-256 fingerprints in Firebase |
| Model not found / invalid model | Retired or misspelled model name | Check the current models list; keep the name in remote config |
| Quota or rate-limit errors | Free-tier limits or bursts | Backoff and retry; upgrade the plan; add per-user limits |
| Empty or truncated text | maxOutputTokens too low, or response stopped by safety filters | Raise the limit sensibly; inspect the finish reason; handle null text |
| UI freezes while streaming | Heavy work per chunk, such as re-parsing Markdown on every token | Throttle UI updates or parse on a background dispatcher |
Cost and abuse control
- Cap input and output. Truncate user input (the
take(500)above) and setmaxOutputTokens. - Ask for brevity in the prompt. "Under 150 words" reduces output tokens and latency.
- Debounce and deduplicate. Never call the model on every keystroke.
- Cache deterministic results such as "explain topic X", keyed by input and prompt version.
- Add per-user quotas. If you need them, or need multiple providers or private data retrieval, move calls behind your own backend.
- Monitor usage in the Firebase and Google Cloud consoles, and set budget alerts.
Use cases this pattern fits
Learning app: "Explain this concept"
Short, bounded prompts with a system instruction for tone. Streaming makes answers feel instant. Low risk, high value.
Accessibility: alt text for user photos
Multimodal input generates draft descriptions that the user reviews and edits before posting. Labelled as AI-generated.
Content app: title and tag suggestions
Suggest, don't decide: the user picks from the generated options. Cache results per item.
Frequently asked questions
Can I put a Gemini API key in BuildConfig instead?
You shouldn't. Anything in an APK can be extracted, and a leaked key can be abused at your expense. Use Firebase AI Logic with App Check, or your own backend.
What happened to the old Google AI client SDK for Android?
Google now directs Android developers to Firebase AI Logic for calling Gemini from mobile apps. If you have older code using the previous client SDK, migrate it following Firebase's migration guide.
Gemini Developer API or Vertex AI?
The Gemini Developer API is the quickest start and has a no-cost tier. Vertex AI suits organisations that need Google Cloud enterprise controls and data governance. Firebase AI Logic supports both, and switching is mostly a backend parameter change.
How do I build a multi-turn chat?
Use model.startChat(history) and sendMessageStream(). The full project, including history trimming, is in Build an AI Chatbot with Kotlin and Jetpack Compose.
Does this work offline?
No, cloud models need connectivity. For offline features, see Gemini Nano and ML Kit GenAI or hybrid inference.
Conclusion
A production-ready Gemini integration is more than one API call: Firebase AI Logic for the SDK, App Check for protection, a system instruction and generation limits for predictable output, streaming state in the ViewModel, and honest error handling in the UI. With those pieces in place, adding a new AI feature is mostly a matter of writing a good prompt.
Next step: build the explain screen above with your own system instruction, then turn it into a full conversation in Build an AI Chatbot Using Kotlin and Jetpack Compose.
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.