popUpTo(route) { inclusive = true } only pops if `route` is already in
the stack. From Home, tapping any other bottom-nav tab left Home in the
back stack, so canPop() returned true and the back arrow appeared on a
root tab.
Pop up to the graph's start destination instead (also inclusive). This
clears Home — and any drawer/deep-push entries above it — before
navigating to the new bottom-nav root, so each bottom-nav tap leaves
exactly one entry in the stack.
https://claude.ai/code/session_01PrirRcL7g8iX7vTqqLTkBS
Bottom-nav taps clear the stack with popUpTo(route) { inclusive = true },
so a back arrow on those screens has nothing to pop. Switch the back-arrow
visibility to a runtime check on INav.canPop() — true when the destination
has a previous back-stack entry (drawer or any deep push), false when it's
the root (bottom-nav entry).
Applied across:
- TopBarWithBackButton: now takes nav: INav and renders the icon only
when nav.canPop()
- UserDrawerSearchTopBar: shows back arrow when canPop, drawer opener
otherwise (covers Home, Messages, Video, Discover, Notifications,
Communities, Articles, Pictures, Shorts, PublicChats, FollowPacks,
LiveStreams, AudioRooms, Longs, Polls, Badges, Products,
BrowseEmojiSets)
- WebBookmarks, Drafts, Wallet: wrap custom navigationIcon in canPop
- ProfileHeader: floating back arrow on the banner when canPop
The previous fix sorted + joined the user pubkeys into a comma-separated
String to keep the key Bundle-storable. That brought back the
StringBuilder + char[] + String + sorted-List allocation per visible
chatroom that the typed-key rework had eliminated.
ChatroomKey.users is often a kotlinx PersistentOrderedSet, which isn't
java.io.Serializable, so we can't just hold the Set reference as-is.
Copying into a HashSet costs one HashMap allocation per key, much less
than the joined-String path, and Set equality stays order-independent so
two equivalent chatrooms still hash to the same bucket.
LazyColumn keys are persisted in a SaveableStateHolder, which on Android
must be Bundle-storable (primitives, Parcelable, or Serializable). The
recent perf rework wrapped keys in plain data classes, which Kotlin does
not auto-mark Serializable, so opening a public chat crashed with:
java.lang.IllegalArgumentException: Type of the key
PublicChannelLazyKey(channelId=...) is not supported.
Mark the sealed interface Serializable, and decompose RoomId/ChatroomKey
fields (which live in quartz commonMain and can't depend on the JVM-only
Serializable interface) into String primitives. Each variant still wraps
a stable per-chatroom identity, so reorders move rows instead of
recreating them.
The AI-suggested alt-text chip was using androidx.compose.material.icons.*,
which the project no longer pulls in (migrated to MaterialSymbols a while
back). The file failed to compile until the dep was either re-added or
the icons migrated. Switching to MaterialSymbols.AutoAwesome / .Close.
The Pause action called stopSelf(), but onDestroy() then triggered the
auto-restart broadcast (because alwaysOnNotificationService was still
enabled), so the notification reappeared seconds later. There was no way
to actually pause without toggling the setting, so the button was just
confusing.
Drops the ACTION_STOP intent, the notification action, and the
always_on_notif_stop string from all locales. Also includes incidental
spotless fixes the pre-commit hook required.
- 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.