git.iko.soy/opencode-android
on master help sign in
Batch of UI/UX improvements: add a TalkBack live region to streaming markdown, theme the home-screen widget for light/dark, fix the typing-row elapsed timer resetting on recycle by driving it from the VM, highlight in-conversation search matches inside message text, intercept markdown link taps with a scheme-validated open/copy dialog, and localize the destructive-command a11y state
commit0d4b9d8ea88f9c7ee1d08b437d4721d213e3c677 authoriko <mail@iko.soy> dateSat 4 Jul 2026, 06:35 (3 months ago) parentd15e78e
12 files changed+265−30
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+13−6 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+27−5 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4−1 app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.kt+3 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+183−11 app/src/main/res/drawable/widget_background.xml+4−2 app/src/main/res/layout/widget_session_item.xml+1−1 app/src/main/res/layout/widget_sessions.xml+4−4 app/src/main/res/values-es/strings.xml+5 app/src/main/res/values-night/colors.xml+7 app/src/main/res/values/colors.xml+9 app/src/main/res/values/strings.xml+5
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+13−6
@@ -165,6 +165,7 @@ import soy.iko.opencode.ui.components.ConnectionBanner
165165import soy.iko.opencode.ui.components.DiffView
166166import soy.iko.opencode.ui.components.PaletteAction
167167import soy.iko.opencode.ui.components.LocalReducedMotion
168+import soy.iko.opencode.ui.components.LocalSearchHighlight
168169import soy.iko.opencode.ui.components.reducedMotionAnimateItem
169170import soy.iko.opencode.ui.components.copyToClipboard
170171import soy.iko.opencode.ui.components.LocalRelativeTimeTick
@@ -1138,6 +1139,11 @@ fun ChatScreen(
11381139 val tts = rememberTtsController()
11391140 val speakingMessageId by tts.speakingId
11401141 val ttsState by tts.state
1142+ // Provide the in-conversation search query to the markdown renderer so matching
1143+ // text spans are highlighted in-place (in addition to the focused-row highlight
1144+ // above). Scoped to the list so cleared search stops highlighting immediately.
1145+ val activeSearchQuery = searchQuery.takeIf { searchActive && it.isNotEmpty() }
1146+ CompositionLocalProvider(LocalSearchHighlight provides activeSearchQuery) {
11411147 LazyColumn(
11421148 state = listState,
11431149 modifier = Modifier.fillMaxSize(),
@@ -1325,13 +1331,13 @@ fun ChatScreen(
13251331 if (running) {
13261332 item(key = "__typing") {
13271333 val workingText = stringResource(R.string.working)
1328− // Elapsed-since-run chip: the typing row only composes while a run is
1329− // active, so anchoring the timer to first composition approximates the
1330− // run start and gives the user a sense of how long the agent has been
1331− // working — distinguishing a healthy long run from a hung one. Updates
1334+ // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1335+ // survives LazyColumn recycling — a local remember reset to 0:00 when
1336+ // the row was disposed and scrolled back into view. The VM stamps the
1337+ // start on the real false→true transition (send/run/init), not on
1338+ // SSE-reconnect relight, so reconnects don't restart the clock. Updates
13321339 // once per second; the value is decorative (the a11y label is the row's).
1333− var startMs by remember { mutableLongStateOf(0L) }
1334− LaunchedEffect(Unit) { startMs = System.currentTimeMillis() }
1340+ val startMs by vm.runStartMs.collectAsStateWithLifecycle()
13351341 val elapsedMs by produceState(0L, startMs) {
13361342 if (startMs == 0L) return@produceState
13371343 while (true) {
@@ -1400,6 +1406,7 @@ fun ChatScreen(
14001406 }
14011407 }
14021408 }
1409+ }
14031410 // Jump-to-latest affordance when the user has scrolled away during a stream.
14041411 val fabMotion = rememberVisibilityTransitions()
14051412 AnimatedVisibility(
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+27−5
@@ -329,6 +329,28 @@ class ChatViewModel(
329329 private val _running = MutableStateFlow(false)
330330 val running: StateFlow<Boolean> = _running.asStateFlow()
331331
332+ /**
333+ * Wall-clock millis at which the current run started (0L when no run is active). Drives
334+ * the typing row's elapsed timer so it survives LazyColumn recycling — a local `remember`
335+ * in the row resets to 0:00 when the row is disposed and scrolled back into view. Stamped
336+ * only on a real false→[beginRun] transition (send/run/init), not on SSE-reconnect relight
337+ * (which continues an existing run and should keep its original elapsed time).
338+ */
339+ private val _runStartMs = MutableStateFlow(0L)
340+ val runStartMs: StateFlow<Long> = _runStartMs.asStateFlow()
341+
342+ /**
343+ * Atomically transition `_running` false→true and stamp the run-start timestamp. Returns
344+ * false (with no state change) when a run is already in flight, so callers can surface a
345+ * "busy" message instead of silently dropping the action. Centralizes the transition so
346+ * every run-start path captures [runStartMs] consistently.
347+ */
348+ private fun beginRun(): Boolean {
349+ if (!_running.compareAndSet(false, true)) return false
350+ _runStartMs.value = System.currentTimeMillis()
351+ return true
352+ }
353+
332354 /** True once the current run ended via SessionIdle/SessionError, until the next run starts
333355 * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
334356 * returns true for ANY message.part.updated, so without this a trailing/replayed part
@@ -931,7 +953,7 @@ class ChatViewModel(
931953 if (conn == null || !container.isOnline.value) {
932954 return enqueueOffline(trimmed, attachments)
933955 }
934− if (!_running.compareAndSet(false, true)) return false
956+ if (!beginRun()) return false
935957 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
936958 // indicator lit (and a later SessionIdle re-closes it).
937959 runEndedByIdle = false
@@ -1202,7 +1224,7 @@ class ChatViewModel(
12021224 val conn = connection ?: return
12031225 // A run is already in flight — surface feedback instead of silently dropping the
12041226 // command (the picker stays openable during a run and dismisses on select).
1205− if (!_running.compareAndSet(false, true)) {
1227+ if (!beginRun()) {
12061228 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
12071229 return
12081230 }
@@ -1416,7 +1438,7 @@ class ChatViewModel(
14161438 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
14171439 return
14181440 }
1419− if (!_running.compareAndSet(false, true)) {
1441+ if (!beginRun()) {
14201442 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
14211443 return
14221444 }
@@ -1435,7 +1457,7 @@ class ChatViewModel(
14351457 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
14361458 fun initProject() {
14371459 val conn = connection ?: return
1438− if (!_running.compareAndSet(false, true)) {
1460+ if (!beginRun()) {
14391461 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
14401462 return
14411463 }
@@ -1461,7 +1483,7 @@ class ChatViewModel(
14611483 ?: _agents.value.firstOrNull { it.isPrimary }?.name
14621484 ?: _agents.value.firstOrNull()?.name
14631485 ?: DEFAULT_SHELL_AGENT
1464− if (!_running.compareAndSet(false, true)) {
1486+ if (!beginRun()) {
14651487 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
14661488 return
14671489 }
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4−1
@@ -119,6 +119,9 @@ fun CommandPickerSheet(
119119 var query by rememberSaveable { mutableStateOf("") }
120120 val keyboardController = LocalSoftwareKeyboardController.current
121121 val haptics = LocalHapticFeedback.current
122+ // Resolved once (not per-row): the semantics lambda below isn't a composable scope,
123+ // so stringResource can't be called inside it.
124+ val destructiveStateLabel = stringResource(R.string.command_confirmation_required)
122125 // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
123126 // it for a short list that's faster to scan by eye than to filter.
124127 val searchFocus = remember { FocusRequester() }
@@ -183,7 +186,7 @@ fun CommandPickerSheet(
183186 }
184187 }
185188 .semantics {
186− if (destructive) stateDescription = "destructive"
189+ if (destructive) stateDescription = destructiveStateLabel
187190 }
188191 .padding(horizontal = 24.dp, vertical = 12.dp),
189192 ) {
app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.kt+3
@@ -9,12 +9,15 @@ import androidx.compose.material3.Typography
99 *
1010 * - [LocalChatTextScale]: a multiplier applied to chat/message/code font sizes.
1111 * - [LocalCodeWrap]: whether code blocks soft-wrap long lines (vs. horizontal scroll).
12+ * - [LocalSearchHighlight]: an in-conversation search query; when non-null, matching
13+ * spans in completed (non-streaming) markdown are highlighted in-place.
1214 *
1315 * Provided once near the app root from the persisted [soy.iko.opencode.data.repo.SettingsStore]
1416 * values. Defaults keep the design behavior when no provider is present (e.g. previews/tests).
1517 */
1618val LocalChatTextScale = compositionLocalOf { 1f }
1719val LocalCodeWrap = compositionLocalOf { false }
20+val LocalSearchHighlight = compositionLocalOf<String?> { null }
1821
1922/**
2023 * Return a copy of this [Typography] with every text role's font size and line height
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+183−11
@@ -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/**
app/src/main/res/drawable/widget_background.xml+4−2
@@ -1,6 +1,8 @@
11<?xml version="1.0" encoding="utf-8"?>
2−<!-- Rounded, slightly translucent Tokyo Night surface for the sessions widget. -->
2+<!-- Rounded, slightly translucent surface for the sessions widget. References a color
3+ resource so it follows the light/dark theme (values/ vs values-night/) instead of
4+ always rendering the dark Tokyo Night surface. -->
35<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
4− <solid android:color="#EE1F2335" />
6+ <solid android:color="@color/widget_background" />
57 <corners android:radius="16dp" />
68</shape>
app/src/main/res/layout/widget_session_item.xml+1−1
@@ -16,6 +16,6 @@
1616 android:layout_height="wrap_content"
1717 android:maxLines="1"
1818 android:ellipsize="end"
19− android:textColor="#C0CAF5"
19+ android:textColor="@color/widget_on_background"
2020 android:textSize="14sp" />
2121</LinearLayout>
app/src/main/res/layout/widget_sessions.xml+4−4
@@ -21,7 +21,7 @@
2121 android:layout_height="wrap_content"
2222 android:layout_weight="1"
2323 android:text="@string/widget_title"
24− android:textColor="#C0CAF5"
24+ android:textColor="@color/widget_on_background"
2525 android:textSize="15sp"
2626 android:textStyle="bold" />
2727
@@ -30,7 +30,7 @@
3030 android:layout_width="wrap_content"
3131 android:layout_height="wrap_content"
3232 android:text="@string/widget_new_session"
33− android:textColor="#7AA2F7"
33+ android:textColor="@color/widget_primary"
3434 android:textSize="13sp"
3535 android:textStyle="bold"
3636 android:paddingStart="12dp"
@@ -48,7 +48,7 @@
4848 android:id="@+id/widget_list"
4949 android:layout_width="match_parent"
5050 android:layout_height="match_parent"
51− android:divider="#33565F89"
51+ android:divider="@color/widget_divider"
5252 android:dividerHeight="1dp" />
5353
5454 <TextView
@@ -57,7 +57,7 @@
5757 android:layout_height="match_parent"
5858 android:gravity="center"
5959 android:text="@string/widget_empty"
60− android:textColor="#9AA5CE"
60+ android:textColor="@color/widget_on_surface_variant"
6161 android:textSize="13sp"
6262 android:visibility="gone" />
6363 </FrameLayout>
app/src/main/res/values-es/strings.xml+5
@@ -643,6 +643,11 @@
643643 <string name="shortcut_close">Cerrar diálogo / paleta</string>
644644 <string name="open_externally">Abrir externamente</string>
645645 <string name="open_externally_failed">No hay ninguna app para abrir este archivo</string>
646+ <!-- Markdown links -->
647+ <string name="open_link">Abrir</string>
648+ <string name="copy_link">Copiar enlace</string>
649+ <string name="link_unsupported">Tipo de enlace no admitido</string>
650+ <string name="command_confirmation_required">Confirmación requerida</string>
646651 <string name="sort_by">Ordenar por</string>
647652 <string name="sort_size">Tamaño</string>
648653 <string name="sort_modified">Modificación</string>
app/src/main/res/values-night/colors.xml+7
@@ -5,4 +5,11 @@
55 <color name="ic_launcher_background">#34548A</color>
66 <!-- Matches the Compose dark colorScheme background (#1A1B26). -->
77 <color name="window_background">#1A1B26</color>
8+
9+ <!-- Home-screen widget palette (dark). Mirrors the Tokyo Night dark colorScheme. -->
10+ <color name="widget_on_background">#C0CAF5</color>
11+ <color name="widget_primary">#7AA2F7</color>
12+ <color name="widget_on_surface_variant">#9AA5CE</color>
13+ <color name="widget_divider">#33565F89</color>
14+ <color name="widget_background">#EE1F2335</color>
815</resources>
app/src/main/res/values/colors.xml+9
@@ -5,4 +5,13 @@
55 <!-- Matches the Compose light colorScheme background so the pre-Compose window
66 doesn't flash a different shade than the UI paints. -->
77 <color name="window_background">#F6F7FB</color>
8+
9+ <!-- Home-screen widget palette (light). Mirrors the Tokyo Night Light colorScheme so
10+ the widget matches the app's theme instead of always showing the dark palette.
11+ values-night/colors.xml carries the dark counterparts. -->
12+ <color name="widget_on_background">#1A1B26</color>
13+ <color name="widget_primary">#34548A</color>
14+ <color name="widget_on_surface_variant">#444B6E</color>
15+ <color name="widget_divider">#33747DAA</color>
16+ <color name="widget_background">#EEF1F3FA</color>
817</resources>
app/src/main/res/values/strings.xml+5
@@ -644,6 +644,11 @@
644644 <string name="shortcut_close">Close dialog / palette</string>
645645 <string name="open_externally">Open externally</string>
646646 <string name="open_externally_failed">No app available to open this file</string>
647+ <!-- Markdown links -->
648+ <string name="open_link">Open</string>
649+ <string name="copy_link">Copy link</string>
650+ <string name="link_unsupported">Unsupported link type</string>
651+ <string name="command_confirmation_required">Confirmation required</string>
647652 <string name="sort_by">Sort by</string>
648653 <string name="sort_size">Size</string>
649654 <string name="sort_modified">Modified</string>