Skip to content

Commit 277192c

Browse files
authored
[FirebaseAI] Add Grounding with Google search sample (#2687)
1 parent c3626e4 commit 277192c

File tree

6 files changed

+167
-22
lines changed

6 files changed

+167
-22
lines changed

firebase-ai/app/build.gradle.kts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ dependencies {
6060
implementation(libs.androidx.lifecycle.viewmodel.compose)
6161
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
6262
implementation(libs.kotlinx.serialization.json)
63+
// Webkit
64+
implementation(libs.androidx.webkit)
65+
66+
// Material for XML-based theme
67+
implementation(libs.material)
6368

6469
// Firebase
6570
implementation(platform(libs.firebase.bom))
@@ -72,4 +77,4 @@ dependencies {
7277
androidTestImplementation(libs.androidx.ui.test.junit4)
7378
debugImplementation(libs.androidx.ui.tooling)
7479
debugImplementation(libs.androidx.ui.test.manifest)
75-
}
80+
}

firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/FirebaseAISamples.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,4 +241,17 @@ val FIREBASE_AI_SAMPLES = listOf(
241241
text("What was the weather in Boston, MA on October 17, 2024?")
242242
}
243243
),
244+
Sample(
245+
title = "Grounding with Google Search",
246+
description = "Use Grounding with Google Search to get responses based on up-to-date information from the web.",
247+
navRoute = "chat",
248+
categories = listOf(Category.TEXT, Category.DOCUMENT),
249+
modelName = "gemini-2.5-flash",
250+
tools = listOf(Tool.googleSearch()),
251+
initialPrompt = content {
252+
text(
253+
"What's the weather in Chicago this weekend?"
254+
)
255+
},
256+
),
244257
)

firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/text/ChatScreen.kt

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
package com.google.firebase.quickstart.ai.feature.text
22

3+
import android.content.Intent
34
import android.graphics.Bitmap
45
import android.net.Uri
56
import android.provider.OpenableColumns
67
import android.text.format.Formatter
8+
import android.webkit.WebResourceRequest
9+
import android.webkit.WebView
10+
import android.webkit.WebViewClient
711
import androidx.activity.compose.rememberLauncherForActivityResult
812
import androidx.activity.result.contract.ActivityResultContracts
913
import androidx.compose.foundation.Image
1014
import androidx.compose.foundation.background
15+
import androidx.compose.foundation.isSystemInDarkTheme
1116
import androidx.compose.foundation.layout.Box
1217
import androidx.compose.foundation.layout.BoxWithConstraints
1318
import androidx.compose.foundation.layout.Column
1419
import androidx.compose.foundation.layout.Row
20+
import androidx.compose.foundation.layout.fillMaxHeight
1521
import androidx.compose.foundation.layout.fillMaxSize
1622
import androidx.compose.foundation.layout.fillMaxWidth
1723
import androidx.compose.foundation.layout.padding
@@ -22,6 +28,7 @@ import androidx.compose.foundation.lazy.items
2228
import androidx.compose.foundation.lazy.rememberLazyListState
2329
import androidx.compose.foundation.shape.CircleShape
2430
import androidx.compose.foundation.shape.RoundedCornerShape
31+
import androidx.compose.foundation.text.ClickableText
2532
import androidx.compose.foundation.text.KeyboardOptions
2633
import androidx.compose.material.icons.Icons
2734
import androidx.compose.material.icons.automirrored.filled.Send
@@ -31,6 +38,7 @@ import androidx.compose.material3.Card
3138
import androidx.compose.material3.CardDefaults
3239
import androidx.compose.material3.DropdownMenu
3340
import androidx.compose.material3.DropdownMenuItem
41+
import androidx.compose.material3.HorizontalDivider
3442
import androidx.compose.material3.Icon
3543
import androidx.compose.material3.IconButton
3644
import androidx.compose.material3.IconButtonDefaults
@@ -50,16 +58,22 @@ import androidx.compose.ui.Modifier
5058
import androidx.compose.ui.draw.clip
5159
import androidx.compose.ui.graphics.asImageBitmap
5260
import androidx.compose.ui.platform.LocalContext
61+
import androidx.compose.ui.text.AnnotatedString
62+
import androidx.compose.ui.text.SpanStyle
5363
import androidx.compose.ui.text.input.KeyboardCapitalization
5464
import androidx.compose.ui.text.style.TextAlign
65+
import androidx.compose.ui.text.style.TextDecoration
5566
import androidx.compose.ui.unit.dp
67+
import androidx.compose.ui.viewinterop.AndroidView
5668
import androidx.lifecycle.compose.collectAsStateWithLifecycle
5769
import androidx.lifecycle.viewmodel.compose.viewModel
58-
import com.google.firebase.ai.type.Content
70+
import androidx.webkit.WebSettingsCompat
71+
import androidx.webkit.WebViewFeature
5972
import com.google.firebase.ai.type.FileDataPart
6073
import com.google.firebase.ai.type.ImagePart
6174
import com.google.firebase.ai.type.InlineDataPart
6275
import com.google.firebase.ai.type.TextPart
76+
import com.google.firebase.ai.type.WebGroundingChunk
6377
import kotlinx.coroutines.launch
6478
import kotlinx.serialization.Serializable
6579

