diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a07fc2a..2189d6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.google.ksp) alias(libs.plugins.google.hilt) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.firebase.perf) } android { @@ -63,10 +64,12 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.ai) + implementation(libs.firebase.ai.ondevice) implementation(libs.firebase.config) implementation(libs.firebase.auth) implementation(libs.firebase.storage) implementation(libs.firebase.firestore) + implementation(libs.firebase.perf) //Library to handle Markdown in Compose implementation(libs.richtext.commonmark) diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt index 1878b28..ac3e97a 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt @@ -1,34 +1,65 @@ package com.google.firebase.example.friendlymeals.data.datasource import android.graphics.Bitmap +import android.util.Log +import com.google.firebase.Firebase +import com.google.firebase.ai.FirebaseAI +import com.google.firebase.ai.InferenceMode +import com.google.firebase.ai.InferenceSource +import com.google.firebase.ai.OnDeviceConfig import com.google.firebase.ai.TemplateGenerativeModel import com.google.firebase.ai.TemplateImagenModel +import com.google.firebase.ai.ondevice.DownloadStatus +import com.google.firebase.ai.ondevice.FirebaseAIOnDevice +import com.google.firebase.ai.ondevice.OnDeviceModelStatus import com.google.firebase.ai.type.ImagePart import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.content import com.google.firebase.example.friendlymeals.data.schema.MealSchema import com.google.firebase.example.friendlymeals.data.schema.RecipeSchema +import com.google.firebase.perf.performance +import com.google.firebase.perf.trace import com.google.firebase.remoteconfig.FirebaseRemoteConfig import kotlinx.serialization.json.Json import javax.inject.Inject @OptIn(PublicPreviewAPI::class) class AIRemoteDataSource @Inject constructor( + private val aiModel: FirebaseAI, private val generativeModel: TemplateGenerativeModel, private val imagenModel: TemplateImagenModel, private val remoteConfig: FirebaseRemoteConfig ) { private val json = Json { ignoreUnknownKeys = true } + private val hybridGenerativeModel = aiModel.generativeModel( + modelName = remoteConfig.getString(HYBRID_CLOUD_MODEL_KEY), + onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_IN_CLOUD) + ) - suspend fun generateIngredients(imageData: String): String { - val response = generativeModel.generateContent( - templateId = remoteConfig.getString(GENERATE_INGREDIENTS_KEY), - inputs = mapOf( - MIME_TYPE_FIELD to MIME_TYPE_VALUE, - IMAGE_DATA_FIELD to imageData + @OptIn(PublicPreviewAPI::class) + suspend fun generateIngredients(image: Bitmap): String { + // Adding a Performance Monitoring trace is completely optional. Traces can help you + // measure how long it takes to generate ingredients on device and in cloud. + Firebase.performance.newTrace("hybrid-inference").trace { + val prompt = content { + image(image) + text(remoteConfig.getString(HYBRID_INGREDIENTS_PROMPT_KEY)) + } + + val response = hybridGenerativeModel.generateContent(prompt) + + // This is an optional function that adds an attribute to the Performance Monitoring + // trace. It helps you identify the source of the inference. + putAttribute( + "inferenceSource", + when (response.inferenceSource) { + InferenceSource.ON_DEVICE -> "On device" + else -> "In cloud" + } ) - ) - return response.text.orEmpty() + return response.text.orEmpty() + } } suspend fun generateRecipe(ingredients: String, notes: String): RecipeSchema? { @@ -80,13 +111,45 @@ class AIRemoteDataSource @Inject constructor( } } + suspend fun loadOnDeviceModel() { + when (FirebaseAIOnDevice.checkStatus()) { + OnDeviceModelStatus.UNAVAILABLE -> { + Log.i(TAG, "On-device model is unavailable") + } + OnDeviceModelStatus.DOWNLOADABLE -> { + FirebaseAIOnDevice.download().collect { status -> + when (status) { + is DownloadStatus.DownloadStarted -> + Log.i(TAG, "Starting download - ${status.bytesToDownload}") + + is DownloadStatus.DownloadInProgress -> + Log.i(TAG, "Download in progress ${status.totalBytesDownloaded} bytes downloaded") + + is DownloadStatus.DownloadCompleted -> + Log.i(TAG, "On-device model download complete") + + is DownloadStatus.DownloadFailed -> + Log.e(TAG, "Download failed $status") + } + } + } + OnDeviceModelStatus.DOWNLOADING -> { + Log.i(TAG, "On-device model is being downloaded") + } + OnDeviceModelStatus.AVAILABLE -> { + Log.i(TAG, "On-device model is available") + } + } + } + companion object { //Remote Config Keys - private const val GENERATE_INGREDIENTS_KEY = "generate_ingredients" private const val GENERATE_RECIPE_KEY = "generate_recipe" private const val GENERATE_RECIPE_PHOTO_GEMINI_KEY = "generate_recipe_photo_gemini" private const val GENERATE_RECIPE_PHOTO_IMAGEN_KEY = "generate_recipe_photo_imagen" private const val SCAN_MEAL_KEY = "scan_meal" + private const val HYBRID_CLOUD_MODEL_KEY = "hybrid_cloud_model" + private const val HYBRID_INGREDIENTS_PROMPT_KEY = "hybrid_ingredients_prompt" //Template input fields private const val IMAGE_DATA_FIELD = "imageData" @@ -97,5 +160,8 @@ class AIRemoteDataSource @Inject constructor( //Template input values private const val MIME_TYPE_VALUE = "image/jpeg" + + //Class TAG + private const val TAG = "AIRemoteDataSource" } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt index a85113e..c1a6253 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt @@ -9,8 +9,8 @@ import javax.inject.Inject class AIRepository @Inject constructor( private val aiRemoteDataSource: AIRemoteDataSource ) { - suspend fun generateIngredients(imageData: String): String { - return aiRemoteDataSource.generateIngredients(imageData) + suspend fun generateIngredients(image: Bitmap): String { + return aiRemoteDataSource.generateIngredients(image) } suspend fun generateRecipe(ingredients: String, notes: String): RecipeSchema? { @@ -28,4 +28,8 @@ class AIRepository @Inject constructor( suspend fun scanMeal(imageData: String): MealSchema? { return aiRemoteDataSource.scanMeal(imageData) } + + suspend fun loadOnDeviceModel() { + aiRemoteDataSource.loadOnDeviceModel() + } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt index e8f8777..178333b 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt @@ -8,7 +8,6 @@ import com.google.firebase.example.friendlymeals.data.repository.AuthRepository import com.google.firebase.example.friendlymeals.data.repository.DatabaseRepository import com.google.firebase.example.friendlymeals.data.repository.StorageRepository import com.google.firebase.example.friendlymeals.data.schema.RecipeSchema -import com.google.firebase.example.friendlymeals.ui.shared.toBase64 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -28,6 +27,7 @@ class GenerateViewModel @Inject constructor( init { loadCurrentUser() + loadOnDeviceModel() } fun loadCurrentUser() { @@ -39,6 +39,12 @@ class GenerateViewModel @Inject constructor( } } + fun loadOnDeviceModel() { + launchCatching { + aiRepository.loadOnDeviceModel() + } + } + fun onIngredientsUpdated(ingredients: String) { _viewState.value = _viewState.value.copy( ingredients = ingredients @@ -62,7 +68,7 @@ class GenerateViewModel @Inject constructor( ingredientsLoading = true ) - val ingredients = aiRepository.generateIngredients(image.toBase64()) + val ingredients = aiRepository.generateIngredients(image) _viewState.value = _viewState.value.copy( ingredientsLoading = false, diff --git a/app/src/main/res/xml/remote_config_defaults.xml b/app/src/main/res/xml/remote_config_defaults.xml index cf28b0b..1175602 100644 --- a/app/src/main/res/xml/remote_config_defaults.xml +++ b/app/src/main/res/xml/remote_config_defaults.xml @@ -1,11 +1,15 @@ - generate_ingredients - generate-ingredients-template-v1-0-0 + hybrid_cloud_model + gemini-3.1-flash-lite-preview - generate_recipe - generate-recipe-template-v1-0-0 + hybrid_ingredients_prompt + Please analyze this image and list all visible food ingredients. Output ONLY a comma-separated list of ingredients. Do not include any introductory text, headers, or concluding remarks. Provide the raw list only. Be specific with measurements where possible. + + + scan_meal + scan-meal-template-v1-0-0 generate_recipe_photo_gemini @@ -16,7 +20,7 @@ generate-recipe-photo-imagen-template-v1-0-0 - scan_meal - scan-meal-template-v1-0-0 + generate_recipe + generate-recipe-template-v1-0-0 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 7db73c4..074ea6b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,4 +6,5 @@ plugins { alias(libs.plugins.google.services) apply false alias(libs.plugins.google.hilt) apply false alias(libs.plugins.google.ksp) apply false + alias(libs.plugins.firebase.perf) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b115e5a..f3d74d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,9 @@ agp = "8.13.1" coilCompose = "2.7.0" exifinterface = "1.4.2" -firebaseBom = "34.8.0" +firebaseAi = "17.10.1" +firebaseAiOndevice = "16.0.0-beta01" +firebaseBom = "34.11.0" kotlin = "2.2.21" coreKtx = "1.17.0" junit = "4.13.2" @@ -21,16 +23,19 @@ navigationCompose = "2.9.6" constraintlayoutCompose = "1.1.1" kotlinxSerializationJson = "1.9.0" richtextCommonmark = "1.0.0-alpha03" +firebasePerf = "2.0.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } +firebase-ai = { module = "com.google.firebase:firebase-ai", version.ref = "firebaseAi" } +firebase-ai-ondevice = { module = "com.google.firebase:firebase-ai-ondevice", version.ref = "firebaseAiOndevice" } firebase-auth = { module = "com.google.firebase:firebase-auth" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } -firebase-ai = { module = "com.google.firebase:firebase-ai" } firebase-config = { module = "com.google.firebase:firebase-config" } firebase-firestore = { module = "com.google.firebase:firebase-firestore" } +firebase-perf = { module = "com.google.firebase:firebase-perf" } firebase-storage = { module = "com.google.firebase:firebase-storage" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } @@ -62,4 +67,5 @@ google-services = { id = "com.google.gms.google-services", version.ref = "google google-hilt = { id = "com.google.dagger.hilt.android", version.ref = "googleHilt" } google-ksp = { id = "com.google.devtools.ksp", version.ref ="googleKotlinKsp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerf" }