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.
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.
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".
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).
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.
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
Tier 1 — 18 fast unit tests for the self-heal logic, virtual time only:
- Extracted TorBackend interface (status + start/stop/reset/resetWithCleanState),
TorService implements it. TorManager now takes a TorBackend by injection
rather than constructing a TorService itself.
- Extracted TorPreferencesPort (torType + externalSocksPort flows + load/save
bypass-approval). TorSharedPreferences implements it via forwarding properties.
- Injected ioDispatcher (default Dispatchers.IO) and nowMs clock (default
System::currentTimeMillis) so tests drive the 45s watchdog + 5-min cooldown
in milliseconds of virtual time.
- Tests cover: persisted-approval load, torType-change bypass clear,
approveBypassForOneHour, onNetworkChange (clear + reset + cooldown prime),
watchdog gentle-reset before first Active, watchdog full-reset after Active,
watchdog cancellation on Active, cooldown blocks within window + permits
outside, status routing for OFF/EXTERNAL/INTERNAL, sessionBypass forcing Off,
activePortOrNull mirroring.
- Uses UnconfinedTestDispatcher inside runTest — flowOn(ioDispatcher) +
WhileSubscribed cross-dispatcher channel needs eager dispatch for
MutableStateFlow.value updates to propagate through advanceUntilIdle.
Tier 3 — TorBootstrapInstrumentedTest scaffold (@LargeTest, @Ignore by default):
- Cold-start bootstrap: TorService.start → first { Active } within 120s.
- HTTPS round-trip: OkHttp via SOCKS to check.torproject.org, asserts IsTor:true.
This is the regression net for the rustls CryptoProvider install after the
Arti bump and for the destroy/handler abort race in the Rust shim.
- reset → re-start: verifies the state-file-lock is released so the second
TorService.start can re-create the TorClient cleanly.
- KDoc documents how to enable + run on a real device (the test needs Tor
network egress + 60–120s of wall-clock per case, hence default-Ignored).
No production behavior changes — only injection seams + interfaces.
Adds a `chain` parameter to zapUser so Gemini can route the zap over
Lightning (default) or onchain Bitcoin (NIP-BC kind:8333).
* chain="lightning" (or null, "ln") — existing Lightning flow:
build kind:9734 → fetch BOLT11 → NWC auto-pay if configured →
return invoice + nwc fields.
* chain="onchain" (or "btc", "bitcoin") — new path:
1. Require the user's Bitcoin chain backend to be configured
in Amethyst Settings → Bitcoin. Throw NotSupported with a
pointer to the settings screen if absent.
2. Validate feeRateSatPerVByte (0.1 ≤ rate ≤ 1000).
3. Call Account.sendOnchainZap, which uses the existing
OnchainZapSender pipeline: build P2TR-paying tx, sign,
broadcast, publish kind:8333 receipt.
4. Return ZapResult with onchainTxid + feeSats + changeSats +
receiptEventId on success, or onchainError + onchainStage on
failure. When the failure stage is "publishing" the tx is
already on-chain — we still surface broadcastTxid so the user
can verify on a block explorer.
ZapResult gains a `chain` discriminator field plus six onchain*
fields. Lightning zaps populate the existing fields and null out
the onchain ones; onchain zaps do the inverse. The kdoc on ZapResult
explains the split.
Trigger phrases in the verb kdoc now include "send N sats onchain to
[user]" / "send Alice N sats via Bitcoin" so Gemini's matcher picks
up the onchain intent specifically.
New parameters:
* chain: String? = null — "lightning" | "onchain" (case-insensitive)
* feeRateSatPerVByte: Double = 5.0 — fee rate for onchain rail,
ignored for Lightning. 5 sat/vB targets fast confirmation under
typical mempool conditions without being aggressive.
zapEvent stays Lightning-only for now — onchain event zaps with
NIP-57 splits need a different pipeline (sendOnchainZapWithSplits)
and the result shape would be quite different. Deferred to a
follow-up if there's demand.
app_metadata.xml updated so the LLM picker pitches the dual-rail
capability to users.
Wins: reduced GeoIP memory usage (moved off heap), CircuitClosed→NotConnected
error change (affects our handler error paths), DATA-cells-on-closed-streams
fix, and a flow-control sidechannel mitigation bug fix. Nothing here directly
addresses the stuck-Tor recovery work in the prior commits, but it's a clean
overdue bump while we're in this code.
Wrapper changes required by the bump:
- arti-client + tor-rtcompat: 0.41 → 0.42 to match the new crate versions
shipped with arti-v2.3.0.
- arti-v2.3.0's tor-rtcompat no longer installs a rustls CryptoProvider
implicitly (changelog: "if the application fails to install a rustls
CryptoProvider, tor-rtcompat no longer installs one itself"). Add a direct
`rustls = "0.23"` dep with the `ring` feature and `install_default()` it
inside INIT_ONCE before runtime creation — otherwise create_bootstrapped
panics on the first TLS handshake. Keeping `ring` (same as 2.2.0
effectively used) rather than 2.3.0's new default `aws-lc-rs`, which is
heavier on Android and has known build.rs pain on aarch64-linux-android.
Heads-up for the next bump: arti-v2.4.0 will explicitly wrap TorClient in
Arc rather than implicitly having Arc-like semantics. We already wrap
explicitly so the migration is a no-op aside from potential Arc<Arc<...>>
cleanup.
Rebuilds: libarti_android.so for arm64-v8a + x86_64.
Audit of db378a1 surfaced three issues; this commit addresses them.
1) First-bootstrap self-heal storm (TorManager). On a fresh install with a
slow network the legitimate first bootstrap takes 30–60s. The 45s
stuck-Connecting watchdog used to fire resetWithCleanState, wiping an
empty state dir and adding a full bootstrap cycle of delay for no gain.
Now: track hasEverBootstrapped (flipped when status reaches Active);
pre-first-bootstrap self-heals use the gentler reset (drop client only,
keep state), post-first-bootstrap use resetWithCleanState. Wiping stale
on-disk guards only matters once we know Arti can actually work.
2) Rust destroy() race (lib.rs). The accept loop in startSocksProxy has no
.await between accept() returning and HANDLER_TASKS.push(h), so an
abort() alone is racy — a new handler can be spawned and pushed AFTER
our drain runs, which then holds an Arc<TorClient> past destroy() and
keeps the state file lock alive. Now: after abort(), await the SOCKS
JoinHandle with a 1s timeout so the listener fully terminates before
we drain HANDLER_TASKS. No new handlers can be added once the listener
is gone.
3) TOKIO_RUNTIME mutex held during block_on(sleep). The previous
`if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref()` kept the
mutex held for the full sleep duration, blocking any other JNI caller
that needs the runtime. Now: clone the runtime Handle and release the
mutex immediately. Same fix applied to stopSocksProxy.
Rebuilds: libarti_android.so for arm64-v8a + x86_64.
When Arti's in-memory TorClient gets into a broken state (bad guards from a
previous network, dead circuits, expired consensus held in memory), nothing
short of a process restart used to recover it: the JNI exposed initialize /
startSocksProxy / stopSocksProxy but no way to drop the TorClient, and the
Kotlin side gated initialize behind a one-shot AtomicBoolean. force-stop
preserved the on-disk arti/state/, toggle-off-then-on only re-bound the SOCKS
listener on the same broken client, and wiping app data was the only way out.
Rust side
- New JNI Java_..._ArtiNative_destroy: aborts the SOCKS listener task, aborts
all in-flight per-connection handlers (each holds an Arc<TorClient> clone
that would otherwise pin the state file lock), waits 500ms, drops the static
ARTI_CLIENT. Next initialize() call creates a fresh client and re-bootstraps.
- Track handler JoinHandles in HANDLER_TASKS so destroy can abort them; cull
finished ones on each accept to keep the Vec bounded.
Kotlin side
- TorService.reset() / resetWithCleanState() — drop the native client, flip
initialized=false. The second variant also wipes arti/state/ on disk to
rebuild guard selection from scratch.
- TorManager.resetEpoch StateFlow is now part of the status combine; bumping
it re-fires the INTERNAL branch which calls service.start() and runs full
Arti re-init.
- onNetworkChange (wired from ConnectivityManager.networkId distinctUntilChanged)
now calls service.reset() + clears the persisted bypass approval + bumps the
epoch. Replaces the previous clearSessionBypass() which only touched the
in-memory bypass half.
- Self-heal watchdog: when status sits at Connecting for >45s (before the 60s
connectionFailure dialog), calls resetWithCleanState. Rate-limited to one
per 5 minutes so a permanently broken network doesn't loop us. onNetworkChange
primes lastSelfHealAtMs so a slow legitimate post-network-change bootstrap
doesn't get a second reset on top of itself.
Rebuilds: libarti_android.so for arm64-v8a + x86_64 (NDK 27, 16KB-page aligned).
Once the user picked "Use regular connection" after a 60s stuck-Connecting
prompt, `lastBypassApprovalMs` was persisted to DataStore for an hour. Inside
that window the connection-failure flow silently flipped `sessionBypass = true`
on every later Connecting span instead of re-prompting, which caused the
status flow to call `service.stop()` and emit `Off` regardless of the user's
`TorType`. The DataStore-backed approval survived force-stop, and toggling
Tor off/on only cleared the in-memory `sessionBypass` half — so the next
bootstrap attempt re-triggered the silent bypass after 60s and the user was
trapped until wiping app data.
Any user-initiated `TorType` change now wipes both halves: the in-memory
`sessionBypass` flag and the persisted approval. The next stuck-Connecting
span will surface the dialog again so the user has a real choice instead of
a silent fall-back to direct.
User feedback: returning "summarize my feed" results that didn't match
what's actually on the home page is misleading. Now the verb invokes
the same `HomeNewThreadFeedFilter` the foreground UI uses, against the
same LocalCache, so the LLM sees what the user would see if they
opened Amethyst.
What this fixes:
* Reposts, polls, long-form, comments, audio, etc. — the home filter
accepts ~17 event kinds; the previous verb saw only kind:1.
* Muted users — now filtered out.
* Replies — excluded (top-level threads only, matching the UI).
* Repost dedup — same note via multiple reposts collapses to one
entry, as on the screen.
* The user's currently-selected NIP-51 follow list (custom lists,
hashtag feeds, communities) — now respected. Was previously
hardcoded to plain kind:3.
Trade-off: reads from LocalCache, so the verb reflects what the
foreground has already pulled. If the user hasn't opened Amethyst in
a while, the digest is sparse. Acceptable for "summarize what I'm
seeing" semantics — for fresh data, the other verbs (search*,
getRecentFromFollows) do their own relay drain.
Implementation:
* Event.toFeedNoteHit() generic projection — handles the broader
event range with snippet-truncation for long content.
* NoteHit gains a `kind: Int` field so the LLM can distinguish
"Alice posted a note" from "Alice published an article" or
"Alice ran a poll".
* TextNoteEvent.toNoteHit() delegates to the generic helper.
* searchArticles' inline NoteHit construction also folds into the
generic helper — one less code path to maintain.
Plus amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md
documenting the broader pattern: every Amethyst screen has a
FeedContentState driven by a *FeedFilter; we'd add one AppFunction
verb per screen, all going through the same filter pipeline the UI
uses. Lists the ~25 unmapped feeds with proposed verb names so the
work has a clear roadmap. Same pipeline will back the future MCP
server.
New verb: getFeedDigest(hoursBack, maxNotes).
Use when the user asks "summarize my Nostr feed", "give me a digest
of what my follows posted today", "recap Nostr", or any other
summary / digest / recap intent.
Returns a structured snapshot for AI summary instead of a raw note
list: total note count, unique author count, top hashtags (≤10) and
top mentioned users (≤10) — with display names resolved from the
local kind:0 cache — alongside the trimmed note body. The LLM uses
the aggregate signals to write a one-paragraph "the conversation
focused on X, with N people posting about Y" instead of having to
re-derive frequencies from a raw list.
Implementation:
* Shared core extracted into fetchFollowFeed(account, since, limit)
so getRecentFromFollows and getFeedDigest don't duplicate the
drain logic.
* Over-fetches by 3× the visible cap so stats are computed over a
larger sample than the LLM sees, capped at 500 events for bounded
on-device work.
* Hashtag bucketing: lowercases + strips leading #, so #Bitcoin
and #bitcoin collapse.
* Mention bucketing: skips self-mentions (some clients tag the
author themself, not useful for the digest).
New @AppFunctionSerializable result types:
* HashtagFrequency, MentionFrequency — count + identifier.
* FeedDigestResult — windowHours, totalNoteCount, uniqueAuthorCount,
topHashtags, topMentions, notes.
Total verb count: 22. app_metadata.xml updated so Gemini's tool
picker can pitch the summary surface specifically.
Known scope: currently returns kind:1 from the user's kind:3 follow
list — does NOT match the in-app home feed exactly. The home feed
includes reposts, long-form, polls, comments, etc., respects the
user's currently selected NIP-51 list, and filters muted users.
Aligning the digest to the home feed (via HomeNewThreadFeedFilter
against LocalCache) is a documented follow-up.
withTimeoutOrNull(deferred.await()) returns a flattened Response? —
both "timeout" and "wallet sent null" produce null, and we already
catch null via the elvis-return above. The explicit `null ->` arm in
the response switch was dead code; the compiler warned about it.
Folded the "wallet sent null we couldn't decrypt" case into the
timeout error message since they're indistinguishable to the caller.
- cover CodePoints helpers and Channel.relays() equal-count behaviour
- Two new test files in commons/src/commonTest/, both run under :commons:jvmTest.
Returning a BOLT11 invoice for the user to paste somewhere defeated
the point of "Gemini, zap Alice 21 sats". Now when the active account
has a Nostr Wallet Connect (NIP-47) wallet configured in Amethyst,
both zap verbs pay the invoice automatically over NIP-47 and report
the outcome inline.
Implementation:
* payViaNwcOrNull(account, bolt11, zappedNote) — null when no NWC
set up (caller falls back to manual). Otherwise wraps the
callback-based Account.sendZapPaymentRequestFor in a
CompletableDeferred + withTimeoutOrNull. 30s budget; if the
wallet doesn't answer in that window the caller sees an
nwcError of "wallet didn't respond within 30s" and still has
the raw invoice to fall back on.
* Decodes the wallet's response: PayInvoiceSuccessResponse carries
the preimage, PayInvoiceErrorResponse carries a typed code +
message, NwcErrorResponse covers transport-level errors, null
means "couldn't decrypt the reply" (rare — wallet misconfigured
or our signer rejected). Each case maps to a typed
NwcOutcome the verbs can render.
* ZapResult / ZapInvoice grow four fields: nwcAttempted, nwcPaid,
nwcPreimage, nwcError. The invoice is still always returned so
Gemini can show it as a manual-payment fallback when NWC isn't
configured or rejects. zapEvent attempts each split independently
— one wallet failure doesn't block the rest.
Kdoc updates note the NWC behavior so the LLM picks up "if NWC is
configured, this just works" — that's the user-visible promise of
asking Gemini to tip someone.
PR #3047 enabled iosArm64 + iosSimulatorArm64 on :commons and added
:commons:compileKotlinIosSimulatorArm64 as a CI gate, but the Phase 2
migration was incomplete — JVM-only APIs survived in commonMain and
several expect declarations had no iOS actual. Every main CI run since
the merge failed at "Compile Commons for iOS".
Migrations in commonMain
- Dispatchers.IO: add `import kotlinx.coroutines.IO` to 16 files, matching
the quartz/NostrClient.kt pattern (kotlinx-coroutines 1.11 exposes IO on
Native via this import; no shim needed).
- synchronized {}: replace with the existing KmpLock + withLock in
EOSECache, AcceptedGamesRegistry, EventDeduplicator, ThumbHashDecoder,
PeerSessionManager. Restructure two PeerSessionManager methods that
late-init vals from inside the lock — withLock returns a tuple now.
- Unicode code points: drop java.lang.Character / String.codePointAt /
String.offsetByCodePoints. Add commons/util/CodePoints.kt with surrogate
-pair-aware KMP helpers; rewrite EmojiCoder + EmojiUtils against them.
- Byte<->String: encodeToByteArray() / decodeToString() / concatToString()
in EmojiCoder, Base83, BlurHashEncoder, RobohashAssembler,
LongFormPublishAction (drops Charsets / String(CharArray) / toByteArray
no-arg).
- Math.round → Double.roundToLong in BlurHashEncoder.
- String.format → Compose Resources stringResource(res, vararg) overload
in LoadingState (FeedErrorState).
- toSortedSet → sortedByDescending { }.mapTo(LinkedHashSet) in Channel —
preserves the descending-by-relay-count iteration order callers depend
on.
- Comparator<T>: kotlin.Comparator on Native takes non-null T. Align
CreatedAtComparator / CreatedAtComparatorAddresses to compare(a, b) and
drop dead null checks in CreatedAtIdHexComparator.
iOS actuals (commons/src/iosMain/)
- WeakReference: switch from typealias to explicit `actual class`. The
expect param is `referent` (matches java.lang.ref); kotlin.native.ref.
WeakReference uses `referred`, so typealias fails the expect/actual
name-match check on Native. Add @file:OptIn(ExperimentalNativeApi).
- PlatformImage: functional IntArray-backed actual (used by BlurHash and
ThumbHash decoders at runtime); Phase 3 will swap to CGImage.
- ChessDismissedGamesStorage: in-memory only; NSUserDefaults wiring lands
with iosApp in Phase 3.
- SecureKeyStorage: stub throwing SecureStorageException. Keychain
Services binding is Phase 4 per the iOS plan.
- formattedDateTime: NSDateFormatter with "yyyy-MM-dd-HH:mm:ss" + POSIX
locale + local time zone (semantically matches the JVM
DateTimeFormatter "uuuu-MM-dd-HH:mm:ss" for post-1970 timestamps).
- checkNotInMainThread: no-op (mirrors jvmMain).
- PlatformNumberFormatter: NSNumberFormatter(.DecimalStyle), with
NSNumber.numberWithLongLong to disambiguate the NSNumber(Long)
overload set.
- isDebug: false constant; iosApp can flip via Swift `DEBUG` flag later.
Verified locally
- :commons:compileKotlinIosSimulatorArm64 + compileKotlinIosArm64 green
- :quartz:iosSimulatorArm64Test green
- :commons:jvmTest + :quartz:jvmTest green (no JVM regression)
- :quartz:verifyKmpPurity + :commons:verifyKmpPurity + spotlessCheck green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A "missing" key in values-<locale>/strings.xml is not always actionable:
Crowdin omits source-identical translations on export (translator chose
"use English" for brand terms like "Nowhere X", loanwords like "Apps",
or version prefixes like "v%1$s"). Adding source-identical fallbacks
locally is noise that the next Crowdin sync strips again; Android already
falls back to values/strings.xml at runtime.
Add a Step 2.5 sync-timestamp filter that uses the latest
"New Crowdin translations by GitHub Action" commit reachable from HEAD as
the cutoff. Keys added to values/strings.xml after that commit are
genuinely new (Crowdin hasn't exported them yet); anything older is
Crowdin's responsibility. The reachable-from-HEAD check survives the
common workflow of deleting the l10n_crowdin_translations branch after
merging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three deliverables:
1) Two new write verbs:
* zapUser(user, sats, comment?) — builds the NIP-57 kind:9734
profile zap and fetches a BOLT11 invoice from the recipient's
Lightning service. Returns the invoice — caller pastes into a
Lightning wallet (no NWC auto-pay yet). 21 sats default,
1M sats cap, 280-char comment cap.
* zapEvent(eventId, sats, comment?) — same but for a specific
note, with full NIP-57 zap-split support via
ZapActions.buildEventZapRequestsForSplits. Returns one invoice
per recipient when the post carries `zap` tags.
Total verb count: 21 (8 read for feeds/profiles, 3 read for
identity / followers, 4 read for inbox/zaps/streams, 4 write
for note/follow/unfollow/dm, 2 write for zaps).
2) Reworked every verb's kdoc first sentence into an LLM-friendly
"use when..." trigger phrase. Gemini's tool picker matches user
queries against the descriptions (we generate them via
@AppFunction(isDescribedByKDoc = true)) — phrasing like "Find a
person on Nostr by name. Use when the user wants to look someone
up..." gives the model concrete prompts to recognise instead of
internal NIP names.
Affected: searchProfiles, getRecentFromFollows, getNotesByUser,
getProfile, searchByHashtag, getActiveAccountInfo, getRecentDms,
getZapsReceived, postNote, followUser, unfollowUser, sendDm,
zapUser, zapEvent.
3) amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md —
verification protocol for testing on-device whether Gemini's
tool picker actually surfaces our verbs from natural-language
prompts. Includes specific test prompts mapped to expected
verbs, fallback diagnostics (clear AppSearch + restart), and
the conditions under which it'd be worth defining our own
@AppFunctionSchemaDefinition namespace.
Plus minor: comment parameters switched to nullable (String? = null)
because KSP rejects non-nullable types with defaults.
Phase 4 from the signer-prompt plan, scoped to Option B (refuse NIP-55
with a typed NotSupportedException). Internal-key and NIP-46 bunker
accounts can now publish from Gemini.
New @AppFunction methods:
* postNote(text) — kind:1 short text note. Caps at 8000 chars to
catch accidentally-pasted documents; publishes to outbox relays
with per-relay ack reported.
* followUser(user) / unfollowUser(user) — kind:3 contact list
update via FollowActions. Detects already-following / not-
following and returns WriteResult.unchanged() rather than
re-publishing the same kind:3. New follows stamp the relay hint
from the target's cached kind:10002 write list, mirroring
User.bestRelayHint().
* sendDm(recipient, text) — NIP-17 gift-wrap via DmActions.buildTextDm.
Resolves per-recipient relay set through DmActions.resolveDmRelays
(permissive mode — falls back through NIP-65 read to bootstrap so
Gemini users don't trip on the strict kind:10050 rule). Returns
one DmDelivery per wrap (recipient + sender's own copy).
Signer gating — requireInProcessSigner():
* Read-only signers (npub-only login) → AppFunctionNotSupportedException
"sign in with a private key or NIP-46 bunker to publish".
* NIP-55 external signers (Amber) → AppFunctionNotSupportedException
"open Amethyst directly to complete the action". Detected via
qualified class name to avoid hard-coupling the bridge to the
nip55AndroidSigner module.
* NostrSignerInternal / NostrSignerRemote — sign in-process; the
NIP-46 round-trip already suspends through .sign(), no special
handling needed.
New @AppFunctionSerializable types:
* WriteResult — { changed, eventId?, publishedTo, rejectedBy }
* SendDmResult — { messageEventId, deliveries: List<DmDelivery> }
* DmDelivery — { recipientNpub, recipientPubkeyHex, wrapId,
publishedTo, rejectedBy, relaySource }
All 19 verbs now registered in the generated dispatcher (15 read + 4
write). app_metadata.xml updated so Gemini's tool picker pitches the
broader surface, including the NIP-55 caveat.
Every verb that returned a pubkey now also returns the best-effort
display name from the local kind:0 cache. Before this commit Gemini
could only say "you got a DM from npub1abc…" — now it can say
"you got a DM from Alice" because the LLM has the field at hand
instead of having to chain another lookup.
* NoteHit gains authorDisplayName (cache-resolved, null when the
author's kind:0 isn't local yet). Applied to every verb that
returns notes: searchNotes / getRecentFromFollows / getNotesByUser /
searchByHashtag / getMyRecentNotes / getMyMentions /
getRepliesToNote / searchArticles.
* DmMessage gains fromDisplayName + sentByMe — the latter lets
the caller distinguish "Alice said X" from "I said Y" when both
appear in the same thread snapshot.
* LiveStreamHit gains streamingUrl (was missing entirely — without
it the verb is useless, you can't watch a stream you can't open)
plus hostDisplayName.
* getProfile cache-hit path now actually populates `about` — was
silently null before because the early-return branch didn't read
it out of UserInfo. Cache-miss path was always correct.
Implementation: one `displayNameOf(HexKey): String?` helper reads from
Amethyst.instance.cache (LocalCache) — the same cache the foreground
UI uses. Zero allocations beyond the lookup, no network round-trip.
Now exposing the full read-only Nostr surface to Gemini. Seven new
@AppFunction methods on top of the previous eight:
* getMyRecentNotes(limit) — author=me filter on kind:1.
* getMyMentions(limit) — p-tag=me filter on kind:1. "Did anyone @ me?".
* getRepliesToNote(eventId, limit) — e-tag=eventId filter on kind:1.
Pair with getMyRecentNotes(1) for "did anyone respond to my last post?".
* getZapsReceived(hoursBack) — drains kind:9735 receipts addressed to
the user in the window, parses the bolt11 invoice from each, sums
sats. Returns total + zap count + unique zappers + count of
receipts whose bolt11 was unparseable.
* getRecentDms(peer?, hoursBack, limit) — NIP-17 gift-wrap drain +
unwrapAndUnsealOrNull decrypt. kind:14 text DMs only for v1 (skip
kind:15 encrypted-file headers to keep payloads bounded). Widens
the `since` filter by 2 days for NIP-59's randomised-past
created_at trick, then trims back to the requested window.
* searchArticles(query, limit) — same as searchNotes but kind:30023
long-form articles. Content snippet truncated at 2000 chars so a
book-length article doesn't blow up the AppFunctions response;
Gemini can ask the user whether to fetch the full article via a
different verb.
* getLiveStreams(limit) — NIP-53 kind:30311 with status=live (uses
quartz's 8-hour staleness guard via LiveActivitiesEvent.isLive).
Returns title, summary, host npub, start time, event id.
Plus updated res/xml/app_metadata.xml so Gemini's tool picker pitches
the full surface to users.
KSP-verified — 15 verbs total in the generated dispatcher:
getActiveAccountInfo getFollowing getLiveStreams
getMyMentions getMyRecentNotes getNotesByUser
getProfile getRecentDms getRecentFromFollows
getRepliesToNote getZapsReceived searchArticles
searchByHashtag searchNotes searchProfiles
Write verbs (post, follow, zap, sendDm) still deferred behind the
signer-prompt plan in amethyst/plans/2026-05-25-appfunctions-signer-prompts.md
— no behavior change there.
After the on-device round-trip proved the AppFunctions plumbing works,
adding the verbs that make Gemini actually useful for a Nostr user.
All read-only, no signer interaction, all build on existing actions /
Account state.
* getRecentFromFollows(limit) — "what's happening on Nostr today?"
Drains recent kind:1 from people the user follows; same relay set
the home-feed UI uses (account.homeRelays).
* getNotesByUser(user, limit) — "what did Vitor post recently?"
Accepts npub or 64-hex. Prefers the target's NIP-65 write relays
when cached, falls back to the active account's home relays.
* getProfile(user) — "who is npub1xq5...?". Cache-first via
LocalCache; falls back to a short network drain for unseen users.
Returns GetProfileResult{found, profile} so callers know whether
the user just isn't in cache or doesn't have a kind:0 yet.
* searchByHashtag(hashtag, limit) — "find Nostr posts about Bitcoin".
NIP-12 `t` tag filter, lowercased to match the client convention.
* getActiveAccountInfo() — "who am I logged in as?" Diagnostic verb
returning npub, display name, follow count, outbox + DM relay
counts. Distinguishes signed-in from signed-out via a flag rather
than a magic empty result.
Plus:
* decodeUserOrThrow helper for npub/hex parsing, throws
AppFunctionInvalidArgumentException with a typed message so
callers see "expected npub1… or 64-char hex" instead of a stack.
* TextNoteEvent.toNoteHit helper — extracted from the existing
searchNotes path to avoid duplication.
* Updated res/xml/app_metadata.xml description so Gemini's tool
picker can pitch a broader summary to the user.
KSP-verified: $AmethystAppFunctions_AppFunctionInvoker now dispatches
all eight verbs (the three from the previous commits plus these five).
After fixing the missing aggregated XML, Pixel 8 logcat still showed:
D AppFunctions: Unable to resolve AppFunctionMetadata.
Comparing against Google's FilipFan/AppFunctionsPilot sample turned up
a separate metadata pointer the system requires:
<property
android:name="android.app.appfunctions.app_metadata"
android:resource="@xml/app_metadata" />
This goes on the <application> element (not the service) and points to
an XML resource — distinct from the asset-side `app_functions.xml`
that the library auto-merges onto the service. The asset metadata
declares "here are my function ids and schemas"; the resource
metadata gives the agent a user-facing summary like "Search Nostr and
read your follows" to show users before they grant access.
Without the resource, the system can find our service and our
function list but can't resolve the descriptive metadata it shows
the user — so Gemini's tool picker stays empty.
Two new files:
* amethyst/src/play/res/xml/app_metadata.xml — short description +
displayDescription. Update when the @AppFunction surface grows.
* play AndroidManifest <property> pointing at the resource.
Also dropped our explicit <service> declaration for
PlatformAppFunctionService — confirmed via the appfunctions-service
AAR that the library auto-merges that exact entry, complete with
permission + intent-filter, so our copy was redundant.
The androidx.appfunctions-compiler runs in per-module mode by default,
emitting only the dispatcher Kotlin code. The aggregator that builds
the `app_functions.xml` + `app_functions_v2.xml` assets is gated behind
a KSP argument that was off.
Symptom on a Pixel 8 running our APK:
D AppFunctions: Unable to resolve AppFunctionMetadata.
Without the aggregated asset, the manifest's
`android.app.appfunctions` property pointed at a file that didn't
exist; the System UI couldn't enumerate our @AppFunction methods so
Gemini's tool picker never saw them.
Setting `appfunctions:aggregateAppFunctions = "true"` on the amethyst
module turns the aggregator on. Verified post-build:
assets/app_functions.xml (688 bytes — manifest pointer + ids)
assets/app_functions_v2.xml (19.7 KB — full schemas + kdoc descriptions)
Both list searchProfiles / searchNotes / getFollowing with the kdoc
descriptions Gemini will render.
Library modules (commons/quartz) would set this to "false" — only the
final app emits the aggregate. We don't currently apply the KSP plugin
in any library module, so this is the only place that matters.
Two follow-up cleanups from the audit.
Base64Image.parse: when the regex matched but the data capture group
was missing, the migrated version returned an empty ByteArray. The
original threw NPE (java.util.Base64.getDecoder().decode(null)). Both
behaviors are accidents — restore the intended contract: throw the
existing "Unable to convert base64 to image" Exception explicitly.
FeedDefinitionSerializerTest gains a serializesToExpectedWireFormat
test that pins the byte-exact JSON output for a representative
multi-field feed. The legacy-Jackson migration claimed byte-identity
but only round-trip and reverse-compat were covered. Any future change
to field ordering / null handling / number formatting now fails this
test loudly, protecting users who have saved feeds on disk and any
downstream consumer expecting the stable order.