diff --git a/firebase-ai/app/src/main/AndroidManifest.xml b/firebase-ai/app/src/main/AndroidManifest.xml
index bad5b1aa6b..edcdd81215 100644
--- a/firebase-ai/app/src/main/AndroidManifest.xml
+++ b/firebase-ai/app/src/main/AndroidManifest.xml
@@ -32,6 +32,16 @@
+
+
+
+
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 9290d8e49e..ede5ebfb39 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
@@ -30,6 +30,7 @@ import com.google.firebase.Firebase
import com.google.firebase.appcheck.appCheck
import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory
import com.google.firebase.quickstart.ai.feature.live.BidiViewModel
+import com.google.firebase.quickstart.ai.feature.hybrid.HybridImagePatternViewModel
import com.google.firebase.quickstart.ai.feature.hybrid.HybridInferenceViewModel
import com.google.firebase.quickstart.ai.feature.hybrid.ThinkingModeViewModel
import com.google.firebase.quickstart.ai.feature.hybrid.SystemInstructionViewModel
@@ -37,6 +38,7 @@ 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
import com.google.firebase.quickstart.ai.ui.ChatScreen
+import com.google.firebase.quickstart.ai.ui.HybridImagePatternScreen
import com.google.firebase.quickstart.ai.ui.ServerPromptScreen
import com.google.firebase.quickstart.ai.ui.StreamRealtimeScreen
import com.google.firebase.quickstart.ai.ui.StreamRealtimeVideoScreen
@@ -136,6 +138,9 @@ class MainActivity : ComponentActivity() {
(vm as? HybridInferenceViewModel)?.let { HybridInferenceScreen(it) }
}
+ ScreenType.HYBRID_IMAGE_PATTERN -> {
+ (vm as? HybridImagePatternViewModel)?.let { HybridImagePatternScreen(it) }
+
ScreenType.THINKING_MODE -> {
(vm as? ThinkingModeViewModel)?.let { ThinkingModeScreen(it) }
}
diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/HybridImagePatternViewModel.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/HybridImagePatternViewModel.kt
new file mode 100644
index 0000000000..0b40e32c67
--- /dev/null
+++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/HybridImagePatternViewModel.kt
@@ -0,0 +1,213 @@
+package com.google.firebase.quickstart.ai.feature.hybrid
+
+import android.content.ContentResolver
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+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.GenerativeBackend
+import com.google.firebase.ai.type.PublicPreviewAPI
+import com.google.firebase.ai.type.content
+import com.google.firebase.quickstart.ai.ui.HybridImagePatternUiState
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+
+@Serializable
+object HybridImagePatternRoute
+
+@OptIn(PublicPreviewAPI::class)
+class HybridImagePatternViewModel : ViewModel() {
+ val uiState: StateFlow
+ field = MutableStateFlow(HybridImagePatternUiState())
+
+ // Used only to check the on-device model status and trigger its download.
+ private val statusCheckModel = Firebase.ai(
+ backend = GenerativeBackend.googleAI(),
+ useLimitedUseAppCheckTokens = true
+ ).generativeModel(
+ modelName = MODEL_NAME,
+ onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_ON_DEVICE)
+ )
+
+ init {
+ checkAndDownloadModel()
+ }
+
+ private fun checkAndDownloadModel() {
+ viewModelScope.launch {
+ try {
+ val status = statusCheckModel.onDeviceExtension?.checkStatus()
+ updateStatus(status)
+
+ 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 downloaded") }
+ }
+
+ is DownloadStatus.DownloadCompleted -> {
+ uiState.update { it.copy(modelStatus = "Model ready") }
+ }
+
+ 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 updateStatus(status: OnDeviceModelStatus?) {
+ 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) }
+ }
+
+ fun setInferenceMode(mode: InferenceModeOption) {
+ uiState.update { it.copy(selectedMode = mode) }
+ }
+
+ fun setFirstImage(bitmap: Bitmap) {
+ uiState.update { it.copy(firstImage = bitmap, errorMessage = null) }
+ }
+
+ fun setSecondImage(bitmap: Bitmap) {
+ uiState.update { it.copy(secondImage = bitmap, errorMessage = null) }
+ }
+
+ /**
+ * Decodes the image at [uri] off the main thread, downsampling it so that its longest
+ * edge is at most [MAX_IMAGE_DIMENSION] pixels, and stores it in the given [slot] (1 or 2).
+ */
+ fun loadImageFromUri(resolver: ContentResolver, uri: Uri, slot: Int) {
+ viewModelScope.launch {
+ try {
+ val bitmap = withContext(Dispatchers.IO) { decodeDownsampled(resolver, uri) }
+ if (bitmap == null) {
+ uiState.update { it.copy(errorMessage = "Could not decode the selected image") }
+ } else if (slot == 1) {
+ setFirstImage(bitmap)
+ } else {
+ setSecondImage(bitmap)
+ }
+ } catch (e: Exception) {
+ uiState.update { it.copy(errorMessage = "Error loading image: ${e.message}") }
+ }
+ }
+ }
+
+ private fun decodeDownsampled(resolver: ContentResolver, uri: Uri): Bitmap? {
+ // First pass: read only the bounds to compute a sample size.
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
+
+ var sampleSize = 1
+ while (maxOf(bounds.outWidth, bounds.outHeight) / sampleSize > MAX_IMAGE_DIMENSION) {
+ sampleSize *= 2
+ }
+
+ // Second pass: decode the downsampled bitmap.
+ val options = BitmapFactory.Options().apply { inSampleSize = sampleSize }
+ return resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) }
+ }
+
+ fun findCommonPatterns() {
+ val firstBitmap = uiState.value.firstImage
+ val secondBitmap = uiState.value.secondImage
+ if (firstBitmap == null || secondBitmap == null) {
+ uiState.update { it.copy(errorMessage = "Please provide both images first.") }
+ return
+ }
+ val mode = uiState.value.selectedMode.mode
+
+ viewModelScope.launch {
+ uiState.update {
+ it.copy(
+ isAnalyzing = true,
+ errorMessage = null,
+ resultText = null,
+ inferenceMode = ""
+ )
+ }
+ try {
+ val model = Firebase.ai(
+ backend = GenerativeBackend.googleAI(),
+ useLimitedUseAppCheckTokens = true
+ ).generativeModel(
+ modelName = MODEL_NAME,
+ onDeviceConfig = OnDeviceConfig(mode = mode)
+ )
+
+ val prompt = content {
+ image(firstBitmap)
+ image(secondBitmap)
+ text(
+ """
+ Compare these two images and find common patterns, themes, objects, or visual elements shared between both images.
+ """.trimIndent()
+ )
+ }
+
+ val response = model.generateContent(prompt)
+ val text = response.text
+ val inferenceMode = if (response.inferenceSource == InferenceSource.ON_DEVICE) {
+ "On-device"
+ } else {
+ "Cloud"
+ }
+ if (text != null) {
+ uiState.update {
+ it.copy(
+ resultText = text,
+ inferenceMode = inferenceMode
+ )
+ }
+ } else {
+ uiState.update { it.copy(errorMessage = "Could not find patterns from the provided images") }
+ }
+ } catch (e: Exception) {
+ uiState.update { it.copy(errorMessage = "Error (${mode}): ${e.message}") }
+ } finally {
+ uiState.update { it.copy(isAnalyzing = false) }
+ }
+ }
+ }
+
+ companion object {
+ private const val MODEL_NAME = "gemini-3.5-flash-lite"
+ private const val MAX_IMAGE_DIMENSION = 1024
+ }
+}
diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/HybridImagePatternScreen.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/HybridImagePatternScreen.kt
new file mode 100644
index 0000000000..71173e79a7
--- /dev/null
+++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/HybridImagePatternScreen.kt
@@ -0,0 +1,349 @@
+package com.google.firebase.quickstart.ai.ui
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.height
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.Compare
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.PhotoLibrary
+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.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.google.firebase.ai.type.PublicPreviewAPI
+import com.google.firebase.quickstart.ai.feature.hybrid.HybridImagePatternViewModel
+import com.google.firebase.quickstart.ai.feature.hybrid.InferenceModeOption
+import java.io.File
+
+@OptIn(PublicPreviewAPI::class)
+@Composable
+fun HybridImagePatternScreen(
+ viewModel: HybridImagePatternViewModel = viewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val context = LocalContext.current
+ var activeImageSlot by rememberSaveable { mutableIntStateOf(1) }
+ // Uri the camera app writes the full-resolution capture to. Saved across process death.
+ var pendingCaptureUri by rememberSaveable { mutableStateOf(null) }
+
+ val cameraLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.TakePicture(),
+ onResult = { success ->
+ val uri = pendingCaptureUri
+ if (success && uri != null) {
+ viewModel.loadImageFromUri(context.contentResolver, uri, slot = activeImageSlot)
+ }
+ pendingCaptureUri = null
+ }
+ )
+
+ val photoPickerLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.PickVisualMedia(),
+ onResult = { uri ->
+ if (uri != null) {
+ viewModel.loadImageFromUri(context.contentResolver, uri, slot = activeImageSlot)
+ }
+ }
+ )
+
+ fun launchCamera() {
+ val captureDir = File(context.cacheDir, "camera").apply { mkdirs() }
+ val captureFile = File(captureDir, "capture_${System.currentTimeMillis()}.jpg")
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ captureFile
+ )
+ pendingCaptureUri = uri
+ cameraLauncher.launch(uri)
+ }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ launchCamera()
+ }
+ }
+
+ fun launchCameraForSlot(slot: Int) {
+ activeImageSlot = slot
+ val permissionCheckResult =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
+ if (permissionCheckResult == PackageManager.PERMISSION_GRANTED) {
+ launchCamera()
+ } else {
+ permissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+
+ fun launchPhotoPickerForSlot(slot: Int) {
+ activeImageSlot = slot
+ photoPickerLauncher.launch(
+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ ) {
+ // Model Status Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Compare,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ Spacer(modifier = Modifier.size(12.dp))
+ Column {
+ Text(
+ "Hybrid AI Status",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ Text(
+ uiState.modelStatus,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ "Select Two Images",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ ImageSlotCard(
+ title = "Image 1",
+ bitmap = uiState.firstImage,
+ onCameraClick = { launchCameraForSlot(1) },
+ onGalleryClick = { launchPhotoPickerForSlot(1) },
+ modifier = Modifier.weight(1f)
+ )
+ ImageSlotCard(
+ title = "Image 2",
+ bitmap = uiState.secondImage,
+ onCameraClick = { launchCameraForSlot(2) },
+ onGalleryClick = { launchPhotoPickerForSlot(2) },
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ "Inference Mode",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Row(
+ modifier = Modifier.horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ InferenceModeOption.entries.forEach { modeOption ->
+ FilterChip(
+ selected = uiState.selectedMode == modeOption,
+ onClick = { viewModel.setInferenceMode(modeOption) },
+ label = { Text(modeOption.label) },
+ enabled = !uiState.isAnalyzing
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Button(
+ onClick = { viewModel.findCommonPatterns() },
+ enabled = uiState.firstImage != null && uiState.secondImage != null && !uiState.isAnalyzing,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (uiState.isAnalyzing) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ }
+ Text("Find Common Patterns")
+ }
+
+ uiState.resultText?.let { resultText ->
+ Spacer(modifier = Modifier.height(16.dp))
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "Common Patterns",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ if (uiState.inferenceMode.isNotEmpty()) {
+ Text(
+ uiState.inferenceMode,
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.Gray
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ resultText,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+
+ uiState.errorMessage?.let { errorMessage ->
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = errorMessage,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+}
+
+@Composable
+private fun ImageSlotCard(
+ title: String,
+ bitmap: Bitmap?,
+ onCameraClick: () -> Unit,
+ onGalleryClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier,
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(12.dp)
+ .fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(140.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ if (bitmap != null) {
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = title,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ Icon(
+ Icons.Default.Image,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = Color.Gray
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ IconButton(onClick = onCameraClick) {
+ Icon(Icons.Default.CameraAlt, contentDescription = "Take Photo for $title")
+ }
+ IconButton(onClick = onGalleryClick) {
+ Icon(Icons.Default.PhotoLibrary, contentDescription = "Pick Image for $title")
+ }
+ }
+ }
+ }
+}
diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/HybridImagePatternUiState.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/HybridImagePatternUiState.kt
new file mode 100644
index 0000000000..d3b9d69e0d
--- /dev/null
+++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/HybridImagePatternUiState.kt
@@ -0,0 +1,15 @@
+package com.google.firebase.quickstart.ai.ui
+
+import android.graphics.Bitmap
+import com.google.firebase.quickstart.ai.feature.hybrid.InferenceModeOption
+
+data class HybridImagePatternUiState(
+ val firstImage: Bitmap? = null,
+ val secondImage: Bitmap? = null,
+ val selectedMode: InferenceModeOption = InferenceModeOption.PREFER_ON_DEVICE,
+ val isAnalyzing: Boolean = false,
+ val modelStatus: String = "Checking model status...",
+ val resultText: String? = null,
+ val inferenceMode: String = "",
+ 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 c4b20978ef..44feac9857 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
@@ -1,5 +1,7 @@
package com.google.firebase.quickstart.ai.ui.navigation
+import com.google.firebase.quickstart.ai.feature.hybrid.HybridImagePatternRoute
+import com.google.firebase.quickstart.ai.feature.hybrid.HybridImagePatternViewModel
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.ThinkingModeRoute
@@ -202,6 +204,14 @@ val FIREBASE_AI_SAMPLES = listOf(
viewModelClass = HybridInferenceViewModel::class,
categories = listOf(Category.HYBRID)
),
+ Sample(
+ title = "Hybrid Image Pattern Finder",
+ description = "Use hybrid inference to compare 2 images and find common patterns between them.",
+ route = HybridImagePatternRoute,
+ screenType = ScreenType.HYBRID_IMAGE_PATTERN,
+ viewModelClass = HybridImagePatternViewModel::class,
+ categories = listOf(Category.HYBRID)
+ ),
Sample(
title = "Hybrid Thinking Mode",
description = "Test enableThinking and response.thoughtSummary across On-Device, Hybrid, and Cloud " +
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 91397a1f8b..8c1bcd2e73 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
@@ -22,6 +22,7 @@ enum class ScreenType {
BIDI,
BIDI_VIDEO,
HYBRID,
+ HYBRID_IMAGE_PATTERN,
THINKING_MODE,
SYSTEM_INSTRUCTION
}
diff --git a/firebase-ai/app/src/main/res/xml/file_paths.xml b/firebase-ai/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000000..58083ede67
--- /dev/null
+++ b/firebase-ai/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,7 @@
+
+
+
+
+