@@ -70,7 +84,7 @@ class ChatRoute(val sampleId: String)
7084
fun ChatScreen(
7185
chatViewModel: ChatViewModel = viewModel<ChatViewModel>()
7286
) {
73-
val messages: List<Content> by chatViewModel.messages.collectAsStateWithLifecycle()
87+
val messages: List<UiChatMessage> by chatViewModel.messages.collectAsStateWithLifecycle()
7488
val isLoading: Boolean by chatViewModel.isLoading.collectAsStateWithLifecycle()
7589
val errorMessage: String? by chatViewModel.errorMessage.collectAsStateWithLifecycle()
7690
val attachments: List<Attachment> by chatViewModel.attachments.collectAsStateWithLifecycle()
@@ -162,17 +176,19 @@ fun ChatScreen(
162176

163177
@Composable
164178
fun ChatBubbleItem(
165-
chatMessage: Content
179+
message: UiChatMessage
166180
) {
167-
val isModelMessage = chatMessage.role == "model"
181+
val isModelMessage = message.content.role == "model"
168182

169-
val backgroundColor = when (chatMessage.role) {
183+
val isDarkTheme = isSystemInDarkTheme()
184+
185+
val backgroundColor = when (message.content.role) {
170186
"user" -> MaterialTheme.colorScheme.tertiaryContainer
171187
else -> MaterialTheme.colorScheme.secondaryContainer
172188
}
173189

174190
val textColor = if (isModelMessage) {
175-
MaterialTheme.colorScheme.onSecondaryContainer
191+
MaterialTheme.colorScheme.onBackground
176192
} else {
177193
MaterialTheme.colorScheme.onTertiaryContainer
178194
}
@@ -196,7 +212,7 @@ fun ChatBubbleItem(
196212
.fillMaxWidth()
197213
) {
198214
Text(
199-
text = chatMessage.role?.uppercase() ?: "USER",
215+
text = message.content.role?.uppercase() ?: "USER",
200216
style = MaterialTheme.typography.bodySmall,
201217
modifier = Modifier.padding(bottom = 4.dp)
202218
)
@@ -212,7 +228,7 @@ fun ChatBubbleItem(
212228
.padding(16.dp)
213229
.fillMaxWidth()
214230
) {
215-
chatMessage.parts.forEach { part ->
231+
message.content.parts.forEach { part ->
216232
when (part) {
217233
is TextPart -> {
218234
Text(
@@ -272,16 +288,98 @@ fun ChatBubbleItem(
272288
}
273289
}
274290
}
291+
message.groundingMetadata?.let { metadata ->
292+
HorizontalDivider(modifier = Modifier.padding(vertical = 18.dp))
293+
294+
// Search Entry Point (WebView)
295+
metadata.searchEntryPoint?.let { searchEntryPoint ->
296+
val context = LocalContext.current
297+
AndroidView(factory = {
298+
WebView(it).apply {
299+
webViewClient = object : WebViewClient() {
300+
override fun shouldOverrideUrlLoading(
301+
view: WebView?,
302+
request: WebResourceRequest?
303+
): Boolean {
304+
request?.url?.let { uri ->
305+
val intent = Intent(Intent.ACTION_VIEW, uri)
306+
context.startActivity(intent)
307+
}
308+
// Return true to indicate we handled the URL loading
309+
return true
310+
}
311+
}
312+
313+
setBackgroundColor(android.graphics.Color.TRANSPARENT)
314+
loadDataWithBaseURL(
315+
null,
316+
searchEntryPoint.renderedContent,
317+
"text/html",
318+
"UTF-8",
319+
null
320+
)
321+
}
322+
},
323+
modifier = Modifier
324+
.clip(RoundedCornerShape(22.dp))
325+
.fillMaxHeight()
326+
.fillMaxWidth()
327+
)
328+
}
329+
330+
if (metadata.groundingChunks.isNotEmpty()) {
331+
Text(
332+
text = "Sources",
333+
style = MaterialTheme.typography.titleSmall,
334+
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
335+
)
336+
metadata.groundingChunks.forEach { chunk ->
337+
chunk.web?.let { SourceLinkView(it) }
338+
}
339+
}
340+
}
275341
}
276342
}
277343
}
278344
}
279345
}
280346
}
281347

348+
@Composable
349+
fun SourceLinkView(
350+
webChunk: WebGroundingChunk
351+
) {
352+
val context = LocalContext.current
353+
val annotatedString = AnnotatedString.Builder(webChunk.title ?: "Untitled Source").apply {
354+
addStyle(
355+
style = SpanStyle(
356+
color = MaterialTheme.colorScheme.primary,
357+
textDecoration = TextDecoration.Underline
358+
),
359+
start = 0,
360+
end = webChunk.title?.length ?: "Untitled Source".length
361+
)
362+
webChunk.uri?.let { addStringAnnotation("URL", it, 0, it.length) }
363+
}.toAnnotatedString()
364+
365+
Row(modifier = Modifier.padding(bottom = 8.dp)) {
366+
Icon(
367+
Icons.Default.Attachment,
368+
contentDescription = "Source link",
369+
modifier = Modifier.padding(end = 8.dp)
370+
)
371+
ClickableText(text = annotatedString, onClick = { offset ->
372+
annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset)
373+
.firstOrNull()?.let { annotation ->
374+
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)))
375+
}
376+
})
377+
}
378+
}
379+
282380
@Composable
283381
fun ChatList(
284-
chatMessages: List<Content>,
382+
chatMessages: List<UiChatMessage>,
285383
listState: LazyListState,
286384
modifier: Modifier = Modifier
287385
) {
@@ -470,4 +568,4 @@ fun AttachmentsList(
470568
}
471569
}
472570
}
473-
}
571+
}

firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/text/ChatViewModel.kt

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import com.google.firebase.ai.type.Content
1515
import com.google.firebase.ai.type.FileDataPart
1616
import com.google.firebase.ai.type.FunctionResponsePart
1717
import com.google.firebase.ai.type.GenerateContentResponse
18+
import com.google.firebase.ai.type.GenerativeBackend
19+
import com.google.firebase.ai.type.GroundingMetadata
1820
import com.google.firebase.ai.type.TextPart
1921
import com.google.firebase.ai.type.asTextOrNull
2022
import com.google.firebase.ai.type.content
@@ -25,6 +27,14 @@ import kotlinx.coroutines.flow.StateFlow
2527
import kotlinx.coroutines.launch
2628
import kotlinx.serialization.json.jsonPrimitive
2729

30+
/**
31+
* A wrapper for a model [Content] object that includes additional UI-specific metadata.
32+
*/
33+
data class UiChatMessage(
34+
val content: Content,
35+
val groundingMetadata: GroundingMetadata? = null,
36+
)
37+
2838
class ChatViewModel(
2939
savedStateHandle: SavedStateHandle
3040
) : ViewModel() {
@@ -42,10 +52,10 @@ class ChatViewModel(
4252
private val _errorMessage = MutableStateFlow<String?>(null)
4353
val errorMessage: StateFlow<String?> = _errorMessage
4454

45-
private val _messageList: MutableList<Content> =
46-
sample.chatHistory.toMutableStateList()
47-
private val _messages = MutableStateFlow<List<Content>>(_messageList)
48-
val messages: StateFlow<List<Content>> =
55+
private val _messageList: MutableList<UiChatMessage> =
56+
sample.chatHistory.map { UiChatMessage(it) }.toMutableStateList()
57+
private val _messages = MutableStateFlow<List<UiChatMessage>>(_messageList)
58+
val messages: StateFlow<List<UiChatMessage>> =
4959
_messages
5060

5161
private val _attachmentsList: MutableList<Attachment> =
@@ -86,16 +96,28 @@ class ChatViewModel(
8696
.text(userMessage)
8797
.build()
8898

89-
_messageList.add(prompt)
99+
_messageList.add(UiChatMessage(prompt))
90100

91101
viewModelScope.launch {
92102
_isLoading.value = true
93103
try {
94104
val response = chat.sendMessage(prompt)
95105
if (response.functionCalls.isEmpty()) {
96-
// Samples without function calling can simply display
97-
// the response in the UI
98-
_messageList.add(response.candidates.first().content)
106+
// Samples without function calling can display the response in the UI
107+
val candidate = response.candidates.first()
108+
109+
// Compliance check for grounding
110+
if (candidate.groundingMetadata != null
111+
&& candidate.groundingMetadata?.groundingChunks?.isNotEmpty() == true
112+
&& candidate.groundingMetadata?.searchEntryPoint == null) {
113+
_errorMessage.value =
114+
"Could not display the response because it was missing required attribution components."
115+
} else {
116+
_messageList.add(
117+
UiChatMessage(candidate.content, candidate.groundingMetadata)
118+
)
119+
_errorMessage.value = null // clear errors
120+
}
99121
} else {
100122
// Samples WITH function calling need to perform
101123
// additional handling
@@ -154,7 +176,9 @@ class ChatViewModel(
154176
})
155177

156178
Log.d("ChatViewModel", "Model responded with: ${finalResponse.text}")
157-
_messageList.add(finalResponse.candidates.first().content)
179+
val candidate = finalResponse.candidates.first()
180+
_messageList.add(UiChatMessage(candidate.content,
181+
candidate.groundingMetadata))
158182
}
159183

160184
else -> {
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<resources>
33

4-
<style name="Theme.FirebaseAIServices" parent="android:Theme.Material.Light.NoActionBar" />
4+
<style name="Theme.FirebaseAIServices" parent="Theme.Material3.DayNight.NoActionBar" />
5+
56
</resources>

gradle/libs.versions.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[versions]
22
agp = "8.11.1"
33
coilCompose = "2.7.0"
4-
firebaseBom = "33.16.0"
4+
firebaseBom = "34.0.0"
55
kotlin = "2.2.0"
66
coreKtx = "1.16.0"
77
junit = "4.13.2"
@@ -13,6 +13,8 @@ activityCompose = "1.10.1"
1313
composeBom = "2025.07.00"
1414
googleServices = "4.4.3"
1515
composeNavigation = "2.9.2"
16+
material = "1.12.0"
17+
webkit = "1.11.0"
1618

1719
[libraries]
1820
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -41,6 +43,8 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3"
4143
compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation"}
4244
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
4345
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationCore" }
46+
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
47+
material = { module = "com.google.android.material:material", version.ref = "material" }
4448

4549
[plugins]
4650
android-application = { id = "com.android.application", version.ref = "agp" }

0 commit comments

Comments
 (0)