@@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.Check
28import androidx.compose.material.icons.filled.ContentCopy
28import androidx.compose.material.icons.filled.ContentCopy
29import androidx.compose.material.icons.filled.ExpandLess
29import androidx.compose.material.icons.filled.ExpandLess
30import androidx.compose.material.icons.filled.ExpandMore
30import androidx.compose.material.icons.filled.ExpandMore
31+import androidx.compose.material3.AlertDialog
31import androidx.compose.material3.Icon
32import androidx.compose.material3.Icon
32import androidx.compose.material3.IconButton
33import androidx.compose.material3.IconButton
33import androidx.compose.material3.MaterialTheme
34import androidx.compose.material3.MaterialTheme
@@ -36,6 +37,7 @@ import androidx.compose.material3.Text
36import androidx.compose.material3.TextButton
37import androidx.compose.material3.TextButton
37import androidx.compose.material3.Typography
38import androidx.compose.material3.Typography
38import androidx.compose.runtime.Composable
39import androidx.compose.runtime.Composable
40+import androidx.compose.runtime.CompositionLocalProvider
39import androidx.compose.runtime.LaunchedEffect
41import androidx.compose.runtime.LaunchedEffect
40import androidx.compose.runtime.getValue
42import androidx.compose.runtime.getValue
41import androidx.compose.runtime.mutableStateOf
43import androidx.compose.runtime.mutableStateOf
@@ -47,19 +49,28 @@ import androidx.compose.runtime.snapshotFlow
47import androidx.compose.ui.Alignment
49import androidx.compose.ui.Alignment
48import androidx.compose.ui.Modifier
50import androidx.compose.ui.Modifier
49import androidx.compose.ui.draw.clip
51import androidx.compose.ui.draw.clip
52+import androidx.compose.ui.graphics.Color
50import androidx.compose.ui.platform.LocalContext
53import androidx.compose.ui.platform.LocalContext
51import androidx.compose.ui.platform.LocalHapticFeedback
54import androidx.compose.ui.platform.LocalHapticFeedback
55+import androidx.compose.ui.platform.LocalUriHandler
56+import androidx.compose.ui.platform.UriHandler
52import androidx.compose.ui.res.pluralStringResource
57import androidx.compose.ui.res.pluralStringResource
53import androidx.compose.ui.res.stringResource
58import 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
54import androidx.compose.ui.text.AnnotatedString
62import androidx.compose.ui.text.AnnotatedString
63+import androidx.compose.ui.text.SpanStyle
55import androidx.compose.ui.text.TextStyle
64import androidx.compose.ui.text.TextStyle
56import androidx.compose.ui.text.font.FontFamily
65import androidx.compose.ui.text.font.FontFamily
57import androidx.compose.ui.unit.dp
66import androidx.compose.ui.unit.dp
58import kotlinx.coroutines.flow.conflate
67import kotlinx.coroutines.flow.conflate
59import kotlinx.coroutines.delay
68import kotlinx.coroutines.delay
69+import com.mikepenz.markdown.compose.LocalMarkdownAnnotator
60import com.mikepenz.markdown.compose.components.MarkdownComponentModel
70import com.mikepenz.markdown.compose.components.MarkdownComponentModel
61import com.mikepenz.markdown.compose.components.markdownComponents
71import com.mikepenz.markdown.compose.components.markdownComponents
62import com.mikepenz.markdown.m3.Markdown
72import com.mikepenz.markdown.m3.Markdown
73+import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
63import org.intellij.markdown.MarkdownElementTypes
74import org.intellij.markdown.MarkdownElementTypes
64import org.intellij.markdown.ast.ASTNode
75import org.intellij.markdown.ast.ASTNode
65import org.intellij.markdown.ast.getTextInNode
76import org.intellij.markdown.ast.getTextInNode
@@ -146,6 +157,54 @@ private fun MarkdownBody(
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) {
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(
195+ this.toString().substring(startLen, endLen),
149 if (streaming) {
208 if (streaming) {
150 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
209 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
151 val markdownState = rememberUpdatedState(markdown)
210 val markdownState = rememberUpdatedState(markdown)
@@ -182,24 +241,137 @@ private fun MarkdownBody(
182 // selection handles would flicker) on each throttle commit. Partial selection
241 // selection handles would flicker) on each throttle commit. Partial selection
183 // becomes available once the stream finishes; the per-message Copy button in
242 // becomes available once the stream finishes; the per-message Copy button in
184 // MessageBubble copies all TextParts at any time.
243 // MessageBubble copies all TextParts at any time.
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) {
185− Markdown(
248+ Markdown(
186− content = renderedContent,
249+ content = renderedContent,
187− modifier = modifier,
250+ modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
188− components = components,
251+ components = components,
190 // A blinking caret at the end of a streaming reply gives a "live" typewriter feel.
254 // A blinking caret at the end of a streaming reply gives a "live" typewriter feel.
191 // Skipped under reduced motion (where blinking is discouraged). It's a tiny separate
255 // Skipped under reduced motion (where blinking is discouraged). It's a tiny separate
192 // element so the markdown AST isn't re-parsed to toggle it.
256 // element so the markdown AST isn't re-parsed to toggle it.
193 if (!LocalReducedMotion.current) StreamingCaret()
257 if (!LocalReducedMotion.current) StreamingCaret()
259+ val content: @Composable () -> Unit = {
195− SelectionContainer {
260+ SelectionContainer {
196− Markdown(
261+ Markdown(
197− content = markdown,
262+ content = markdown,
198− modifier = modifier,
263+ modifier = modifier,
199− components = components,
264+ components = components,
268+ CompositionLocalProvider(LocalUriHandler provides safeUriHandler) {
269+ if (highlightAnnotator != null) {
270+ CompositionLocalProvider(LocalMarkdownAnnotator provides highlightAnnotator) {
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 ->
284+ canOpen = isSafeLinkScheme(url),
285+ onDismiss = { pendingLink = null },
288+ runCatching { defaultUriHandler.openUri(url) }
292+ copyToClipboard(context, context.getString(R.string.copy_link), url)
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).
305+private fun addSearchHighlights(
306+ builder: AnnotatedString.Builder,
312+ val q = query.trim()
313+ if (q.isEmpty()) return
314+ var idx = chunk.indexOf(q, ignoreCase = true)
317+ SpanStyle(background = color),
319+ baseOffset + idx + q.length,
321+ idx = chunk.indexOf(q, idx + q.length, ignoreCase = true)
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"
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.
338+private fun LinkActionDialog(
341+ onDismiss: () -> Unit,
346+ onDismissRequest = onDismiss,
350+ style = MaterialTheme.typography.bodySmall,
351+ color = MaterialTheme.colorScheme.onSurfaceVariant,
357+ stringResource(R.string.link_unsupported),
358+ style = MaterialTheme.typography.bodyMedium,
359+ color = MaterialTheme.colorScheme.error,
364+ androidx.compose.foundation.layout.Row {
365+ TextButton(onClick = onCopy) { Text(stringResource(R.string.copy_link)) }
367+ TextButton(onClick = onOpen) { Text(stringResource(R.string.open_link)) }
372+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }