diff --git a/README.md b/README.md index 4997ce29..dbf33cb6 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Here is the list of samples you can find in the `/samples` folder: | Samples | | |:----------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Gemini Hybrid sample | ✨📱☁️ **Hybrid Inference**:
A sample demonstrating a hybrid approach to generative AI, utilizing both on-device (Gemini Nano via ML Kit) and cloud-based (Gemini via Firebase AI SDK) models. It showcases how to fallback to the cloud when on-device capabilities are unavailable.



**[> Browse code](samples/gemini-hybrid)**

| +| | | | Gemini Image Chat sample | ✨🖼️🍌 **Gemini Image Chat**:
A chatbot app using the new [Gemini 3 Pro Image model](https://deepmind.google/models/gemini-image/pro/) (a.k.a. "Nano Banana Pro") enabling image generation and iterations via conversation with the Gemini model. Ask the model to generate an image and ask for tweaks in the chat.



**[> Browse code](samples/gemini-image-chat)**

| | | | | Gemini Chatbot sample | ✨🗣️ **Gemini Chatbot**:
A chatbot app using the Gemini Flash model. You can tweak the [system instructions](https://firebase.google.com/docs/ai-logic/system-instructions) in the model configuration to change the tone or the persona of the model.



**[> Browse code](samples/gemini-chatbot)**

| @@ -42,7 +44,7 @@ Here is the list of samples you can find in the `/samples` folder: | | | | Gemini Nano Image description | ✨📱🔍 **On-device Image Description**:
A sample letting you generate image descriptions using Gemini Nano via the [GenAI Image Description API](https://developers.google.com/ml-kit/genai/image-description/android).



**[> Browse code](samples/genai-image-description)**

| | | | -| Gemini Nano Rewrite | ✨📱🖋️ **On-device Writing Assistance**:
A sample letting you proofread and rewrite text using Gemini Nano via the [GenAI Rewriting API](https://developers.google.com/ml-kit/genai/rewriting/android).



**[> Browse code](samples/genai-writing-assistance)**

| +| Gemini Nano Rewrite | ✨📱🖋️ **On-device Writing Assistance**:
A sample letting you proofread and rewrite text using Gemini Nano via the [GenAI Rewriting API](https://developers.google.com/ml-kit/genai/rewriting/android).



**[> Browse code](samples/genai-writing-assistance)**

| | | | | Imagen sample | 🖼️ **Image Generation with Imagen**:
A sample using [Imagen to generate images](https://developer.android.com/ai/imagen#generate-image) of landscapes, objects and people in various artistic style.



**[> Browse code](samples/imagen)**

| | | | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cb0cef6c..8cdfcf69 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,7 @@ dependencies { implementation(project(":samples:gemini-live-todo")) implementation(project(":samples:gemini-video-metadata-creation")) implementation(project(":samples:gemini-image-chat")) + implementation(project(":samples:gemini-hybrid")) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt b/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt index c4b10287..b16cafff 100644 --- a/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt +++ b/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt @@ -31,13 +31,26 @@ import com.android.ai.samples.geminivideosummary.ui.VideoSummarizationScreen import com.android.ai.samples.genai_image_description.GenAIImageDescriptionScreen import com.android.ai.samples.genai_summarization.GenAISummarizationScreen import com.android.ai.samples.genai_writing_assistance.GenAIWritingAssistanceScreen +import com.android.ai.samples.geminihybrid.GeminiHybridScreen import com.android.ai.samples.imagen.ui.ImagenScreen import com.android.ai.samples.imagenediting.ui.ImagenEditingScreen import com.android.ai.samples.magicselfie.ui.MagicSelfieScreen import com.android.ai.theme.extendedColorScheme +import com.google.firebase.ai.type.PublicPreviewAPI +@OptIn(PublicPreviewAPI::class) @RequiresPermission(Manifest.permission.RECORD_AUDIO) val sampleCatalog = listOf( + SampleCatalogItem( + title = R.string.gemini_hybrid_sample_list_title, + description = R.string.gemini_hybrid_sample_list_description, + route = "GeminiHybridScreen", + sampleEntryScreen = { GeminiHybridScreen() }, + tags = listOf(SampleTags.GEMINI_NANO, SampleTags.GEMINI_FLASH, SampleTags.ML_KIT, SampleTags.FIREBASE), + needsFirebase = true, + keyArt = R.drawable.img_keyart_text, + isFeatured = true, + ), SampleCatalogItem( title = R.string.gemini_image_chat_list_title, description = R.string.gemini_image_chat_list_description, @@ -56,7 +69,6 @@ val sampleCatalog = listOf( tags = listOf(SampleTags.IMAGEN, SampleTags.FIREBASE), needsFirebase = true, keyArt = R.drawable.img_keyart_imagen, - isFeatured = true, ), SampleCatalogItem( title = R.string.gemini_multimodal_sample_list_title, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b0fad8b..37c8270b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,8 @@ "Simple to-do app using the Gemini Live API to interact with the items in the list" Chat with Nano Banana Pro Conversational Image generation with Gemini 3 Pro Image + Gemini Hybrid + Inference with Firebase Hybrid SDK using either Gemini Nano on-device or Gemini Flash in the Cloud. Firebase Required This feature requires Firebase to be initialized. Close diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19f57495..b228c7a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,8 @@ [versions] agp = "8.8.2" coilCompose = "3.1.0" -firebaseBom = "34.5.0" +firebaseAiOndevice = "16.0.0-beta01" +firebaseBom = "34.10.0" lifecycleRuntimeCompose = "2.9.1" mlkitGenAi = "1.0.0-beta1" kotlin = "2.1.0" @@ -40,8 +41,9 @@ richtext = "1.0.0-alpha02" androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } +firebase-ai-ondevice = { module = "com.google.firebase:firebase-ai-ondevice", version.ref = "firebaseAiOndevice" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } -firebase-ai = { group = "com.google.firebase", name = "firebase-ai" } +firebase-ai = { module = "com.google.firebase:firebase-ai"} firebase-common-ktx = { group = "com.google.firebase", name = "firebase-common-ktx", version.ref = "firebaseCommonKtx" } genai-image-description = { module = "com.google.mlkit:genai-image-description", version.ref = "mlkitGenAi" } genai-proofreading = { module = "com.google.mlkit:genai-proofreading", version.ref = "mlkitGenAi" } @@ -94,4 +96,4 @@ google-gms-google-services = { id = "com.google.gms.google-services", version.re hilt-plugin = { id = "com.google.dagger.hilt.android", version.ref = "hilt"} ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } \ No newline at end of file +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/samples/gemini-hybrid/.gitignore b/samples/gemini-hybrid/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/samples/gemini-hybrid/.gitignore @@ -0,0 +1 @@ +/build diff --git a/samples/gemini-hybrid/README.md b/samples/gemini-hybrid/README.md new file mode 100644 index 00000000..650cc640 --- /dev/null +++ b/samples/gemini-hybrid/README.md @@ -0,0 +1,28 @@ +# Gemini Hybrid Sample + +This sample is part of the [AI Sample Catalog](../../). To build and run this sample, you should clone the entire repository. + +## Description + +This sample demonstrates how to use the Firebase Hybrid SDK, utilizing both on-device (Gemini Nano via [ML Kit Prompt API](https://developers.google.com/ml-kit/genai/prompt/android)) and cloud-based models via the [Firebase AI Logic SDK](https://firebase.google.com/docs/ai-logic). + +The sample lets users generate generic user reviews for a hotel based on a few selected topics. + +
+Gemini Hybrid SDK in action +
+ +## How it works + +Here is how the model is instantiated to leverage hybrid inference: +```kotlin +val model = Firebase.ai(backend = GenerativeBackend.googleAI()) + .generativeModel( + "gemini-2.5-flash-lite", + onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_ON_DEVICE) + ) + +val response = model.generateContent(prompt) +``` + +Read more about the [Firebase Hybrid SDK](https://firebase.google.com/docs/ai-logic/hybrid/android/get-started?api=dev) in the Firebase documentation. diff --git a/samples/gemini-hybrid/build.gradle.kts b/samples/gemini-hybrid/build.gradle.kts new file mode 100644 index 00000000..fa698ae1 --- /dev/null +++ b/samples/gemini-hybrid/build.gradle.kts @@ -0,0 +1,75 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.android.ai.samples.geminihybrid" + compileSdk = 36 + + buildFeatures { + compose = true + } + + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.material3) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.material.icons.extended) + implementation(libs.hilt.android) + implementation(libs.hilt.navigation.compose) + implementation(libs.androidx.runtime.livedata) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.ai) + implementation(libs.firebase.ai.ondevice) + + implementation(project(":ui-component")) + debugImplementation(libs.ui.tooling) + ksp(libs.hilt.compiler) +} diff --git a/samples/gemini-hybrid/consumer-rules.pro b/samples/gemini-hybrid/consumer-rules.pro new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/samples/gemini-hybrid/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/samples/gemini-hybrid/gemini_hybrid.png b/samples/gemini-hybrid/gemini_hybrid.png new file mode 100644 index 00000000..1324a668 Binary files /dev/null and b/samples/gemini-hybrid/gemini_hybrid.png differ diff --git a/samples/gemini-hybrid/proguard-rules.pro b/samples/gemini-hybrid/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/samples/gemini-hybrid/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/samples/gemini-hybrid/src/main/java/com/android/ai/samples/geminihybrid/GeminiHybridScreen.kt b/samples/gemini-hybrid/src/main/java/com/android/ai/samples/geminihybrid/GeminiHybridScreen.kt new file mode 100644 index 00000000..a15fb129 --- /dev/null +++ b/samples/gemini-hybrid/src/main/java/com/android/ai/samples/geminihybrid/GeminiHybridScreen.kt @@ -0,0 +1,486 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(PublicPreviewAPI::class, ExperimentalMaterial3ExpressiveApi::class) + +package com.android.ai.samples.geminihybrid + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedToggleButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SplitButtonDefaults +import androidx.compose.material3.SplitButtonLayout +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.runtime.Composable +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.theme.surfaceContainerHighestLight +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.UndoButton +import com.google.firebase.ai.InferenceMode +import com.google.firebase.ai.type.PublicPreviewAPI + + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun GeminiHybridScreen(viewModel: GeminiHybridViewModel = hiltViewModel()) { + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + + AISampleCatalogTheme { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + SampleDetailTopAppBar( + sampleName = stringResource(R.string.gemini_hybrid_title), + sampleDescription = stringResource(R.string.gemini_hybrid_description), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/samples/gemini-hybrid", + onBackClick = { backDispatcher?.onBackPressed() }, + ) + }, + ) { innerPadding -> + Box( + Modifier + .padding(innerPadding) + .fillMaxSize() + .clip(RoundedCornerShape(40.dp)) + .background(color = surfaceContainerHighestLight) + .padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 32.dp), + contentAlignment = Alignment.Center, + ) { + val scrollState = rememberScrollState() + + Column( + Modifier + .padding(top = 16.dp) + .imePadding() + .widthIn(max = 646.dp) + .fillMaxHeight() + .verticalScroll(scrollState), + ) { + Text( + text = stringResource(R.string.gemini_hotel_review), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(8.dp), + ) + + val status = uiState.status + when { + status is GeminiStatus.Initial -> { + InitialReviewUi( + tags = viewModel.tags, + selectedTags = uiState.selectedTags, + onTagToggle = viewModel::toggleTag, + selectedMode = uiState.selectedMode, + onModeSelected = viewModel::setInferenceMode, + onGenerate = { + val tagStrings = + uiState.selectedTags.map { ContextCompat.getString(context, it) } + viewModel.generateReview(tagStrings) + }, + ) + } + + status is GeminiStatus.Generating && !status.isTranslation -> { + GeneratingUi(status) + } + + status is GeminiStatus.Error -> { + ErrorUi(status.message, onReset = viewModel::reset) + } + + else -> { + SuccessReviewUi( + reviewText = uiState.reviewText, + reviewInferenceStatus = uiState.reviewInferenceStatus, + onReviewTextChanged = viewModel::updateReviewText, + languageKeys = viewModel.languageMap.keys.toList(), + languageMap = viewModel.languageMap, + selectedLanguage = uiState.selectedLanguage, + onLanguageSelected = viewModel::setSelectedLanguage, + onTranslate = { + viewModel.translate( + uiState.reviewText, + uiState.selectedLanguage + ) + }, + onReset = viewModel::reset, + generationStatus = status, + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun InitialReviewUi( + tags: List, + selectedTags: List, + onTagToggle: (Int) -> Unit, + selectedMode: InferenceMode, + onModeSelected: (InferenceMode) -> Unit, + onGenerate: () -> Unit, +) { + Text( + stringResource(R.string.select_topics_for_your_review), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.titleMedium, + ) + FlowRow( + modifier = Modifier + .padding(8.dp), + ) { + tags.forEach { tagResId -> + val isSelected = selectedTags.contains(tagResId) + OutlinedToggleButton( + checked = isSelected, + onCheckedChange = { onTagToggle(tagResId) }, + colors = ToggleButtonDefaults.outlinedToggleButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary, + ), + modifier = Modifier.padding(horizontal = 6.dp), + ) { + Text( + stringResource(tagResId), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + } + } + } + Spacer(Modifier.height(50.dp)) + InferenceModeDropdown( + selectedMode = selectedMode, + onModeSelected = onModeSelected, + ) + + GenerateButton( + text = stringResource(R.string.gemini_hybrid_generate_btn), + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_text), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, start = 8.dp, end = 8.dp), + enabled = selectedTags.isNotEmpty(), + onClick = onGenerate, + ) +} + +@Composable +fun GeneratingUi(status: GeminiStatus.Generating) { + val statusText = if (status.isCloud) { + stringResource(R.string.gemini_hybrid_status_generating_cloud) + } else { + stringResource(R.string.gemini_hybrid_status_generating_on_device) + } + Column(modifier = Modifier.fillMaxWidth()) { + StatusText(statusText) + if (status.partialOutput.isNotEmpty()) { + OutputText(status.partialOutput) + } + } +} + +@Composable +fun SuccessReviewUi( + reviewText: String, + reviewInferenceStatus: Int?, + onReviewTextChanged: (String) -> Unit, + languageKeys: List, + languageMap: Map, + selectedLanguage: String, + onLanguageSelected: (String) -> Unit, + onTranslate: () -> Unit, + onReset: () -> Unit, + generationStatus: GeminiStatus, +) { + Column(modifier = Modifier.fillMaxWidth()) { + reviewInferenceStatus?.let { + StatusText(stringResource(it)) + } + OutlinedTextField( + value = reviewText, + onValueChange = onReviewTextChanged, + modifier = Modifier + .padding(4.dp) + .fillMaxWidth() + .heightIn(max = 200.dp), + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Box(modifier = Modifier.padding(start = 8.dp, top = 12.dp)) { + LanguageDropdown( + languageKeys = languageKeys, + languageMap = languageMap, + selectedLanguage = selectedLanguage, + onLanguageSelected = onLanguageSelected, + ) + } + + GenerateButton( + text = stringResource(R.string.gemini_hybrid_translate_btn), + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_text), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, start = 8.dp, end = 8.dp), + enabled = reviewText.isNotBlank() && generationStatus !is GeminiStatus.Generating, + onClick = onTranslate, + ) + + Spacer(modifier = Modifier.height(20.dp)) + when (generationStatus) { + is GeminiStatus.Generating -> { + if (generationStatus.isTranslation) { + val statusText = if (generationStatus.isCloud) { + stringResource(R.string.gemini_hybrid_status_generating_cloud) + } else { + stringResource(R.string.gemini_hybrid_status_generating_on_device) + } + StatusText(statusText) + if (generationStatus.partialOutput.isNotEmpty()) { + OutputText(generationStatus.partialOutput) + } + } + } + + is GeminiStatus.Success -> { + if (generationStatus.isTranslation) { + val inferenceStatus = if (generationStatus.isCloud) { + R.string.gemini_hybrid_generated_cloud + } else { + R.string.gemini_hybrid_generated_on_device + } + + StatusText(stringResource(inferenceStatus)) + OutputText(generationStatus.output) + } + } + + else -> {} + } + + UndoButton( + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + onClick = onReset, + ) + } +} + +@Composable +fun ErrorUi(message: String, onReset: () -> Unit) { + Column { + StatusText(message) + UndoButton( + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + onClick = onReset, + ) + } +} + +@Composable +fun LanguageDropdown( + languageKeys: List, + languageMap: Map, + selectedLanguage: String, + onLanguageSelected: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + Box { + SplitButtonLayout( + leadingButton = { + SplitButtonDefaults.LeadingButton( + onClick = { expanded = true }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + ) { + Text(stringResource(languageMap[selectedLanguage] ?: R.string.gemini_hybrid_lang_korean).uppercase()) + } + }, + trailingButton = { + SplitButtonDefaults.TrailingButton( + onClick = { expanded = true }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + ) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + ) + } + }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + languageKeys.forEach { key -> + DropdownMenuItem( + text = { Text(stringResource(languageMap[key]!!)) }, + onClick = { + onLanguageSelected(key) + expanded = false + }, + ) + } + } + } +} + +@PublicPreviewAPI +@Composable +fun InferenceModeDropdown( + selectedMode: InferenceMode, + onModeSelected: (InferenceMode) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val modes = listOf( + InferenceMode.ONLY_ON_DEVICE to stringResource(R.string.gemini_hybrid_mode_only_on_device), + InferenceMode.ONLY_IN_CLOUD to stringResource(R.string.gemini_hybrid_mode_only_cloud), + InferenceMode.PREFER_ON_DEVICE to stringResource(R.string.gemini_hybrid_mode_prefer_on_device), + InferenceMode.PREFER_IN_CLOUD to stringResource(R.string.gemini_hybrid_mode_prefer_cloud), + ) + val selectedText = modes.find { it.first == selectedMode }?.second ?: "" + + Box(modifier = Modifier.padding(start = 8.dp, top = 12.dp)) { + SplitButtonLayout( + leadingButton = { + SplitButtonDefaults.LeadingButton( + onClick = { expanded = true }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + ) { + Text(selectedText) + } + }, + trailingButton = { + SplitButtonDefaults.TrailingButton( + onClick = { expanded = true }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + ) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + ) + } + }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + modes.forEach { (mode, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + onModeSelected(mode) + expanded = false + }, + ) + } + } + } +} + +@Composable +fun StatusText(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp), + ) +} + +@Composable +fun OutputText(text: String, modifier: Modifier = Modifier) { + TextField( + value = text, + onValueChange = {}, + readOnly = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + ), + modifier = modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), + ) +} diff --git a/samples/gemini-hybrid/src/main/java/com/android/ai/samples/geminihybrid/GeminiHybridViewModel.kt b/samples/gemini-hybrid/src/main/java/com/android/ai/samples/geminihybrid/GeminiHybridViewModel.kt new file mode 100644 index 00000000..684b76a1 --- /dev/null +++ b/samples/gemini-hybrid/src/main/java/com/android/ai/samples/geminihybrid/GeminiHybridViewModel.kt @@ -0,0 +1,251 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ai.samples.geminihybrid + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.Firebase +import com.google.firebase.ai.InferenceMode +import com.google.firebase.ai.InferenceSource +import com.google.firebase.ai.OnDeviceConfig +import com.google.firebase.ai.ai +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.PublicPreviewAPI +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +sealed interface GeminiStatus { + data object Initial : GeminiStatus + data class Generating( + val isCloud: Boolean, + val partialOutput: String = "", + val isTranslation: Boolean = false + ) : GeminiStatus + + data class Success( + val output: String, + val isCloud: Boolean, + val isTranslation: Boolean = false + ) : GeminiStatus + + data class Error(val message: String) : GeminiStatus +} + +@OptIn(PublicPreviewAPI::class) +data class GeminiHybridUiState( + val selectedMode: InferenceMode = InferenceMode.ONLY_ON_DEVICE, + val selectedTags: List = emptyList(), + val reviewText: String = "", + val reviewInferenceStatus: Int? = null, + val selectedLanguage: String = "Korean", + val status: GeminiStatus = GeminiStatus.Initial +) + +@PublicPreviewAPI +@HiltViewModel +class GeminiHybridViewModel @Inject constructor() : ViewModel() { + private val _uiState = MutableStateFlow(GeminiHybridUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val tags = listOf( + R.string.location, + R.string.view, + R.string.service, + R.string.comfort, + R.string.food, + R.string.spacious, + R.string.natural_light, + ) + + val languageMap = mapOf( + "Korean" to R.string.gemini_hybrid_lang_korean, + "Spanish" to R.string.gemini_hybrid_lang_spanish, + "French" to R.string.gemini_hybrid_lang_french, + "German" to R.string.gemini_hybrid_lang_german + ) + + fun setInferenceMode(mode: InferenceMode) { + _uiState.update { it.copy(selectedMode = mode) } + } + + fun toggleTag(tagResId: Int) { + _uiState.update { state -> + val newTags = if (state.selectedTags.contains(tagResId)) { + state.selectedTags - tagResId + } else { + state.selectedTags + tagResId + } + state.copy(selectedTags = newTags) + } + } + + fun updateReviewText(text: String) { + _uiState.update { it.copy(reviewText = text) } + } + + fun setSelectedLanguage(language: String) { + _uiState.update { it.copy(selectedLanguage = language) } + } + + fun generateReview(tagStrings: List) { + if (tagStrings.isEmpty()) { + _uiState.update { it.copy(status = GeminiStatus.Error("Please select at least one tag")) } + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + status = GeminiStatus.Generating( + isCloud = it.selectedMode == InferenceMode.ONLY_IN_CLOUD, + isTranslation = false + ) + ) + } + try { + val prompt = + "Write a simple, short and generic hotel review positively covering the following themes: ${ + tagStrings.joinToString(", ") + }. Generate a generic review strictly from themes, don't hallucinate a hotel name or a location. Return only the review text." + + val model = Firebase.ai(backend = GenerativeBackend.googleAI()) + .generativeModel( + "gemini-2.5-flash-lite", + onDeviceConfig = OnDeviceConfig(mode = _uiState.value.selectedMode) + ) + model.generateContentStream(prompt).collect { chunk -> + val isCloud = chunk.inferenceSource == InferenceSource.IN_CLOUD + _uiState.update { state -> + val currentStatus = state.status + val newStatus = if (currentStatus is GeminiStatus.Generating) { + currentStatus.copy( + isCloud = isCloud, + partialOutput = currentStatus.partialOutput + (chunk.text ?: "") + ) + } else { + GeminiStatus.Generating( + isCloud = isCloud, + partialOutput = chunk.text ?: "", + isTranslation = false + ) + } + state.copy(status = newStatus) + } + } + + val finalState = _uiState.value + val finalStatus = finalState.status + if (finalStatus is GeminiStatus.Generating) { + val output = finalStatus.partialOutput.trimEnd() + val inferenceStatusResId = if (finalStatus.isCloud) { + R.string.gemini_hybrid_generated_cloud + } else { + R.string.gemini_hybrid_generated_on_device + } + _uiState.update { + it.copy( + reviewText = output, + reviewInferenceStatus = inferenceStatusResId, + status = GeminiStatus.Success(output, finalStatus.isCloud, isTranslation = false) + ) + } + } + } catch (e: Exception) { + Log.e("GeminiHybrid", "Inference failed", e) + _uiState.update { + it.copy(status = GeminiStatus.Error(e.localizedMessage ?: "Unknown error occurred")) + } + } + } + } + + fun translate(text: String, language: String) { + if (text.isBlank()) { + _uiState.update { it.copy(status = GeminiStatus.Error("Text to translate cannot be empty")) } + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + status = GeminiStatus.Generating( + isCloud = it.selectedMode == InferenceMode.ONLY_IN_CLOUD, + isTranslation = true + ) + ) + } + try { + val prompt = + "Translate the following text to $language. Return ONLY the translated text, no explanations:\n\n$text" + + val model = Firebase.ai(backend = GenerativeBackend.googleAI()) + .generativeModel( + "gemini-2.5-flash-lite", + onDeviceConfig = OnDeviceConfig(mode = _uiState.value.selectedMode) + ) + + model.generateContentStream(prompt).collect { chunk -> + val isCloud = chunk.inferenceSource == InferenceSource.IN_CLOUD + _uiState.update { state -> + val currentStatus = state.status + val newStatus = if (currentStatus is GeminiStatus.Generating) { + currentStatus.copy( + isCloud = isCloud, + partialOutput = currentStatus.partialOutput + (chunk.text ?: "") + ) + } else { + GeminiStatus.Generating( + isCloud = isCloud, + partialOutput = chunk.text ?: "", + isTranslation = true + ) + } + state.copy(status = newStatus) + } + } + + val finalState = _uiState.value + val finalStatus = finalState.status + if (finalStatus is GeminiStatus.Generating) { + _uiState.update { + it.copy( + status = GeminiStatus.Success( + finalStatus.partialOutput, + finalStatus.isCloud, + isTranslation = true + ) + ) + } + } + } catch (e: Exception) { + Log.e("GeminiHybrid", "Inference failed", e) + _uiState.update { + it.copy(status = GeminiStatus.Error(e.localizedMessage ?: "Unknown error occurred")) + } + } + } + } + + fun reset() { + _uiState.value = GeminiHybridUiState() + } +} diff --git a/samples/gemini-hybrid/src/main/res/values/strings.xml b/samples/gemini-hybrid/src/main/res/values/strings.xml new file mode 100644 index 00000000..82a3fc9e --- /dev/null +++ b/samples/gemini-hybrid/src/main/res/values/strings.xml @@ -0,0 +1,28 @@ + + + Hybrid Inference + Inference with Firebase Hybrid SDK using either Gemini Nano on-device or Gemini Flash in the Cloud. + Hotel review generation + Generate Review + Translate + Korean + Spanish + French + German + Generating on-device… + Generating in cloud… + Generated in the cloud + Generated on device + PREFER ON-DEVICE + PREFER CLOUD + ONLY ON-DEVICE + ONLY CLOUD + LOCATION + VIEW + SERVICE + COMFORT + FOOD + SPACIOUS + NATURAL LIGHT + Select topics for your review: + diff --git a/settings.gradle.kts b/settings.gradle.kts index 977e9376..74adde7f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -50,4 +50,5 @@ include(":samples:gemini-video-summarization") include(":samples:gemini-live-todo") include(":samples:gemini-video-metadata-creation") include(":samples:gemini-image-chat") +include(":samples:gemini-hybrid") include(":ui-component")