Skip to content

Commit ce77102

Browse files
committed
Add Grounding with Google search sample
1 parent 3dbf8e0 commit ce77102

6 files changed

Lines changed: 185 additions & 22 deletions

File tree

firebase-ai/app/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ dependencies {
6161
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
6262
implementation(libs.kotlinx.serialization.json)
6363

64+
// Material
65+
implementation(libs.material)
66+
6467
// Firebase
6568
implementation(platform(libs.firebase.bom))
6669
implementation(libs.firebase.ai)
@@ -72,4 +75,7 @@ dependencies {
7275
androidTestImplementation(libs.androidx.ui.test.junit4)
7376
debugImplementation(libs.androidx.ui.tooling)
7477
debugImplementation(libs.androidx.ui.test.manifest)
78+
79+
// Webkit
80+
implementation(libs.androidx.webkit)
7581
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.google.firebase.quickstart.ai
22

33
import com.google.firebase.ai.type.ResponseModality
4+
import com.google.firebase.ai.type.Tool
45
import com.google.firebase.ai.type.content
56
import com.google.firebase.ai.type.generationConfig
67
import com.google.firebase.quickstart.ai.ui.navigation.Category
@@ -203,5 +204,18 @@ val FIREBASE_AI_SAMPLES = listOf(
203204
" anything important which people say in the video."
204205
)
205206
}
207+
),
208+
Sample(
209+
title = "Grounding with Google Search",
210+
description = "Use Grounding with Google Search to get responses based on up-to-date information from the web.",
211+
navRoute = "chat",
212+
categories = listOf(Category.TEXT, Category.DOCUMENT),
213+
modelName = "gemini-2.5-flash",
214+
tools = listOf(Tool.googleSearch()),
215+
initialPrompt = content {
216+
text(
217+
"What's the weather in Chicago this weekend?"
218+
)
219+
},
206220
)
207221
)

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

Lines changed: 128 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,118 @@ 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+
// Use WebSettingsCompat to safely set the dark mode on API < 23.
314+
// This is a no-op on API >= 23. On versions > 23, the WebView
315+
// will correctly use the system theme.
316+
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
317+
if (isDarkTheme) {
318+
WebSettingsCompat.setForceDark(
319+
settings,
320+
WebSettingsCompat.FORCE_DARK_ON
321+
)
322+
} else {
323+
WebSettingsCompat.setForceDark(
324+
settings,
325+
WebSettingsCompat.FORCE_DARK_OFF
326+
)
327+
}
328+
}
329+
330+
// The HTML content from the backend has its own styling.
331+
// Set the WebView background to transparent to let the chat bubble's
332+
// background show through.
333+
setBackgroundColor(android.graphics.Color.TRANSPARENT)
334+
loadDataWithBaseURL(
335+
null,
336+
searchEntryPoint.renderedContent,
337+
"text/html",
338+
"UTF-8",
339+
null
340+
)
341+
}
342+
},
343+
modifier = Modifier
344+
.clip(RoundedCornerShape(22.dp))
345+
.fillMaxHeight()
346+
.fillMaxWidth()
347+
)
348+
}
349+
350+
if (metadata.groundingChunks.isNotEmpty()) {
351+
Text(
352+
text = "Sources",
353+
style = MaterialTheme.typography.titleSmall,
354+
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
355+
)
356+
metadata.groundingChunks.forEach { chunk ->
357+
chunk.web?.let { SourceLinkView(it) }
358+
}
359+
}
360+
}
275361
}
276362
}
277363
}
278364
}
279365
}
280366
}
281367

368+
@Composable
369+
fun SourceLinkView(
370+
webChunk: WebGroundingChunk
371+
) {
372+
val context = LocalContext.current
373+
val annotatedString = AnnotatedString.Builder(webChunk.title ?: "Untitled Source").apply {
374+
addStyle(
375+
style = SpanStyle(
376+
color = MaterialTheme.colorScheme.primary,
377+
textDecoration = TextDecoration.Underline
378+
),
379+
start = 0,
380+
end = webChunk.title?.length ?: "Untitled Source".length
381+
)
382+
webChunk.uri?.let { addStringAnnotation("URL", it, 0, it.length) }
383+
}.toAnnotatedString()
384+
385+
Row(modifier = Modifier.padding(bottom = 8.dp)) {
386+
Icon(
387+
Icons.Default.Attachment,
388+
contentDescription = "Source link",
389+
modifier = Modifier.padding(end = 8.dp)
390+
)
391+
ClickableText(text = annotatedString, onClick = { offset ->
392+
annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset)
393+
.firstOrNull()?.let { annotation ->
394+
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)))
395+
}
396+
})
397+
}
398+
}
399+
282400
@Composable
283401
fun ChatList(
284-
chatMessages: List<Content>,
402+
chatMessages: List<UiChatMessage>,
285403
listState: LazyListState,
286404
modifier: Modifier = Modifier
287405
) {
@@ -470,4 +588,4 @@ fun AttachmentsList(
470588
}
471589
}
472590
}
473-
}
591+
}

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

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,22 @@ import com.google.firebase.ai.ai
1313
import com.google.firebase.ai.type.Content
1414
import com.google.firebase.ai.type.FileDataPart
1515
import com.google.firebase.ai.type.GenerativeBackend
16+
import com.google.firebase.ai.type.GroundingMetadata
1617
import com.google.firebase.ai.type.TextPart
1718
import com.google.firebase.ai.type.asTextOrNull
1819
import com.google.firebase.quickstart.ai.FIREBASE_AI_SAMPLES
1920
import kotlinx.coroutines.flow.MutableStateFlow
2021
import kotlinx.coroutines.flow.StateFlow
2122
import kotlinx.coroutines.launch
2223

