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,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
283381fun 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+ }
0 commit comments