If a user's NIP-65 outbox advertises only relays that don't hold their
kind 0, profile fetching used to give up after EOSE on those relays.
filterUserMetadataForKey now widens to the account's indexer relays
once every outbox relay has either EOSE'd or is in cannotConnectRelays
and metadata is still missing. UserWatcherSubAssembler invalidates
filters on EOSE so the fallback re-evaluates without waiting for an
unrelated trigger.
Replace the up/down chevron IconButtons on each wallet card with a drag
handle, matching the pattern used across the relay-settings screens.
Reuses RelayDragState / rememberRelayDragState / draggableRelayItem /
relayDragHandle from relays/common — same gesture handling, elevation
animation, and swap-on-threshold behavior.
The handle and item modifier are only attached when there is more than
one wallet to reorder.
The wallet detail screen's Send, Receive and Transactions buttons navigated to
parameterless routes. Each destination created a fresh WalletViewModel with no
selection, so the action ran against `_defaultWalletId` (the account default)
instead of the wallet being viewed. Paying, invoicing, and listing
transactions could therefore go to the wrong wallet.
Parameterize WalletSend/WalletReceive/WalletTransactions with `walletId`,
plumb it through AppNavigation, pass it from WalletDetailScreen, and have
each screen call `selectWallet(walletId)` before operating.
Dropping both `authors` and `#p` from the kind-23195 subscription filter
fixed wallets that don't set those fields the way NIP-47 implies, but
broke purpose-built NWC relays (notably relay.getalby.com/v1) that use
`#p` as the routing key — without it the relay never delivers the
response to our subscription, so the wallet screen sits on a spinner.
Restore `#p: [client pubkey]` in the relay filter. Keep `authors` out
since that field was the one actually causing the broader interop pain.
Spec-compliant responses always carry the `p` tag, so adding it back
does not exclude any conforming wallet. End-to-end authenticity is
still enforced by NIP-04 decryption against the per-connection shared
secret and by the client-side author check in NwcPaymentTracker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug had four ingredients (per the kdoc on TorArtiNativeIntegrationTest).
We had one test for #1; now there's targeted coverage for each:
1) Native TorClient gets stuck (bad guards / dead circuits / expired
consensus) with no way to drop it in-process:
`destroy then re-initialize releases the state file lock cleanly`
(was already there — added exit-IP logging so the developer can eyeball
that the circuit actually changed across the destroy).
2) In-flight per-connection handlers holding Arc<TorClient> clones,
pinning the state file lock past destroy:
NEW `destroy aborts an in-flight SOCKS handler quickly`
Opens a SOCKS HTTPS request, lets the handler get into the data plane,
calls destroy() concurrently, asserts:
- destroy() returns within 3s (the budgeted abort+sleep window),
- the in-flight request thread dies within 5s,
- a fresh initialize on the SAME data dir succeeds afterward
(this is the actual regression net — pre-fix the orphaned handler's
Arc would keep the TorClient alive and the lock held).
3) stopSocksProxy *deliberately* preserves the running client so the
legitimate stop/start toggle is fast. We need to keep that path
working after the destroy/abort changes:
NEW `stopSocksProxy then startSocksProxy reuses the running TorClient`
Asserts the second startSocksProxy returns in < 5s — no re-bootstrap.
4) State / fd / memory leaks accumulating across many destroy/init cycles
(the watchdog can drive these forever):
NEW `survives multiple destroy then initialize cycles`
5 full cycles of initialize → startSocksProxy → fetch → destroy.
Logs per-cycle elapsed time + exit IP so degradation is observable
even when it's not yet a hard failure.
Plus two extra robustness tests:
NEW `proxies concurrent SOCKS requests in parallel`
5 in-flight HTTPS-via-SOCKS requests at once. Exercises the Rust
accept loop, HANDLER_TASKS retain-on-push, and Arc<TorClient> clone
independence under load.
NEW `destroy is idempotent`
destroy-without-init, double-destroy, init-after-double-destroy.
Cheap regression net for unwrap-on-None panics in the Rust shim.
All new tests gated by -Pamethyst.arti.integration=true alongside the
existing ones; the smoke test (`library loads and reports a version`)
still runs unconditionally on Linux x86_64. Total runtime for the slow
suite is ~10-15 minutes against Tor, depending on bootstrap luck.
Closes the test gap below the tier-1 unit tests by running the real Arti
JNI shim end-to-end on JVM. Cheaper than an emulator + connectedAndroidTest,
and exercises the exact Rust + JNI code path the Android .so does.
Three tests in TorArtiNativeIntegrationTest:
1. `library loads and reports a version` — always-on smoke check. Loads
libarti_android.so via System.loadLibrary and calls ArtiNative.getVersion.
~10ms. Catches build/link regressions (e.g. a stale .so after an ARTI
bump, a missing JNI symbol export, a forgotten rebuild on this path).
Skipped on non-Linux-x86_64 hosts with a clear message pointing at the
build-arti-host.sh rebuild step.
2. `bootstraps and proxies an HTTPS request through Tor` — opt-in via
-Pamethyst.arti.integration=true. ArtiNative.initialize → startSocksProxy
→ OkHttp-via-SOCKS → check.torproject.org/api/ip. Asserts "IsTor":true.
Regression net for the rustls CryptoProvider install we added after the
v2.3.0 bump and for the destroy/handler-abort fixes in the Rust shim.
3. `destroy then re-initialize releases the state file lock cleanly` — opt-in.
The direct unit-test mirror of the self-heal path: bootstrap, destroy, hit
the SAME data dir with initialize again, verify it succeeds without a
"state file already locked" error and that traffic still flows.
Wiring:
- New tools/arti-build/build-arti-host.sh — companion to build-arti.sh.
Cargo-builds the wrapper crate for the host target (x86_64-linux on most
dev machines, but the script maps macOS / arm64-linux too) and copies to
amethyst/src/test/native-libs/<host-tag>/libarti_android.so.
- amethyst/build.gradle.kts testOptions.unitTests.all configures
-Djava.library.path so System.loadLibrary("arti_android") finds the
checked-in host .so. Also forwards -Pamethyst.arti.integration so the
opt-in gate works from a Gradle invocation.
- Checked-in src/test/native-libs/x86_64-linux/libarti_android.so for the
most common dev/CI host (~6 MB).
Wrapper change to make the JVM path actually run:
- lib.rs: on #[cfg(not(target_os = "android"))], call
builder.storage().permissions().dangerously_trust_everyone() so Arti's
fs-mistrust check doesn't reject /tmp data dirs on hosts where parent
directories have unusual UIDs (typical in containers). Android keeps its
strict default — the app's private filesDir is already sandboxed by the OS.
Compiled-out on Android, so the shipped Android .so is functionally
unchanged.
Verified in this session:
- Smoke test passes without -P (3 tests, 1 ran, 2 skipped).
- Full unit test suite still passes.
- With -P the bootstrap tests get past Arti's permissions check; they hang
on actual relay I/O in this container because outbound TCP egress is
restricted to a CDN allow-list, not Tor relays. Tests succeed on hosts
with unrestricted outbound — see the test kdoc.
Zaps and DMs move real artifacts (money, private messages) to a Nostr
pubkey. Nostr has no global namespace, so "zap Alice" is ambiguous —
multiple users can publish the same display name. Four safeguards now
make it much harder for Gemini (or any agent) to misroute a write:
1. `expectedDisplayName: String?` on followUser / sendDm / zapUser.
Agent passes the name it understood; verb cross-checks that the
resolved profile's name / display name / NIP-05 contains it (or
vice-versa). Mismatch aborts with a typed error carrying the npub
and NIP-05 so the agent can re-prompt.
2. `requireFollow: Boolean = true` default on sendDm and zapUser.
Refuses to act on a pubkey the user doesn't already follow on
Nostr. Strongest guard against same-name impersonators — even if
the agent picked the wrong Alice, the user almost certainly isn't
following her. Override to false only when the user explicitly
approves acting on a stranger.
3. Updated kdocs instruct the agent to confirm with the user using
all three identity signals (display name + npub + NIP-05) before
invoking. The kdoc is what Gemini reads to learn the verb's
contract, so this is where the instruction goes.
4. searchProfiles now filters out hits whose NIP-05 claim explicitly
fails verification (the listed domain refuses to sign for that
pubkey). Network errors / no-claim profiles are kept (inconclusive,
not refutations). Verifications run in parallel with a 4s overall
budget; on timeout we surface all candidates rather than censor.
https://claude.ai/code/session_013NKVhEF2KqyCrV7ufaiQ6N
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.
Pure text paragraphs now render as a single Text composable instead
of individual words in FlowRow, eliminating unwanted inter-word gaps.
Co-Authored-By: Claude <noreply@anthropic.com>
Type @ followed by a name in the compose/reply dialog to see a
dropdown of matching users from the local cache. Selecting a user
inserts their nostr:npub reference. Shows avatar + display name +
truncated npub.
Co-Authored-By: Claude <noreply@anthropic.com>
Address bugs and gaps surfaced by an audit of the prior 14 commits.
JVM tests passed because of typealias / platform-type lenience that
won't hold on Native; these are real iOS compile / behavior issues.
BUG fixes (iOS compile failures):
- commons/.../Note.kt:899 — Iterable.sumOf { -> BigDecimal } is a
JVM-stdlib-only overload. Common stdlib ships sumOf only for
Int/Long/Double/Float/UInt/ULong. Replaced with fold(BigDecimal(0)).
- commons/.../Note.kt:889 — BigDecimal(it.event?.content): the quartz
expect-class constructor takes String non-null; JVM accepted nullable
via platform-type lenience and threw NPE caught downstream. Switched
to ?.let { content -> BigDecimal(content) }.
- commons/.../Note.kt:838 — `catch (e: java.lang.Exception)` -> `Exception`.
- commons/.../feeds/custom/FeedDefinitionBuilder.kt + FeedBuilderState.kt:
inline FQN `java.util.UUID.randomUUID().toString()` -> kotlin.uuid.Uuid.
random().toString() (Kotlin 2.0+, @OptIn ExperimentalUuidApi).
inline `System.currentTimeMillis() / 1000` -> TimeUtils.now() (already
used elsewhere in the codebase).
- commons/.../viewmodels/NestViewModelTest.kt: moved from commonTest to
jvmTest. The test imports NestViewModel + nestsclient, both of which
the prior PR moved to jvmAndroid. commonTest depends on commonMain
only, so the test would fail to compile for iosSimulatorArm64Test.
SUBTLE fixes:
- commons/.../UserRelaysCache.kt: the flow field used double-checked
locking on a non-volatile var. JMM hazard on Native (ARM weak memory
model) — outer fast-path could observe a partially-published
WeakReference. Added @kotlin.concurrent.Volatile.
- commons/.../util/UrlValidation.ios.kt: NSURL.URLWithString("http:")
returns non-null with scheme="http" and no host; JVM's URI.toURL()
rejects with MalformedURLException. Reject scheme-only network URLs
(http/https/ws/wss/ftp without a host) to match JVM behavior.
- commons/.../util/KmpLock.kt commonMain doc: corrected "NSLock" ->
"NSRecursiveLock" to match the actual iOS implementation.
verifyKmpPurity gate extended (commons + quartz):
- Adds patterns: System.currentTimeMillis, Thread.sleep, java.util.UUID,
kotlin.jvm.Synchronized, kotlin.jvm.Volatile.
- Each pattern paired with a hint pointing at the canonical KMP
replacement; the error message surfaces both.
- Skips lines that start with //, *, or /* to avoid false positives on
KDoc / migration notes.
Pre-stages the three iosMain actuals that the macOS CI run is most
likely to demand once it compiles :commons for Native (the dev
container can't extract the K/N LLVM toolchain to validate locally).
- KmpLock.ios.kt: NSRecursiveLock — mirrors the ReentrantLock
semantics the jvmAndroid actual exposes (reentrant per-thread).
- WeakReference.ios.kt: actual typealias to kotlin.native.ref.
WeakReference<T> — same constructor + get(): T? shape as the
jvmAndroid typealias to java.lang.ref.WeakReference<T>.
- UrlValidation.ios.kt: NSURL.URLWithString with an explicit scheme
check, since NSURL is more permissive than JVM's URI.toURL() and
accepts scheme-less relatives that the JVM contract rejects.
Lands together so the next CI run's failure mode (if any) is more
informative than "iosArm64 unresolved reference" three times over.