Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions firebase-ai/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.kotlinx.serialization.json)

// Material for XML-based theme
implementation(libs.material)

// Firebase
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.ai)
Expand All @@ -72,4 +75,7 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)

// Webkit
implementation(libs.androidx.webkit)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.google.firebase.quickstart.ai

import com.google.firebase.ai.type.ResponseModality
import com.google.firebase.ai.type.Tool
import com.google.firebase.ai.type.content
import com.google.firebase.ai.type.generationConfig
import com.google.firebase.quickstart.ai.ui.navigation.Category
Expand Down Expand Up @@ -203,5 +204,18 @@ val FIREBASE_AI_SAMPLES = listOf(
" anything important which people say in the video."
)
}
),
Sample(
title = "Grounding with Google Search",
description = "Use Grounding with Google Search to get responses based on up-to-date information from the web.",
navRoute = "chat",
categories = listOf(Category.TEXT, Category.DOCUMENT),
modelName = "gemini-2.5-flash",
tools = listOf(Tool.googleSearch()),
initialPrompt = content {
text(
"What's the weather in Chicago this weekend?"
)
},
)
)
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
package com.google.firebase.quickstart.ai.feature.text

import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.provider.OpenableColumns
import android.text.format.Formatter
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
Expand All @@ -22,6 +28,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
Expand All @@ -31,6 +38,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
Expand All @@ -50,16 +58,22 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.firebase.ai.type.Content
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import com.google.firebase.ai.type.FileDataPart
import com.google.firebase.ai.type.ImagePart
import com.google.firebase.ai.type.InlineDataPart
import com.google.firebase.ai.type.TextPart
import com.google.firebase.ai.type.WebGroundingChunk
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable

Expand All @@ -70,7 +84,7 @@ class ChatRoute(val sampleId: String)
fun ChatScreen(
chatViewModel: ChatViewModel = viewModel<ChatViewModel>()
) {
val messages: List<Content> by chatViewModel.messages.collectAsStateWithLifecycle()
val messages: List<UiChatMessage> by chatViewModel.messages.collectAsStateWithLifecycle()
val isLoading: Boolean by chatViewModel.isLoading.collectAsStateWithLifecycle()
val errorMessage: String? by chatViewModel.errorMessage.collectAsStateWithLifecycle()
val attachments: List<Attachment> by chatViewModel.attachments.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -162,17 +176,19 @@ fun ChatScreen(

@Composable
fun ChatBubbleItem(
chatMessage: Content
message: UiChatMessage
) {
val isModelMessage = chatMessage.role == "model"
val isModelMessage = message.content.role == "model"

val backgroundColor = when (chatMessage.role) {
val isDarkTheme = isSystemInDarkTheme()

val backgroundColor = when (message.content.role) {
"user" -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.secondaryContainer
}

val textColor = if (isModelMessage) {
MaterialTheme.colorScheme.onSecondaryContainer
MaterialTheme.colorScheme.onBackground
} else {
MaterialTheme.colorScheme.onTertiaryContainer
}
Expand All @@ -196,7 +212,7 @@ fun ChatBubbleItem(
.fillMaxWidth()
) {
Text(
text = chatMessage.role?.uppercase() ?: "USER",
text = message.content.role?.uppercase() ?: "USER",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 4.dp)
)
Expand All @@ -212,7 +228,7 @@ fun ChatBubbleItem(
.padding(16.dp)
.fillMaxWidth()
) {
chatMessage.parts.forEach { part ->
message.content.parts.forEach { part ->
when (part) {
is TextPart -> {
Text(
Expand Down Expand Up @@ -272,16 +288,98 @@ fun ChatBubbleItem(
}
}
}
message.groundingMetadata?.let { metadata ->
HorizontalDivider(modifier = Modifier.padding(vertical = 18.dp))

// Search Entry Point (WebView)
metadata.searchEntryPoint?.let { searchEntryPoint ->
val context = LocalContext.current
AndroidView(factory = {
WebView(it).apply {
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
request?.url?.let { uri ->
val intent = Intent(Intent.ACTION_VIEW, uri)
context.startActivity(intent)
}
// Return true to indicate we handled the URL loading
return true
}
}

setBackgroundColor(android.graphics.Color.TRANSPARENT)
loadDataWithBaseURL(
null,
searchEntryPoint.renderedContent,
"text/html",
"UTF-8",
null
)
}
},
modifier = Modifier
.clip(RoundedCornerShape(22.dp))
.fillMaxHeight()
.fillMaxWidth()
)
}

if (metadata.groundingChunks.isNotEmpty()) {
Text(
text = "Sources",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
metadata.groundingChunks.forEach { chunk ->
chunk.web?.let { SourceLinkView(it) }
}
}
}
}
}
}
}
}
}

@Composable
fun SourceLinkView(
webChunk: WebGroundingChunk
) {
val context = LocalContext.current
val annotatedString = AnnotatedString.Builder(webChunk.title ?: "Untitled Source").apply {
addStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
),
start = 0,
end = webChunk.title?.length ?: "Untitled Source".length
)
webChunk.uri?.let { addStringAnnotation("URL", it, 0, it.length) }
}.toAnnotatedString()

