Commit Graph
14144 Commits
Author SHA1 Message Date
Claude e510fcceca fix(music): synthetic waveform now actually varies per track
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.
2026-05-27 14:12:01 +00:00
Claude 5f2f70b0bf fix(music): subscribe to relays, fake waveform, dedupe hashtag row
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.
2026-05-27 13:56:33 +00:00
Claude 9724931355 feat(music): voice-style audio player for audio-only tracks + follow-list spinner
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.
2026-05-27 13:10:13 +00:00
Claude 62943d8f0a fix(music): preserve tags on edit/toggle, lock concurrent toggles, search, mute
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".
2026-05-27 11:10:09 +00:00
Claude 8424140d60 chore(music): @Preview composables for every new UI surface
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.
2026-05-27 02:51:36 +00:00
Claude 2210341291 refactor(music): align Add-to-Playlist with bookmark-management UI
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.
2026-05-27 02:37:52 +00:00
Claude 058f42b6e8 feat(music): full-screen feed, composer with FAB, add-to-playlist sheet
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.
2026-05-27 02:27:20 +00:00
Claude db9f4da394 fix(music): cover IS the player — tap actually starts playback
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).
2026-05-27 01:28:15 +00:00
Claude bbb7a1282e feat(music): wire MusicTrack/MusicPlaylist kinds into feed filters, search, and labels
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)
2026-05-27 01:17:53 +00:00
Claude 336105f98b feat(music): wire music events into LocalCache consume dispatch
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.
2026-05-27 01:04:55 +00:00
Claude 973c2eeff7 feat(music): add Music Track (kind 36787) and Music Playlist (kind 34139)
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.
2026-05-27 00:30:34 +00:00
Vitor Pamplona 5fce6764b5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-05-26 18:48:06 -04:00
Vitor PamplonaandClaude Opus 4.7 cdb5e01821 fix(nwc): re-add #p to response filter for Alby relay routing
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>
2026-05-26 18:44:14 -04:00
Vitor Pamplona 2dd0166fee Better checks the id and sig before verifying the event. 2026-05-26 18:42:12 -04:00
Vitor PamplonaandGitHub f3ac87689a Merge pull request #3053 from vitorpamplona/claude/tor-stops-working-1PIcU
Add Tor self-heal watchdog + integration tests + Arti v2.3.0
2026-05-26 17:52:54 -04:00
Claude 2c89a62789 test(tor): expand tier-3 to verify each root cause of the wall-and-stop bug
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.
2026-05-26 21:43:14 +00:00
Claude e39ea55fd6 test(tor): tier-3 integration — JVM host build of Arti, smoke + bootstrap tests
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.
2026-05-26 21:34:26 +00:00
Vitor PamplonaandGitHub f2bfd7a315 Merge pull request #3052 from vitorpamplona/claude/brave-clarke-hJ0PK
onchain zaps + nip-05 filter when returning users to Gemini
2026-05-26 17:22:25 -04:00
Claude 93163141b9 feat(amethyst): anti-impersonation safeguards on AppFunctions write verbs
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
2026-05-26 21:07:38 +00:00
Claude 3517606b81 test(tor): tier-1 TorManager unit tests + tier-3 instrumented scaffold
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.
2026-05-26 20:31:32 +00:00
Claude 612e05fa62 feat(amethyst): zapUser supports onchain (NIP-BC) rail
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.
2026-05-26 20:31:29 +00:00
Claude 9a2adf091d chore(tor): bump Arti to v2.3.0
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.
2026-05-26 20:06:15 +00:00
Claude c3ddd4e7be fix(tor): audit fixes — first-bootstrap grace + tighten destroy() race
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.
2026-05-26 19:37:36 +00:00
Claude db378a105c feat(tor): self-heal — drop & rebuild Arti on network change and stuck Connecting
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).
2026-05-26 19:09:54 +00:00
Claude 321adebfe6 fix(tor): clear remembered-approval window on user TorType toggle
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.
2026-05-26 17:54:04 +00:00
Vitor PamplonaandGitHub b258028b54 Merge pull request #3051 from vitorpamplona/claude/brave-clarke-hJ0PK
Phase 2: Gemini AppFunctions bridge + CLI action verbs
2026-05-26 12:23:57 -04:00
Claude 6671cb3cbc refactor(amethyst): getFeedDigest now uses HomeNewThreadFeedFilter — mirrors the home page exactly
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.
2026-05-26 16:04:47 +00:00
Claude dc2c7f9be1 feat(amethyst): getFeedDigest verb — feed-summary surface for the LLM
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.
2026-05-26 15:58:30 +00:00
Claude 4617be2068 chore(amethyst): collapse unreachable NWC when branch
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.
2026-05-26 15:40:34 +00:00
Claude ee0942d555 Merge remote-tracking branch 'origin/main' into claude/brave-clarke-hJ0PK 2026-05-26 15:32:47 +00:00
David KasparandGitHub d1610bf976 Merge pull request #3048 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-26 16:24:44 +02:00
Crowdin Bot 153b9495da New Crowdin translations by GitHub Action 2026-05-26 14:21:22 +00:00
Vitor PamplonaandGitHub 9cd438570f Merge pull request #3050 from davotoula/fix/ios-build-failure
Complete Phase 2 KMP migration to unblock iOS CI
2026-05-26 10:19:16 -04:00
davotoula 28865f38c3 tests:
- cover CodePoints helpers and Channel.relays() equal-count behaviour
- Two new test files in commons/src/commonTest/, both run under :commons:jvmTest.
2026-05-26 15:53:37 +02:00
Claude d0f6739a30 feat(amethyst): NWC auto-pay for zapUser and zapEvent
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.
2026-05-26 13:37:05 +00:00
davotoula 771ba67f31 Code review:
- guard shared NSDateFormatter in formattedDateTime iOS actual
2026-05-26 14:53:38 +02:00
Vitor PamplonaandGitHub bb921ad643 Merge pull request #3049 from nrobi144/feat/desktop-rich-text-and-profile
feat(desktop): rich text migration, profile metadata, copy JSON, @mention autocomplete
2026-05-26 07:55:55 -04:00
davotoulaandClaude Opus 4.7 5ec60e285b fix(commons): unblock :commons iOS compile after Phase 2 target flip
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>
2026-05-26 13:38:01 +02:00
davotoulaandClaude Opus 4.7 027808ae54 skills(find-missing-translations): filter out keys Crowdin already owns
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>
2026-05-26 09:20:51 +02:00
Claude 5efb5d90e5 feat(amethyst): zap verbs + LLM-friendly kdocs + Gemini discovery plan
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.
2026-05-26 00:01:13 +00:00
Claude 8e1b31a550 feat(amethyst): four write verbs for Gemini — postNote, follow, unfollow, sendDm
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.
2026-05-25 23:41:58 +00:00
Vitor PamplonaandGitHub e93492f491 Merge pull request #3047 from vitorpamplona/claude/zealous-mendel-TK91O
Phase 1: iOS support for Quartz and Commons (KMP purity)
2026-05-25 19:38:51 -04:00
Claude ac105ca2f3 chore(amethyst): enrich AppFunction outputs so Gemini can name names
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.
2026-05-25 23:11:10 +00:00
Claude c10ed49631 feat(amethyst): Tier 2+3 read-only Gemini verbs — 15 total
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.
2026-05-25 23:06:47 +00:00
Claude 5c46d0a7b3 feat(amethyst): five more read-only Gemini verbs — full Tier 1 read surface
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).
2026-05-25 22:58:30 +00:00
Claude 0619788644 fix(amethyst): supply app_metadata so AppFunctions discovery actually works
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.
2026-05-25 22:03:46 +00:00
Claude 82188b719a fix(amethyst): generate app_functions.xml so the system can resolve metadata
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.
2026-05-25 21:50:56 +00:00
Claude 44aa262363 fix(commons): tighter Base64Image contract + pin serializer wire format
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.
2026-05-25 20:05:00 +00:00
David KasparandGitHub 97bb6a5720 Merge pull request #3046 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-25 21:04:55 +02:00
Crowdin Bot 61372313fd New Crowdin translations by GitHub Action 2026-05-25 18:55:58 +00:00