Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion ai-sample-catalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -159,6 +162,7 @@ data class SampleCatalogItem(
val sampleEntryScreen: @Composable () -> Unit,
val tags: List<SampleTags> = emptyList(),
val needsFirebase: Boolean = false,
val needsBilling: Boolean = false,
val isFeatured: Boolean = false,
@DrawableRes val keyArt: Int? = null,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
}
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions ai-sample-catalog/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
<string name="gemini_hybrid_sample_list_title">Gemini Hybrid</string>
<string name="gemini_hybrid_sample_list_description">Inference with Firebase Hybrid SDK using either Gemini Nano on-device or Gemini Flash in the Cloud.</string>
<string name="firebase_required">Firebase Required</string>
<string name="firebase_required_description">This feature requires Firebase to be initialized.</string>
<string name="firebase_required_description">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.</string>
<string name="billing_required_note">Image generation models such as Nano Banana typically require a billed Firebase project (Blaze plan).</string>
<string name="close">Close</string>
<string name="firebase_doc_button">Show me how</string>
</resources>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()))
}
}
}
Expand Down Expand Up @@ -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()))
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions ai-sample-catalog/samples/gemini-image-chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<div style="text-align: center;">
<img width="320" alt="Gemini Image chat in action" src="android_nano_banana.png" />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions ai-sample-catalog/samples/magic-selfie/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<div style="text-align: center;">
<img width="320" alt="Magic Selfie in action" src="magic_selfie.png" />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions ai-sample-catalog/samples/nanobanana/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<div style="text-align: center;">
<img width="320" alt="Nanobanana Image Generation in action" src="../../samples/gemini-image-chat/android_nano_banana.png" />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions ai-sample-catalog/ui-component/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,6 @@ dependencies {
implementation(libs.richtext.commonmark)
implementation(libs.ui.tooling.preview)
debugImplementation(libs.ui.tooling)

testImplementation(libs.junit)
}
Original file line number Diff line number Diff line change
@@ -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<String> {
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")
}
Loading