Row(modifier = Modifier.padding(bottom = 8.dp)) {
Icon(
Icons.Default.Attachment,
contentDescription = "Source link",
modifier = Modifier.padding(end = 8.dp)
)
ClickableText(text = annotatedString, onClick = { offset ->
annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)))
}
})
}
}

@Composable
fun ChatList(
chatMessages: List<Content>,
chatMessages: List<UiChatMessage>,
listState: LazyListState,
modifier: Modifier = Modifier
) {
Expand Down Expand Up @@ -470,4 +568,4 @@ fun AttachmentsList(
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,22 @@ import com.google.firebase.ai.ai
import com.google.firebase.ai.type.Content
import com.google.firebase.ai.type.FileDataPart
import com.google.firebase.ai.type.GenerativeBackend
import com.google.firebase.ai.type.GroundingMetadata
import com.google.firebase.ai.type.TextPart
import com.google.firebase.ai.type.asTextOrNull
import com.google.firebase.quickstart.ai.FIREBASE_AI_SAMPLES
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

/**
* A wrapper for a model [Content] object that includes additional UI-specific metadata.
*/
data class UiChatMessage(
val content: Content,
val groundingMetadata: GroundingMetadata? = null,
)

class ChatViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel() {
Expand All @@ -37,10 +46,10 @@ class ChatViewModel(
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage

private val _messageList: MutableList<Content> =
sample.chatHistory.toMutableStateList()
private val _messages = MutableStateFlow<List<Content>>(_messageList)
val messages: StateFlow<List<Content>> =
private val _messageList: MutableList<UiChatMessage> =
sample.chatHistory.map { UiChatMessage(it) }.toMutableStateList()
private val _messages = MutableStateFlow<List<UiChatMessage>>(_messageList)
val messages: StateFlow<List<UiChatMessage>> =
_messages

private val _attachmentsList: MutableList<Attachment> =
Expand All @@ -61,7 +70,8 @@ class ChatViewModel(
).generativeModel(
modelName = sample.modelName ?: "gemini-2.0-flash",
systemInstruction = sample.systemInstructions,
generationConfig = sample.generationConfig
generationConfig = sample.generationConfig,
tools = sample.tools
)
chat = generativeModel.startChat(sample.chatHistory)

Expand All @@ -80,14 +90,26 @@ class ChatViewModel(
.text(userMessage)
.build()

_messageList.add(prompt)
_messageList.add(UiChatMessage(prompt))

viewModelScope.launch {
_isLoading.value = true
try {
val response = chat.sendMessage(prompt)
_messageList.add(response.candidates.first().content)
_errorMessage.value = null // clear errors
val candidate = response.candidates.first()

// Compliance check for grounding
if (candidate.groundingMetadata != null
&& candidate.groundingMetadata?.groundingChunks?.isNotEmpty() == true
&& candidate.groundingMetadata?.searchEntryPoint == null) {
_errorMessage.value =
"Could not display the response because it was missing required attribution components."
} else {
_messageList.add(
UiChatMessage(candidate.content, candidate.groundingMetadata)
)
_errorMessage.value = null // clear errors
}
} catch (e: Exception) {
_errorMessage.value = e.localizedMessage
} finally {
Expand All @@ -114,4 +136,4 @@ class ChatViewModel(

private fun decodeBitmapFromImage(input: ByteArray) =
BitmapFactory.decodeByteArray(input, 0, input.size)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.google.firebase.quickstart.ai.ui.navigation

import com.google.firebase.ai.type.Content
import com.google.firebase.ai.type.Tool
import com.google.firebase.ai.type.GenerationConfig
import java.util.UUID

Expand All @@ -25,5 +26,6 @@ data class Sample(
val initialPrompt: Content? = null,
val systemInstructions: Content? = null,
val generationConfig: GenerationConfig? = null,
val chatHistory: List<Content> = emptyList()
)
val chatHistory: List<Content> = emptyList(),
val tools: List<Tool>? = null,
)
3 changes: 2 additions & 1 deletion firebase-ai/app/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<style name="Theme.FirebaseAIServices" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.FirebaseAIServices" parent="Theme.Material3.DayNight.NoActionBar" />

</resources>
Loading
Loading