From 8e9de32663a15cabe0d6fe6314097f75e31cbe3f Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Paz Date: Thu, 22 Oct 2026 10:57:06 -0400 Subject: [PATCH 1/3] Enable Gradle parallel tooling configuration across all projects --- admob/gradle.properties | 3 +++ analytics/gradle.properties | 5 ++++- appdistribution/gradle.properties | 3 +++ auth/gradle.properties | 3 +++ config/gradle.properties | 3 +++ crash/gradle.properties | 3 +++ database/gradle.properties | 3 +++ dataconnect/gradle.properties | 5 ++++- firebase-ai/gradle.properties | 5 ++++- firestore/gradle.properties | 5 ++++- functions/gradle.properties | 3 +++ gradle.properties | 3 +++ inappmessaging/gradle.properties | 3 +++ messaging/gradle.properties | 3 +++ perf/gradle.properties | 3 +++ storage/gradle.properties | 3 +++ 16 files changed, 52 insertions(+), 4 deletions(-) diff --git a/admob/gradle.properties b/admob/gradle.properties index e78e65c08a..1023438b78 100644 --- a/admob/gradle.properties +++ b/admob/gradle.properties @@ -16,3 +16,6 @@ org.gradle.jvmargs=-Xmx1536m # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true android.useAndroidX=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/analytics/gradle.properties b/analytics/gradle.properties index c79c847155..1023438b78 100644 --- a/analytics/gradle.properties +++ b/analytics/gradle.properties @@ -15,4 +15,7 @@ org.gradle.jvmargs=-Xmx1536m # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/appdistribution/gradle.properties b/appdistribution/gradle.properties index 6dd0218ed7..62b17e9eea 100644 --- a/appdistribution/gradle.properties +++ b/appdistribution/gradle.properties @@ -16,3 +16,6 @@ android.useAndroidX=true # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/auth/gradle.properties b/auth/gradle.properties index 6dd0218ed7..62b17e9eea 100644 --- a/auth/gradle.properties +++ b/auth/gradle.properties @@ -16,3 +16,6 @@ android.useAndroidX=true # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/config/gradle.properties b/config/gradle.properties index 6dd0218ed7..62b17e9eea 100644 --- a/config/gradle.properties +++ b/config/gradle.properties @@ -16,3 +16,6 @@ android.useAndroidX=true # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/crash/gradle.properties b/crash/gradle.properties index 6dd0218ed7..62b17e9eea 100644 --- a/crash/gradle.properties +++ b/crash/gradle.properties @@ -16,3 +16,6 @@ android.useAndroidX=true # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/database/gradle.properties b/database/gradle.properties index 29b531a1d1..5a812299b5 100644 --- a/database/gradle.properties +++ b/database/gradle.properties @@ -15,3 +15,6 @@ android.useAndroidX=true # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/dataconnect/gradle.properties b/dataconnect/gradle.properties index 20e2a01520..70361662cd 100644 --- a/dataconnect/gradle.properties +++ b/dataconnect/gradle.properties @@ -20,4 +20,7 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/firebase-ai/gradle.properties b/firebase-ai/gradle.properties index 20e2a01520..70361662cd 100644 --- a/firebase-ai/gradle.properties +++ b/firebase-ai/gradle.properties @@ -20,4 +20,7 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/firestore/gradle.properties b/firestore/gradle.properties index d49269eb1e..0b2f324d32 100644 --- a/firestore/gradle.properties +++ b/firestore/gradle.properties @@ -18,4 +18,7 @@ org.gradle.jvmargs=-Xmx1536m # Don't use the gradle build cache since this sample uses experimental SDKs android.enableBuildCache=false -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/functions/gradle.properties b/functions/gradle.properties index 6dd0218ed7..62b17e9eea 100644 --- a/functions/gradle.properties +++ b/functions/gradle.properties @@ -16,3 +16,6 @@ android.useAndroidX=true # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle.properties b/gradle.properties index 93387cb63e..c609b02652 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,3 +8,6 @@ org.gradle.caching=true android.useAndroidX=true android.nonTransitiveRClass=false android.nonFinalResIds=false + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/inappmessaging/gradle.properties b/inappmessaging/gradle.properties index 6dd0218ed7..62b17e9eea 100644 --- a/inappmessaging/gradle.properties +++ b/inappmessaging/gradle.properties @@ -16,3 +16,6 @@ android.useAndroidX=true # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/messaging/gradle.properties b/messaging/gradle.properties index 6dd0218ed7..62b17e9eea 100644 --- a/messaging/gradle.properties +++ b/messaging/gradle.properties @@ -16,3 +16,6 @@ android.useAndroidX=true # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/perf/gradle.properties b/perf/gradle.properties index 6dd0218ed7..62b17e9eea 100644 --- a/perf/gradle.properties +++ b/perf/gradle.properties @@ -16,3 +16,6 @@ android.useAndroidX=true # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/storage/gradle.properties b/storage/gradle.properties index 6dd0218ed7..62b17e9eea 100644 --- a/storage/gradle.properties +++ b/storage/gradle.properties @@ -16,3 +16,6 @@ android.useAndroidX=true # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true From 78f4613ca5a9faeb4687b27bd763be903869a516 Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Paz Date: Tue, 27 Oct 2026 16:46:44 -0400 Subject: [PATCH 2/3] [AI] Add Hybrid Image Pattern Finder sample Introduce the Hybrid Image Pattern Finder sample to compare two images and identify shared patterns using hybrid generative AI inference. * Add: `HybridImagePatternViewModel` and `HybridImagePatternUiState` to handle model status checking, downloading, downsampled image decoding, and multi-modal content generation. * Create: `HybridImagePatternScreen` with Compose UI for selecting images via camera or photo picker, choosing inference modes, and viewing analysis results. * Register: `ScreenType.HYBRID_IMAGE_PATTERN` and configure routing in `FirebaseAISamples` and `MainActivity`. --- .../firebase/quickstart/ai/MainActivity.kt | 6 + .../hybrid/HybridImagePatternViewModel.kt | 213 +++++++++++ .../ai/ui/HybridImagePatternScreen.kt | 333 ++++++++++++++++++ .../ai/ui/HybridImagePatternUiState.kt | 15 + .../ai/ui/navigation/FirebaseAISamples.kt | 10 + .../quickstart/ai/ui/navigation/Sample.kt | 1 + 6 files changed, 578 insertions(+) create mode 100644 firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/HybridImagePatternViewModel.kt create mode 100644 firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/HybridImagePatternScreen.kt create mode 100644 firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/HybridImagePatternUiState.kt diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt index 46f1d2871c..5b7a0433f5 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,12 +30,14 @@ 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.SystemInstructionViewModel import com.google.firebase.quickstart.ai.feature.text.ChatViewModel import com.google.firebase.quickstart.ai.feature.text.ServerPromptTemplateViewModel import com.google.firebase.quickstart.ai.feature.text.SvgViewModel 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 @@ -134,6 +136,10 @@ class MainActivity : ComponentActivity() { (vm as? HybridInferenceViewModel)?.let { HybridInferenceScreen(it) } } + ScreenType.HYBRID_IMAGE_PATTERN -> { + (vm as? HybridImagePatternViewModel)?.let { HybridImagePatternScreen(it) } + } + ScreenType.SYSTEM_INSTRUCTION -> { (vm as? SystemInstructionViewModel)?.let { SystemInstructionScreen(it) } } diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/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..358f87c47f --- /dev/null +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/HybridImagePatternScreen.kt @@ -0,0 +1,333 @@ +package com.google.firebase.quickstart.ai.ui + +import android.Manifest +import android.content.pm.PackageManager +import android.graphics.Bitmap +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.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.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 + +@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) } + + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicturePreview(), + onResult = { bitmap -> + if (bitmap != null) { + if (activeImageSlot == 1) { + viewModel.setFirstImage(bitmap) + } else { + viewModel.setSecondImage(bitmap) + } + } + } + ) + + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> + if (uri != null) { + viewModel.loadImageFromUri(context.contentResolver, uri, slot = activeImageSlot) + } + } + ) + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + cameraLauncher.launch(null) + } + } + + fun launchCameraForSlot(slot: Int) { + activeImageSlot = slot + val permissionCheckResult = + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) + if (permissionCheckResult == PackageManager.PERMISSION_GRANTED) { + cameraLauncher.launch(null) + } 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 510982d1cc..7b8f3c22fa 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.SystemInstructionRoute @@ -200,6 +202,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 System Instructions", description = """Test systemInstruction (single text, multiple text parts, and non-text part validation) 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 348c7d32aa..250d8b9e0b 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, SYSTEM_INSTRUCTION } From ae87d919aa4f49903a86ac4ce27f14f4435ec48a Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Paz Date: Tue, 27 Oct 2026 17:13:37 -0400 Subject: [PATCH 3/3] Address comments --- firebase-ai/app/src/main/AndroidManifest.xml | 10 ++++++ .../ai/ui/HybridImagePatternScreen.kt | 36 +++++++++++++------ .../app/src/main/res/xml/file_paths.xml | 7 ++++ 3 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 firebase-ai/app/src/main/res/xml/file_paths.xml 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/ui/HybridImagePatternScreen.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/HybridImagePatternScreen.kt index 358f87c47f..71173e79a7 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -38,6 +39,7 @@ 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 @@ -49,10 +51,12 @@ 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 @@ -62,17 +66,17 @@ fun HybridImagePatternScreen( 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.TakePicturePreview(), - onResult = { bitmap -> - if (bitmap != null) { - if (activeImageSlot == 1) { - viewModel.setFirstImage(bitmap) - } else { - viewModel.setSecondImage(bitmap) - } + contract = ActivityResultContracts.TakePicture(), + onResult = { success -> + val uri = pendingCaptureUri + if (success && uri != null) { + viewModel.loadImageFromUri(context.contentResolver, uri, slot = activeImageSlot) } + pendingCaptureUri = null } ) @@ -85,11 +89,23 @@ fun HybridImagePatternScreen( } ) + 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) { - cameraLauncher.launch(null) + launchCamera() } } @@ -98,7 +114,7 @@ fun HybridImagePatternScreen( val permissionCheckResult = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) if (permissionCheckResult == PackageManager.PERMISSION_GRANTED) { - cameraLauncher.launch(null) + launchCamera() } else { permissionLauncher.launch(Manifest.permission.CAMERA) } 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 @@ + + + + +