Four UI polish fixes that all touch RenderMusicTrack:
1) Suppress the "Listen to my song …" auto-fill text.
Several Blossom uploaders pre-populate the kind-36787 content field
with that exact prefix — it just restates the title/artist that the
card already shows above. MUSIC_TRACK_BOILERPLATE_PREFIXES is the
one place to extend if more publishing tools start producing similar
noise.
2) Cover and player now read as a single piece of UI.
Previously the cover wore top-rounded corners and the audio player
(via RenderAudioWithWaveform) used `imageModifier`'s all-rounded
chrome plus a 5dp top padding — so two visually disconnected blocks
sat above and below a gap.
The audio player is now inlined: GetMediaItem → GetVideoController →
RenderVoicePlayer with a custom border modifier that rounds only the
bottom corners. No top padding. The result reads as one continuous
card with the cover up top and the player below.
3) Internal DisplayUncitedHashtags row is gone.
RenderAudioWithWaveform renders its own hashtag row for voice
messages (where it's the only chip display). MusicTrack already
renders TopicChips externally, so the inner row was producing a
second, visually-different row of the same chips. Inlining the
player chain (point 2) also drops that row.
4) TopicChip is clickable.
Tapping `#electronic` now navigates to Route.Hashtag("electronic") —
the same destination RichTextViewer's inline hashtags go to. The chip
adds an optional `onClick` parameter so existing callers (and the
preview block) keep working unchanged.
The previous version drew envelope and carrier as pure functions of the
sample position — so 60% of every bar was identical across all tracks and
only the per-bar jitter (40% weight, narrow range) wiggled. Every track
ended up with the same slow-fade-sine silhouette and only the noise
pattern differed, which looked like every waveform was the same shape.
Now every shape parameter — phase offset, carrier frequency, baseline,
envelope strength, noise envelope — is drawn from the seeded RNG before
the per-bar loop. Two different track ids produce visibly different
waveforms: some have many tight bars, some few wide ones, some fade in
and out, others stay flat. The seed is also bit-shuffled with a Knuth
multiplicative constant XOR'd with the id length so two ids whose Java
hashCode happens to collide still diverge.
ExoPlayer still owns playback progress; the bars are purely decorative.
style(nests): import TimeUtils in CreateNestViewModel instead of inline FQN
HIGH-1: import java.io.RandomAccessFile in MetadataStripper instead of
inline fully-qualified name
HIGH-2: catch AvifMetadataNotVerifiableException in the 6 ViewModels
that call MetadataStripper.strip directly (profile picture, emoji pack
list+display, bookmark group, nest, channel)
MEDIUM-1: tighten AvifAnimatedDecoderFactory.createAnimatedImageDecoder
annotation from @RequiresApi(P) to @RequiresApi(S); the outer guard is
already SDK_INT < S.
MEDIUM-2: replace the curried lambda DI seam in MetadataStripper with
a named fun interface (AvifExifReader).
MEDIUM-3: rename isGifUrl -> isAnimatedMediaUrl (MyAsyncImage) and
BaseMediaContent.isGif() -> isAnimatedMedia() (ZoomableContentView)
since both predicates now cover AVIF as well as GIF.
- AvifAnimatedDecoderFactory.isAvif now iterates a single brand list
with .any { rangeEquals(8, it) } instead of three || branches.
- MetadataStripper.inspectAvifMetadata dropped the outer defensive
try/catch; the inner catch already converts parse failures to
AvifMetadataNotVerifiableException and the rest of the function
cannot realistically throw.
- PreviewMetadataCalculator extracts the shared ImageDecoder allocator
+ exception path from decodeAvifBytes and decodeAvifFromUri into a
single private decodeAvif(source) helper.
- RobohashFallbackAsyncImage merges its identical Loading and Error
when branches into one via Kotlin's multi-value branch syntax.
- MediaCompressorTest drops a no-op MockKAnnotations.init(this) call
and the now-unused import; no @MockK fields exist.
fix(ui): default avatar contentScale to Crop, not Fit
fix(images): skip thumbnail cache for animated AVIF profile pictures
fix(ui): animate profile pictures regardless of URL extension
15 bite-sized tasks across 6 phases (A foundation, B upload pipeline, C animation
lifecycle audit, D test fixtures + instrumented tests, E manual on-device
verification, F ship). Each task has exact file paths, full test code, full
patch code, exact commands, expected output, and per-task commits.
Companion to amethyst/plans/2026-05-26-avif-support.md spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs(amethyst): document strip-toggle-off AVIF EXIF leak as known limitation
docs(amethyst): document Desktop AVIF gaps from spot-check
docs(amethyst): record animated AVIF playback caveats from on-device testing
docs(amethyst): note API < 31 gallery-picker greys out AVIF (OS limit)
docs(amethyst): tighten API < 31 known-limitation with on-device findings
docs(amethyst): plan and design for AVIF instrumented tests
Three follow-ups against testing the Scatman track event.
1) Music feed pulls from relays now.
The "doesn't load anything even on Global" bug: there was no relay
subscription wired for kind 36787 / 34139, so the feed only showed
tracks that happened to already be in LocalCache (own publishes,
direct nostr: links, hashtag visits). Mirrors the LongsFilterAssembler
stack:
- MusicTracksFilterAssembler + MusicTracksSubAssembler register a
per-user-and-follow-list subscription on the music screen and
whenever MUSIC_TRACKS is pinned to the bottom bar.
- SubAssemblyHelper.makeMusicTracksFilter dispatches the active
TopFilter to the right per-relay filter set:
· Global → unscoped kind 36787/34139 from outbox/proxy relays
· AllFollows / Authors / MutedAuthors → scoped to the active
author set
Hashtag/Geohash/Community variants fall through to emptyList for
now; add per-case handlers when the spinner grows those routes.
- RelaySubscriptionsCoordinator owns the assembler instance.
- LocalPreferences persists defaultMusicTracksFollowList (key
"defaultMusicTracksFollowList") so the spinner's choice survives a
relaunch like every other feed.
2) Synthetic waveform replaces the empty audio strip.
Kind 36787 has no `waveform` tag in the spec, so RenderAudioWithWaveform
was given `null` and showed a flat audio bar. Now seeded off the track
address: each track keeps the same decorative shape across recompositions,
and a sine envelope + carrier + jitter makes the result read as "music
waveform" rather than pure noise. ExoPlayer still owns playback progress
— this is purely visual.
3) Hashtags now render in exactly one row.
The Scatman test case (4 `t` tags including "music") was showing both
DisplayUncitedHashtags (every `t`) and a TopicChip FlowRow (every `t`
except the "music" genre marker), so the user saw visually-different
duplicate chip rows. Dropped DisplayUncitedHashtags from MusicTrackHeader;
TopicChips is the canonical music-aesthetic chip row and already
excludes the genre marker.
Tapping a chip silently failed if no installed app handled the
type-specific URI scheme (bitcoin:, ethereum:, monero:, etc.).
Surface that case through the existing toastManager so users know
to install a compatible wallet.
https://claude.ai/code/session_01R7kRziq14Hc22dPwAnZRAr
Two related polish passes after comparing the music UI against the rest of
the codebase.
1) Voice-message audio player wins for audio-only tracks
The Voice (NIP-A0) renderer's RenderAudioWithWaveform is the right
playback widget for a kind-36787 track that has no `video` URL — it's
ExoPlayer-backed (same as VideoView) but exposes:
- inline tap-to-show controls with play/pause centered
- top buttons for Share / Save-to-gallery / Picture-in-Picture / Mute
- a 100dp compact strip (vs. VideoView's 16:9 video surface that
leaves a blank rectangle for audio-only streams)
- waveform-aware (we pass null since kind 36787 has no `waveform` tag)
The new layout for an audio-only track:
- album-art cover at the top (square, 1:1)
- compact audio player below
- title / artist / meta below that
When a `video` URL is set the track keeps the full VideoView path —
that's a real music-video file and deserves the video surface.
2) Music feed top bar now has a follow-list spinner
Every sibling feed (Articles, Longs, Polls, Pictures, etc) exposes a
FeedFilterSpinner backed by its own settings flag. The music screen
previously only showed a static "Music" title and silently reused the
Home follow list, so users couldn't filter the music feed independent
of Home.
- AccountSettings: defaultMusicTracksFollowList (defaults to Global) +
changeDefaultMusicTracksFollowList(name) setter pair.
- Account: liveMusicTracksFollowLists + …PerRelay flows, sourced from
the new setting via the existing topNavFilterFlow machinery.
- MusicTracksFeedFilter switches its filter params + feedKey to the
dedicated flow.
- MusicTracksTopBar swaps the static Text for FeedFilterSpinner with
the same kind3GlobalPeopleRoutes catalog Articles/Longs use (All
Follows, Your Follows, kind3 Follows, Around Me, Global, custom
people lists, interest sets, mute list).
- WatchAccountForMusicTracksScreen now watches liveMusicTracksFollowLists
so list switches invalidate the feed.
Audit pass turned up several real bugs in the music feature. Rolled the
fixes into one commit since they all touch the publish/edit path.
Data-loss bugs (must-fix)
- NewMusicTrackViewModel.publish() in edit mode rebuilt the event via
MusicTrackEvent.build() with only the composer-visible fields, silently
dropping every other tag the original event carried — video URL,
released, track_number, format, bitrate, sample_rate, language,
explicit, extra `t` genre tags, zap splits, anything custom. Adds a
MusicTrackEvent.edit(earlierVersion, ...) companion that clones the
existing TagArray and only mutates composer-managed fields, mirroring
PinListEvent/BookmarkListEvent's `add`/`remove`/`resign` pattern.
- AddToMusicPlaylistViewModel.toggle() and .createWithTrack() had the
same problem for playlists. Adds MusicPlaylistEvent.addTrack /
removeTrack companions that preserve the rest of the tag array. The
VM now uses these instead of round-tripping through build().
Correctness bugs (must-fix)
- MusicPlaylistEvent.build() emitted only one of `public`/`private`
while isPublic() defaulted to `true`-when-absent. A private playlist
therefore round-tripped as isPublic() && isPrivate(). isPublic() now
falls back to !isPrivate(), and build() still emits the explicit pair
so unrelated clients reading either flag agree on visibility.
- AddToMusicPlaylistViewModel's `isWorking` flag wasn't a concurrency
primitive — fast taps on different rows could race and the loser's
broadcast would replace the winner's. Adds a Mutex around toggle /
createWithTrack so they serialize.
- The initial rescan() ran on the composition thread (init() is called
from the sheet's body). Both initial and live re-scans now run on
Dispatchers.IO inside the same Job, and the live collector filters
bundles by kind so we don't re-walk LocalCache for every Text Note.
- NewMusicTrackViewModel.init() set `isEditing = true` based purely on
`editDTag != null`, so a stale dTag pointing at no cached event left
the user on a Delete button that no-op'd. `isEditing` now derives
from loadedEvent and falls back to create-mode when the lookup misses.
- MusicTrackHeader synthesized mimeType "video/${format ?: "mp4"}" when
a `video` URL was present — but `format` is the AUDIO format per
spec, so a track with both `video=...mp4` and `format=mp3` emitted
"video/mp3". Pass null when videoUrl wins and let ExoPlayer sniff.
- MusicTracksFeedFilter only consulted params.match(), which checks
the follow list but not mute/spammer/word lists. Muted authors leaked
through. Mirror LongsFeedFilter and AND `account.isAcceptable(note)`.
Search & idiomatic fixes (should-fix)
- MusicTrackEvent + MusicPlaylistEvent now implement SearchableEvent so
the local SQLite FTS indexes title/artist/album/description rather
than only the JSON content. Searching "Pink Floyd" by artist now
matches the local cache instead of waiting for relay results.
- MusicTracksFeedFilter.feedKey() now class-prefixes with "music-" so it
can't collide with other feeds that key off the same Home follow list.
- MusicPlaylistEvent.build() renamed `description`/`shortDescription` to
`content`/`description` so the parameter names match the spec.
Nits
- Drop trackCount() — callers prefer `trackAddresses().size`.
- Drop six unused strings (composer placeholders for not-yet-wired
Blossom audio/cover upload UI).
- Wrap preview runBlocking { justConsume(...) } in remember{} so it
runs once per preview key instead of every recompose.
- Use a hex-shaped id for preview events instead of "track_xxx_yyy".
Closes the UX gap where a user who creates a pack via the in-app UI has no
path to add it to their NIP-51 kind-10030 selection without leaving the
pack-management screens.
- Wire DragAndDropTarget on avatar circle and banner area
- Image-only filter (jpg/png/gif/webp/avif)
- Visual drag-over feedback (primary border highlight)
- Fix avatar: only show placeholder icon when no image set
(previously overlay was visible behind the loaded avatar)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both umbrelOS (via getumbrel/umbrel-apps#4962) and StartOS / Start9
(via Start9-Community/namecoin-core-startos) ship a self-hosted
Namecoin Core that this backend can target. Generalize the
help/strings so umbrel users discover the feature too.
No logic changes.
Replace the awkward small icon button with a full 100dp tappable circle.
Shows surfaceVariant background when empty, semi-transparent overlay with
centered upload icon when image is present. Spinner replaces icon during
upload.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace plain text payment-target rows with a FlowRow of pill-shaped
clickable chips that carry a type-aware icon, brand color, uppercase
label, and a truncated address. Tap opens a type-specific URI scheme
(bitcoin:, lightning:, ethereum:, monero:, liquidnetwork:, dash:,
payto:// fallback) so wallets can actually pick up the intent; long-press
still copies the authority to the clipboard.
Adds previews wherever there's something visual worth previewing:
- MusicTrack.kt: RenderMusicTrack (full event with cover + meta),
RenderMusicTrackExplicit (explicit-badge variant), MusicTrackCover (no
cover fallback), ExplicitBadge, TopicChip. Uses the same
Event-constructor + LocalCache.justConsume + LoadNote pattern Attestation
and Wiki previews already use.
- MusicPlaylist.kt: RenderMusicPlaylist (with three resolved track refs),
RenderMusicPlaylistCollaborativePrivate (chips visible),
MusicPlaylistCover, MissingPlaylistTrackRow, TrackCoverPlaceholder,
PlaylistTag.
- MusicTracksScreen.kt: full feed scaffold via mockAccountViewModel().
- MusicTracksTopBar.kt: the static-title top bar.
- NewMusicTrackButton.kt: the FAB on its own.
- NewMusicTrackScreen.kt: the composer form.
- AddToMusicPlaylistSheet.kt: NewMusicPlaylistDialog leaf, and the empty
state of the full sheet.
- MusicPlaylistManagementItem.kt: four variants — not-in / in /
empty (zero tracks) / untitled.
Skipped MusicTracksFeedLoaded — FeedState.Loaded can't be easily mocked
and no existing feed-loaded previews exist in the codebase. The screen
preview already covers the scaffold around it.
URLs in the constructed events point at example.invalid so MyAsyncImage
falls back to the deterministic DefaultImageHeader robohash and VideoView
shows its tap-to-load thumbnail state — both of which are the real
first-paint state users see before tapping anything.
Compared the music-playlist-management sheet against the existing
PostBookmarkListManagementScreen and noticed the playlist sheet was the odd
one out — custom TopAppBar with a Done action, inline TextField + Button row
for creating new lists, custom Row + Checkbox per playlist, no way to tap
into a playlist to view it.
The bookmark screen uses a richer Material3 pattern that's already familiar
to users. Aligning the music sheet to it:
- TopBarWithBackButton (back arrow + title) replaces the bespoke top bar.
- Scaffold FAB → NewListButton opens a small AlertDialog with a name field
(lighter than the bookmark route to a full edit screen, but consistent in
affordance). The previous inline create-row is gone.
- Each row is now a MusicPlaylistManagementItem mirroring
BookmarkGroupManagementItem: leading icon + total-track-count chip,
headline title, supporting "In this playlist" / "Not in this playlist"
status text, trailing round IconButton (red Remove / blue Add).
- Tapping the row navigates into the playlist's note view (Route.Note with
the addressable's tag); tapping the trailing button toggles membership.
Previously these were collapsed into a single whole-row tap that toggled
but never let the user actually see the playlist.
ViewModel unchanged — the same toggle()/createWithTrack() operations now
just feed a more conventional UI.
Builds three discrete user-facing surfaces on top of the kind 36787 / 34139
quartz events:
1. Music feed screen (Route.MusicTracks)
- MusicTracksFeedFilter pulls every kind-36787 addressable that passes the
user's Home follow list + hidden/blocked rules. Reuses liveHomeFollowLists
rather than introducing a new AccountSettings flag (deferred to a future
iteration that wants its own spinner).
- MusicTracksScreen mirrors LongsScreen's scaffold: bottom-nav-aware top
bar, refreshable feed list, FAB. Each row renders through NoteCompose so
reactions/zaps/replies behave like Home.
- Discoverable via the side drawer (DrawerFeedsItems) and pinnable to the
bottom bar (NavBarItem.MUSIC_TRACKS).
2. New-music-track composer (Route.NewMusicTrack)
- NewMusicTrackViewModel mirrors NewCalendarCollectionViewModel: title /
artist / audio URL / cover URL / album / duration / lyrics fields,
dTag-preserving edit mode, NIP-09 deletion. Publishes via
account.signAndComputeBroadcast(MusicTrackEvent.build(...)).
- NewMusicTrackButton FAB wires the music screen entry to the composer.
- MVP intentionally skips audio-file Blossom upload from inside the
composer — users paste URLs they uploaded elsewhere. NewMediaModel
wiring can be added later without disturbing the rest of the flow.
3. Add-to-playlist sheet (Route.AddToMusicPlaylist)
- AddToMusicPlaylistViewModel scans LocalCache.addressables for the user's
own kind-34139 playlists (filterIntoSet(kind, pubKey)) and offers
toggle-membership + create-with-track operations. Each mutation
re-signs the playlist with the existing dTag so the address points at
the new ordered track list.
- AddToMusicPlaylistSheet renders a checkbox list with an inline
"new playlist" creator row.
- DropDownMenu exposes the entry from the …-menu on any MusicTrackEvent
note via the existing M3ActionRow row pattern.
String resources added under values/strings.xml. Drawer + nav-bar catalog
entries cover the new route. BottomBarFeedPreloaders adds a documented `Unit`
arm for MUSIC_TRACKS so the exhaustive `when` stays exhaustive without
forcing a relay-subscription file before there's logic to put in it.
The previous layout stacked a non-functional static cover (with a decorative
play-button overlay) above the real VideoView, so tapping the prominent UI
element did nothing while the actual playback widget sat smaller below.
Use LoadThumbAndThenVideoView / VideoView as the primary header so the
album art is the player's own thumbnail and ExoPlayer handles the
tap-to-play / streaming. Falls back to a plain cover only when the event
has no playable URL at all (data-integrity case).
If a user's NIP-65 outbox advertises only relays that don't hold their
kind 0, profile fetching used to give up after EOSE on those relays.
filterUserMetadataForKey now widens to the account's indexer relays
once every outbox relay has either EOSE'd or is in cannotConnectRelays
and metadata is still missing. UserWatcherSubAssembler invalidates
filters on EOSE so the fallback re-evaluates without waiting for an
unrelated trigger.
Audit pass after LocalCache wiring: MusicTrackEvent (36787) and
MusicPlaylistEvent (34139) were materialized and rendered, but a dozen
filter sites still listed AudioTrackEvent.KIND alone, so music events
silently dropped out of feeds, search, notifications, and relay labels.
Mirrors the AudioTrackEvent pattern in every place AudioTrackEvent.KIND
or `is AudioTrackEvent` appears:
Feed display filters (acceptableEvent + ADDRESSABLE_KINDS):
- HomeNewThreadFeedFilter
- FollowPackFeedNewThreadFeedFilter
- UserProfileNewThreadFeedFilter
- UserProfileMutualFeedFilter
- HashtagFeedFilter
- GeoHashFeedFilter
Relay subscription kind lists:
- FilterPostsByGeohash (PostsByGeohashKinds)
- FilterPostsByHashtags (PostsByHashtagKinds2)
- FilterPostsByRelay (PostsByRelayKinds2)
- SearchPostsByText (SearchPostsByTextKinds1)
- Desktop SearchFilterFactory.defaultKindGroup1
Notifications:
- NotificationFeedFilter.ADDRESSABLE_KINDS (NOTIFICATION_KINDS picks
this up automatically)
Relay information screen:
- kindDisplayName → R.string.kind_music_track / kind_music_playlist
(with the two new string resources)
MusicTrackEvent (36787) and MusicPlaylistEvent (34139) are addressable, so
both are dispatched through consumeBaseReplaceable in justConsumeInnerInner.
Without this, the events arrive from relays but are never stored in
LocalCache and the UI renderers see nothing to render.
Adds quartz support for two new addressable Nostr event kinds, modeled after
the NIP-88 poll structure, plus modern Compose renderers wired into both the
feed (NoteCompose) and the thread/master view (ThreadFeedView).
Quartz:
- MusicTrackEvent (36787): title/artist/url and optional album, track_number,
released, duration, format, bitrate, sample_rate, language, explicit, image,
video. Each field is a dedicated *Tag class with parse/assemble, plus a
TagArrayBuilder DSL and a typed build() factory. Auto-emits the "t music"
hashtag and an NIP-31 alt description.
- MusicPlaylistEvent (34139): title, image, description, ordered "a" track
references to MusicTrackEvent, plus public/private/collaborative flags.
- Both registered in EventFactory so LocalCache materializes them as typed
events instead of generic Event.
Amethyst UI:
- MusicTrack.kt: square cover with overlaid play affordance, large title,
artist row with note icon, meta row (album/track #/release/duration/explicit
badge), embedded VideoView for audio (or video URL when present) with
cover thumbnail, lyric/credit content via TranslatableRichTextViewer, and
topic chips for extra t tags.
- MusicPlaylist.kt: cover with track-count badge, title, count + collaborative
/private chips, descriptions, and an ordered list of tracks resolved via
LoadAddressableNote (clickable, falls back to "loading"/"unknown track" for
missing references). Capped at 25 with a "+N more tracks" footer.
- Track-count strings use <plurals> for correct CLDR pluralization.
- Wired into NoteCompose `when` dispatch and ThreadFeedView header dispatch.
Replace the up/down chevron IconButtons on each wallet card with a drag
handle, matching the pattern used across the relay-settings screens.
Reuses RelayDragState / rememberRelayDragState / draggableRelayItem /
relayDragHandle from relays/common — same gesture handling, elevation
animation, and swap-on-threshold behavior.
The handle and item modifier are only attached when there is more than
one wallet to reorder.
The wallet detail screen's Send, Receive and Transactions buttons navigated to
parameterless routes. Each destination created a fresh WalletViewModel with no
selection, so the action ran against `_defaultWalletId` (the account default)
instead of the wallet being viewed. Paying, invoicing, and listing
transactions could therefore go to the wrong wallet.
Parameterize WalletSend/WalletReceive/WalletTransactions with `walletId`,
plumb it through AppNavigation, pass it from WalletDetailScreen, and have
each screen call `selectWallet(walletId)` before operating.
Dropping both `authors` and `#p` from the kind-23195 subscription filter
fixed wallets that don't set those fields the way NIP-47 implies, but
broke purpose-built NWC relays (notably relay.getalby.com/v1) that use
`#p` as the routing key — without it the relay never delivers the
response to our subscription, so the wallet screen sits on a spinner.
Restore `#p: [client pubkey]` in the relay filter. Keep `authors` out
since that field was the one actually causing the broader interop pain.
Spec-compliant responses always carry the `p` tag, so adding it back
does not exclude any conforming wallet. End-to-end authenticity is
still enforced by NIP-04 decryption against the per-connection shared
secret and by the client-side author check in NwcPaymentTracker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug had four ingredients (per the kdoc on TorArtiNativeIntegrationTest).
We had one test for #1; now there's targeted coverage for each:
1) Native TorClient gets stuck (bad guards / dead circuits / expired
consensus) with no way to drop it in-process:
`destroy then re-initialize releases the state file lock cleanly`
(was already there — added exit-IP logging so the developer can eyeball
that the circuit actually changed across the destroy).
2) In-flight per-connection handlers holding Arc<TorClient> clones,
pinning the state file lock past destroy:
NEW `destroy aborts an in-flight SOCKS handler quickly`
Opens a SOCKS HTTPS request, lets the handler get into the data plane,
calls destroy() concurrently, asserts:
- destroy() returns within 3s (the budgeted abort+sleep window),
- the in-flight request thread dies within 5s,
- a fresh initialize on the SAME data dir succeeds afterward
(this is the actual regression net — pre-fix the orphaned handler's
Arc would keep the TorClient alive and the lock held).
3) stopSocksProxy *deliberately* preserves the running client so the
legitimate stop/start toggle is fast. We need to keep that path
working after the destroy/abort changes:
NEW `stopSocksProxy then startSocksProxy reuses the running TorClient`
Asserts the second startSocksProxy returns in < 5s — no re-bootstrap.
4) State / fd / memory leaks accumulating across many destroy/init cycles
(the watchdog can drive these forever):
NEW `survives multiple destroy then initialize cycles`
5 full cycles of initialize → startSocksProxy → fetch → destroy.
Logs per-cycle elapsed time + exit IP so degradation is observable
even when it's not yet a hard failure.
Plus two extra robustness tests:
NEW `proxies concurrent SOCKS requests in parallel`
5 in-flight HTTPS-via-SOCKS requests at once. Exercises the Rust
accept loop, HANDLER_TASKS retain-on-push, and Arc<TorClient> clone
independence under load.
NEW `destroy is idempotent`
destroy-without-init, double-destroy, init-after-double-destroy.
Cheap regression net for unwrap-on-None panics in the Rust shim.
All new tests gated by -Pamethyst.arti.integration=true alongside the
existing ones; the smoke test (`library loads and reports a version`)
still runs unconditionally on Linux x86_64. Total runtime for the slow
suite is ~10-15 minutes against Tor, depending on bootstrap luck.
Closes the test gap below the tier-1 unit tests by running the real Arti
JNI shim end-to-end on JVM. Cheaper than an emulator + connectedAndroidTest,
and exercises the exact Rust + JNI code path the Android .so does.
Three tests in TorArtiNativeIntegrationTest:
1. `library loads and reports a version` — always-on smoke check. Loads
libarti_android.so via System.loadLibrary and calls ArtiNative.getVersion.
~10ms. Catches build/link regressions (e.g. a stale .so after an ARTI
bump, a missing JNI symbol export, a forgotten rebuild on this path).
Skipped on non-Linux-x86_64 hosts with a clear message pointing at the
build-arti-host.sh rebuild step.
2. `bootstraps and proxies an HTTPS request through Tor` — opt-in via
-Pamethyst.arti.integration=true. ArtiNative.initialize → startSocksProxy
→ OkHttp-via-SOCKS → check.torproject.org/api/ip. Asserts "IsTor":true.
Regression net for the rustls CryptoProvider install we added after the
v2.3.0 bump and for the destroy/handler-abort fixes in the Rust shim.
3. `destroy then re-initialize releases the state file lock cleanly` — opt-in.
The direct unit-test mirror of the self-heal path: bootstrap, destroy, hit
the SAME data dir with initialize again, verify it succeeds without a
"state file already locked" error and that traffic still flows.
Wiring:
- New tools/arti-build/build-arti-host.sh — companion to build-arti.sh.
Cargo-builds the wrapper crate for the host target (x86_64-linux on most
dev machines, but the script maps macOS / arm64-linux too) and copies to
amethyst/src/test/native-libs/<host-tag>/libarti_android.so.
- amethyst/build.gradle.kts testOptions.unitTests.all configures
-Djava.library.path so System.loadLibrary("arti_android") finds the
checked-in host .so. Also forwards -Pamethyst.arti.integration so the
opt-in gate works from a Gradle invocation.
- Checked-in src/test/native-libs/x86_64-linux/libarti_android.so for the
most common dev/CI host (~6 MB).
Wrapper change to make the JVM path actually run:
- lib.rs: on #[cfg(not(target_os = "android"))], call
builder.storage().permissions().dangerously_trust_everyone() so Arti's
fs-mistrust check doesn't reject /tmp data dirs on hosts where parent
directories have unusual UIDs (typical in containers). Android keeps its
strict default — the app's private filesDir is already sandboxed by the OS.
Compiled-out on Android, so the shipped Android .so is functionally
unchanged.
Verified in this session:
- Smoke test passes without -P (3 tests, 1 ran, 2 skipped).
- Full unit test suite still passes.
- With -P the bootstrap tests get past Arti's permissions check; they hang
on actual relay I/O in this container because outbound TCP egress is
restricted to a CDN allow-list, not Tor relays. Tests succeed on hosts
with unrestricted outbound — see the test kdoc.
Zaps and DMs move real artifacts (money, private messages) to a Nostr
pubkey. Nostr has no global namespace, so "zap Alice" is ambiguous —
multiple users can publish the same display name. Four safeguards now
make it much harder for Gemini (or any agent) to misroute a write:
1. `expectedDisplayName: String?` on followUser / sendDm / zapUser.
Agent passes the name it understood; verb cross-checks that the
resolved profile's name / display name / NIP-05 contains it (or
vice-versa). Mismatch aborts with a typed error carrying the npub
and NIP-05 so the agent can re-prompt.
2. `requireFollow: Boolean = true` default on sendDm and zapUser.
Refuses to act on a pubkey the user doesn't already follow on
Nostr. Strongest guard against same-name impersonators — even if
the agent picked the wrong Alice, the user almost certainly isn't
following her. Override to false only when the user explicitly
approves acting on a stranger.
3. Updated kdocs instruct the agent to confirm with the user using
all three identity signals (display name + npub + NIP-05) before
invoking. The kdoc is what Gemini reads to learn the verb's
contract, so this is where the instruction goes.
4. searchProfiles now filters out hits whose NIP-05 claim explicitly
fails verification (the listed domain refuses to sign for that
pubkey). Network errors / no-claim profiles are kept (inconclusive,
not refutations). Verifications run in parallel with a 4s overall
budget; on timeout we surface all candidates rather than censor.
https://claude.ai/code/session_013NKVhEF2KqyCrV7ufaiQ6N