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 | |
|:----------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+|
| ✨📱☁️ **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**:
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**:
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:
| | |
|
| ✨📱🔍 **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)**
|
| | |
-|
| ✨📱🖋️ **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)**
|
+|
| ✨📱🖋️ **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)**
|
| | |
|
| 🖼️ **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.
+
+
+

+
+
+## 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")