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