1
1
package com.google.firebase.quickstart.ai.feature.text
2
2
3
+ import android.content.Intent
3
4
import android.graphics.Bitmap
4
5
import android.net.Uri
5
6
import android.provider.OpenableColumns
6
7
import android.text.format.Formatter
8
+ import android.webkit.WebResourceRequest
9
+ import android.webkit.WebView
10
+ import android.webkit.WebViewClient
7
11
import androidx.activity.compose.rememberLauncherForActivityResult
8
12
import androidx.activity.result.contract.ActivityResultContracts
9
13
import androidx.compose.foundation.Image
10
14
import androidx.compose.foundation.background
15
+ import androidx.compose.foundation.isSystemInDarkTheme
11
16
import androidx.compose.foundation.layout.Box
12
17
import androidx.compose.foundation.layout.BoxWithConstraints
13
18
import androidx.compose.foundation.layout.Column
14
19
import androidx.compose.foundation.layout.Row
20
+ import androidx.compose.foundation.layout.fillMaxHeight
15
21
import androidx.compose.foundation.layout.fillMaxSize
16
22
import androidx.compose.foundation.layout.fillMaxWidth
17
23
import androidx.compose.foundation.layout.padding
@@ -22,6 +28,7 @@ import androidx.compose.foundation.lazy.items
22
28
import androidx.compose.foundation.lazy.rememberLazyListState
23
29
import androidx.compose.foundation.shape.CircleShape
24
30
import androidx.compose.foundation.shape.RoundedCornerShape
31
+ import androidx.compose.foundation.text.ClickableText
25
32
import androidx.compose.foundation.text.KeyboardOptions
26
33
import androidx.compose.material.icons.Icons
27
34
import androidx.compose.material.icons.automirrored.filled.Send
@@ -31,6 +38,7 @@ import androidx.compose.material3.Card
31
38
import androidx.compose.material3.CardDefaults
32
39
import androidx.compose.material3.DropdownMenu
33
40
import androidx.compose.material3.DropdownMenuItem
41
+ import androidx.compose.material3.HorizontalDivider
34
42
import androidx.compose.material3.Icon
35
43
import androidx.compose.material3.IconButton
36
44
import androidx.compose.material3.IconButtonDefaults
@@ -50,16 +58,22 @@ import androidx.compose.ui.Modifier
50
58
import androidx.compose.ui.draw.clip
51
59
import androidx.compose.ui.graphics.asImageBitmap
52
60
import androidx.compose.ui.platform.LocalContext
61
+ import androidx.compose.ui.text.AnnotatedString
62
+ import androidx.compose.ui.text.SpanStyle
53
63
import androidx.compose.ui.text.input.KeyboardCapitalization
54
64
import androidx.compose.ui.text.style.TextAlign
65
+ import androidx.compose.ui.text.style.TextDecoration
55
66
import androidx.compose.ui.unit.dp
67
+ import androidx.compose.ui.viewinterop.AndroidView
56
68
import androidx.lifecycle.compose.collectAsStateWithLifecycle
57
69
import androidx.lifecycle.viewmodel.compose.viewModel
58
- import com.google.firebase.ai.type.Content
70
+ import androidx.webkit.WebSettingsCompat
71
+ import androidx.webkit.WebViewFeature
59
72
import com.google.firebase.ai.type.FileDataPart
60
73
import com.google.firebase.ai.type.ImagePart
61
74
import com.google.firebase.ai.type.InlineDataPart
62
75
import com.google.firebase.ai.type.TextPart
76
+ import com.google.firebase.ai.type.WebGroundingChunk
63
77
import kotlinx.coroutines.launch
64
78
import kotlinx.serialization.Serializable
65
79
@@ -70,7 +84,7 @@ class ChatRoute(val sampleId: String)
70
84
fun ChatScreen (
71
85
chatViewModel : ChatViewModel = viewModel<ChatViewModel >()
72
86
) {
73
- val messages: List <Content > by chatViewModel.messages.collectAsStateWithLifecycle()
87
+ val messages: List <UiChatMessage > by chatViewModel.messages.collectAsStateWithLifecycle()
74
88
val isLoading: Boolean by chatViewModel.isLoading.collectAsStateWithLifecycle()
75
89
val errorMessage: String? by chatViewModel.errorMessage.collectAsStateWithLifecycle()
76
90
val attachments: List <Attachment > by chatViewModel.attachments.collectAsStateWithLifecycle()
@@ -162,17 +176,19 @@ fun ChatScreen(
162
176
163
177
@Composable
164
178
fun ChatBubbleItem (
165
- chatMessage : Content
179
+ message : UiChatMessage
166
180
) {
167
- val isModelMessage = chatMessage .role == " model"
181
+ val isModelMessage = message.content .role == " model"
168
182
169
- val backgroundColor = when (chatMessage.role) {
183
+ val isDarkTheme = isSystemInDarkTheme()
184
+
185
+ val backgroundColor = when (message.content.role) {
170
186
" user" -> MaterialTheme .colorScheme.tertiaryContainer
171
187
else -> MaterialTheme .colorScheme.secondaryContainer
172
188
}
173
189
174
190
val textColor = if (isModelMessage) {
175
- MaterialTheme .colorScheme.onSecondaryContainer
191
+ MaterialTheme .colorScheme.onBackground
176
192
} else {
177
193
MaterialTheme .colorScheme.onTertiaryContainer
178
194
}
@@ -196,7 +212,7 @@ fun ChatBubbleItem(
196
212
.fillMaxWidth()
197
213
) {
198
214
Text (
199
- text = chatMessage .role?.uppercase() ? : " USER" ,
215
+ text = message.content .role?.uppercase() ? : " USER" ,
200
216
style = MaterialTheme .typography.bodySmall,
201
217
modifier = Modifier .padding(bottom = 4 .dp)
202
218
)
@@ -212,7 +228,7 @@ fun ChatBubbleItem(
212
228
.padding(16 .dp)
213
229
.fillMaxWidth()
214
230
) {
215
- chatMessage .parts.forEach { part ->
231
+ message.content .parts.forEach { part ->
216
232
when (part) {
217
233
is TextPart -> {
218
234
Text (
@@ -272,16 +288,98 @@ fun ChatBubbleItem(
272
288
}
273
289
}
274
290
}
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
+ }
275
341
}
276
342
}
277
343
}
278
344
}
279
345
}
280
346
}
281
347
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
+
282
380
@Composable
283
381
fun ChatList (
284
- chatMessages : List <Content >,
382
+ chatMessages : List <UiChatMessage >,
285
383
listState : LazyListState ,
286
384
modifier : Modifier = Modifier
287
385
) {
@@ -470,4 +568,4 @@ fun AttachmentsList(
470
568
}
471
569
}
472
570
}
473
- }
571
+ }
0 commit comments