๐Ÿค– Android AI and Generative AI

Gemini API Integration in Android Using Kotlin

A complete, production-minded walkthrough for calling Gemini from an Android app in Kotlin: Firebase AI Logic setup, App Check protection, model configuration, streaming responses into Jetpack Compose, sending images, and handling the errors you'll actually see.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 6 min read ๐ŸŽฏ Intermediate
On this page
  1. How the pieces fit
  2. Step 1: Set up Firebase and AI Logic
  3. Step 2: Protect the endpoint with App Check
  4. Step 3: Your first request
  5. Step 4: Model the UI state and stream into it
  6. Step 5: The Compose screen
  7. Step 6: Sending images (multimodal input)
  8. Step 7: Handle the errors you'll actually see
  9. Cost and abuse control
  10. Use cases this pattern fits
  11. Frequently asked questions
  12. 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.

Versions and model names

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

Gemini request path through Firebase AI Logic with App Check The Compose screen sends a prompt to the ViewModel, which calls a repository using the Firebase AI Logic SDK. The SDK attaches an App Check token proving the request comes from your genuine app, sends it to the Firebase AI Logic proxy, which calls the Gemini model and streams chunks back to the UI. streamed chunks โ†’ StateFlow โ†’ recomposition Composeprompt + output ViewModelUiState Firebase AI SDK+ App Check token AI Logic proxyverifies, forwards Gemini
Your app never holds a Gemini API key. Firebase's proxy verifies the App Check token and calls Gemini on your behalf.

Step 1: Set up Firebase and AI Logic

  1. Create or open a Firebase project in the Firebase console and register your Android app with its exact package name.
  2. 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.
  3. Download google-services.json into your app/ directory.
  4. Add the Google services plugin and dependencies as shown below.
build.gradle.kts (project)Gradle KTS
plugins {
    // ...existing Android/Kotlin plugins
    id("com.google.gms.google-services") version "<latest>" apply false
}
app/build.gradle.ktsGradle KTS
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.

App.ktKotlin
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Firebase.initialize(this)
        Firebase.appCheck.installAppCheckProviderFactory(
            if (BuildConfig.DEBUG) DebugAppCheckProviderFactory.getInstance()
            else PlayIntegrityAppCheckProviderFactory.getInstance()
        )
    }
}
Debug tokens are secrets

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:

GeminiTextRepository.ktKotlin
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.

ExplainViewModel.ktKotlin
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

ExplainScreen.ktKotlin
@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.

Describe a photoKotlin
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.

Errors.ktKotlin
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."
}
SymptomLikely causeFix
Requests rejected after enabling enforcementApp Check token missing or invalidRegister the debug token for debug builds; use Play Integrity for release; check SHA-256 fingerprints in Firebase
Model not found / invalid modelRetired or misspelled model nameCheck the current models list; keep the name in remote config
Quota or rate-limit errorsFree-tier limits or burstsBackoff and retry; upgrade the plan; add per-user limits
Empty or truncated textmaxOutputTokens too low, or response stopped by safety filtersRaise the limit sensibly; inspect the finish reason; handle null text
UI freezes while streamingHeavy work per chunk, such as re-parsing Markdown on every tokenThrottle UI updates or parse on a background dispatcher

Cost and abuse control

  • Cap input and output. Truncate user input (the take(500) above) and set maxOutputTokens.
  • 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.

See all our apps โ†’

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.

Share: LinkedIn X / Twitter WhatsApp