From 270108c389a694003e7bffc95bb334b1094ca1fe Mon Sep 17 00:00:00 2001 From: Vinay Guthal Date: Tue, 6 Oct 2026 09:39:33 -0400 Subject: [PATCH 1/5] add quick start app for system isntructions --- .../firebase/quickstart/ai/MainActivity.kt | 6 + .../hybrid/SystemInstructionViewModel.kt | 249 +++++++++++++++ .../ai/ui/SystemInstructionScreen.kt | 289 ++++++++++++++++++ .../ai/ui/SystemInstructionUiState.kt | 10 + .../ai/ui/navigation/FirebaseAISamples.kt | 10 + .../quickstart/ai/ui/navigation/Sample.kt | 3 +- gradle/libs.versions.toml | 4 +- 7 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt create mode 100644 firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt create mode 100644 firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionUiState.kt diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt index 8cbafed1f6..ec379528f0 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt @@ -28,6 +28,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.google.firebase.quickstart.ai.feature.live.BidiViewModel import com.google.firebase.quickstart.ai.feature.hybrid.HybridInferenceViewModel +import com.google.firebase.quickstart.ai.feature.hybrid.SystemInstructionViewModel import com.google.firebase.quickstart.ai.feature.text.ChatViewModel import com.google.firebase.quickstart.ai.feature.text.ServerPromptTemplateViewModel import com.google.firebase.quickstart.ai.feature.text.SvgViewModel @@ -37,6 +38,7 @@ import com.google.firebase.quickstart.ai.ui.StreamRealtimeScreen import com.google.firebase.quickstart.ai.ui.StreamRealtimeVideoScreen import com.google.firebase.quickstart.ai.ui.HybridInferenceScreen import com.google.firebase.quickstart.ai.ui.SvgScreen +import com.google.firebase.quickstart.ai.ui.SystemInstructionScreen import com.google.firebase.quickstart.ai.ui.navigation.FIREBASE_AI_SAMPLES import com.google.firebase.quickstart.ai.ui.navigation.MainMenuScreen import com.google.firebase.quickstart.ai.ui.navigation.ScreenType @@ -123,6 +125,10 @@ class MainActivity : ComponentActivity() { ScreenType.HYBRID -> { (vm as? HybridInferenceViewModel)?.let { HybridInferenceScreen(it) } } + + ScreenType.SYSTEM_INSTRUCTION -> { + (vm as? SystemInstructionViewModel)?.let { SystemInstructionScreen(it) } + } } } } diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt new file mode 100644 index 0000000000..44b1c33f96 --- /dev/null +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt @@ -0,0 +1,249 @@ +package com.google.firebase.quickstart.ai.feature.hybrid + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.Firebase +import com.google.firebase.ai.DownloadStatus +import com.google.firebase.ai.InferenceMode +import com.google.firebase.ai.InferenceSource +import com.google.firebase.ai.OnDeviceConfig +import com.google.firebase.ai.OnDeviceModelStatus +import com.google.firebase.ai.ai +import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.content +import com.google.firebase.quickstart.ai.MainActivity +import com.google.firebase.quickstart.ai.ui.SystemInstructionUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +object SystemInstructionRoute + +enum class SystemInstructionCase(val label: String) { + SINGLE_TEXT("Single TextPart"), + MULTIPLE_TEXT("Multiple TextParts"), + NON_TEXT_PART("Non-Text Part (Image)"), + NONE("No System Instruction") +} + +enum class GenerationMethod(val label: String) { + UNARY("generateContent"), + STREAMING("generateContentStream") +} + +@OptIn(PublicPreviewAPI::class) +class SystemInstructionViewModel : ViewModel() { + val uiState: StateFlow + field = MutableStateFlow(SystemInstructionUiState()) + + private val statusCheckModel = Firebase.ai(backend = GenerativeBackend.googleAI()).generativeModel( + modelName = "gemini-3.5-flash-lite", + onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_ON_DEVICE) + ) + + init { + checkAndDownloadModel() + } + + private fun checkAndDownloadModel() { + viewModelScope.launch { + try { + val status = statusCheckModel.onDeviceExtension?.checkStatus() + val statusText = when (status) { + OnDeviceModelStatus.AVAILABLE -> "Model available" + OnDeviceModelStatus.DOWNLOADABLE -> "Model downloadable" + OnDeviceModelStatus.DOWNLOADING -> "Model downloading..." + else -> "On-device model unavailable" + } + uiState.update { it.copy(modelStatus = statusText) } + + if (status == OnDeviceModelStatus.DOWNLOADABLE) { + statusCheckModel.onDeviceExtension?.download()?.collect { downloadStatus -> + when (downloadStatus) { + is DownloadStatus.DownloadStarted -> { + uiState.update { it.copy(modelStatus = "Downloading model...") } + } + is DownloadStatus.DownloadInProgress -> { + val progress = downloadStatus.totalBytesDownloaded + uiState.update { it.copy(modelStatus = "Downloading: $progress bytes") } + } + is DownloadStatus.DownloadCompleted -> { + uiState.update { it.copy(modelStatus = "Model available") } + } + is DownloadStatus.DownloadFailed -> { + uiState.update { + it.copy( + modelStatus = "Download failed", + errorMessage = "Model download failed" + ) + } + } + } + } + } + } catch (e: Exception) { + uiState.update { + it.copy( + modelStatus = "Error checking status", + errorMessage = e.message + ) + } + } + } + } + + private fun buildSystemInstruction( + case: SystemInstructionCase, + primaryInstruction: String, + secondaryInstruction: String + ): Content? { + return when (case) { + SystemInstructionCase.SINGLE_TEXT -> content { + text(primaryInstruction) + } + SystemInstructionCase.MULTIPLE_TEXT -> content { + text(primaryInstruction) + text(secondaryInstruction) + } + SystemInstructionCase.NON_TEXT_PART -> content { + text(primaryInstruction) + image(MainActivity.catImage) + } + SystemInstructionCase.NONE -> null + } + } + + fun runTest( + promptText: String, + primaryInstruction: String, + secondaryInstruction: String, + case: SystemInstructionCase, + mode: InferenceMode, + method: GenerationMethod + ) { + viewModelScope.launch { + uiState.update { + it.copy( + isLoading = true, + errorMessage = null, + responseText = null, + inferenceSource = null, + logOutput = "Running inference..." + ) + } + + val systemInstruction = buildSystemInstruction(case, primaryInstruction, secondaryInstruction) + val partsSummary = systemInstruction?.parts?.joinToString(", ") { part -> + part::class.java.simpleName + } ?: "null" + + Log.i(TAG, "==========================================================") + Log.i(TAG, ">>> [SYSTEM INSTRUCTION TEST - INPUT] >>>") + Log.i(TAG, "Case: ${case.name} ($partsSummary)") + Log.i(TAG, "Inference Mode: $mode") + Log.i(TAG, "Generation Method: ${method.label}") + Log.i(TAG, "Prompt: \"$promptText\"") + Log.i(TAG, "==========================================================") + + try { + val model = Firebase.ai(backend = GenerativeBackend.googleAI()).generativeModel( + modelName = "gemini-3.5-flash-lite", + onDeviceConfig = OnDeviceConfig(mode = mode), + systemInstruction = systemInstruction + ) + + var fullText = "" + var source: InferenceSource? = null + + when (method) { + GenerationMethod.UNARY -> { + val response = model.generateContent(promptText) + fullText = response.text.orEmpty() + source = response.inferenceSource + } + GenerationMethod.STREAMING -> { + model.generateContentStream(promptText).collect { chunk -> + fullText += chunk.text.orEmpty() + if (chunk.inferenceSource != null) { + source = chunk.inferenceSource + } + uiState.update { + it.copy( + responseText = fullText, + inferenceSource = formatInferenceSource(source) + ) + } + } + } + } + + val sourceName = formatInferenceSource(source) + Log.i(TAG, "<<< [SYSTEM INSTRUCTION TEST - SUCCESS] <<<") + Log.i(TAG, "Inference Source: $sourceName") + Log.i(TAG, "Response Text: \"$fullText\"") + + val summaryLog = """ + === INPUT === + Case: ${case.label} (parts: $partsSummary) + Mode: $mode + Method: ${method.label} + Prompt: "$promptText" + + === OUTPUT (SUCCESS) === + Inference Source: $sourceName + Response: + $fullText + """.trimIndent() + + uiState.update { + it.copy( + isLoading = false, + responseText = fullText, + inferenceSource = sourceName, + logOutput = summaryLog + ) + } + } catch (e: Exception) { + val causeName = e.cause?.let { " (cause: ${it::class.java.simpleName})" }.orEmpty() + Log.e(TAG, "<<< [SYSTEM INSTRUCTION TEST - FAILURE] <<<", e) + + val errorLog = """ + === INPUT === + Case: ${case.label} (parts: $partsSummary) + Mode: $mode + Method: ${method.label} + + === OUTPUT (FAILURE) === + Exception: ${e::class.java.simpleName}$causeName + Message: ${e.localizedMessage ?: e.toString()} + """.trimIndent() + + uiState.update { + it.copy( + isLoading = false, + errorMessage = "${e::class.java.simpleName}$causeName: ${e.localizedMessage ?: e.toString()}", + logOutput = errorLog + ) + } + } + } + } + + private fun formatInferenceSource(source: InferenceSource?): String = + when (source) { + InferenceSource.ON_DEVICE -> "ON_DEVICE" + InferenceSource.IN_CLOUD -> "IN_CLOUD" + null -> "UNKNOWN" + else -> source.toString() + } + + companion object { + private const val TAG = "SystemInstructionDemo" + } +} diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt new file mode 100644 index 0000000000..6245716343 --- /dev/null +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt @@ -0,0 +1,289 @@ +package com.google.firebase.quickstart.ai.ui + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.firebase.ai.InferenceMode +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.quickstart.ai.feature.hybrid.GenerationMethod +import com.google.firebase.quickstart.ai.feature.hybrid.SystemInstructionCase +import com.google.firebase.quickstart.ai.feature.hybrid.SystemInstructionViewModel + +@OptIn(PublicPreviewAPI::class) +@Composable +fun SystemInstructionScreen( + viewModel: SystemInstructionViewModel = viewModel() +) { + val uiState by viewModel.uiState.collectAsState() + var prompt by remember { mutableStateOf("Tell me a fun fact about space.") } + var primaryInstruction by remember { + mutableStateOf("Respond like a 17th-century pirate in 1-2 short sentences.") + } + var secondaryInstruction by remember { + mutableStateOf("Always end your response with the phrase 'Arrr, matey!'") + } + var selectedCase by remember { mutableStateOf(SystemInstructionCase.SINGLE_TEXT) } + var selectedMode by remember { mutableStateOf(InferenceMode.ONLY_ON_DEVICE) } + var selectedMethod by remember { mutableStateOf(GenerationMethod.UNARY) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "On-Device Model Status", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = uiState.modelStatus, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + Text( + text = "1. System Instruction Case:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SystemInstructionCase.entries.forEach { case -> + FilterChip( + selected = selectedCase == case, + onClick = { selectedCase = case }, + label = { Text(case.label) } + ) + } + } + + if (selectedCase != SystemInstructionCase.NONE) { + OutlinedTextField( + value = primaryInstruction, + onValueChange = { primaryInstruction = it }, + label = { Text("System Instruction (TextPart 1)") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3 + ) + } + + if (selectedCase == SystemInstructionCase.MULTIPLE_TEXT) { + OutlinedTextField( + value = secondaryInstruction, + onValueChange = { secondaryInstruction = it }, + label = { Text("System Instruction (TextPart 2 - Concatenated)") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3 + ) + } + + OutlinedTextField( + value = prompt, + onValueChange = { prompt = it }, + label = { Text("User Prompt") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3 + ) + + Text( + text = "2. Inference Mode:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val modes = listOf( + InferenceMode.ONLY_ON_DEVICE to "ONLY_ON_DEVICE", + InferenceMode.PREFER_ON_DEVICE to "PREFER_ON_DEVICE", + InferenceMode.PREFER_IN_CLOUD to "PREFER_IN_CLOUD", + InferenceMode.ONLY_IN_CLOUD to "ONLY_IN_CLOUD" + ) + modes.forEach { (mode, label) -> + FilterChip( + selected = selectedMode == mode, + onClick = { selectedMode = mode }, + label = { Text(label) } + ) + } + } + + Text( + text = "3. Generation Method:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + GenerationMethod.entries.forEach { method -> + FilterChip( + selected = selectedMethod == method, + onClick = { selectedMethod = method }, + label = { Text(method.label) } + ) + } + } + + Button( + onClick = { + viewModel.runTest( + promptText = prompt, + primaryInstruction = primaryInstruction, + secondaryInstruction = secondaryInstruction, + case = selectedCase, + mode = selectedMode, + method = selectedMethod + ) + }, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.size(8.dp)) + } + Text("Run System Instruction Test") + } + + if (uiState.errorMessage != null) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Error", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = uiState.errorMessage!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + + if (uiState.responseText != null) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Response", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Surface( + color = MaterialTheme.colorScheme.primary, + shape = MaterialTheme.shapes.small + ) { + Text( + text = uiState.inferenceSource ?: "UNKNOWN", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + Text( + text = uiState.responseText!!, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "Diagnostics & Logcat Summary", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = uiState.logOutput, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace + ) + } + } + } +} diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionUiState.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionUiState.kt new file mode 100644 index 0000000000..5724b83442 --- /dev/null +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionUiState.kt @@ -0,0 +1,10 @@ +package com.google.firebase.quickstart.ai.ui + +data class SystemInstructionUiState( + val modelStatus: String = "Checking model status...", + val isLoading: Boolean = false, + val responseText: String? = null, + val inferenceSource: String? = null, + val logOutput: String = "Configure system instruction options above and tap Run.", + val errorMessage: String? = null +) diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/FirebaseAISamples.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/FirebaseAISamples.kt index 8f6f7b4aad..808c8d8a19 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/FirebaseAISamples.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/FirebaseAISamples.kt @@ -2,6 +2,8 @@ package com.google.firebase.quickstart.ai.ui.navigation import com.google.firebase.quickstart.ai.feature.hybrid.HybridInferenceRoute import com.google.firebase.quickstart.ai.feature.hybrid.HybridInferenceViewModel +import com.google.firebase.quickstart.ai.feature.hybrid.SystemInstructionRoute +import com.google.firebase.quickstart.ai.feature.hybrid.SystemInstructionViewModel import com.google.firebase.quickstart.ai.feature.live.StreamAudioViewModel import com.google.firebase.quickstart.ai.feature.live.StreamRealtimeAudioRoute import com.google.firebase.quickstart.ai.feature.live.StreamRealtimeVideoRoute @@ -197,5 +199,13 @@ val FIREBASE_AI_SAMPLES = listOf( screenType = ScreenType.HYBRID, viewModelClass = HybridInferenceViewModel::class, categories = listOf(Category.HYBRID) + ), + Sample( + title = "Hybrid System Instructions", + description = "Test systemInstruction (single text, multiple text parts, and non-text part validation) across On-Device, Hybrid, and Cloud inference modes.", + route = SystemInstructionRoute, + screenType = ScreenType.SYSTEM_INSTRUCTION, + viewModelClass = SystemInstructionViewModel::class, + categories = listOf(Category.HYBRID) ) ) diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/Sample.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/Sample.kt index d800f01841..348c7d32aa 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/Sample.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/Sample.kt @@ -21,7 +21,8 @@ enum class ScreenType { SERVER_PROMPT, BIDI, BIDI_VIDEO, - HYBRID + HYBRID, + SYSTEM_INSTRUCTION } data class Sample( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee0f5d7640..bb5552496b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,8 +51,8 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3C coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil3Compose" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil3Compose" } compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation"} -firebase-ai = { module = "com.google.firebase:firebase-ai" } -firebase-ai-ondevice = { module = "com.google.firebase:firebase-ai-ondevice", version = "16.0.0-beta05" } +firebase-ai = { module = "com.google.firebase:firebase-ai", version = "18.0.0" } +firebase-ai-ondevice = { module = "com.google.firebase:firebase-ai-ondevice", version = "16.0.0-beta06" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" } From e5b866f1868506cc5ce10447a7ba6873f79e2ac7 Mon Sep 17 00:00:00 2001 From: Vinay Guthal Date: Tue, 6 Oct 2026 11:18:09 -0400 Subject: [PATCH 2/5] Update firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../ai/feature/hybrid/SystemInstructionViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt index 44b1c33f96..3a8d581dcc 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt @@ -39,8 +39,8 @@ enum class GenerationMethod(val label: String) { @OptIn(PublicPreviewAPI::class) class SystemInstructionViewModel : ViewModel() { - val uiState: StateFlow - field = MutableStateFlow(SystemInstructionUiState()) + private val _uiState = MutableStateFlow(SystemInstructionUiState()) + val uiState: StateFlow = _uiState private val statusCheckModel = Firebase.ai(backend = GenerativeBackend.googleAI()).generativeModel( modelName = "gemini-3.5-flash-lite", From f29dac767078db0141ad0aeeb82cb741ded32897 Mon Sep 17 00:00:00 2001 From: Vinay Guthal Date: Tue, 6 Oct 2026 11:18:33 -0400 Subject: [PATCH 3/5] Update firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt index 3a8d581dcc..b6fd5ebe52 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt @@ -91,7 +91,7 @@ class SystemInstructionViewModel : ViewModel() { uiState.update { it.copy( modelStatus = "Error checking status", - errorMessage = e.message + errorMessage = e.localizedMessage ?: e.toString() ) } } From 7ff40afef811a8e3759c63940c5a51464465f567 Mon Sep 17 00:00:00 2001 From: Vinay Guthal Date: Tue, 6 Oct 2026 11:18:46 -0400 Subject: [PATCH 4/5] Update firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../quickstart/ai/ui/SystemInstructionScreen.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt index 6245716343..e9b43f88e8 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt @@ -44,16 +44,16 @@ fun SystemInstructionScreen( viewModel: SystemInstructionViewModel = viewModel() ) { val uiState by viewModel.uiState.collectAsState() - var prompt by remember { mutableStateOf("Tell me a fun fact about space.") } - var primaryInstruction by remember { + var prompt by rememberSaveable { mutableStateOf("Tell me a fun fact about space.") } + var primaryInstruction by rememberSaveable { mutableStateOf("Respond like a 17th-century pirate in 1-2 short sentences.") } - var secondaryInstruction by remember { + var secondaryInstruction by rememberSaveable { mutableStateOf("Always end your response with the phrase 'Arrr, matey!'") } - var selectedCase by remember { mutableStateOf(SystemInstructionCase.SINGLE_TEXT) } - var selectedMode by remember { mutableStateOf(InferenceMode.ONLY_ON_DEVICE) } - var selectedMethod by remember { mutableStateOf(GenerationMethod.UNARY) } + var selectedCase by rememberSaveable { mutableStateOf(SystemInstructionCase.SINGLE_TEXT) } + var selectedMode by rememberSaveable { mutableStateOf(InferenceMode.ONLY_ON_DEVICE) } + var selectedMethod by rememberSaveable { mutableStateOf(GenerationMethod.UNARY) } Column( modifier = Modifier From ab9d9b9a4f4dd95501694cb3fb669d343f824efe Mon Sep 17 00:00:00 2001 From: Vinay Guthal Date: Tue, 6 Oct 2026 11:33:27 -0400 Subject: [PATCH 5/5] address comments --- .../hybrid/SystemInstructionViewModel.kt | 56 ++++++++++++------- .../ai/ui/SystemInstructionScreen.kt | 36 ++++++------ 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt index b6fd5ebe52..bec9fea436 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/SystemInstructionViewModel.kt @@ -1,5 +1,6 @@ package com.google.firebase.quickstart.ai.feature.hybrid +import android.graphics.Bitmap import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -14,8 +15,9 @@ import com.google.firebase.ai.type.Content import com.google.firebase.ai.type.GenerativeBackend import com.google.firebase.ai.type.PublicPreviewAPI import com.google.firebase.ai.type.content -import com.google.firebase.quickstart.ai.MainActivity import com.google.firebase.quickstart.ai.ui.SystemInstructionUiState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -37,11 +39,21 @@ enum class GenerationMethod(val label: String) { STREAMING("generateContentStream") } +@OptIn(PublicPreviewAPI::class) +enum class InferenceModeOption(val label: String, val mode: InferenceMode) { + ONLY_ON_DEVICE("ONLY_ON_DEVICE", InferenceMode.ONLY_ON_DEVICE), + PREFER_ON_DEVICE("PREFER_ON_DEVICE", InferenceMode.PREFER_ON_DEVICE), + PREFER_IN_CLOUD("PREFER_IN_CLOUD", InferenceMode.PREFER_IN_CLOUD), + ONLY_IN_CLOUD("ONLY_IN_CLOUD", InferenceMode.ONLY_IN_CLOUD) +} + @OptIn(PublicPreviewAPI::class) class SystemInstructionViewModel : ViewModel() { private val _uiState = MutableStateFlow(SystemInstructionUiState()) val uiState: StateFlow = _uiState + private var testJob: Job? = null + private val statusCheckModel = Firebase.ai(backend = GenerativeBackend.googleAI()).generativeModel( modelName = "gemini-3.5-flash-lite", onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_ON_DEVICE) @@ -61,23 +73,23 @@ class SystemInstructionViewModel : ViewModel() { OnDeviceModelStatus.DOWNLOADING -> "Model downloading..." else -> "On-device model unavailable" } - uiState.update { it.copy(modelStatus = statusText) } + _uiState.update { it.copy(modelStatus = statusText) } if (status == OnDeviceModelStatus.DOWNLOADABLE) { statusCheckModel.onDeviceExtension?.download()?.collect { downloadStatus -> when (downloadStatus) { is DownloadStatus.DownloadStarted -> { - uiState.update { it.copy(modelStatus = "Downloading model...") } + _uiState.update { it.copy(modelStatus = "Downloading model...") } } is DownloadStatus.DownloadInProgress -> { val progress = downloadStatus.totalBytesDownloaded - uiState.update { it.copy(modelStatus = "Downloading: $progress bytes") } + _uiState.update { it.copy(modelStatus = "Downloading: $progress bytes") } } is DownloadStatus.DownloadCompleted -> { - uiState.update { it.copy(modelStatus = "Model available") } + _uiState.update { it.copy(modelStatus = "Model available") } } is DownloadStatus.DownloadFailed -> { - uiState.update { + _uiState.update { it.copy( modelStatus = "Download failed", errorMessage = "Model download failed" @@ -88,7 +100,7 @@ class SystemInstructionViewModel : ViewModel() { } } } catch (e: Exception) { - uiState.update { + _uiState.update { it.copy( modelStatus = "Error checking status", errorMessage = e.localizedMessage ?: e.toString() @@ -101,7 +113,8 @@ class SystemInstructionViewModel : ViewModel() { private fun buildSystemInstruction( case: SystemInstructionCase, primaryInstruction: String, - secondaryInstruction: String + secondaryInstruction: String, + testImage: Bitmap? ): Content? { return when (case) { SystemInstructionCase.SINGLE_TEXT -> content { @@ -113,7 +126,9 @@ class SystemInstructionViewModel : ViewModel() { } SystemInstructionCase.NON_TEXT_PART -> content { text(primaryInstruction) - image(MainActivity.catImage) + if (testImage != null) { + image(testImage) + } } SystemInstructionCase.NONE -> null } @@ -125,10 +140,12 @@ class SystemInstructionViewModel : ViewModel() { secondaryInstruction: String, case: SystemInstructionCase, mode: InferenceMode, - method: GenerationMethod + method: GenerationMethod, + testImage: Bitmap? = null ) { - viewModelScope.launch { - uiState.update { + testJob?.cancel() + testJob = viewModelScope.launch { + _uiState.update { it.copy( isLoading = true, errorMessage = null, @@ -138,7 +155,8 @@ class SystemInstructionViewModel : ViewModel() { ) } - val systemInstruction = buildSystemInstruction(case, primaryInstruction, secondaryInstruction) + val systemInstruction = + buildSystemInstruction(case, primaryInstruction, secondaryInstruction, testImage) val partsSummary = systemInstruction?.parts?.joinToString(", ") { part -> part::class.java.simpleName } ?: "null" @@ -170,10 +188,8 @@ class SystemInstructionViewModel : ViewModel() { GenerationMethod.STREAMING -> { model.generateContentStream(promptText).collect { chunk -> fullText += chunk.text.orEmpty() - if (chunk.inferenceSource != null) { - source = chunk.inferenceSource - } - uiState.update { + source = chunk.inferenceSource + _uiState.update { it.copy( responseText = fullText, inferenceSource = formatInferenceSource(source) @@ -201,7 +217,7 @@ class SystemInstructionViewModel : ViewModel() { $fullText """.trimIndent() - uiState.update { + _uiState.update { it.copy( isLoading = false, responseText = fullText, @@ -209,6 +225,8 @@ class SystemInstructionViewModel : ViewModel() { logOutput = summaryLog ) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { val causeName = e.cause?.let { " (cause: ${it::class.java.simpleName})" }.orEmpty() Log.e(TAG, "<<< [SYSTEM INSTRUCTION TEST - FAILURE] <<<", e) @@ -224,7 +242,7 @@ class SystemInstructionViewModel : ViewModel() { Message: ${e.localizedMessage ?: e.toString()} """.trimIndent() - uiState.update { + _uiState.update { it.copy( isLoading = false, errorMessage = "${e::class.java.simpleName}$causeName: ${e.localizedMessage ?: e.toString()}", diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt index e9b43f88e8..5e1685ccdf 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/SystemInstructionScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,9 +32,10 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.firebase.ai.InferenceMode import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.quickstart.ai.MainActivity import com.google.firebase.quickstart.ai.feature.hybrid.GenerationMethod +import com.google.firebase.quickstart.ai.feature.hybrid.InferenceModeOption import com.google.firebase.quickstart.ai.feature.hybrid.SystemInstructionCase import com.google.firebase.quickstart.ai.feature.hybrid.SystemInstructionViewModel @@ -52,7 +53,7 @@ fun SystemInstructionScreen( mutableStateOf("Always end your response with the phrase 'Arrr, matey!'") } var selectedCase by rememberSaveable { mutableStateOf(SystemInstructionCase.SINGLE_TEXT) } - var selectedMode by rememberSaveable { mutableStateOf(InferenceMode.ONLY_ON_DEVICE) } + var selectedMode by rememberSaveable { mutableStateOf(InferenceModeOption.ONLY_ON_DEVICE) } var selectedMethod by rememberSaveable { mutableStateOf(GenerationMethod.UNARY) } Column( @@ -138,17 +139,11 @@ fun SystemInstructionScreen( modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - val modes = listOf( - InferenceMode.ONLY_ON_DEVICE to "ONLY_ON_DEVICE", - InferenceMode.PREFER_ON_DEVICE to "PREFER_ON_DEVICE", - InferenceMode.PREFER_IN_CLOUD to "PREFER_IN_CLOUD", - InferenceMode.ONLY_IN_CLOUD to "ONLY_IN_CLOUD" - ) - modes.forEach { (mode, label) -> + InferenceModeOption.entries.forEach { modeOption -> FilterChip( - selected = selectedMode == mode, - onClick = { selectedMode = mode }, - label = { Text(label) } + selected = selectedMode == modeOption, + onClick = { selectedMode = modeOption }, + label = { Text(modeOption.label) } ) } } @@ -178,8 +173,9 @@ fun SystemInstructionScreen( primaryInstruction = primaryInstruction, secondaryInstruction = secondaryInstruction, case = selectedCase, - mode = selectedMode, - method = selectedMethod + mode = selectedMode.mode, + method = selectedMethod, + testImage = MainActivity.catImage ) }, enabled = !uiState.isLoading, @@ -196,7 +192,8 @@ fun SystemInstructionScreen( Text("Run System Instruction Test") } - if (uiState.errorMessage != null) { + val errorMessage = uiState.errorMessage + if (errorMessage != null) { Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.errorContainer @@ -211,7 +208,7 @@ fun SystemInstructionScreen( color = MaterialTheme.colorScheme.onErrorContainer ) Text( - text = uiState.errorMessage!!, + text = errorMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer ) @@ -219,7 +216,8 @@ fun SystemInstructionScreen( } } - if (uiState.responseText != null) { + val responseText = uiState.responseText + if (responseText != null) { Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer @@ -255,7 +253,7 @@ fun SystemInstructionScreen( } } Text( - text = uiState.responseText!!, + text = responseText, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onPrimaryContainer )