24+
/**
25+
* A wrapper for a model [Content] object that includes additional UI-specific metadata.
26+
*/
27+
data class UiChatMessage(
28+
val content: Content,
29+
val groundingMetadata: GroundingMetadata? = null,
30+
)
31+
2332
class ChatViewModel(
2433
savedStateHandle: SavedStateHandle
2534
) : ViewModel() {
@@ -37,10 +46,10 @@ class ChatViewModel(
3746
private val _errorMessage = MutableStateFlow<String?>(null)
3847
val errorMessage: StateFlow<String?> = _errorMessage
3948

40-
private val _messageList: MutableList<Content> =
41-
sample.chatHistory.toMutableStateList()
42-
private val _messages = MutableStateFlow<List<Content>>(_messageList)
43-
val messages: StateFlow<List<Content>> =
49+
private val _messageList: MutableList<UiChatMessage> =
50+
sample.chatHistory.map { UiChatMessage(it) }.toMutableStateList()
51+
private val _messages = MutableStateFlow<List<UiChatMessage>>(_messageList)
52+
val messages: StateFlow<List<UiChatMessage>> =
4453
_messages
4554

4655
private val _attachmentsList: MutableList<Attachment> =
@@ -61,7 +70,8 @@ class ChatViewModel(
6170
).generativeModel(
6271
modelName = sample.modelName ?: "gemini-2.0-flash",
6372
systemInstruction = sample.systemInstructions,
64-
generationConfig = sample.generationConfig
73+
generationConfig = sample.generationConfig,
74+
tools = sample.tools
6575
)
6676
chat = generativeModel.startChat(sample.chatHistory)
6777

@@ -80,14 +90,26 @@ class ChatViewModel(
8090
.text(userMessage)
8191
.build()
8292

83-
_messageList.add(prompt)
93+
_messageList.add(UiChatMessage(prompt))
8494

8595
viewModelScope.launch {
8696
_isLoading.value = true
8797
try {
8898
val response = chat.sendMessage(prompt)
89-
_messageList.add(response.candidates.first().content)
90-
_errorMessage.value = null // clear errors
99+
val candidate = response.candidates.first()
100+
101+
// Compliance check for grounding
102+
if (candidate.groundingMetadata != null
103+
&& candidate.groundingMetadata?.groundingChunks?.isNotEmpty() == true
104+
&& candidate.groundingMetadata?.searchEntryPoint == null) {
105+
_errorMessage.value =
106+
"Could not display the response because it was missing required attribution components."
107+
} else {
108+
_messageList.add(
109+
UiChatMessage(candidate.content, candidate.groundingMetadata)
110+
)
111+
_errorMessage.value = null // clear errors
112+
}
91113
} catch (e: Exception) {
92114
_errorMessage.value = e.localizedMessage
93115
} finally {
@@ -114,4 +136,4 @@ class ChatViewModel(
114136

115137
private fun decodeBitmapFromImage(input: ByteArray) =
116138
BitmapFactory.decodeByteArray(input, 0, input.size)
117-
}
139+
}

firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/Sample.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.google.firebase.quickstart.ai.ui.navigation
22

33
import com.google.firebase.ai.type.Content
4+
import com.google.firebase.ai.type.Tool
45
import com.google.firebase.ai.type.GenerationConfig
56
import java.util.UUID
67

@@ -25,5 +26,6 @@ data class Sample(
2526
val initialPrompt: Content? = null,
2627
val systemInstructions: Content? = null,
2728
val generationConfig: GenerationConfig? = null,
28-
val chatHistory: List<Content> = emptyList()
29-
)
29+
val chatHistory: List<Content> = emptyList(),
30+
val tools: List<Tool>? = null,
31+
)
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>

0 commit comments

Comments
 (0)