git.iko.soy/opencode-android
on master help sign in
Fix 4 bugs: ripgrep byte-offset highlighting for non-ASCII, session-list local-timezone date grouping, orphaned error group summary on reply-failed notification, unbounded dayKeyCache leak across sessions
commit1181007fb4b05c45b868c773b210d4228160324a authoriko <mail@iko.soy> dateTue 7 Jul 2026, 06:44 (3 months ago) parent8b41e1c
4 files changed+71−16
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+8 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+24−6 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+28−4 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+11−6
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+8
@@ -379,9 +379,17 @@ object SessionNotifications {
379379 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
380380 .setCategory(NotificationCompat.CATEGORY_ERROR)
381381 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
382+ // Group with the other error notifications (mirrors postError/postOutboxDropped)
383+ // so this notification joins the GROUP_ERROR stack and the group summary stays
384+ // consistent. Without .setGroup, reusing the NS_ERROR id space would overwrite a
385+ // prior postError for this session without the group association, leaving an
386+ // orphaned SUMMARY_ERROR_ID group summary with no children lingering in the shade.
387+ .setGroup(GROUP_ERROR)
382388 .build()
383389 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
384390 .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
391+ runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
392+ .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
385393 }
386394
387395 /** A queued outbox message was permanently undeliverable (e.g. the target session was
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+24−6
@@ -3260,16 +3260,34 @@ private fun buildMessageListItems(
32603260 * synchronized access-ordered LinkedHashMap reordered its linked list node on every
32613261 * `getOrPut` hit, which is real work multiplied by N messages × ~20 snapshots/sec during
32623262 * streaming. A ConcurrentHashMap has no access-order bookkeeping and is lock-free for
3263− * reads, so the common case (a hit) is a single volatile read + hash probe. The bound
3264− * is enforced via [removeEldestEntry] is not available on ConcurrentHashMap, so we cap
3265− * via `compute`-guarded size checks; in practice the cache size is bounded by the number
3266− * of distinct message timestamps in a session (≤ [NetworkConfig.maxInMemoryMessages]).
3263+ * reads, so the common case (a hit) is a single volatile read + hash probe.
3264+ * [removeEldestEntry] is not available on ConcurrentHashMap, so the bound is enforced via
3265+ * a size check in [dayKey]: once the cap ([DAY_KEY_CACHE_MAX]) is reached the cache is
3266+ * cleared; in practice the cache size is bounded by the number of distinct message
3267+ * timestamps in a session (≤ [NetworkConfig.maxInMemoryMessages]) and the cap is a
3268+ * safety valve for the process-lifetime cache across many sessions.
32673269 */
32683270private val dayKeyCache = java.util.concurrent.ConcurrentHashMap<Long, String>(256)
32693271
3270−private fun dayKey(epochMillis: Long): String = dayKeyCache.getOrPut(epochMillis) {
3271− java.time.Instant.ofEpochMilli(epochMillis)
3272+/** Upper bound on [dayKeyCache] entries. Message timestamps are immutable once assigned, so
3273+ * the natural bound is the number of distinct timestamps in a session (≤
3274+ * [NetworkConfig.maxInMemoryMessages]); this cap is a safety valve for the process-lifetime
3275+ * cache across many sessions, keeping a runaway leak from accumulating unbounded memory. */
3276+private const val DAY_KEY_CACHE_MAX = 1024
3277+
3278+private fun dayKey(epochMillis: Long): String {
3279+ val cached = dayKeyCache[epochMillis]
3280+ if (cached != null) return cached
3281+ val value = java.time.Instant.ofEpochMilli(epochMillis)
32723282 .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
3283+ // Guard the size under the cache's concurrency: only the first thread to see the cap
3284+ // exceeded evicts (draining is O(N) but rare and idempotent), so concurrent inserts
3285+ // past the cap don't each pay the scan. computeIfAbsent would hold the segment lock
3286+ // during the (cheap) date computation but can't express the size guard, so use put
3287+ // after the fact; a duplicate put of the same key/value is harmless.
3288+ if (dayKeyCache.size >= DAY_KEY_CACHE_MAX) dayKeyCache.clear()
3289+ dayKeyCache[epochMillis] = value
3290+ return value
32733291}
32743292
32753293// The medium-date formatter is an ICU locale lookup; cache one instance instead of
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+28−4
@@ -598,15 +598,39 @@ private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, on
598598 }
599599}
600600
601−/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
602− * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
601+/** Build the matched line with each ripgrep submatch bolded. Ripgrep reports submatch
602+ * offsets as UTF-8 *byte* columns within the line, but String indexing is by UTF-16 char,
603+ * so the raw offsets must be translated to char indices before substring(); otherwise any
604+ * line containing non-ASCII characters would highlight the wrong span (or, when clamped,
605+ * nothing at all). */
603606private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
604607 val line = match.lineText
608+ // Precompute the byte-offset → char-index mapping once per call: walk the line tracking
609+ // the cumulative UTF-8 byte length of each code point, recording the char index at each
610+ // byte boundary a submatch can land on. Submatches per line are few, but a per-submatch
611+ // walk would re-scan the prefix each time; this builds a single lookup table.
612+ val byteToChar = java.util.TreeMap<Int, Int>()
613+ byteToChar[0] = 0
614+ var bytes = 0
615+ var i = 0
616+ while (i < line.length) {
617+ val cp = line.codePointAt(i)
618+ bytes += when (cp) {
619+ in 0..0x7F -> 1
620+ in 0x80..0x7FF -> 2
621+ in 0x800..0xFFFF -> 3
622+ else -> 4
623+ }
624+ i += if (cp >= 0x10000) 2 else 1
625+ byteToChar[bytes] = i
626+ }
605627 return buildAnnotatedString {
606628 var cursor = 0
607629 for (sub in match.submatches.sortedBy { it.start }) {
608− val start = sub.start.coerceIn(0, line.length)
609− val end = sub.end.coerceIn(start, line.length)
630+ // floorEntry maps a byte offset to the char index at or before it; an offset past
631+ // the end maps to line.length via the null fallback.
632+ val start = (byteToChar.floorEntry(sub.start)?.value ?: line.length).coerceIn(0, line.length)
633+ val end = (byteToChar.floorEntry(sub.end)?.value ?: line.length).coerceIn(start, line.length)
610634 if (start > cursor) append(line.substring(cursor, start))
611635 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
612636 cursor = end
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+11−6
@@ -1136,15 +1136,20 @@ private sealed interface SessionListEntry {
11361136}
11371137
11381138/** Bucket a session's last-activity timestamp into a display group. Returns null when no
1139− * timestamp is available (the session is then grouped under "Older"). */
1139+ * timestamp is available (the session is then grouped under "Older"). Day boundaries are
1140+ * resolved in the device's local timezone (not UTC) so a session near local midnight lands
1141+ * in the right "today"/"yesterday" bucket for users outside UTC. */
11401142private fun sessionDateGroup(session: soy.iko.opencode.data.model.Session, now: Long): String {
11411143 val ts = session.time?.updated ?: session.time?.created ?: return "older"
1142− val dayMs = 24L * 60 * 60 * 1000
1143− val todayStart = (now / dayMs) * dayMs
1144+ val today = java.time.Instant.ofEpochMilli(now)
1145+ .atZone(java.time.ZoneId.systemDefault()).toLocalDate()
1146+ val sessionDay = java.time.Instant.ofEpochMilli(ts)
1147+ .atZone(java.time.ZoneId.systemDefault()).toLocalDate()
1148+ val daysBetween = java.time.Period.between(sessionDay, today).days
11441149 return when {
1145− ts >= todayStart -> "today"
1146− ts >= todayStart - dayMs -> "yesterday"
1147− ts >= todayStart - 7 * dayMs -> "last_week"
1150+ daysBetween <= 0 -> "today"
1151+ daysBetween == 1 -> "yesterday"
1152+ daysBetween <= 7 -> "last_week"
11481153 else -> "older"
11491154 }
11501155}