@@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.Check
2828import androidx.compose.material.icons.filled.ContentCopy
2929import androidx.compose.material.icons.filled.ExpandLess
3030import androidx.compose.material.icons.filled.ExpandMore
31+import androidx.compose.material3.AlertDialog
3132import androidx.compose.material3.Icon
3233import androidx.compose.material3.IconButton
3334import androidx.compose.material3.MaterialTheme
@@ -36,6 +37,7 @@ import androidx.compose.material3.Text
3637import androidx.compose.material3.TextButton
3738import androidx.compose.material3.Typography
3839import androidx.compose.runtime.Composable
40+import androidx.compose.runtime.CompositionLocalProvider
3941import androidx.compose.runtime.LaunchedEffect
4042import androidx.compose.runtime.getValue
4143import androidx.compose.runtime.mutableStateOf
@@ -47,19 +49,28 @@ import androidx.compose.runtime.snapshotFlow
4749import androidx.compose.ui.Alignment
4850import androidx.compose.ui.Modifier
4951import androidx.compose.ui.draw.clip
52+import androidx.compose.ui.graphics.Color
5053import androidx.compose.ui.platform.LocalContext
5154import androidx.compose.ui.platform.LocalHapticFeedback
55+import androidx.compose.ui.platform.LocalUriHandler
56+import androidx.compose.ui.platform.UriHandler
5257import androidx.compose.ui.res.pluralStringResource
5358import androidx.compose.ui.res.stringResource
59+import androidx.compose.ui.semantics.LiveRegionMode
60+import androidx.compose.ui.semantics.liveRegion
61+import androidx.compose.ui.semantics.semantics
5462import androidx.compose.ui.text.AnnotatedString
63+import androidx.compose.ui.text.SpanStyle
5564import androidx.compose.ui.text.TextStyle
5665import androidx.compose.ui.text.font.FontFamily
5766import androidx.compose.ui.unit.dp
5867import kotlinx.coroutines.flow.conflate
5968import kotlinx.coroutines.delay
69+import com.mikepenz.markdown.compose.LocalMarkdownAnnotator
6070import com.mikepenz.markdown.compose.components.MarkdownComponentModel
6171import com.mikepenz.markdown.compose.components.markdownComponents
6272import com.mikepenz.markdown.m3.Markdown
73+import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
6374import org.intellij.markdown.MarkdownElementTypes
6475import org.intellij.markdown.ast.ASTNode
6576import org.intellij.markdown.ast.getTextInNode
@@ -146,6 +157,54 @@ private fun MarkdownBody(
146157 )
147158 }
148159 }
160+ val context = LocalContext.current
161+ // Intercept link taps so the scheme can be validated before the library fires an
162+ // implicit ACTION_VIEW. A hallucinated javascript:/file:/intent: URL must not launch
163+ // an unexpected intent chooser. Taps are routed to a dialog offering Open (only for
164+ // http/https/mailto) and Copy link. The default handler is captured here and invoked
165+ // from the dialog's Open action.
166+ val defaultUriHandler = LocalUriHandler.current
167+ var pendingLink by remember { mutableStateOf<String?>(null) }
168+ val safeUriHandler = remember {
169+ object : UriHandler {
170+ override fun openUri(uri: String) {
171+ pendingLink = uri
172+ }
173+ }
174+ }
175+ // In-conversation search highlighting: when LocalSearchHighlight carries a query,
176+ // wrap the default MarkdownAnnotator to add a translucent background span over each
177+ // case-insensitive match in the rendered text. Skipped while streaming so the
178+ // throttled re-render isn't invalidated on every token. Offsets are derived from the
179+ // builder's actual rendered output (not the raw markdown), so highlights land on the
180+ // right characters even inside formatted spans.
181+ val searchQuery = LocalSearchHighlight.current?.takeIf { it.isNotBlank() }
182+ val highlightColor = MaterialTheme.colorScheme.secondaryContainer
183+ val baseAnnotator = LocalMarkdownAnnotator.current
184+ val highlightAnnotator = if (searchQuery != null && !streaming) {
185+ remember(baseAnnotator, searchQuery, highlightColor) {
186+ DefaultMarkdownAnnotator(
187+ annotate = { text, node ->
188+ // `this` is the AnnotatedString.Builder being filled for this node.
189+ val startLen = this.length
190+ val handled = baseAnnotator.annotate?.invoke(this, text, node) ?: false
191+ val endLen = this.length
192+ if (endLen > startLen) {
193+ addSearchHighlights(
194+ this,
195+ this.toString().substring(startLen, endLen),
196+ startLen,
197+ searchQuery,
198+ highlightColor,
199+ )
200+ }
201+ handled
202+ },
203+ )
204+ }
205+ } else {
206+ null
207+ }
149208 if (streaming) {
150209 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
151210 val markdownState = rememberUpdatedState(markdown)
@@ -182,24 +241,137 @@ private fun MarkdownBody(
182241 // selection handles would flicker) on each throttle commit. Partial selection
183242 // becomes available once the stream finishes; the per-message Copy button in
184243 // MessageBubble copies all TextParts at any time.
185− Markdown(
186− content = renderedContent,
187− modifier = modifier,
188− components = components,
189− )
244+ // A polite live region announces streaming content to TalkBack users. The throttle
245+ // (50ms) plus TalkBack's own polite-queue coalescing keeps announcements from
246+ // interrupting on every token; the reader hears the reply grow in measured chunks.
247+ CompositionLocalProvider(LocalUriHandler provides safeUriHandler) {
248+ Markdown(
249+ content = renderedContent,
250+ modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
251+ components = components,
252+ )
253+ }
190254 // A blinking caret at the end of a streaming reply gives a "live" typewriter feel.
191255 // Skipped under reduced motion (where blinking is discouraged). It's a tiny separate
192256 // element so the markdown AST isn't re-parsed to toggle it.
193257 if (!LocalReducedMotion.current) StreamingCaret()
194258 } else {
195− SelectionContainer {
196− Markdown(
197− content = markdown,
198− modifier = modifier,
199− components = components,
200− )
259+ val content: @Composable () -> Unit = {
260+ SelectionContainer {
261+ Markdown(
262+ content = markdown,
263+ modifier = modifier,
264+ components = components,
265+ )
266+ }
267+ }
268+ CompositionLocalProvider(LocalUriHandler provides safeUriHandler) {
269+ if (highlightAnnotator != null) {
270+ CompositionLocalProvider(LocalMarkdownAnnotator provides highlightAnnotator) {
271+ content()
272+ }
273+ } else {
274+ content()
275+ }
201276 }
202277 }
278+ // Open/copy dialog for the most recently tapped markdown link. Offer Open only for
279+ // schemes the app is willing to hand to the system (http/https/mailto); for anything
280+ // else, copy is the only escape so a model hallucination can't fire an unexpected intent.
281+ pendingLink?.let { url ->
282+ LinkActionDialog(
283+ url = url,
284+ canOpen = isSafeLinkScheme(url),
285+ onDismiss = { pendingLink = null },
286+ onOpen = {
287+ pendingLink = null
288+ runCatching { defaultUriHandler.openUri(url) }
289+ },
290+ onCopy = {
291+ pendingLink = null
292+ copyToClipboard(context, context.getString(R.string.copy_link), url)
293+ },
294+ )
295+ }
296+}
297+
298+/**
299+ * Adds a translucent background span over each case-insensitive occurrence of [query] found
300+ * in [chunk], mapping the chunk-relative offsets into the builder via [baseOffset]. Called
301+ * from the search-highlight annotator wrapper; [chunk] is the text the default annotator
302+ * appended to the builder for a single markdown node, so the offsets line up with what the
303+ * user actually sees (markdown syntax already stripped).
304+ */
305+private fun addSearchHighlights(
306+ builder: AnnotatedString.Builder,
307+ chunk: String,
308+ baseOffset: Int,
309+ query: String,
310+ color: Color,
311+) {
312+ val q = query.trim()
313+ if (q.isEmpty()) return
314+ var idx = chunk.indexOf(q, ignoreCase = true)
315+ while (idx >= 0) {
316+ builder.addStyle(
317+ SpanStyle(background = color),
318+ baseOffset + idx,
319+ baseOffset + idx + q.length,
320+ )
321+ idx = chunk.indexOf(q, idx + q.length, ignoreCase = true)
322+ }
323+}
324+
325+/** Schemes the app is willing to forward to the system's URI handler from a markdown link. */
326+private fun isSafeLinkScheme(uri: String): Boolean {
327+ val scheme = uri.substringBefore(':', "").lowercase()
328+ return scheme == "http" || scheme == "https" || scheme == "mailto"
329+}
330+
331+/**
332+ * Dialog shown when the user taps a markdown link. For http/https/mailto URIs, offers Open
333+ * (via the platform default handler) and Copy link. For any other scheme, only Copy is
334+ * offered (with an "unsupported link type" note) so a hallucinated javascript:/file: URL
335+ * can't launch an unexpected intent.
336+ */
337+@Composable
338+private fun LinkActionDialog(
339+ url: String,
340+ canOpen: Boolean,
341+ onDismiss: () -> Unit,
342+ onOpen: () -> Unit,
343+ onCopy: () -> Unit,
344+) {
345+ AlertDialog(
346+ onDismissRequest = onDismiss,
347+ title = {
348+ Text(
349+ text = url,
350+ style = MaterialTheme.typography.bodySmall,
351+ color = MaterialTheme.colorScheme.onSurfaceVariant,
352+ )
353+ },
354+ text = {
355+ if (!canOpen) {
356+ Text(
357+ stringResource(R.string.link_unsupported),
358+ style = MaterialTheme.typography.bodyMedium,
359+ color = MaterialTheme.colorScheme.error,
360+ )
361+ }
362+ },
363+ confirmButton = {
364+ androidx.compose.foundation.layout.Row {
365+ TextButton(onClick = onCopy) { Text(stringResource(R.string.copy_link)) }
366+ if (canOpen) {
367+ TextButton(onClick = onOpen) { Text(stringResource(R.string.open_link)) }
368+ }
369+ }
370+ },
371+ dismissButton = {
372+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
373+ },
374+ )
203375}
204376
205377/**