●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Gemini API × Android Jetpack Compose Complete Integration Guide — Production Design Patterns for Kotlin Native AI Apps
Build native Android AI apps with Kotlin and Jetpack Compose. Covers Google AI SDK, MVVM, multimodal chat UI, Room DB, WorkManager, and production security.
Setup and context: Why Android Native × Gemini API?
As AI-powered mobile app development options multiply, building with native Android (Kotlin + Jetpack Compose) and the Gemini API offers unique advantages that cross-platform frameworks simply cannot match.
Deep OS integration is the primary differentiator. Android's Camera2 API, MediaStore, WorkManager, and notification system all work seamlessly with Kotlin coroutines, enabling peak performance for background processing and media operations. Jetpack Compose's declarative UI model is also a natural fit for streaming responses—dynamic content updates that would require complex state management elsewhere become surprisingly elegant with StateFlow and collectAsStateWithLifecycle.
On the ecosystem side, the Google AI SDK for Android is purpose-built for this combination. It handles the gRPC transport layer, manages Bitmap encoding for multimodal requests, and exposes Kotlin Flow-based APIs that slot directly into a modern MVVM architecture. You get first-class support for gemini-2.0-flash, gemini-2.5-pro, and future models without waiting for third-party adapters.
This guide walks you through a complete, production-ready design covering:
Secure SDK integration with Gradle Kotlin DSL and secrets-gradle-plugin
Clean MVVM architecture with ViewModel + StateFlow + Repository
Text and image multimodal chat UI with Jetpack Compose
Conversation history persistence with Room Database
Background AI summarization tasks with WorkManager
Hilt dependency injection for testable, modular code
A testing strategy using Compose Testing, Hilt Test, and fake repositories
Protecting Your API Key with secrets-gradle-plugin
Never hardcode your API key in source code. The secrets-gradle-plugin reads from local.properties and injects values as BuildConfig fields, keeping secrets out of version control entirely.
# local.properties (add to .gitignore immediately)
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
// In app/build.gradle.ktssecrets { propertiesFileName = "local.properties" defaultPropertiesFileName = "local.defaults.properties"}
Create local.defaults.properties for CI environments:
# local.defaults.properties (safe to commit)
GEMINI_API_KEY=placeholder_key_for_ci
This makes BuildConfig.GEMINI_API_KEY available in your Kotlin code. The plugin handles everything at compile time—no reflection, no runtime file reads.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Normalize Gemini failures into seven categories and surface per-device error rates with Crashlytics custom keys
✦Separate expected failures (safety, 429) from real crashes so genuine NPEs do not get buried in non-fatals
✦Survive process death and rotation mid-stream with SavedStateHandle partial stashing plus Room as the source of truth
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Multimodal Image Handling: Camera and Gallery Integration
One of Gemini's most powerful capabilities is multimodal understanding—the ability to reason about images alongside text. Integrating image input well requires handling both camera captures and gallery selections cleanly.
Setting Up the Image Picker
// ui/chat/ImagePickerHandler.kt@Composablefun rememberImagePickerHandler( onImageSelected: (Bitmap) -> Unit): ImagePickerState { val context = LocalContext.current // Gallery picker val galleryLauncher = rememberLauncherForActivityResult( ActivityResultContracts.GetContent() ) { uri -> uri?.let { val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) } else { @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(context.contentResolver, uri) } onImageSelected(bitmap) } } // Camera capture var tempUri by remember { mutableStateOf<Uri?>(null) } val cameraLauncher = rememberLauncherForActivityResult( ActivityResultContracts.TakePicture() ) { success -> if (success) { tempUri?.let { uri -> val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) } else { @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(context.contentResolver, uri) } onImageSelected(bitmap) } } } return ImagePickerState( onPickFromGallery = { galleryLauncher.launch("image/*") }, onTakePhoto = { // Create temp file in cache dir for camera output val tempFile = File.createTempFile("camera_", ".jpg", context.cacheDir) val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", tempFile ) tempUri = uri cameraLauncher.launch(uri) } )}data class ImagePickerState( val onPickFromGallery: () -> Unit, val onTakePhoto: () -> Unit)
Persisting Conversation History with Room Database
Entities, DAO, and Database
// data/local/ChatEntity.kt@Entity(tableName = "chat_messages")data class ChatEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val sessionId: String, val text: String, val role: String, // "USER" or "MODEL" val imageUri: String?, val timestamp: Long, val tokenCount: Int = 0)// data/local/ChatDao.kt@Daointerface ChatDao { @Query("SELECT * FROM chat_messages WHERE sessionId = :sessionId ORDER BY timestamp ASC") fun observeSessionMessages(sessionId: String): Flow<List<ChatEntity>> @Query("SELECT * FROM chat_messages WHERE sessionId = :sessionId ORDER BY timestamp ASC") suspend fun getSessionMessages(sessionId: String): List<ChatEntity> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertMessage(entity: ChatEntity) @Query("DELETE FROM chat_messages WHERE sessionId = :sessionId") suspend fun clearSession(sessionId: String) @Query("DELETE FROM chat_messages WHERE timestamp < :cutoffMs") suspend fun deleteOlderThan(cutoffMs: Long) @Query("SELECT COUNT(*) FROM chat_messages WHERE sessionId = :sessionId") suspend fun countMessages(sessionId: String): Int}// data/local/ChatDatabase.kt@Database(entities = [ChatEntity::class], version = 1, exportSchema = false)abstract class ChatDatabase : RoomDatabase() { abstract fun chatDao(): ChatDao}
Session Management Strategy
A practical approach for AI chat apps is to organize conversations into sessions. Each session gets a UUID, which lets you:
Display a conversation list (like Claude or ChatGPT)
Load context from previous sessions when needed
Purge old sessions to manage device storage
Running a periodic cleanup via WorkManager (deleting sessions older than 30 days) prevents the local database from growing unbounded on long-term users' devices.
Background AI Tasks with WorkManager
For heavy operations—summarization, translation, daily report generation—WorkManager ensures reliability even when the app is in the background or closed.
// worker/SummarizeWorker.kt@HiltWorkerclass SummarizeWorker @AssistedInject constructor( @Assisted context: Context, @Assisted params: WorkerParameters, private val geminiRepository: GeminiRepository) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val sessionId = inputData.getString(KEY_SESSION_ID) ?: return Result.failure() return try { val messages = geminiRepository.getChatHistory(sessionId) if (messages.isEmpty()) return Result.success(workDataOf(KEY_SUMMARY to "")) val prompt = buildString { appendLine("Summarize the following conversation in 3 concise sentences:") messages.forEach { appendLine("${it.role}: ${it.text}") } } var summaryText = "" geminiRepository.sendMessage( listOf(ChatMessage(text = prompt, role = Role.USER)) ).collect { result -> if (!result.isComplete && result.error == null) { summaryText += result.text } } Result.success(workDataOf(KEY_SUMMARY to summaryText)) } catch (e: Exception) { if (runAttemptCount < 3) Result.retry() else Result.failure() } } companion object { const val KEY_SESSION_ID = "session_id" const val KEY_SUMMARY = "summary" fun buildRequest(sessionId: String): OneTimeWorkRequest { return OneTimeWorkRequestBuilder<SummarizeWorker>() .setInputData(workDataOf(KEY_SESSION_ID to sessionId)) .setConstraints( Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() ) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .addTag("ai_summarize") .build() } }}
Enqueue the worker and observe the result:
// In ViewModelfun requestSummary(sessionId: String) { val request = SummarizeWorker.buildRequest(sessionId) WorkManager.getInstance(context).enqueue(request) WorkManager.getInstance(context) .getWorkInfoByIdLiveData(request.id) .observeForever { info -> if (info?.state == WorkInfo.State.SUCCEEDED) { val summary = info.outputData.getString(SummarizeWorker.KEY_SUMMARY) // Update UI with summary } }}
Performance Optimization: Keeping the UI Smooth
Token Counting Before Requests
Sending excessively long conversations wastes tokens and increases latency. Use countTokens() to measure your payload before committing to an API call:
// In GeminiDataSourcesuspend fun countTokens(messages: List<ChatMessage>): Int { val model = buildGenerativeModel() val contents = messages.map { msg -> content(msg.role.name.lowercase()) { text(msg.text) } } return try { model.countTokens(*contents.toTypedArray()).totalTokens } catch (e: Exception) { -1 // Return -1 to indicate unavailable, caller handles gracefully }}
In the ViewModel, add a soft warning when approaching the window limit:
private suspend fun checkTokenCount() { val count = geminiDataSource.countTokens(_uiState.value.messages) if (count > 800_000) { // 80% of 1M limit _uiState.update { it.copy(showTokenWarning = true) } }}
Bitmap Downscaling for Image Uploads
Sending full-resolution camera photos (12–50 MP) is unnecessary and increases upload time. Downscale bitmaps before passing them to the SDK:
fun Bitmap.downscaleForApi(maxDimension: Int = 1024): Bitmap { val ratio = maxDimension.toFloat() / maxOf(width, height) if (ratio >= 1f) return this val newWidth = (width * ratio).toInt() val newHeight = (height * ratio).toInt() return Bitmap.createScaledBitmap(this, newWidth, newHeight, true)}
Call this in the ViewModel before invoking sendMessage:
fun sendMessage(userInput: String, rawBitmap: Bitmap? = null) { val scaledBitmap = rawBitmap?.downscaleForApi(maxDimension = 1024) // ... rest of sendMessage logic}
This typically reduces image payloads from 10–20 MB down to under 500 KB with negligible quality loss for AI interpretation tasks.
LazyColumn Performance with key Parameter
When using LazyColumn for the chat list, always provide stable key values to help Compose avoid unnecessary recompositions:
Without a key, Compose re-renders the entire list whenever any message is added. With key = { message.timestamp }, only the new item is composed. For a chat app receiving continuous streaming updates, this difference is immediately visible in profiler traces.
Security Design: Preparing for Production
Multi-Layer API Key Protection
APK files can be decompiled—any key in your binary is potentially recoverable. For apps with real users, a layered approach is essential:
Recommended Architecture: Backend Proxy
The safest approach is to never put the API key in the app. Route requests through your own authenticated backend:
Android App → Your Backend API (user auth) → Gemini API
The backend validates the user's session token before forwarding to Gemini. This way, even if someone reverse-engineers your APK, they can't use your Gemini quota directly.
Development approach (BuildConfig only):
class GeminiDataSource @Inject constructor() { private val apiKey: String get() = if (BuildConfig.DEBUG) { BuildConfig.GEMINI_API_KEY } else { "" // Production: use token from backend auth response }}
ProGuard / R8 Obfuscation
# app/proguard-rules.pro
# Gemini SDK
-keep class com.google.ai.client.generativeai.** { *; }
-keepattributes Signature
-keepattributes *Annotation*
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-keepclassmembers @androidx.room.Entity class * { *; }
# Hilt
-keep class dagger.hilt.** { *; }
-keep @dagger.hilt.InstallIn class * { *; }
Obfuscation alone doesn't protect API keys—it raises the bar for attackers but isn't sufficient as a sole defense. Always combine it with the backend proxy pattern for genuine production security.
Testing Strategy
ViewModel Unit Tests with Fake Repository
The repository abstraction layer is what makes unit testing the ViewModel practical. You don't need a real API key or network.
// test/fake/FakeGeminiRepository.ktclass FakeGeminiRepository : GeminiRepository { var streamResponse: Flow<GenerationResult> = emptyFlow() val savedMessages = mutableListOf<ChatMessage>() override fun sendMessage(messages: List<ChatMessage>, imageBitmap: Bitmap?): Flow<GenerationResult> { return streamResponse } override suspend fun getChatHistory(sessionId: String): List<ChatMessage> = savedMessages override suspend fun saveChatMessage(message: ChatMessage) { savedMessages.add(message) } override suspend fun clearSession(sessionId: String) { savedMessages.clear() }}// test/ChatViewModelTest.kt@OptIn(ExperimentalCoroutinesApi::class)class ChatViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() private val fakeRepository = FakeGeminiRepository() private lateinit var viewModel: ChatViewModel @Before fun setup() { viewModel = ChatViewModel(fakeRepository) } @Test fun `sendMessage appends user message immediately`() = runTest { fakeRepository.streamResponse = flow { emit(GenerationResult(text = "", isComplete = true)) } viewModel.sendMessage("Hello Gemini") val firstMessage = viewModel.uiState.value.messages.first() assertEquals("Hello Gemini", firstMessage.text) assertEquals(Role.USER, firstMessage.role) } @Test fun `streaming chunks accumulate in pendingBotText`() = runTest { fakeRepository.streamResponse = flowOf( GenerationResult(text = "Hello", isComplete = false), GenerationResult(text = " there", isComplete = false), GenerationResult(text = "", isComplete = true) ) viewModel.sendMessage("Hi") advanceUntilIdle() val finalMessages = viewModel.uiState.value.messages assertEquals("Hello there", finalMessages.last().text) assertFalse(viewModel.uiState.value.isLoading) } @Test fun `error from repository surfaces in uiState`() = runTest { fakeRepository.streamResponse = flowOf( GenerationResult(error = "Rate limit exceeded", isComplete = true) ) viewModel.sendMessage("test") advanceUntilIdle() assertNotNull(viewModel.uiState.value.errorMessage) assertFalse(viewModel.uiState.value.isLoading) }}
Compose UI Tests with Hilt
// androidTest/ChatScreenTest.kt@HiltAndroidTestclass ChatScreenTest { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<MainActivity>() @Test fun chatInput_sendMessage_displaysUserBubble() { composeTestRule.onNodeWithTag("ChatInput") .performTextInput("What can Gemini do?") composeTestRule.onNodeWithTag("SendButton") .performClick() composeTestRule.onNodeWithText("What can Gemini do?") .assertIsDisplayed() } @Test fun cancelButton_appearsWhileLoading() { composeTestRule.onNodeWithTag("ChatInput") .performTextInput("Generate a long response") composeTestRule.onNodeWithTag("SendButton").performClick() // Cancel button should appear during streaming composeTestRule.onNodeWithTag("CancelButton").assertIsDisplayed() }}
Reporting Failures to Crashlytics So You Can Actually See Your Error Rate
Shipping AI apps as an indie developer taught me that catching an exception and showing it in the UI leaves you permanently blind to what actually fails on real devices, and how often. Gemini API failures are not a single thing: rate limits (HTTP 429), safety blocks, RECITATION truncation, network timeouts, invalid keys. Collapse them into one generic exception and your production error rate disappears into fog.
I normalize each failure into a category, then record it to Firebase Crashlytics with custom keys. That way the dashboard can answer "what share of calls are 429s?" and "is one device model disproportionately hitting safety blocks?"
// GeminiFailure.kt — normalize failures into operable categoriesenum class GeminiFailureType { RATE_LIMIT, SAFETY_BLOCKED, RECITATION, TIMEOUT, AUTH, NETWORK, UNKNOWN}fun classifyGeminiFailure(t: Throwable): GeminiFailureType = when { t is com.google.ai.client.generativeai.type.PromptBlockedException -> GeminiFailureType.SAFETY_BLOCKED t is com.google.ai.client.generativeai.type.ServerException && t.message?.contains("429") == true -> GeminiFailureType.RATE_LIMIT t.message?.contains("RECITATION") == true -> GeminiFailureType.RECITATION t is java.net.SocketTimeoutException -> GeminiFailureType.TIMEOUT t is com.google.ai.client.generativeai.type.InvalidAPIKeyException -> GeminiFailureType.AUTH t is java.io.IOException -> GeminiFailureType.NETWORK else -> GeminiFailureType.UNKNOWN}
// Called from the catch block in GeminiDataSource.ktimport com.google.firebase.crashlytics.FirebaseCrashlyticsprivate fun reportFailure(t: Throwable, modelName: String, attempt: Int) { val type = classifyGeminiFailure(t) FirebaseCrashlytics.getInstance().apply { setCustomKey("gemini_failure_type", type.name) setCustomKey("gemini_model", modelName) setCustomKey("gemini_attempt", attempt) // Treat expected failures (safety / 429) as logs, not crashes, // so they don't drown the real NPEs you need to fix. if (type == GeminiFailureType.SAFETY_BLOCKED || type == GeminiFailureType.RATE_LIMIT) { log("gemini_${type.name.lowercase()} model=$modelName attempt=$attempt") } else { recordException(t) } }}
The deliberate split here is keeping "expected" failures (safety, 429) out of recordException. If you send every production 429 as a non-fatal, Crashlytics floods and the genuine NPEs get buried. I settled on this line while cleaning up crash metrics for my own wallpaper apps in production: count 429 and safety blocks via custom-keyed logs, and let only UNKNOWN / AUTH / NETWORK surface as non-fatals. Tagging the model name also lets the same dashboard show whether failure patterns shifted after a default model was quietly swapped during a generational update.
Once failures are classified and accumulating, operations get quietly easier. I personally just glance at the gemini_failure_type ratios once a week: if NETWORK spikes on a particular device, I loosen timeouts and retries for weak-connection environments; if SAFETY_BLOCKED creeps up, I revisit how the prompt is worded. As an indie developer, the measurement effort is itself a cost, so narrowing the whole practice to one thing — making only the failures you care about visible without polluting crash metrics — is what keeps it sustainable.
Surviving Config Changes and Process Death Mid-Stream
ViewModel + StateFlow survives configuration changes (rotation), but process death is a different problem. On a low-memory device the OS can terminate your backgrounded process outright, and the ViewModel goes with it. A user who returns mid-stream lands on an empty chat. This path is common on real hardware and easy to miss with emulator-only testing.
I handle it in two layers: stash the in-flight partial text into SavedStateHandle chunk by chunk so a return from process death can repaint "so far" instantly, and persist completed turns to Room, keeping the database as the single source of truth.
class ChatViewModel @Inject constructor( private val repository: GeminiRepository, private val savedState: SavedStateHandle) : ViewModel() { fun sendMessage(prompt: String) { viewModelScope.launch { val buffer = StringBuilder() repository.streamResponse(prompt) .onCompletion { cause -> if (cause == null) { repository.persistTurn(prompt, buffer.toString()) // commit to Room savedState[KEY_PARTIAL] = "" // clear the stash } } .collect { chunk -> buffer.append(chunk.text) savedState[KEY_PARTIAL] = buffer.toString() // stash each chunk emitToUi(buffer.toString()) } } } init { // On return, restore any stashed text as an "incomplete" response val pending = savedState.get<String>(KEY_PARTIAL).orEmpty() if (pending.isNotBlank()) { emitToUi(pending, incomplete = true) // "interrupted — regenerate?" UI } } companion object { private const val KEY_PARTIAL = "partial_response" }}
Because SavedStateHandle rides the onSaveInstanceState bundle, it can only hold small data — tens of KB at most. Stash just "the partial text of the latest response," not the full transcript, and let Room hold the committed history. Reading low-end device reviews, "the conversation vanished when I came back" is a surprisingly common complaint, and partial stashing alone visibly improves perceived reliability.
Where to Start
Replace the catch block of a single Gemini call with classifyGeminiFailure and push a gemini_failure_type custom key to Crashlytics. After a few days of production data, you'll see — in numbers — what actually fails in your own app, and on which devices. From there, separating retryable failures (429, timeout) from failures you should explain gently in the UI (safety blocks) makes the design priorities settle on their own.
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.