- Cache the AICore FeatureStatus check per service instance — was
re-running an RPC on every image attach.
- Two-pass decode with inSampleSize so 12 MP camera shots become
~1024 px before we hand them to the describer (avoids 40+ MB
ARGB_8888 allocations and the GC churn that follows).
- Switch ML Kit clients to var + lazy-on-first-use so close() no
longer triggers init for clients we never invoked.
- Wrap the composable's suggestAltText call in try/finally so a
cancellation mid-inference resets the spinner state.
Adds com.google.mlkit:genai-image-description as the primary alt-text
source — Gemini Nano via AICore produces full descriptive sentences on
supported devices. When checkFeatureStatus reports anything other than
AVAILABLE (or AICore is missing), the service falls back to the legacy
play-services-mlkit-image-labeling keyword join. Both paths sit behind
the same MLKitImageLabelService.suggestAltText API; the F-Droid stub is
unchanged.
Wire ML Kit image labeling into the media-attach dialog so the alt-text
field is prefilled with a confidence-filtered, comma-separated label
list when the user picks an image and the field is still empty. A
spinner shows during labeling and a dismissible "AI-suggested, edit me"
chip lets the user revert. Play flavor uses
play-services-mlkit-image-labeling; F-Droid ships a no-op stub.
11 cases driving the predicate matrix against a real TextFieldState on
device:
- mention-free text passes through
- pure delete fully covering a mention is allowed
- partial overlap (at start, at end, inside) collapses atomically
- scope-exact replace with non-empty text collapses (SwiftKey case)
- scope-broader replace passes through
- append after mention preserves it
- trailing space and trailing newline are consumed during atomic collapse
- multiple mentions: only the touched one collapses
- cheap-gate path (mention-free original) is verified
All 11 pass on Pixel 9a; gives the predicate a regression net so future
predicate-tuning doesn't reintroduce the @Vitor Pamplona bug.
Run via:
./gradlew :amethyst:connectedPlayDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=\
com.vitorpamplona.amethyst.MentionPreservingInputTransformationTest
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(compose): tighten @OptIn scope from @file to the object
fix(compose): allow full-cover changes through; collapse only on partial overlap
refactor(compose): hoist MENTION_REGEX, fast-path mention-free text, drop redundant scaffolding
fix(compose): also collapse mention atomically on full-range non-empty replaces
fix(compose): atomically delete the whole mention on partial-overlap edits
fix(compose): opt-in ExperimentalFoundationApi in MentionPreservingInputTransformation
Address review findings:
- Add trap for temp dir cleanup on error
- Use -Zxz for max distro compatibility (older dpkg lacks zstd)
- Use --root-owner-group for correct file ownership
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Microsoft SwiftKey re-enters word-edit mode over a previously-committed
display token after autocorrect-on-space, then issues setComposingText
with a shortened version. Compose's auto-derived offset mapping for
OutputTransformation uses identity inside a wedge, so the IME's
replacement only overwrites the leading characters of the underlying
@npub1... bech32, leaving an orphan tail that no longer matches the
mention regex. The wedge collapses, the orphan bech32 becomes visible,
and the cursor lands in the middle of it. Gboard never enters word-edit
mode for previously-committed tokens, so it doesn't trigger this.
Add MentionPreservingInputTransformation that runs on every input
change and reverts any edit whose original-text range partially
intersects a complete mention without fully covering it. The mention
stays atomic; the IME re-reads the unchanged buffer and moves on.
Wire it into all OutputTransformation-using fields: chats, new note,
group DM, public channel, public message, classifieds, long-form.
https://claude.ai/code/session_01LVmmGa3Npuv2d1eeYm9BdZ
The custom OffsetMapping for the @-mention VisualTransformation used
percentage-based interpolation when the cursor offset fell inside a
substituted "@npub1..." range. An IME using extracted-text mode (e.g.
SwiftKey on Pixel 9a) could place the cursor in the middle of the
displayed "@DisplayName", which mapped to the middle of the underlying
bech32 npub. A subsequent backspace then deleted a char from inside
the bech32, the npub stopped matching the regex's 58-char length
check, and the collapsed mention "expanded" with the cursor stuck in
the middle of the now-visible raw npub.
Treat each substitution as an atomic wedge: any cursor strictly inside
a substituted range snaps to the wedge's trailing edge in both
directions. Tests are rewritten to verify the snap-to-boundary
semantics; the prior assertions pinned the buggy percentage behavior.
This fixes the cursor-jump-into-npub symptom in EditPostView and
ForwardZapTo (which use VisualTransformation directly). Chat input
fields use OutputTransformation with Compose's auto-derived mapping
and are not affected by this code path.
https://claude.ai/code/session_01LVmmGa3Npuv2d1eeYm9BdZ
Neither AccountInfoAndListsFromKeyKinds2 nor BasicAccountInfoKinds2 included
InterestSetEvent.KIND, so a fresh login on a new device wouldn't pull the
user's existing interest sets — they only showed up if the device already
had them in cache or the user re-created them locally. The spinner's
INTEREST_SETS group would silently be empty.
Also bump the AccountInfoAndListsFromKeyKinds2 limit from 20 to 80 so the
combined list of NIP-51 lists (10 kinds, now 11) actually fits.
The dialog used to hand the caller an integer index into the latest options
list, but the indexes were captured from a snapshot taken at remember time.
If options changed (a new community/list arrived) between dialog open and
tap, the user could pick "Community A" and have an unrelated entry selected.
Pass the resolved FeedDefinition directly so the picked item can never drift.
Other audit fixes folded into the same composable:
- Match the placeholder by both subclass and code string so TopFilter
variants that share an Address-derived code (PeopleList vs MuteList)
no longer collide.
- Drop the local mutableStateOf for `selected` and the derivedStateOf-in-
remember for `currentText` — both were redundant with the StateFlow
round-trip and caused an extra recomposition per pick.
- De-duplicate RenderOption with Name.name(context) (also fixes the
accessibility text disagreeing with the visible label for Geohash).
- Pre-compute the ordered (group, items) list once per options change.
- Drop IndexedFeedDefinition (no longer needed), use Spacer.width instead
of a Spacer with start padding.
Property-level @OptIn doesn't propagate through the lazy{} delegate body,
so lint flags the DataSourceBitmapLoader.Builder chain (lines 87-90) with
UnsafeOptInUsageError. File-level annotation is a one-line fix that lets
:amethyst:lintPlayDebug pass without changing runtime semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cases removed:
- Trivial Modifier allocations (Modifier.weight/padding/size) — slot table
cost dominates the cost of building a fresh Modifier each recomposition.
- Map.keys views over relayStatuses on desktop screens — .keys is a
property read on the same map, no need to memoize.
- coerceIn() arithmetic on AudioWaveform Dp/Float params — two compares
are cheaper than the slot table read+compare.
- fadeIn()/fadeOut() in AnimatedVisibility — small EnterTransition
allocations that don't justify the slot table overhead.
Audit-only changes; no behavior changes.
https://claude.ai/code/session_011Ea2pVjwvCEx7X4izwryV4
Android Lint's UnsafeOptInUsageError doesn't recognize @OptIn placed on a
`by lazy` property as covering the lambda body, so each Media3 unstable-API
call inside the initializer (Builder, setExecutorService, setDataSourceFactory,
build) was flagged. Move the construction into a real function carrying the
@OptIn annotation; the lazy delegate just calls it. No behavior change.
The previous fix used `"ch:${id}"`, `"dm:${users.sorted().joinToString}"`
etc., which allocates a StringBuilder + char[] + new String per call —
worst case for the DM branch which also allocates a sorted List on top.
Replace with a sealed `ChatroomLazyKey` and per-type data classes that
just wrap the existing String / RoomId / ChatroomKey. Equality and
hashCode are auto-generated, so Compose still moves rows correctly on
reorder, and we drop most of the per-key allocations:
ch:abc -> PublicChannelLazyKey(abc) # 1 wrapper, reused String
dm:userA,userB -> PrivateChatLazyKey(chatroomKey) # 1 wrapper, reused ChatroomKey
eph:roomId -> EphemeralChannelLazyKey(roomId) # 1 wrapper, reused RoomId
The chatroom list keyed each row by `if (index == 0) index else item.idHex`.
Two problems:
1. Position 0 was hardcoded to key `0`, so when a new chatroom moved to
the top, the existing composition slot was reused with state from the
previous chatroom — observed as "the row updated and reordered but
still shows the old result" right at the top.
2. For other positions, the key was the latest message's `idHex`. When a
new message arrived in any chatroom the chatroom's representative
Note got replaced (different idHex), so Compose threw away the row
and rebuilt it from scratch — wasted work.
Fix: derive a stable key from chatroom identity instead of message id —
nostr group id for marmot rooms, channel id for public/ephemeral
channels, sorted user set for DMs. Falls back to `item.idHex` for
unrecognized event types (drafts etc.). Reorders now move the row;
new-message updates re-use the slot.
Follow-up to the first perf pass. Same goal: cut allocation cost for
note rows that scroll inside LazyColumn feeds.
- AppDefinition: key the `remember { tags.toImmutableListOfLists() }`
block by `note` so it actually invalidates when the note changes
- NIP90ContentDiscoveryResponse: drop the `remember(note) {
Modifier.fillMaxWidth() }` wrapper — `Modifier.fillMaxWidth()` is a
constant call
- PeopleList: key the `derivedStateOf` for `name` by `noteEvent`, and
switch `LaunchedEffect(Unit)` to `LaunchedEffect(noteEvent)` so the
participants reload when the underlying event changes
- PinList: replace `val pins by remember { mutableStateOf(noteEvent
.pinnedEvents()) }` with `val pins = remember(noteEvent) { … }` —
the `mutableStateOf` wrapper was unnecessary and the missing key
meant `pins` could go stale on event updates
- LongForm: return the `topics` list as `ImmutableList` so Compose
treats it as a stable parameter to the consuming `forEach`
- RelayList: drop the `mutableStateOf(RelayListCard(…))` wrap inside
4 `remember` blocks (DisplayRelaySet, DisplayNIP65RelayList write/
read, DisplayDMRelayList) — the value never changes after creation;
also key by `noteEvent` rather than `baseNote`, and cache
`noteEvent.description()`
- Torrent: wrap `noteEvent.title() + totalSizeBytes()`, content
comparison and `files().toImmutableList()` in `remember(noteEvent)`
so they don't recompute and reallocate on every recomposition
Reduce per-recomposition allocation cost for note rows that render inside
LazyColumn feeds:
- AudioTrack: add `noteEvent` keys to `remember` for media/cover/subject/
participants/waveform/content so the cached values invalidate when the
underlying event changes
- Classifieds: wrap `imageMetas().map { MediaUrlImage(...) }`, title,
summary, price and location in `remember(noteEvent)` so they aren't
recomputed on every recomposition; hoist the static price-tag modifier
to a top-level `val`
- Report: collapse the per-recomposition `map { stringRes(...) }` chain
into a single `remember(reportTypes, noteEvent)` over a deduplicated
set of report types, and key the `base` collection by `noteEvent`
- Highlight: key the URL-parse `remember` by `url` so it actually
re-validates when the parameter changes
- PrivateMessage: key `remember { noteEvent.with(...) }` by `noteEvent`,
drop the silly `remember { Modifier.fillMaxWidth() }` wrapper, and
key `isLoggedUser` by `note.author` instead of `note.event?.id`
- PictureDisplay / FileHeader / Video: drop the unnecessary
`mutableStateOf(...)` wrap inside `remember` blocks that produce
immutable `BaseMediaContent` values; cache `images.map { it.url }`
preload list, and key `title`/`summary`/`image`/`isYouTube` by event
- Poll: add the missing `it.label` and `card` keys to `remember` blocks
that derive booleans from those parameters
- MeetingSpace: hoist the three `MeetingSpace*Flag` modifier chains to
top-level `val`s instead of allocating them each composition
The remember(accountViewModel) { ... } I added was cargo-cult. The
getter is just a chain of val property accesses
(account.settings.syncedSettings.videoPlayer.buttonItems) returning the
same StateFlow instance every call. collectAsStateWithLifecycle keys on
that flow reference, which is identity-stable, so re-calling the getter
on every recompose costs nothing meaningful and doesn't cause a
re-subscription. Inline back to the original one-liner.
- GifVideoView: revert the dimensions remember() to the original one-line
expression. Unlike VideoView's equivalent block, GifVideoView only
*reads* — there's no MediaAspectRatioCache.add() side effect to gate.
The replaced code spent three slot reads + three equality checks per
recompose to skip an int division and an LruCache.get(), neither of
which allocates. It was a wash at best, a small loss at worst. The
original is simpler and roughly the same cost.
- PlaybackServiceClient: bump the executor from newSingleThreadExecutor()
back up to newFixedThreadPool(4). The work per listener is genuinely
trivial in the steady state, but a single thread leaves us exposed to
one stuck listener (e.g. the defensive 5s controllerFuture.get()
timeout actually firing) stalling every other video on screen behind
it. With a feed often holding several visible videos at once, that's
a real regression risk. A fixed pool of 4 keeps us bounded against
churn while letting independent listeners proceed in parallel.
Round-up of the small leftovers from the audit. None move the needle on
their own; together they remove a real cancellation bug and tighten the
playback types.
- PlaybackServiceClient.executorService: Executors.newCachedThreadPool()
→ Executors.newSingleThreadExecutor(). The work per callback is
Future.get() on an already-completed future plus a non-blocking
trySend; a single thread is plenty. The previous unbounded pool could
spin up a thread per concurrent video, each lingering for the 60 s
keep-alive afterwards.
- MediaControllerState.controller: var → val. The field was never
reassigned anywhere (grep confirms), and a non-observable var on a
@Stable class is a footgun — Compose can't see writes to a plain var,
so any future write would silently miss recomposition.
- MediaControllerState.currrentMedia() → currentMedia(). Typo. Updated
the single caller in PipVideoView.
- LoadThumbAndThenVideoView: real cancellation bug fix. The Coil fetch
was launched into AccountViewModel.viewModelScope via a side helper
(loadThumb), so a scroll-away didn't cancel the in-flight image
request — wasted bandwidth and a late callback writing into stale
state. Inline the Coil call into the LaunchedEffect's own scope so
cancellation propagates, and key the effect on thumbUri so a recycled
audio-track slot with a new cover doesn't stall on the prior
Pair(true, ...) gate. Drop the now-unused AccountViewModel.loadThumb
and its only-here imports.
Pure-readability refactor — no behaviour change, all 410 unit tests still pass.
TranslatableRichTextViewer.kt (358 → 192 lines)
- Extract the in-line LaunchedEffect block (cache check + ML Kit await + cancellation
bridge + result validation + caching) into a private `suspend translateAndCache`
function. The effect body is now four lines: try/catch around one call.
- Add a small `ResultOrError.toTranslationConfig(content)` extension that returns a
TranslationConfig only when an actual translation took place, replacing the
five-condition inline if/else inside the effect.
- Move TranslationMessage / LangSettingsDropdown / CheckmarkRow out to a sibling
file (TranslationStatusBar.kt). They render the "Translated from X to Y" footer
and don't belong in the orchestrator file.
TranslationStatusBar.kt (new)
- Renamed the public composable to `TranslationStatusBar` to make its role obvious.
- Split the status text and the dropdown into separate private composables so each
fits on screen at a glance.
- Add a tiny `LangMenuItem(checked, label, onClick)` to dedupe the four
`DropdownMenuItem { text = { CheckmarkRow(...) }, onClick = ... }` blocks.
- Hoist `rememberDeviceLocales()` out of the dropdown body for clarity.
- Cache `settings.preferenceBetween(source, target)` once per dropdown render
instead of calling it twice with identical args.
LanguageTranslatorService.kt
- Extract the in-flight cache plumbing into a `private inline fun dedupe(key, factory)`
helper. `autoTranslate` is now three lines that read top-to-bottom:
pre-filter, dedupe, identifyLanguage → translateOrSkip.
- Promote the inline `when` deciding whether to translate (matches translateTo,
is "und", is in dontTranslateFrom) into a named `translateOrSkip` function so
the policy is greppable.
TranslationDictionary.kt
- Add a `private inline fun Pattern.forEachMatch(text, block)` extension.
The four near-identical `val matcher = …; while (matcher.find()) addUnique(matcher.group())`
loops collapse to three one-liners; the URL detector loop stays explicit because
it has its own filter.
`inline` on dedupe and forEachMatch keeps the lambda allocations gone, so this is
a zero-cost refactor at runtime.
https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
- Add a Concurrency section to the SQLite store README covering the
Room-style 1-writer + N-reader pool, the in-memory degradation, and
the non-reentrant Mutex contract.
- Refresh the SQLite "How to Use" examples to call out the suspend
context and recommend transaction-batching for hot inserts.
- Switch the ExpirationWorker example from Worker to CoroutineWorker
now that deleteExpiredEvents is suspend.
- Note in the FS README that the IEventStore API is suspend even
though the FS layer keeps a synchronous flock manager (the
withWriteLock helper is inline so suspend bodies pass through).
- Update the FsMaintenanceTest description to match the
coroutine-based concurrency test.
- Document the Mutex non-reentrancy footgun in
SQLiteConnectionPool's KDoc so module logic doesn't try to re-enter
the pool from inside useWriter.
https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5
androidx.sqlite SQLiteConnection is not thread-safe; SQLiteEventStore
shared a single lazy connection across all callers, so two coroutines
calling insertEvent() at the same time would race on BEGIN IMMEDIATE
and the modules' prepared statements, surfacing as
"cannot start a transaction within a transaction" or SQLITE_MISUSE.
Mirror Room's design: introduce SQLiteConnectionPool with one writer
connection guarded by a coroutine Mutex and N reader connections
handed out via a Channel-as-semaphore (file-backed DBs only; in-memory
DBs share the writer because each ":memory:" connection is a separate
DB). Convert IEventStore + SQLiteEventStore + EventStore + FsEventStore
+ LiveEventStore to suspend, route writes through useWriter and reads
through useReader. RelaySession now launches handleEvent / handleCount
on its scope. CLI Context helpers and StoreCommands.sweepExpired pick
up suspend.
Add ParallelInsertTest to lock the behaviour in: 8 coroutines × 200
inserts, parallel reads alongside writes, transaction batches across
coroutines, and a reopen smoke test all pass against a file-backed DB.
https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5
The existing background warmup in Amethyst.initiate() deferred the lazy
videoCache touch by 10 seconds. SimpleCache's constructor opens a SQLite
index via StandaloneDatabaseProvider and walks every cached span on disk
— a few hundred ms on a populated 4 GB cache — so we really do not want
that running on the main thread.
But 10 s is long enough that a fast user (or a deep link / push notification
that lands directly on a video-bearing screen) can win the `lazy { }` race
and trigger init on the main thread inside PlaybackService.onGetSession,
which is exactly the hitch the warmup was meant to prevent.
Drop to 1.5 s — long enough to let the urgent first-paint work above
(account load, image loader, ui state, robohash) breathe, short enough
that a typical user can't scroll and tap a video before the warmup wins.
Document the trade-off in a comment so the timing isn't a magic number.
Round-4 audit cleanups. Each item is small but each runs on the hot path
that recomposes during every active video, so they add up while scrolling.
P1 — DimensionTag identity invalidating remember:
- DimensionTag (in quartz) is a regular class with no equals override, so
reference equality means a freshly parsed tag for the same event is !=
to the previous one. The remember(videoUri, dimensions) blocks added in
the earlier perf commits were re-running their lambda on every recompose.
Switch to primitive (width, height) keys in VideoView and GifVideoView so
the cache lookups + MediaAspectRatioCache writes only fire when the
dimensions actually change.
P1 — Static gradient brushes:
- TopGradientOverlay / BottomGradientOverlay were calling
Brush.verticalGradient(colors = colors) inside the modifier chain, which
allocated a fresh Brush on every recomposition while the controllers
were visible (i.e. on every active video most of the time). Pre-build
both brushes as file-level vals so they're allocated exactly once per
process.
P2 — ImmutableList for action collections:
- RenderTopButtons / AnimatedOverflowMenuButton / OverflowMenuButton were
passing List<VideoPlayerAction> across composable boundaries. Plain List
is unstable in Compose, forcing the overflow tree to recompose any time
an unrelated parent state (volume, tracks, controllerVisible) ticked.
Use ImmutableList end-to-end via toImmutableList() at the producer side.
P2 — videoPlayerButtonItemsFlow remember:
- accountViewModel.videoPlayerButtonItemsFlow() was being called fresh
every recomposition, with the result handed straight to
collectAsStateWithLifecycle. Hoist the call into remember(accountViewModel)
so the flow reference is stable.
P2 — MuteButton dispatcher cleanup:
- The 2-second hold timer was using LaunchedEffect { launch(Dispatchers.IO)
{ delay(2000); holdOn.value = false } }. The wrapped launch was just
redundant dispatcher hopping — delay() doesn't hold a thread and the
Compose write is fine on Main. Inline it.
Closes the remaining audit items so the eager-prepare model also pays off
on scroll-back. The big change is keeping the most recent N feed players
paused-with-buffer instead of stop()'ing them on release.
P0 — Warm-slot ExoPlayer pool:
- ExoPlayerPool now retains up to N (default 3) paused players keyed by
the mediaId they last loaded. Acquire takes an optional preferredMediaId
hint and returns the matching warm player intact; only the cold fallback
path runs stop()/clearMediaItems(). Warm slots count against the
device's MediaCodec budget (poolSize) so the cold cap is poolSize -
warmSize, with warm slots themselves capped at poolSize-1 to guarantee
there's always at least one cold slot for a brand-new URI.
- Plumb videoUri through connection hints (PlaybackServiceClient ->
PlaybackService.onGetSession -> MediaSessionPool.getSession ->
ExoPlayerPool.acquirePlayer) so the service can find a warm match.
Constants for the bundle keys live on PlaybackService.
- GetVideoController.onEach now checks
state.controller.currentMediaItem?.mediaId before calling setMediaItem.
On a warm hit it leaves the player and its buffer alone — calling
setMediaItem in that case would reset the player and undo the whole
point of the warm pool. STATE_IDLE survivors still get a re-prepare.
P1 — Stop rebuilding the MediaController on transient lifecycle dips:
- GetVideoController.collectAsStateWithLifecycle was tearing down and
re-binding the MediaController every time the activity lifecycle
dropped below STARTED (system dialogs, briefly switching apps,
notification shade). Switch to plain collectAsState — the controller
now lives until the composable actually leaves composition, so a
brief lifecycle dip no longer costs a full IPC rebind + buffer
reload. Real backgrounding still tears down via composable disposal.
P2 — Smaller fixes flushed at the same time:
- GetVideoController: only write controller.volume when it differs
from target. Combined with the new dedup, several feed videos
preloading no longer fire one volume IPC per ready callback.
- CurrentPlayPositionCacher: the resume threshold was `5 * 60`, which
in milliseconds is 300 ms — i.e. "always seek". Bump to 5_000 (5 s)
so trivially short clips don't pay an extra seek + buffer flush at
STATE_READY just to land 100 ms away from where they started.
- MediaSessionPool: stop allocating a fresh DataSourceBitmapLoader per
session — the loader has no per-session state, so it's now a single
lazy instance shared across all sessions in a pool.
- MediaSessionPool.cleanupUnused was racy: concurrent releases all won
the time check and each launched a redundant sweep coroutine. Replace
with a CAS-guarded AtomicLong on a nano-precision timestamp.
Extracts the placeholder dictionary logic out of LanguageTranslatorService into a
pure-JVM TranslationDictionary helper so the round-trip can be unit-tested
without ML Kit / Android runtime, and adds 24 tests covering it.
The existing TranslationsTest is androidTestPlay-only — it needs a real device or
emulator with Google Play services, so it can't validate a refactor in plain CI
or local dev. The new tests cover the riskiest part of this branch: that PUA
placeholders survive an arbitrary "translation" of the surrounding text and
decode back to the exact original tokens.
Test coverage
- isWorthTranslating: short text / letterless text rejected, mixed letters+emoji accepted
- placeholder: produces single PUA codepoint, rejects out-of-range index
- build: collects URLs, NIP-19 nostr refs, Lightning invoices, NIP-08 #[N] refs
- build: deduplicates repeated occurrences and skips Chinese-punctuation URL false-positives
- encode/decode round-trip on plain URLs, multi-URL strings, and mixed real-world content
- encode replaces longer values first to avoid prefix collisions
- decode preserves user text containing the OLD "B0/C0/A0" tokens (regression for the
pre-rewrite collision bug)
- case-sensitive replacement preserves user text that differs only in case from a placeholder
- decode handles null and empty-dictionary inputs
- simulated translation (rewrite English to Portuguese around the placeholders) round-trips
#[0] and nostr:nevent1... unchanged
LanguageTranslatorService now delegates to TranslationDictionary.{build, encode,
decode, isWorthTranslating} — public API (autoTranslate / translate /
identifyLanguage / clear) and behaviour are unchanged.
Result: 410 tests run, 408 passed, 2 pre-existing skips, 0 failures.
https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
jpackage runs `dpkg-shlibdeps` against the bundled JDK runtime's native
libs (libfontmanager.so etc. link libicu) and pins Depends to the build
host's libicu version. CI builds on ubuntu-24.04, so the .deb requires
libicu74 — uninstallable on Ubuntu 22.04 (libicu70), Debian 12 (libicu72),
Debian 11 (libicu67), and Debian 13 (libicu76). Neither jpackage nor the
Compose Multiplatform DSL exposes a way to override the auto-generated
Depends.
Add scripts/relax-deb-libicu.sh which extracts the .deb with `dpkg-deb -R`,
rewrites `libicuNN` (or any alternation thereof) to
`libicu66 | libicu67 | libicu70 | libicu72 | libicu74 | libicu76 | libicu77`,
and repacks. The script is idempotent and a no-op for .debs without a
libicu Depends.
Wire it into both release legs (desktopApp + amy CLI) in
create-release.yml, and into the desktop test/build leg in build.yml so
testers downloading the CI artifact hit the same fix.
Fixes a cluster of issues in TranslatableRichTextViewer + LanguageTranslatorService
that caused stale translations, redundant ML Kit work, and visible jitter on every
note that scrolls into view.
Bugs fixed
- Effect now actually re-runs when "Translate to" / "Don't translate from" change.
Previously LaunchedEffect(Unit) snapshotted the settings once and ignored
subsequent updates.
- Translation cache now keys on (content, translateTo, dontTranslateFrom) instead
of just content, so changing the target language no longer serves a stale
translation in the wrong language.
- Cancelled / "no translation needed" outcomes are now cached, so language
identification no longer re-runs on every recomposition / scroll-back of text in
the user's own language or in the don't-translate set.
- ML Kit Tasks are now awaited via kotlinx.coroutines.tasks.await with
ensureActive() checks; cancelling the composable's coroutine no longer races
against an in-flight callback that mutates Compose state after disposal.
- Encoded placeholders no longer collide with arbitrary user text. Replaced the
old "B0/C0/A0" tokens (which a user could legitimately type) with single
Unicode Private Use Area codepoints, and made replacement case-sensitive so
e.g. "b0" in body text is no longer rewritten on decode.
- Translation pipeline propagates failures: continueWith now rethrows
task.exception instead of silently calling .result on a failed sub-task.
- buildDictionary protects legacy NIP-08 references (#[N]) via the placeholder
table, replacing the fragile post-translation "# [" -> "#[" string fix.
Performance
- LanguageTranslatorService de-duplicates concurrent translation requests for the
same (text, settings) via an in-flight ConcurrentHashMap, so reposts /
notifications / threads sharing the same content fire one ML Kit pipeline
instead of N.
- executorService is now a private bounded fixed pool sized on
availableProcessors() / 2 instead of a publicly-mutable unbounded cached pool
that could spawn dozens of threads under heavy scroll.
- Skip ML Kit entirely for texts shorter than 4 chars or with no letter
codepoints (emoji-only, punctuation) — language identification is unreliable
there anyway.
- Translation cache bumped from 100 to 500 entries to cover long threads /
long-form articles.
- Single-call translation (one ML Kit call for the whole text) preserves
sentence-level context across paragraphs that the old per-line split discarded.
Jitter
- Removed CrossfadeIfEnabled around the rich-text body. The old code rendered two
full RichTextViewer trees (and re-parsed URLs / hashtags / NIP-19 references
twice) during the ~300ms crossfade whenever a translation arrived. Body now
swaps directly; only the translation toggle hint sits below.
- Replaced derivedStateOf around a trivial ternary with a plain expression.
- Locale.forLanguageTag(...).displayName memoized per source/target tag so the
CLDR display-name lookup doesn't run on every recomposition of the
"Translated from X to Y" hint.
- Device-locale list lifted out of the dropdown render loop and remembered, so
ConfigurationCompat.getLocales no longer fires per recomposition while the
language menu is open.
- Dropdown body is only composed when expanded — it was already cheap inside
Material3's DropdownMenu, but skipping the wrapper composition entirely is
measurably tighter.
The exposed API (LanguageTranslatorService.autoTranslate / .translate /
.identifyLanguage / .clear, ResultOrError) is unchanged; TranslatableRichTextViewer's
two public composables keep their signatures, so the ~30 call sites and the
existing TranslationsTest don't need any updates. TranslationConfig drops the
showOriginal field — that toggle is now derived live from
AccountLanguagePreferences.preferenceBetween(...) so changing the user's
language preference is reflected immediately without invalidating the cache.
https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
Three small infrastructure fixes that support the eager-prepare model
(every visible feed video calls setMediaItem + prepare immediately so
it's ready when the user scrolls to it).
- ExoPlayerPool.create(): the warmup that builds poolStartingSize
players up front was already written but never invoked. Wire it from
PlaybackService.lazyPool() the first time a pool is requested. The
builds now run on the pool's main-looper scope with a yield() between
each so they're spread across frames instead of stalling the UI in
one ~150–600 ms burst. Idempotent via an AtomicBoolean.
- ExoPlayerBuilder: install a feed-tuned DefaultLoadControl
(10s/15s/750ms/2000ms) instead of the 50s/50s/2.5s/5s defaults. Every
visible video preloads, so 5 simultaneous players were each trying to
buffer 50s ahead — fighting for network and burning ~30 MB of buffer
per HD player. Capping at 15s keeps the active video smooth, lets it
start playing as soon as ~750 ms is buffered, and slashes peak memory
on feeds with several preloads.
- PlaybackService.onUpdateNotification: the third forEachIndexed loop
was missing its return, so on the muted-but-playing fallback path
super.onUpdateNotification was called once per playing session
instead of once total. With multiple feed videos preloading
simultaneously this was hammering the notification system every time
a player changed state. Match the first two loops by returning after
the first match. Also drop the unused `idx` from forEachIndexed.
Tightens the hot path that runs whenever a video appears inside a note
in the feed (RichText -> ZoomableContentView -> VideoView).
- VideoView: resolve aspect ratio once per (uri, dim), and prime
MediaAspectRatioCache from the imeta dim tag so repeat appearances
(PiP, dialog, list re-enter) don't have to wait for ExoPlayer's
onVideoSizeChanged before reserving layout space.
- VideoView: key the manual "tap to show" toggle on videoUri so a
recycled feed slot doesn't inherit stale state from the previous video.
- VideoViewInner: hoist proxyPortForVideo() into a remember(videoUri) —
the result was being recomputed every recomposition only to be dropped
by GetMediaItem's URI-keyed remember.
- RenderVideoPlayer: stop holding container size in compose state.
The size is only read in onDoubleTap, so a non-state IntArray holder
removes a recomposition of the whole player tree on every layout pass.
Also memoize isLiveStreaming() so the .m3u8 substring scan doesn't run
on every recomposition.
- RenderTopButtons: same isLiveStreaming() memoization.
- GetVideoController: switch the remaining non-lambda Log.d call to the
lambda overload so the message string isn't formatted when the log
level is filtered out.