From 810e7dcff845a395d7661d13ca40eea3491c72dd Mon Sep 17 00:00:00 2001 From: Saad Khan Date: Wed, 16 Sep 2026 13:46:33 +0500 Subject: [PATCH] fix(ai-sample-catalog): explain Firebase AI setup, billing, and blocked requests Replace raw GenerateContent blocked errors with guidance for API key restrictions and Blaze billing, and document those requirements in the catalog. --- ai-sample-catalog/README.md | 9 +- .../ai/catalog/domain/SampleCatalog.kt | 4 + .../com/android/ai/catalog/ui/CatalogApp.kt | 5 +- .../android/ai/catalog/ui/CatalogRowCard.kt | 15 ++- .../android/ai/catalog/ui/CatalogWideCard.kt | 11 ++- .../app/src/main/res/values/strings.xml | 5 +- .../geminichatbot/GeminiChatbotViewModel.kt | 3 +- .../geminihybrid/GeminiHybridViewModel.kt | 5 +- .../samples/gemini-image-chat/README.md | 2 + .../GeminiImageChatViewModel.kt | 3 +- .../ui/GeminiMultimodalViewModel.kt | 3 +- .../VideoMetadataCreationViewModel.kt | 3 +- .../viewmodel/VideoSummarizationViewModel.kt | 3 +- .../samples/magic-selfie/README.md | 2 + .../magicselfie/ui/MagicSelfieViewModel.kt | 3 +- .../samples/nanobanana/README.md | 2 + .../nanobanana/ui/NanobananaViewModel.kt | 3 +- .../ui-component/build.gradle.kts | 2 + .../ai/uicomponent/FirebaseAiErrorMessages.kt | 76 +++++++++++++++ .../FirebaseAiErrorMessagesTest.kt | 93 +++++++++++++++++++ 20 files changed, 237 insertions(+), 15 deletions(-) create mode 100644 ai-sample-catalog/ui-component/src/main/java/com/android/ai/uicomponent/FirebaseAiErrorMessages.kt create mode 100644 ai-sample-catalog/ui-component/src/test/java/com/android/ai/uicomponent/FirebaseAiErrorMessagesTest.kt diff --git a/ai-sample-catalog/README.md b/ai-sample-catalog/README.md index e0c035f2..aac4f81f 100644 --- a/ai-sample-catalog/README.md +++ b/ai-sample-catalog/README.md @@ -12,7 +12,9 @@ some of Google's models. > [Now In Android](https://github.com/android/nowinandroid) > **Requires Firebase setup** the samples relying on Google Cloud models (Gemini Pro, Gemini Flash, etc...) -> require setting up a Firebase project and connecting the app to Firebase (read more [here](https://firebase.google.com/docs/ai-logic/get-started?platform=android&api=dev#set-up-firebase)). +> require setting up a Firebase project and connecting the app to Firebase (read more [here](https://firebase.google.com/docs/ai-logic/get-started?platform=android&api=dev#set-up-firebase)). +> Enable the **Firebase AI Logic API** and include it in your Android API key restrictions, otherwise generate-content requests are blocked. +> Image generation samples (Nano Banana / Nano Banana Pro) typically also require the [Blaze billing plan](https://firebase.google.com/docs/ai-logic/error-codes). > 🚧 **Work-in-Progress:** we are working on bringing more samples into the application. @@ -24,6 +26,11 @@ some of Google's models. file (`google-services.json`) to the `/app` directory. Read more in the [Firebase documentation](https://firebase.google.com/docs/ai-logic/get-started?platform=android&api=dev#set-up-firebase) and the [Android-specific setup guide](https://firebase.google.com/docs/android/learn-more?authuser=0#google-services-plugin-and-file). 4. Sync & Run `app` configuration +If a cloud sample fails with an error that generate-content requests are blocked, confirm that: +- The Firebase AI Logic API is enabled for the project +- The Android API key in Google Cloud Console includes the Firebase AI Logic API +- Image generation samples are using a project on the Blaze plan when required by the model + The app will open with the samples list screen that allows you to navigate throughout the different available samples. diff --git a/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt b/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt index ad0e9b4e..ebf18918 100644 --- a/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt +++ b/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt @@ -58,6 +58,7 @@ val sampleCatalog = listOf( tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE), keyArt = R.drawable.img_keyart_chatbot, needsFirebase = true, + needsBilling = true, isFeatured = true, ), SampleCatalogItem( @@ -110,6 +111,7 @@ val sampleCatalog = listOf( sampleEntryScreen = { NanobananaScreen() }, tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE), needsFirebase = true, + needsBilling = true, keyArt = R.drawable.img_keyart_imagen, ), SampleCatalogItem( @@ -119,6 +121,7 @@ val sampleCatalog = listOf( sampleEntryScreen = { MagicSelfieScreen() }, tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE), needsFirebase = true, + needsBilling = true, keyArt = R.drawable.img_keyart_magic_selfie, ), SampleCatalogItem( @@ -159,6 +162,7 @@ data class SampleCatalogItem( val sampleEntryScreen: @Composable () -> Unit, val tags: List = emptyList(), val needsFirebase: Boolean = false, + val needsBilling: Boolean = false, val isFeatured: Boolean = false, @DrawableRes val keyArt: Int? = null, ) diff --git a/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogApp.kt b/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogApp.kt index 813d638d..07bc0675 100644 --- a/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogApp.kt +++ b/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogApp.kt @@ -156,7 +156,7 @@ fun CatalogApp(modifier: Modifier = Modifier) { isDialogOpened = false val intent = Intent( Intent.ACTION_VIEW, - "https://firebase.google.com/docs/vertex-ai/get-started#no-existing-firebase".toUri(), + FIREBASE_AI_LOGIC_GET_STARTED_URL.toUri(), ) context.startActivity(intent) }, @@ -227,3 +227,6 @@ fun isFirebaseInitialized(): Boolean { return false } } + +private const val FIREBASE_AI_LOGIC_GET_STARTED_URL = + "https://firebase.google.com/docs/ai-logic/get-started?platform=android&api=dev#set-up-firebase" diff --git a/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogRowCard.kt b/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogRowCard.kt index 8b05a006..174097ab 100644 --- a/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogRowCard.kt +++ b/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogRowCard.kt @@ -85,12 +85,25 @@ fun CatalogRowCard(catalogItem: SampleCatalogItem, onClick: () -> Unit) { } Text( modifier = Modifier - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + .padding( + start = 16.dp, + end = 16.dp, + bottom = if (catalogItem.needsBilling) 8.dp else 16.dp, + ), style = MaterialTheme.typography.bodyMedium, text = stringResource(catalogItem.description), maxLines = 3, overflow = TextOverflow.Ellipsis, ) + if (catalogItem.needsBilling) { + Text( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + text = stringResource(R.string.billing_required_note), + ) + } } } } diff --git a/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogWideCard.kt b/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogWideCard.kt index 1a3ed640..144dc24c 100644 --- a/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogWideCard.kt +++ b/ai-sample-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogWideCard.kt @@ -82,10 +82,19 @@ fun CatalogWideCard(catalogItem: SampleCatalogItem, onClick: () -> Unit) { } Text( modifier = Modifier - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + .padding(start = 16.dp, end = 16.dp, bottom = if (catalogItem.needsBilling) 8.dp else 16.dp), style = MaterialTheme.typography.bodyMedium, text = stringResource(catalogItem.description), ) + if (catalogItem.needsBilling) { + Text( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + text = stringResource(R.string.billing_required_note), + ) + } } } } diff --git a/ai-sample-catalog/app/src/main/res/values/strings.xml b/ai-sample-catalog/app/src/main/res/values/strings.xml index f8876099..6d12417e 100644 --- a/ai-sample-catalog/app/src/main/res/values/strings.xml +++ b/ai-sample-catalog/app/src/main/res/values/strings.xml @@ -28,7 +28,8 @@ 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. + This sample needs a Firebase project with Firebase AI Logic enabled. Add google-services.json, enable the Firebase AI Logic API, and include that API in this app\'s Android API key restrictions. + Image generation models such as Nano Banana typically require a billed Firebase project (Blaze plan). Close Show me how - \ No newline at end of file + diff --git a/ai-sample-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotViewModel.kt b/ai-sample-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotViewModel.kt index 9afebba7..fa7ec193 100644 --- a/ai-sample-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotViewModel.kt +++ b/ai-sample-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotViewModel.kt @@ -18,6 +18,7 @@ package com.android.ai.samples.geminichatbot import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.ai.uicomponent.ChatMessage +import com.android.ai.uicomponent.toUserFacingAiErrorMessage import com.google.firebase.Firebase import com.google.firebase.ai.ai import com.google.firebase.ai.type.GenerativeBackend @@ -99,7 +100,7 @@ class GeminiChatbotViewModel @Inject constructor() : ViewModel() { } } catch (e: Exception) { _uiState.update { - it.copy(geminiMessageState = GeminiMessageState.Error(e.localizedMessage ?: "Something went wrong, try again")) + it.copy(geminiMessageState = GeminiMessageState.Error(e.toUserFacingAiErrorMessage())) } } } diff --git a/ai-sample-catalog/samples/gemini-hybrid/src/main/java/com/android/ai/samples/geminihybrid/GeminiHybridViewModel.kt b/ai-sample-catalog/samples/gemini-hybrid/src/main/java/com/android/ai/samples/geminihybrid/GeminiHybridViewModel.kt index 529c5afe..a8ba68ea 100644 --- a/ai-sample-catalog/samples/gemini-hybrid/src/main/java/com/android/ai/samples/geminihybrid/GeminiHybridViewModel.kt +++ b/ai-sample-catalog/samples/gemini-hybrid/src/main/java/com/android/ai/samples/geminihybrid/GeminiHybridViewModel.kt @@ -18,6 +18,7 @@ package com.android.ai.samples.geminihybrid import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.ai.uicomponent.toUserFacingAiErrorMessage import com.google.firebase.Firebase import com.google.firebase.ai.InferenceMode import com.google.firebase.ai.InferenceSource @@ -208,7 +209,7 @@ class GeminiHybridViewModel @Inject constructor() : ViewModel() { } catch (e: Exception) { Log.e("GeminiHybrid", "Inference failed", e) _uiState.update { - it.copy(status = GeminiStatus.Error(e.localizedMessage ?: "Unknown error occurred")) + it.copy(status = GeminiStatus.Error(e.toUserFacingAiErrorMessage())) } } } @@ -278,7 +279,7 @@ class GeminiHybridViewModel @Inject constructor() : ViewModel() { } catch (e: Exception) { Log.e("GeminiHybrid", "Inference failed", e) _uiState.update { - it.copy(status = GeminiStatus.Error(e.localizedMessage ?: "Unknown error occurred")) + it.copy(status = GeminiStatus.Error(e.toUserFacingAiErrorMessage())) } } } diff --git a/ai-sample-catalog/samples/gemini-image-chat/README.md b/ai-sample-catalog/samples/gemini-image-chat/README.md index 7c3b0ac4..ab242e19 100644 --- a/ai-sample-catalog/samples/gemini-image-chat/README.md +++ b/ai-sample-catalog/samples/gemini-image-chat/README.md @@ -6,6 +6,8 @@ This sample is part of the [AI Sample Catalog](../../). To build and run this sa This sample demonstrates a chat bot using `Gemini 3 Pro Image` (a.k.a [Nano Banana Pro](https://deepmind.google/models/gemini-image/pro/)) that can understand both text and images and generate images and text in return. Users can send a message that includes an image, and the generative model will respond based on the multimodal input. This showcases how to build powerful, interactive image generation chat experiences with the Gemini API. +This model typically requires a billed Firebase project. If generate-content requests are blocked, enable the Firebase AI Logic API, allow it on the Android API key, and confirm the project is on the Blaze plan. +
Gemini Image chat in action
diff --git a/ai-sample-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatViewModel.kt b/ai-sample-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatViewModel.kt index d6ea58e5..1da10bb4 100644 --- a/ai-sample-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatViewModel.kt +++ b/ai-sample-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatViewModel.kt @@ -19,6 +19,7 @@ import android.graphics.Bitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.ai.uicomponent.ChatMessage +import com.android.ai.uicomponent.toUserFacingAiErrorMessage import com.google.firebase.Firebase import com.google.firebase.ai.ai import com.google.firebase.ai.type.GenerativeBackend @@ -116,7 +117,7 @@ class GeminiImageChatViewModel @Inject constructor() : ViewModel() { } } catch (e: Exception) { _uiState.update { - it.copy(geminiMessageState = GeminiMessageState.Error(e.localizedMessage ?: "Something went wrong, try again")) + it.copy(geminiMessageState = GeminiMessageState.Error(e.toUserFacingAiErrorMessage())) } } } diff --git a/ai-sample-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalViewModel.kt b/ai-sample-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalViewModel.kt index e7ac9416..6d920c9c 100644 --- a/ai-sample-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalViewModel.kt +++ b/ai-sample-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalViewModel.kt @@ -19,6 +19,7 @@ import android.graphics.Bitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.ai.samples.geminimultimodal.data.GeminiDataSource +import com.android.ai.uicomponent.toUserFacingAiErrorMessage import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -38,7 +39,7 @@ class GeminiMultimodalViewModel @Inject constructor(private val geminiDataSource val result = geminiDataSource.generateText(bitmap, prompt) _uiState.value = GeminiMultimodalUiState.Success(result) } catch (e: Exception) { - _uiState.value = GeminiMultimodalUiState.Error(e.message) + _uiState.value = GeminiMultimodalUiState.Error(e.toUserFacingAiErrorMessage()) } } } diff --git a/ai-sample-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/viewmodel/VideoMetadataCreationViewModel.kt b/ai-sample-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/viewmodel/VideoMetadataCreationViewModel.kt index 09097d66..3e8180c0 100644 --- a/ai-sample-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/viewmodel/VideoMetadataCreationViewModel.kt +++ b/ai-sample-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/viewmodel/VideoMetadataCreationViewModel.kt @@ -36,6 +36,7 @@ import com.android.ai.samples.geminivideometadatacreation.generateHashtags import com.android.ai.samples.geminivideometadatacreation.generateLinks import com.android.ai.samples.geminivideometadatacreation.generateThumbnails import com.android.ai.samples.geminivideometadatacreation.util.sampleVideoList +import com.android.ai.uicomponent.toUserFacingAiErrorMessage import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -88,7 +89,7 @@ class VideoMetadataCreationViewModel @Inject constructor(private val application _uiState.update { it.copy( metadataCreationState = MetadataCreationState.Error( - e.localizedMessage ?: "An unknown error occurred", + e.toUserFacingAiErrorMessage(), ), ) } diff --git a/ai-sample-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/viewmodel/VideoSummarizationViewModel.kt b/ai-sample-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/viewmodel/VideoSummarizationViewModel.kt index 71e9450e..1bbdc18c 100644 --- a/ai-sample-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/viewmodel/VideoSummarizationViewModel.kt +++ b/ai-sample-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/viewmodel/VideoSummarizationViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.ai.samples.geminivideosummary.util.VideoItem import com.android.ai.samples.geminivideosummary.util.sampleVideoList +import com.android.ai.uicomponent.toUserFacingAiErrorMessage import com.google.firebase.Firebase import com.google.firebase.ai.ai import com.google.firebase.ai.type.GenerativeBackend @@ -93,7 +94,7 @@ class VideoSummarizationViewModel @Inject constructor() : ViewModel() { } catch (error: Exception) { _uiState.update { it.copy( - summarizationState = SummarizationState.Error(error.localizedMessage ?: "An unknown error occurred"), + summarizationState = SummarizationState.Error(error.toUserFacingAiErrorMessage()), ) } Log.e(tag, "Error processing prompt : $error") diff --git a/ai-sample-catalog/samples/magic-selfie/README.md b/ai-sample-catalog/samples/magic-selfie/README.md index a122803a..89723a49 100644 --- a/ai-sample-catalog/samples/magic-selfie/README.md +++ b/ai-sample-catalog/samples/magic-selfie/README.md @@ -6,6 +6,8 @@ This sample is part of the [AI Sample Catalog](../../). To build and run this sa This sample demonstrates how to create a "magic selfie" by replacing the background of a user's photo with a generated image. It uses the Nano Banana 2 (`gemini-3.1-flash-image`) model to perform semantic image editing, transforming the background based on a text prompt while preserving the subject. +This image generation model typically requires a billed Firebase project (Blaze plan). +
Magic Selfie in action
diff --git a/ai-sample-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieViewModel.kt b/ai-sample-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieViewModel.kt index 64eac062..2d86aa88 100644 --- a/ai-sample-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieViewModel.kt +++ b/ai-sample-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieViewModel.kt @@ -19,6 +19,7 @@ import android.graphics.Bitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.ai.samples.magicselfie.data.MagicSelfieRepository +import com.android.ai.uicomponent.toUserFacingAiErrorMessage import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -38,7 +39,7 @@ class MagicSelfieViewModel @Inject constructor(private val magicSelfieRepository val resultBitmap = magicSelfieRepository.generateMagicSelfie(bitmap, prompt) _uiState.value = MagicSelfieUiState.Success(resultBitmap) } catch (e: Exception) { - _uiState.value = MagicSelfieUiState.Error(e.message) + _uiState.value = MagicSelfieUiState.Error(e.toUserFacingAiErrorMessage()) } } } diff --git a/ai-sample-catalog/samples/nanobanana/README.md b/ai-sample-catalog/samples/nanobanana/README.md index dd4a4f20..120c2387 100644 --- a/ai-sample-catalog/samples/nanobanana/README.md +++ b/ai-sample-catalog/samples/nanobanana/README.md @@ -6,6 +6,8 @@ This sample is part of the [AI Sample Catalog](../../). To build and run this sa This sample demonstrates how to generate images from text prompts using the Gemini 3.1 Flash Image model (a.k.a. "Nano Banana"). Users can input a text description, and the generative model will create an image based on that prompt, showcasing the power of text-to-image generation with Gemini. +Image generation models typically require a billed Firebase project (Blaze plan). +
Nanobanana Image Generation in action
diff --git a/ai-sample-catalog/samples/nanobanana/src/main/java/com/android/ai/samples/nanobanana/ui/NanobananaViewModel.kt b/ai-sample-catalog/samples/nanobanana/src/main/java/com/android/ai/samples/nanobanana/ui/NanobananaViewModel.kt index 5e65ff05..58e99a87 100644 --- a/ai-sample-catalog/samples/nanobanana/src/main/java/com/android/ai/samples/nanobanana/ui/NanobananaViewModel.kt +++ b/ai-sample-catalog/samples/nanobanana/src/main/java/com/android/ai/samples/nanobanana/ui/NanobananaViewModel.kt @@ -18,6 +18,7 @@ package com.android.ai.samples.nanobanana.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.ai.samples.nanobanana.data.NanobananaDataSource +import com.android.ai.uicomponent.toUserFacingAiErrorMessage import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -38,7 +39,7 @@ class NanobananaViewModel @Inject constructor(private val nanobananaDataSource: val bitmap = nanobananaDataSource.generateImage(prompt) _uiState.value = NanobananaUIState.ImageGenerated(bitmap, contentDescription = prompt) } catch (e: Exception) { - _uiState.value = NanobananaUIState.Error(e.message) + _uiState.value = NanobananaUIState.Error(e.toUserFacingAiErrorMessage()) } } } diff --git a/ai-sample-catalog/ui-component/build.gradle.kts b/ai-sample-catalog/ui-component/build.gradle.kts index f4f78fd6..3c65c3c3 100644 --- a/ai-sample-catalog/ui-component/build.gradle.kts +++ b/ai-sample-catalog/ui-component/build.gradle.kts @@ -67,4 +67,6 @@ dependencies { implementation(libs.richtext.commonmark) implementation(libs.ui.tooling.preview) debugImplementation(libs.ui.tooling) + + testImplementation(libs.junit) } diff --git a/ai-sample-catalog/ui-component/src/main/java/com/android/ai/uicomponent/FirebaseAiErrorMessages.kt b/ai-sample-catalog/ui-component/src/main/java/com/android/ai/uicomponent/FirebaseAiErrorMessages.kt new file mode 100644 index 00000000..8e1af98c --- /dev/null +++ b/ai-sample-catalog/ui-component/src/main/java/com/android/ai/uicomponent/FirebaseAiErrorMessages.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 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.uicomponent + +/** + * Maps Firebase AI Logic failures to actionable messages for sample UIs. + * + * Cloud samples currently surface [Throwable.message] directly, which is often a raw + * 403 ("GenerateContent are blocked") or quota error. Those strings do not explain + * API key restrictions or billing requirements. + */ +object FirebaseAiErrorMessages { + const val API_BLOCKED = + "Requests to Firebase AI Logic are blocked. Enable the Firebase AI Logic API " + + "and include it in this app's Android API key restrictions in Google Cloud Console." + const val BILLING_REQUIRED = + "This model or feature requires a billed Firebase project (Blaze plan). " + + "Image generation models such as Nano Banana typically are not available on the free Spark plan." + const val PROMPT_BLOCKED = + "The model blocked this request. Try a different prompt or image. " + + "If this keeps happening, review the safety settings for the sample." + const val GENERIC = "Something went wrong, try again" +} + +fun Throwable.toUserFacingAiErrorMessage(): String { + val errorText = collectErrorText(this) + val classNames = collectClassNames(this) + + return when { + classNames.any { it.contains("promptblocked") } -> FirebaseAiErrorMessages.PROMPT_BLOCKED + isApiAccessBlocked(errorText) -> FirebaseAiErrorMessages.API_BLOCKED + isBillingOrQuota(errorText) -> FirebaseAiErrorMessages.BILLING_REQUIRED + !message.isNullOrBlank() -> message.orEmpty() + else -> FirebaseAiErrorMessages.GENERIC + } +} + +private fun collectErrorText(throwable: Throwable): String { + return generateSequence(throwable) { it.cause } + .take(5) + .joinToString(separator = " ") { "${it.javaClass.simpleName} ${it.message.orEmpty()}" } + .lowercase() +} + +private fun collectClassNames(throwable: Throwable): List { + return generateSequence(throwable) { it.cause } + .take(5) + .map { it.javaClass.simpleName.lowercase() } + .toList() +} + +private fun isApiAccessBlocked(errorText: String): Boolean { + return errorText.contains("firebasevertexai") || + errorText.contains("are blocked") || + (errorText.contains("requests to this api") && errorText.contains("blocked")) +} + +private fun isBillingOrQuota(errorText: String): Boolean { + return errorText.contains("billing") || + errorText.contains("quota") || + errorText.contains("resource exhausted") || + errorText.contains("429") +} diff --git a/ai-sample-catalog/ui-component/src/test/java/com/android/ai/uicomponent/FirebaseAiErrorMessagesTest.kt b/ai-sample-catalog/ui-component/src/test/java/com/android/ai/uicomponent/FirebaseAiErrorMessagesTest.kt new file mode 100644 index 00000000..b8266fb6 --- /dev/null +++ b/ai-sample-catalog/ui-component/src/test/java/com/android/ai/uicomponent/FirebaseAiErrorMessagesTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 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.uicomponent + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FirebaseAiErrorMessagesTest { + + @Test + fun apiKeyRestrictionError_mapsToApiBlockedGuidance() { + val error = Exception( + "Requests to this API firebasevertexai.googleapis.com method " + + "google.firebase.vertexai.v1beta.GenerativeService.GenerateContent are blocked", + ) + + assertEquals( + FirebaseAiErrorMessages.API_BLOCKED, + error.toUserFacingAiErrorMessage(), + ) + } + + @Test + fun wrappedApiKeyRestrictionError_mapsToApiBlockedGuidance() { + val error = Exception("Failed to generate content", Exception("GenerateContent are blocked")) + + assertEquals( + FirebaseAiErrorMessages.API_BLOCKED, + error.toUserFacingAiErrorMessage(), + ) + } + + @Test + fun quotaExceededError_mapsToBillingGuidance() { + val error = Exception( + "You exceeded your current quota, please check your plan and billing details", + ) + + assertEquals( + FirebaseAiErrorMessages.BILLING_REQUIRED, + error.toUserFacingAiErrorMessage(), + ) + } + + @Test + fun resourceExhaustedError_mapsToBillingGuidance() { + val error = Exception("Resource exhausted, please try again later.") + + assertEquals( + FirebaseAiErrorMessages.BILLING_REQUIRED, + error.toUserFacingAiErrorMessage(), + ) + } + + @Test + fun promptBlockedException_mapsToSafetyGuidance() { + val error = PromptBlockedException("A request was blocked.") + + assertEquals( + FirebaseAiErrorMessages.PROMPT_BLOCKED, + error.toUserFacingAiErrorMessage(), + ) + } + + @Test + fun unknownError_keepsOriginalMessage() { + val error = Exception("timeout talking to the model") + + assertEquals("timeout talking to the model", error.toUserFacingAiErrorMessage()) + } + + @Test + fun emptyError_usesGenericFallback() { + val error = Exception() + + assertEquals(FirebaseAiErrorMessages.GENERIC, error.toUserFacingAiErrorMessage()) + } + + private class PromptBlockedException(message: String) : Exception(message) +}