Compare commits

...
Author SHA1 Message Date
Vitor Pamplona d18b7f770f perf(feed): defer animation transitions until there is something to animate
`updateTransition` and `AnimatedContent` allocate a Transition, its animation
list and its seeking state on *first* composition — but first composition has
nothing to animate, because target and initial state are the same value. In a
feed that is waste: every card scrolled in built six of them, and during a scroll
essentially none ever ran, since reaction counts and icons do not change in the
second a card is on screen.

`DeferredCrossfade` and `DeferredAnimatedContent` render the plain content until
the target actually moves, then build the transition seeded at the *original*
value via `MutableTransitionState` and immediately re-target it — so the first
real change still animates exactly as before, and later changes animate through
the now-live transition normally. The existing `isPerformanceMode()` branch,
which genuinely drops the animation, is untouched and still takes precedence.

Measured on an SM-T220 against a frozen corpus served by a local relay (a real
capture: 105 notes, 68 profiles, 501 reactions, 75 boosts, 22 zaps), interleaved
with the unmodified build, two runs per arm:

  frame duration P90    27.53 -> 26.90   -2.3%   (baseline spread 0.1%)
  frame overrun  P90    21.65 -> 17.44  -19.4%   (baseline spread 6.3%)
  frame duration P50                     -1.1%   (inside a 1.7% spread)

Modest at the frame level by nature: on this device the main thread sits blocked
in `postAndWait` on the RenderThread for roughly two-thirds of every frame, so
composition savings largely do not surface. Removing 24 flow subscriptions per
card, every clickable, or every counter each moved `postAndWait` by only ~2%.

`DeferredAnimationTest` drives the clock manually and asserts the outgoing and
incoming content coexist mid-transition, which only a running animation does; a
regression turning the deferral into a snap fails it.
2026-09-01 15:41:05 -04:00
826fc826db feat(notifications): surface NIP-34 PR replies, merges, closes, and drafts
Amethyst already notifies on NIP-34 issues (1621), patches (1617), pull
requests (1618), and PR updates (1619), but the four remaining
participant-facing kinds arrive on the device and go nowhere:

- **1622 GitReplyEvent** — legacy comment. Deprecated by NIP-22 but still
  in the wild (any old-shape ngit/gitworkshop event, and freshly-signed
  ones from clients that haven't migrated). Was fetched by
  `NotificationsPerKeyKinds2` and stored in `LocalCache`, but no
  notification-tab kind-gate and no push consumer branch.
- **1630 / 1631 / 1632 / 1633 GitStatus{Open,Applied,Closed,Draft}** —
  merged, closed, reopened, drafted. Not fetched at all: no relay
  subscription anywhere in the app asks for them for the current user,
  and no repo-scoped fetch pulls them for a visible PR either. As a
  result `GitStatusIndex.latestByTarget` — the source of the
  "closed/merged" pill on the repo listing — could only ever populate
  for the local user's own drafts, since nothing else lands in cache.

Symptom on `main` today: someone merges your PR on a NIP-34 relay
(mine, in a recent example) and Amethyst is silent. No badge on the
notifications icon, no push, no pill on the repo row, nothing. Opening
the PR thread will surface the status through the reply pane's
engagement fetch, but the user has to know to look.

## Fix

Wire all five kinds through the four notification-plumbing layers they
have to pass through, matching the existing patch/issue/PR shape:

1. **`FilterNotificationsToPubkey.NotificationsPerKeyKinds2`** — add
   the four status kinds so `#p`=me on inbox relays actually pulls
   merges/closes for PRs and issues the user participates in. NIP-34
   status events p-tag every prior participant of the target, so a
   pubkey filter is the right primitive.

2. **`FilterRepliesAndReactionsToNotes.RepliesAndReactionsKinds2`** —
   add PR-update (1619) and the four status kinds so when a repo,
   PR, patch, or issue row is on screen the engagement `#e`=<target>
   fetch pulls their status transitions and revision chain. This is
   the wire that finally makes `GitStatusIndex` see data for anyone
   who isn't a p-tagged participant.

3. **`NotificationFeedFilter.NOTIFICATION_KINDS`** + `tagsAnEventByUser`
   short-circuit — add reply (1622) and the four status kinds so the
   in-app Notifications tab renders them. Trust the p-tag relay gate
   (same policy applied to patches/issues/PRs above), because chasing
   a chain of prior status events to reconfirm participant relevance
   would require walking events that aren't guaranteed to be in cache.

4. **`NotificationDispatcher.NOTIFICATION_KINDS`** — add the same five
   kinds so `LocalCache.observeEvents` fires the push consumer. Flip
   the constant from `private` to `internal` so the new contract test
   can pin it against the in-app feed's set without opening it to the
   whole world.

5. **`EventNotificationConsumer.consume()`** — route each of the five
   kinds to `CodeNotification.notify(...)`, matching the existing
   patch/issue/PR/PR-update branches.

6. **`CodeNotification`** — five new `notify(...)` overloads. Reply
   uses a single title string. Status kinds pick their title from the
   *target*'s kind so a 1631 on a kind-1618 PR reads "merged a pull
   request" but the same 1631 targeting a kind-1617 patch reads
   "applied a patch" (matches gitworkshop's conventions). Falls back
   to a generic wording when the target isn't yet in cache — rare,
   because the p-tag subscription pulls a status event regardless of
   whether its target has ever been seen.

7. **`LocalCache.computeReplyTo`** — add `GitStatusEvent` and
   `GitPullRequestUpdateEvent` branches so status/revision events
   thread under their target patch/PR/issue in `Note.replies`. Only
   the marked-`root` `e` tag (for status) / `parentPullRequestId()`
   (for PR update); the repository `a` tag is not a reply target.

8. **`KindDisplayName`** — wire the four status kinds plus PR + PR-
   Update into the kind→label mapping used by the relay debug screen
   (the pre-existing `kind_git_pr` / `kind_git_pr_update` strings
   already existed but weren't wired; the status labels are new).

9. **Strings** — new `app_notification_code_channel_message_reply`,
   four `_status_open/applied/closed/draft` titles plus target-kind-
   specialized applied/closed variants (`_status_applied_pr`,
   `_status_applied_patch`, `_status_applied_issue`, and the closed
   trio); new `kind_git_status_{open,applied,closed,draft}` labels.
   `translatable="true"` (Crowdin's default) so translators can pick
   up appropriate phrasing.

Nothing changes for events the user isn't p-tagged on: the relay-side
filter is still `#p`=me. Nothing changes for the four kinds already
covered: their existing branches are untouched.

## Tests

New `Nip34NotificationCoverageTest` pins the full NIP-34 collaboration
surface across the four independent kind lists that have to move
together (relay subscription, engagement fetch, in-app kind gate,
push kind gate). Miss any one and one specific transition silently
drops. Tests explain the failure mode in each assertion message.

Existing `NotificationKindsContractTest` and every other test under
`notifications/*` still passes.

`./gradlew :amethyst:compilePlayDebugKotlin` clean.
`./gradlew :amethyst:testPlayDebugUnitTest --tests
"…notifications.*"` all green (58 tests including the 4 new).
`./gradlew spotlessCheck` clean.

(cherry picked from commit 3f2c52b97a68e6e3274443682ddbeddb3dbd6fe9)

Applied from nostr proposal
819c0ccc881ced7753675f9ba6a262579eb9772d8b910d14727b5531ede52014
(branch feat/nip34-pr-notifications). Cherry-picked rather than merged via
`ngit pr merge` because that proposal is not surfaced by `ngit pr list` --
it is absent from every status and `ngit pr view` reports "proposal not
found", even though the event is well formed on relay.ngit.dev with the
correct a-tag, p-tag and r-tag.

One fix folded in on top of the original commit: the new test imported
`RepliesAndReactionsKinds2` from
`com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers`,
which no longer exists. `FilterRepliesAndReactionsToNotes.kt` moved to
`commons` (`com.vitorpamplona.amethyst.commons.relayClient.event.watchers`)
in the 537 commits since this branch's merge-base. Git followed the rename
for the production edit but not for the new test file's hardcoded import, so
the branch did not compile as submitted.

Verified on current main after that fix:
- Nip34NotificationCoverageTest: 4 tests, 0 failures.
- Full *notifications* unit-test package: 5 classes, 24 tests, 0 failures.

Premise confirmed against main before applying: NotificationsPerKeyKinds2
carried 1617/1618/1619/1621/1622 but no 1630-1633, and neither
NotificationFeedFilter.NOTIFICATION_KINDS nor
NotificationDispatcher.NOTIFICATION_KINDS listed the status kinds -- so a
merge/close on a thread you participate in was fetched nowhere and rendered
nowhere.

Open question left for follow-up, not a blocker: nothing checks that a status
event's author is a maintainer in the repo's kind-30617 announcement, so any
pubkey can p-tag you with a 1631 and produce a "merged a pull request"
notification. The notification strings name the actor, so the claim is at
least attributable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 15:53:12 -04:00
6cf09f0d75 feat(namecoin): add electrumx2.testls.space LE-cert server to default list
Add electrumx2.testls.space:50012 to DEFAULT_ELECTRUMX_SERVERS and
TOR_ELECTRUMX_SERVERS as a redundancy endpoint for the testls.space
operator. It runs on the same box as relay.testls.bit (23.158.233.10)
but terminates TLS at nginx with a publicly-trusted Let's Encrypt cert
(CN=electrumx2.testls.space, issuer LE YE1) instead of the self-signed
relay.testls.bit cert on the standard ports.

usePinnedTrustStore is left at the default (false) since the system
trust store is sufficient, same as electrum.nmc.ethicnology.com.

The same nginx vhost also exposes WSS on port 50014, making it the
second browser-viable public Namecoin ElectrumX endpoint (alongside
electrum.nmc.ethicnology.com) for pure-browser Nostr clients that
cannot use self-signed certs.

(cherry picked from commit 645382a95b9a314eb6a4e1221ba97dfc1c13f1ae)

Applied from nostr proposal
c0eb8d1a09651c377827f4aa97c1ed7a2f93fafceb823233c5650506b661b8ba
(branch feat/electrumx2-le-server). Cherry-picked rather than merged via
`ngit pr merge` because that proposal is not surfaced by `ngit pr list` --
it is absent from all statuses and `ngit pr view` reports "proposal not
found", though the event is well formed on relay.ngit.dev with the correct
a-tag and r-tag. It appears to collide with a stale earlier proposal for the
same branch name.

Endpoint verified before applying:
- electrumx2.testls.space resolves to 23.158.233.10, the same host as the
  existing relay.testls.bit / 23.158.233.10 entries, as the commit claims.
- TLS on :50012 presents CN=electrumx2.testls.space issued by Let's Encrypt
  (C=US, O=Let's Encrypt, CN=YE1), so usePinnedTrustStore = false is correct.
- server.version reports ElectrumX 1.16.0 and server.features reports
  genesis_hash 000000000062b72c5e2ceb45fbc8587e807c155b0da735e6483dfba2f0a9c770,
  i.e. it indexes Namecoin rather than Bitcoin.

Note this is the third default entry pointing at host 23.158.233.10, so it
adds certificate-path redundancy (works where a self-signed cert is stripped)
rather than host redundancy. Low risk: nameShowWithFallback tries servers
sequentially and returns on first success, and this entry is appended last in
both lists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 15:38:19 -04:00
Vitor PamplonaandClaude Opus 5 9ea7c36cf9 Merge PR: fix: narrow FileProvider external root to the app-specific dir
Merges nostr proposal 91f762d3 into main:
- fix: narrow FileProvider external root to the app-specific dir

Replaces `<external-path path=".">` with `<external-files-path>`, so the
FileProvider no longer roots at /storage/emulated/0, and adds
FileProviderPathsTest to pin both directions on device.

Supersedes proposal a5d172d8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 11:38:37 -04:00
Vitor PamplonaandClaude Opus 5 69532badee fix: narrow FileProvider external root to the app-specific dir
`<external-path path=".">` rooted the provider at
Environment.getExternalStorageDirectory() (/storage/emulated/0), which is far
broader than anything Amethyst hands out. The only external-storage consumer is
TakePicture's getPhotoUri/getVideoUri, both of which write into
getExternalFilesDir(...) — so `<external-files-path>` describes the actual
surface exactly.

Not a live vulnerability: the provider is exported="false", every
getUriForFile() call site builds its File from app-controlled constants under
cacheDir or getExternalFilesDir, and the one name derived from event content
(shareIcs) is passed through IcsExport.safeFilename, which strips '/' — so no
attacker-influenced path can reach the provider today. This is defence in
depth plus an accurate declaration.

Prefer external-files-path over hardcoding the path under
Android/data/<applicationId>/: the latter is wrong for the .debug and
.benchmark applicationIdSuffixes, while external-files-path resolves per
variant. The `external_files` name is kept so the generated content:// URI
shape does not change.

FileProviderPathsTest pins both halves on device: the capture URIs still
resolve under /external_files/, cacheDir still resolves under /cache/, and a
file at the external-storage root no longer maps. Against the old config that
last case fails with
content://com.vitorpamplona.amethyst.debug.provider/external_files/Download/not-ours.pdf.

Supersedes nostr proposal a5d172d8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 11:36:31 -04:00
Vitor PamplonaandGitHub a1f9cc96f1 Merge pull request #4023 from davotoula/fix/save-video-to-movies-dir
Save videos to Movies/ instead of Pictures/ (fixes #4009)
2026-08-31 10:13:16 -04:00
Vitor PamplonaandGitHub a0a55af174 Merge pull request #4022 from davotoula/fix/nwc-omit-null-request-params
Fix/nwc omit null request params
2026-08-31 08:50:46 -04:00
davotoula defbdfbb28 Code reviews: apply review cleanups to the media-save fix and its tests
- Extract the duplicated drive-the-save harness (temp file, runBlocking save,
  success/error assertions) from both instrumented e2e tests into
  MediaSaverTestSupport, following the AvifInstrumentedTestSupport precedent,
  with UUID-based filenames per the existing convention.
- MediaSaverToDiskMediaStoreTest: record inserted rows as item Uris
  (ContentUris.withAppendedId) instead of Pair + hand-built _ID selection; trim
  the KDoc paragraph that re-quoted the MediaProvider rejection verbatim - the
  canonical copy lives on MediaStoreTarget.
- MediaSaverToDiskLegacyStorageTest: derive watchedDirs from
  MediaStoreTarget.entries instead of a third hand-maintained directory list;
  move the exact run recipe (assemble, install -g, am instrument) into the class
  KDoc and point the skip message at it; unfold the write-probe .also puzzle.
- MediaSaverToDisk: drop the outer withContext in saveDownloadingIfNeeded (both
  delegates now dispatch themselves, leaving the decision in the leaf writers);
  scope `val extension` to the pre-Q branch that uses it; drop the rot-prone
  composable file name from save()'s KDoc.

Considered and left alone: isSaveableMimeType deriving from MediaStoreTarget.of
(kept - one definition of the accepted set beats re-spelling the prefix triple);
the nested Dispatchers.IO in save()/downloadAndSave (load-bearing for direct
call sites, fast-path no-op when nested); the redundant launch(Dispatchers.IO)
at two call sites outside this branch.
2026-08-31 14:10:07 +02:00
davotoula af49bcc396 On device testing
test: only ever delete MediaStore rows the test itself inserted
test: cover the pre-Q save path on API 26
test: cover #4009 end-to-end against a real MediaStore on API 29
2026-08-31 12:38:49 +02:00
davotoula cbd0a4a174 fix: save videos to Movies/ instead of Pictures/ (#4009)
MediaProvider validates the primary directory of RELATIVE_PATH against the
collection being inserted into. saveContentQ paired
MediaStore.Video.Media.EXTERNAL_CONTENT_URI with Environment.DIRECTORY_PICTURES,
so every video save built content://media/external/video/media +
"Pictures/Amethyst" and Android 10 rejected it with

    IllegalArgumentException: Primary directory Pictures not allowed for
    content://media/external/video/media; allowed directories are [DCIM, Movies]

Newer Android releases don't reject the mismatch, which is why the crash only
reproduces on older devices - but the file still landed under Pictures/ rather
than Movies/ everywhere, confirmed on a current device.

Collection and directory now travel together in a MediaStoreTarget enum, so the
two cannot drift apart again, and the catch-all falls through to Downloads
(which accepts any file) instead of the Video collection. The MIME type is
resolved above the SDK_INT fork and both writers route through the enum: the API
level now decides how a file is written, never which directory it belongs in, so
the pre-Q path stops filing videos and PDFs under Pictures/ too.

The directory names are spelled out as literals because Environment's DIRECTORY_*
are plain static fields that the unit-test android.jar nulls out. The JVM test
covers the routing; MediaStoreTargetInstrumentedTest pins the literals back to
the platform constants on-device.

Stop leaking a file descriptor and blocking the UI on local saves
2026-08-31 12:38:49 +02:00
davotoula 34c60fada1 Code review: omit nulls one level down too + make the null-omission guard cover every method
fix(nwc): omit nulls one level down too, inside pay_keysend's TLV records
refactor(nwc): make the null-omission guard cover every method, on every target
2026-08-31 11:39:07 +02:00
davotoula 0030432e20 fix(nwc): omit absent request params instead of sending them as null
Viewing transactions on one NWC wallet failed with

    Invalid list_transactions params: from must be an integer

because Amethyst sent every optional parameter explicitly:

    {"method":"list_transactions","params":{"from":null,"until":null,"limit":20,
     "offset":0,"unpaid":false,"unpaid_outgoing":null,"unpaid_incoming":null,"type":null}}

NIP-47 marks those optional, and a wallet is free to type `from` as an integer
and refuse a null. Nothing in the request was wrong except the nulls.

The two serialization backends had disagreed since they were written.
Nip47RequestKSerializer builds every params object with
`params.x?.let { put("x", it) }`, so kotlinx has always omitted nulls; Jackson
serializes the params classes reflectively and wrote them. The same request was
two different documents depending on the platform, and only JVM/Android was
broken — which is why it survived: the tests that cover this shape run against
the backend that was already correct.

A Jackson mixin now applies NON_NULL to all twelve NIP-47 params classes. A
mixin rather than an annotation because the classes live in commonMain and
Jackson annotations are JVM-only.

The regression test asserts the property rather than the symptom: no request
type may emit a null param, and both backends must produce the same document.
The second is the one that would have caught this.

Not new to any recent change — the reflective serialization predates it. What
changed is that 24a8540ad9 surfaces a NIP-47 refusal instead of rendering it as
an empty list, so users now see the error rather than an empty transaction
screen. Older builds sent the same request and were refused just as silently.
2026-08-31 10:52:15 +02:00
Vitor PamplonaandGitHub 36819b1011 Merge pull request #4021 from davotoula/feat/nwc-outgoing-attribution
Nwc outgoing attribution (NWC-06)
2026-08-30 17:26:12 -04:00
davotoula 985b4c2ea3 fix(nwc): only claim a binding for a zap request the provider accepted
Kotlin review found the feature's own soundness property could be false on
the wire.

lnAddressInvoice drops the zap request for a provider that does not advertise
`allowsNostr` — `nostrRequest = if (allowsNostr) nostrRequest else null` — but
assembleInvoice set Payable.zapRequest unconditionally from the request it had
built. So paying a lightning address whose provider ignores `nostr=` still
attached metadata.nostr to the payment, for an invoice whose description_hash
commits to nothing about it. Every claim the feature makes — the KDoc, the
byte-identity test, the wallet-side binding check we asked BrollyZapper to
keep strict — rests on those bytes being what the callback hashed. Here they
were not.

A conformant wallet refuses such a row, so no false attribution was displayed;
what was wrong is that we asserted a binding we could not support, and spent
the 4096-char budget doing it. lnAddressInvoice now reports the request it
actually sent, and only that is carried forward.

The size estimate also counted raw string length for `comment`, which is free
text a user typed. JSON escaping expands it — a quote or backslash to two
characters, a control character to six — so an escaping-heavy comment could
breach the ceiling unnoticed, and NWC-06 makes the wallet drop the WHOLE
object then, taking recipient_data with it. escapedLength() counts what
actually reaches the wire; KEY_OVERHEAD drops to the fixed punctuation cost
now that escaping is no longer hiding inside it.

Both paths were untested and now have regression tests.

Not changed: dropMetadataIfUnsupported still mutates the caller's Request. The
review confirmed every current call site builds a fresh request inline, and
the contract is documented on both public send functions.

Verified: quartz + amethyst suites, commons/desktopApp/cli/geode compile,
spotless clean.
2026-08-30 22:31:29 +02:00
davotoulaandClaude Opus 5 c25517c2d6 refactor(nwc): share anyToJsonElement, and drop a refresh that never refreshed
Cleanup pass over the squashed branch. Net -109 lines.

anyToJsonElement was a second copy of a private helper that already existed in
ClinkKSerializers, serializing the same Map<String, Any?> shape. Worse than
tidiness: RawJson is declared in nip01Core and registered globally for Jackson,
but the kotlinx half lived inside one NIP's package, so a RawJson routed
through Clink's copy would have been emitted as a quoted, escaped JSON string —
exactly the corruption RawJson exists to prevent. One declaration now, beside
the other kotlinx serializers at nip01Core level, and Clink picks up the RawJson
and Array branches its copy lacked.

The getFresh call in fetchTransactions is deleted. Its own KDoc claimed it
re-read capabilities "bypassing the info cache's TTL", and getFresh does no such
thing: it returns a fresh entry as-is, so the case it was written for — a wallet
that added `06` twenty minutes ago — was the one case it could not cover. It
also refreshed the SELECTED wallet while zaps read the DEFAULT one. The send
path's currentOrFetch already fetches on cold and background-refreshes on
stale, so nothing is lost. A real force-refresh would mean a relay request per
refresh press, which is a policy decision rather than a cleanup.

Three KDoc blocks documented behaviour their function no longer had after the
walletInfo refactor: prefersNip44 kept four paragraphs about waiting, and
supportsMetadata opened "WAITS ON A COLD CACHE" while doing neither. The
rationale now lives once, on the one function that waits, and supportsMetadata
is inlined into its only caller. Also deleted a comment claiming a metadata-free
method "returns before the info cache is consulted" — both call sites fetch
first, so it never did.

Smaller: RawJson becomes a data class; the unused metadata parameter comes off
PayInvoiceMethod.create(bolt11, amount); TransactionRowLabels drops a derivable
flag and a twice-computed fallback; KEY_OVERHEAD's comment now says what its
slack is for; the three blank-description tests become one loop; a test that
asserted the Kotlin stdlib now calls displayDescription(); and two test comments
had lost their backticked literal to a heredoc.

Verified: quartz + amethyst suites, commons/desktopApp/cli/geode compile,
spotless clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019upqJTtAMNNfxKCDV1xDn3
2026-08-30 22:31:29 +02:00
davotoula 790458f9a0 Field-testing fixes: timings + read request's own bytes
perf(nwc): read the wallet's info event once per send, not twice
fix(nwc): wait for the wallet's info event before deciding it lacks NWC-06
fix(nwc): send the zap request's own bytes, not a rebuild of it
2026-08-30 22:31:29 +02:00
davotoula 6f71eb0c4c Code review: seal Request + gate metadata at the choke point
refactor(nwc): seal Request so a metadata-bearing method cannot be missed
refactor(nwc): gate metadata at the choke point, not the call site
2026-08-30 22:31:29 +02:00
davotoula 04d506e81e feat(nwc): name the payee on outgoing wallet transactions
Outgoing rows in the NWC wallet history showed an arrow, an amount and a
date, with an invisible blank line where the label should be. Two causes.

The row rendered an empty string. `tx.description ?: fallback` only catches
null, and wallets send `"description": ""` for a payment with no memo, so the
row got a `Text("")` — a line with the height of a real one and nothing in it.
NwcPaymentNotifier already guarded this; the screen did not. Resolution moves
out of the composable into a pure `TransactionRowLabels`, so the behaviour is
a unit test rather than a Compose one.

And we never told the wallet who we were paying. `PayInvoiceParams.metadata`
existed and nothing set it, while a NIP-57 invoice commits to a
description_hash rather than a memo — so the wallet had nothing to lift
either. ZapPaymentHandler held the signed zap request, the lightning address
and the message at the moment it fetched the invoice, and dropped all three.

Amethyst now sends NWC-06 metadata: the zap request, the recipient's address
and the comment. `nostr` is built from the event's TYPED fields, never by
re-parsing its JSON — a verifying wallet recomputes the event id from those
values, and toAnyValue() resolves numbers with toDoubleOrNull() BEFORE
toLongOrNull(), so a round-trip would emit "kind": 9734.0 on the kotlinx path
while the JVM path stayed correct. Over NWC-06's 4096-character ceiling the
zap request is dropped and the much smaller recipient_data/comment pair
survives, so the row still names the payee instead of arriving blank.

SENT ONLY TO A WALLET THAT ADVERTISES `06` in the info event's extensions tag,
which NwcInfoEvent now parses. Users pair with wallets we do not control, and
one that types metadata narrowly would accept today's "metadata": null but
refuse an object — costing a payment for a cosmetic field. The gate sits in
NwcSignerState where the request is built rather than at the call site, so no
caller can route round it, and "not yet fetched" reads as no. Every wallet
that has not advertised receives a request byte-identical to today's; there is
a test for exactly that.

The blank-string guard is what fixes existing history, for every wallet, with
no wallet change at all.
2026-08-30 22:31:28 +02:00
Vitor PamplonaandGitHub b10be95a6e Merge pull request #4020 from vitorpamplona/claude/patch-review-apply-wen13f
Give Trusted Lists and contact cards their own extractor branches
2026-08-30 13:43:13 -04:00
Vitor PamplonaandClaude 514f4b26f0 Give Trusted Lists and contact cards their own extractor branches
kinds 30392-30395 and 30382 had no branch in SearchFieldExtractor, so
both fell through to the generic `is SearchableEvent ->` case, which puts
the whole of indexableContent() in the TERTIARY (body) tier.

For a Trusted List that whole content IS its title, and a title is not
body text. On a tiered backend the difference is large and measurable: on
search-staging, a 30392 titled exactly "Verified Human" matched the query
`Verified Human` on the same rung as a profile whose bio happens to say
"humans are amazing" - 550 against 130 000 on that schema's ladder, a
236x discount - and reached the title only through trigram substring
rather than the prefix/typo columns a title normally gets.

A contact card decomposes the same way every other kind in this file
does: petname() is a trust provider's NAME for a person - the direct
analogue of kind 0's `name`, and what a people search is looking for -
and summary() is the description beside it.

The card's topics change ROLE, and that is the one behaviour change here.
topics() is TopicTag, which is the `t` tag under another name - same
predicate, same array - so the tiers() funnel already carries every topic
as a hashtag. The old fallback therefore indexed them TWICE, once inside
the concatenated body and once in the hashtag role; they are now carried
once, in the role, and whether that is tokenized or kept as keywords is
the backend's call per IndexableFields. Since build() puts petname and
summary in the NIP-44 content, topics are the only public text on a card
this library authors, so that shape is pinned by its own test - including
that a hashtags-only extraction does not normalize to None.

Nothing else changes what is indexed, only which tier each accessor lands
in. indexableContent() is untouched, so the SQLite and filesystem stores
(the only in-tree consumers, both of which index the flat form) are
bit-identical. SearchFieldExtractor has no in-tree consumer at all - it
is the protocol surface external tiered backends read - so the app, both
flavours, and every feed are unaffected by construction.

The encrypted half of a contact card stays out of the index as before:
petName()/summary() read the public tag array only.
2026-08-30 17:26:07 +00:00
David KasparandGitHub 91d4d66461 Merge pull request #4019 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-30 17:17:58 +02:00
vitorpamplonaandgithub-actions[bot] 8a43d707e6 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-30 14:44:32 +00:00
Vitor PamplonaandGitHub de3ce56bcc Merge pull request #4018 from vitorpamplona/claude/org-json-kotlin-serialization-bxhfcr
Migrate JSON parsing from org.json to kotlinx.serialization
2026-08-30 10:41:53 -04:00
Claude 2aa4a43337 refactor: consolidate JsonObject tree accessors and pin ime envelope parsing
Follow-ups from the branch audit:

- Adds commons/util/JsonTreeUtils.kt: one shared set of total, null-safe
  JsonObject accessors (parseJsonObjectOrNull, stringOrNull, intOrNull,
  longOrNull, doubleOrNull, booleanOrNull, objectOrNull, withString) for
  ad-hoc JSON trees. Replaces the two near-identical private sets this
  branch had introduced (nappletHost's JsonEnvelope.kt, now deleted, and
  EmbeddedImeBridge's file-local helpers) and FeedDefinitionSerializer's
  identical bool/int/long copies. FeedDefinitionSerializer keeps its
  deliberately stricter isString-guarded string(), now documented, and
  NappletProtocolJson keeps its throwing accessors (rejecting malformed
  input at the trust boundary is its job). Quartz's copies stay: quartz
  cannot depend on commons.
- Adds EmbeddedImeBridgeTest (16 JVM tests) pinning parseImeEvent /
  parseSelectionGeometry: per-event parsing, defaulting of absent fields,
  the total-accessor behavior for mistyped fields, and the ime.resync
  envelope. This parser became JVM-testable when it moved off Android's
  org.json; the browser suite in tools/ime-test still owns the page side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVcZwp65oybotmq5o66foW
2026-08-30 14:29:28 +00:00
Claude 2415565ebf refactor: replace org.json with kotlinx.serialization in source files
Sweeps the last org.json usages out of the Kotlin sources and moves them
to kotlinx.serialization's JSON tree API (already the project standard):

- nappletHost: bridge/broker envelope handling in NappletHostActivity,
  NappletHostService, NappletBrowserActivity, NappletBrowserService and
  NappletFaviconSniffer now parses with Json.parseToJsonElement via new
  total helpers in JsonEnvelope.kt (absent/mistyped fields degrade to
  empty/false instead of throwing, matching the old opt* semantics).
  Adds the kotlinx-serialization-json runtime to the module (tree API
  only, so no serialization plugin needed).
- amethyst embed IME relay: EmbeddedImeBridge parses ime.* envelopes
  with JsonObject accessors; RemoteImeView and EmbeddedTabLayer build
  their outgoing envelopes with buildJsonObject.
- tools/ime-test: drops the now-stale "org.json is stubbed in JVM unit
  tests" rationale from the README and shim-events.mjs header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVcZwp65oybotmq5o66foW
2026-08-30 05:52:29 +00:00
David KasparandGitHub a370b1d8c5 Merge pull request #4016 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-30 06:19:22 +02:00
vitorpamplonaandgithub-actions[bot] e38981fd1d chore: sync Crowdin translations and seed translator npub placeholders 2026-08-30 00:13:08 +00:00
Vitor PamplonaandGitHub 7ff8e3b5ac Merge pull request #4017 from vitorpamplona/claude/ci-bugs-4npv9z
fix(i18n): unbreak main's lint; publish desktop test reports on failure
2026-08-29 20:10:11 -04:00
Claude e4d288a9c0 fix(i18n): drop three orphaned AI-writing keys from the translations
6f97faf1 removed ai_writing_help, ai_tone_more_direct and ai_tone_punchy
from the default locale when it reworked the restored AI writing helper,
but left them in all 55 translated strings.xml files. Android Lint's
ExtraTranslation reports one error per (key, locale), so
:amethyst:lintFdroidBenchmark fails with exactly 3 x 55 = 165 errors —
the count CI reports — and main has been red since #4015 merged.

Mirrors a5e2aae9, the original removal of these same keys, which touched
56 files: the default locale and all 55 translations. Nothing in Kotlin
references any of the three, so there is nothing to restore instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017shnUK5t71BkBXgTAcACbA
2026-08-29 22:42:28 +00:00
Claude f4c452a761 ci(desktop): publish test reports when the desktop job fails
build-desktop runs five test suites (:quartz:jvmTest, :commons:jvmTest,
:nestsClient:jvmTest, :cli:test, :desktopApp:test) across three OSes and
was the only test-running job with no failure reporting — test-geode,
test-quartz-ios and test-and-build-android all upload on failure.

When a test failed there, the console printed the test name and the
exception class and nothing else, and the reports died with the runner.
Run 10540's macOS leg is the case in point:

  NostrClientNegentropySyncTest[jvm] >
    multiRoundReconcileStreamsEveryEventThrough[jvm] FAILED
      com.vitorpamplona.quartz...NegentropySyncException at
      NostrClientNegentropySyncTest.kt:146

Line 146 is the runBlocking frame, so all that survives is "something
threw". NegentropySyncException carries a `detail` naming which of the
four branches fired — connect timeout, idle silence mid-reconcile,
NEG-ERR, or disconnect — and that string is what says whether the run
hit a real protocol fault or lost a race against a loaded runner. It
was unrecoverable.

Two steps, mirroring the Android job: the same pinned
mikepenz/action-junit-report annotates the failing assertion inline
(annotate_only keeps this working under `permissions: contents: read`
and on fork PRs), and the HTML reports upload on failure for the full
stack traces the annotations truncate. Artifacts are named per-OS
because the three matrix legs share a run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017shnUK5t71BkBXgTAcACbA
2026-08-29 21:14:31 +00:00
Vitor PamplonaandGitHub 12a1b571ba Merge pull request #4015 from vitorpamplona/claude/revert-text-generation-features-n4vz2r
Revert "feat: remove the AI writing helper from the post composer"
2026-08-29 16:27:32 -04:00
Claude 6f97faf167 fix(composer): address the audit of the restored AI writing helper
Bugs

- Emptying the composer left the proposals on screen. precomputeAiResults
  early-returned on short text without clearing state, and cancel() — which
  runs after a post is sent — resets every other suggestion source but not
  this one. The freshly emptied composer kept showing proposals for the note
  just published, and "Use This" pasted it back in.
- The client caches were plain HashMaps written by the nine tone coroutines
  at once. Two tones mapped to the same rewriter, so every batch raced on the
  same key and orphaned a Rewriter nothing would close. They are now
  ConcurrentHashMaps built through computeIfAbsent, and close() drains them.
- The assistant held the Activity context inside a ViewModel that outlives it.
  It now keeps the application context, and the screen passes that too, as
  MLKitImageLabelService already does.
- DOWNLOADABLE was folded into "unavailable" and nothing ever called
  downloadFeature(), so on a device whose model had not been fetched the
  feature could never start. Status is now re-read (throttled) while it is not
  ready, and the model is requested once when the user has the setting on.
- lastComputedText was stamped before inference, so a cancelled run marked
  that text as done and returning to it showed nothing. It is stamped after
  the run completes.
- The Settings toggle was read as a plain StateFlow value, so turning it off
  did not hide the panel. The screen collects it now.
- precomputeAiResults/showAiPanel touch a lateinit accountViewModel; they now
  guard it like the functions above them.

Performance

- Language detection ran once per tone over identical text; it is memoized per
  text, so a batch detects once instead of nine times.
- MORE_DIRECT and PUNCHY issued the same request as PROFESSIONAL and SHORTER
  — ML Kit has no other output type for them — so two of nine inferences were
  wasted and two chip pairs rendered identical text. Both tones are dropped.
- Applying a proposal cleared lastComputedText, and the programmatic edit
  re-entered onMessageChanged, so accepting a suggestion immediately queued a
  fresh batch over it. It now remembers the applied text.
- Inference blocked on future.get(), which coroutine cancellation cannot stop,
  so abandoned batches kept running. Futures are awaited through
  suspendCancellableCoroutine and cancelled with the coroutine.
- Drafts under 20 characters no longer spend the model at all, and proposals
  identical to the draft are dropped instead of becoming a chip.

Cleanup

- Deletes MockWritingAssistant (shipped in main behind a dead flag, carrying
  its own "remove before shipping" note) and the unused AiWritingHelpButton.
- Hides the Settings tile on F-Droid, where the assistant is a no-op.
- Panel takes an ImmutableMap; the ML Kit language constants are mapped
  explicitly instead of relying on the two APIs numbering them alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfjXrjHdJ435kQwyp2HnL
2026-08-29 20:18:34 +00:00
Vitor PamplonaandGitHub 7665acb878 Merge pull request #4011 from davotoula/fix/rapid-settings-toggles-lost
fix: rapid settings toggles are silently discarded
2026-08-29 16:06:05 -04:00
davotoulaandClaude Opus 5 f150696a26 fix: wait out the second instead of stamping created_at in the future
Review feedback on the PR: created_at has second resolution, so nothing
on nostr can replace an address more than once per second — and a client
that keeps out-stamping the previous version drifts a second further into
the future per republish, which relays may reject.

That is right, and it points at a better guard than `+ 1`. One second is
the real floor on how often an address can be replaced, so a client that
replaces one faster should wait for the clock rather than invent a
timestamp. awaitCreatedAtToSupersede suspends until the second the
previous version claimed has passed, then stamps the real time — the new
version still wins, and no event is ever dated ahead of the clock.

The wait is bounded (MAX_SUPERSEDE_WAIT_SECONDS). A version further ahead
than that came from another device's skewed clock rather than this
client's own burst, and sleeping it out could take hours, so past the
bound out-stamping is still the only way to supersede.

Applied to the two paths that can accumulate drift across repeated edits
and were already suspending under a mutex: the NIP-78 settings blob and
the per-d-tag app recommendations. RoomParticipantActions keeps the
non-suspending form — it is reached from Compose click handlers, and its
stamp derives from the single event being acted on, so it sits at most one
second ahead and cannot drift.

Note the debounce added earlier already keeps the settings pickers from
publishing sub-second at all (measured on device: 23 rapid toggles → 3
events, each stamped at the true wall-clock second, the `+ 1` never
firing). This makes that a guarantee rather than a consequence of timing,
and extends it to the settings paths that are deliberately not debounced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Voa2KcknNffhvPqsRG92hx
2026-08-29 19:34:36 +02:00
Claude 3eff5f2d87 Revert "feat: remove the AI writing helper from the post composer"
This reverts commit a5e2aae960 (PR #3979),
bringing the on-device AI writing assistant back to the post composer:

- restores the WritingAssistant abstraction with its play (ML Kit GenAI /
  Gemini Nano) and fdroid (no-op) implementations, the mock, and the
  AiWritingHelp panel/button
- restores the AI state, the precompute job and the lifecycle wiring in
  ShortNotePostViewModel and ShortNotePostScreen
- restores the genai-proofreading, genai-prompt and genai-rewriting
  dependencies
- restores the "Propose text improvements" setting end to end: the Compose
  Settings tile, automaticallyProposeAiImprovements in UiSettings /
  UiSettingsFlow, the ui.propose_ai_improvements DataStore key, and the
  ai_writing_* / ai_tone_* strings in every locale

The one deviation from a straight revert: initWritingAssistant now takes a
`Context` by its simple name instead of the inline fully-qualified name the
original had, since the file already imports it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfjXrjHdJ435kQwyp2HnL
2026-08-29 17:19:24 +00:00
davotoula bfcff43868 fix: stop reporting coroutine cancellation as a signer failure
java.util.concurrent.CancellationException extends IllegalStateException,
so reportSignerErrors' trailing `catch (e: IllegalStateException)` arm was
swallowing every cancelled signer coroutine and showing it to the user as
a "signer not found" toast carrying the raw exception text —
"JobCancellationException: StandaloneCoroutine was cancelled;
job=StandaloneCoroutine{Cancelled}@3ecbac".

Latent since the arm was written: nothing cancelled those jobs, so it
never fired. The navigation pickers' debounce cancels a superseded
publish on every rapid edit, which made it fire on essentially every
fast toggle — confirmed on device, and confirmed absent again with this
change. Swallowing it also broke structured concurrency, since the
cancellation never propagated.

Caught by device testing of the debounce, not by review
2026-08-29 19:10:45 +02:00
davotoula ef22469410 Code review:
fix: publish picker edits on the account scope, not viewModelScope
refactor: one home for the replaceable-event republish timestamp
2026-08-29 19:10:45 +02:00
davotoula be7f099b7f Batch navigation picker edits
perf: publish navigation picker edits once the toggles stop
fix: stop rapid settings toggles from overwriting each other
2026-08-29 19:10:44 +02:00
Vitor PamplonaandGitHub 5ea4d6770e Merge pull request #4014 from davotoula/feat/persist-drawer-section-collapse
feat(drawer): remember which side-menu sections are collapsed
2026-08-29 12:57:44 -04:00
David KasparandGitHub 8d457de93e Merge pull request #4012 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-29 18:52:26 +02:00
davotoula 72324011a4 feat(drawer): remember which side-menu sections are collapsed
Closes #4010.

The drawer's section headings (You / Navigate / Feeds / Create / System)
fold away on tap, but CollapsibleSection kept that in a local
`remember { mutableStateOf(true) }`, so every heading sprang open again
on the next launch. The state is now hoisted out of the composable and
mirrored to the shared `ui.*` DataStore.

Device-global and never published: which headings you have folded is a
per-device view choice, unlike the hidden rows beside it in the same
drawer, which stay per-account and NIP-78-synced.

The preference stores the *collapsed* headings rather than the expanded
ones, for the same reason DrawerItemVisibility stores the hidden rows: a
heading nobody has ever collapsed simply isn't in the set, so a section
added in a later release opens expanded for everyone with no migration,
and the stored default is exactly the stock drawer. Names, not ordinals,
so reordering DrawerSectionId renames nothing by accident and a value
left by another build costs that one heading rather than the whole read.

DrawerSectionCollapsePreferences takes the DataStore rather than a
Context, which lets a plain unit test drive the full save/restore cycle
against a temp file: toggle, cancel the scope, then build a second
instance over the same file — what a relaunch does.
2026-08-29 18:22:28 +02:00
vitorpamplonaandgithub-actions[bot] 4d578006db chore: sync Crowdin translations and seed translator npub placeholders 2026-08-29 15:46:25 +00:00
Vitor PamplonaandGitHub 42652e6b36 Merge pull request #4013 from vitorpamplona/claude/parser-npub-detection-upwxad
Parse bracketed NIP-19 entities and fix token refresh race
2026-08-29 11:43:39 -04:00
Claude c6916d49d1 refactor: keep the bracket-peel helper off the public surface
`nip19OpeningPunctuationLength` has one caller, inside this file. Nothing
outside needs it, and the behaviour is covered through `parseText`.
2026-08-29 15:34:47 +00:00
Claude f4bf36030b perf: price the NIP-19 bracket peel, and make it free
The peel added a check to the per-word segmenting loop, which every word of
every rendered note walks. Measured on a 68 KB / 12,992-word plain-prose note
(no brackets, no entities — where the check can only cost and never pay),
median of 3 JVM runs:

  no check (main)          1,523,109 ns/op    —
  CharArray + `in`         1,601,363 ns/op    +5.1%
  `when` over char consts  1,536,011 ns/op    +0.8%

`CharArray.contains` is a linear scan, and a miss — the answer for nearly every
word — compares against all twelve before rejecting, at ~6 ns/word. A `when`
over char literals compiles to one lookupswitch and lands inside run-to-run
noise (its three runs straddle main's).

Adds RichTextParserBenchmark alongside the existing prodbench suite so the
per-word loop has a standing guard.
2026-08-29 15:27:25 +00:00
David KasparandGitHub 3b4ac12eb1 Merge pull request #4007 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-29 17:09:18 +02:00
Claude 0d9b2d8d29 fix: never hand a completed sign-job to a new Blossom token caller
`signOnce` retired the in-flight entry from `invokeOnCompletion`, which runs
when the job ends — after `fresh.complete()` has already resumed the awaiting
caller. In that window the map still holds a *completed* deferred, so the next
caller took the leader/follower branch and was handed the token that job had
already signed instead of signing a new one.

A caller whose token has just expired does exactly that: `header()` misses the
cache, reaches `signOnce`, and gets the expired token straight back.
`BlossomReadAuthTokenProviderTest.refreshesAfterExpiry` closes that window
immediately, so it hit the bug on every run and has been failing on main.

Remove the entry before completing it. `invokeOnCompletion` keeps its
now-idempotent removal as the cancellation safety net.

The test also asserted the re-signed header differed byte-for-byte from the
first. That cannot hold: the injected `clock` only drives the cache TTL, while
BlossomAuthorizationEvent takes `created_at` from `TimeUtils.now()`, so two
signings in the same second produce identical events. Count signatures instead,
which is what "must be re-signed" actually means.
2026-08-29 15:00:56 +00:00
Claude 2f220656f8 fix: detect NIP-19 entities wrapped in brackets or quotes
`wordIdentifier` classifies a word by its first character, so a bare
`npub1…`/`@npub1…` glued behind an opening bracket or quote — as in the
kind 1111 comment `(@npub1hgvtv4z…)` — never reached
`startsWithNIP19Scheme` and rendered as plain text.

The `nostr:`-prefixed spelling was unaffected: the URL detector finds the
URI inside the parentheses and `fixMissingSpaces` splits it into its own
word. Bare entities are not URIs, so nothing separated them.

Peel a leading run of opening brackets/quotes off into its own
`RegularTextSegment` when a NIP-19 scheme follows, which is what the
`nostr:` path already produces. Trailing punctuation needs no handling —
it is already captured as the entity's `additionalChars`.
2026-08-29 14:25:19 +00:00
vitorpamplonaandgithub-actions[bot] b71f26917a chore: sync Crowdin translations and seed translator npub placeholders 2026-08-29 01:56:07 +00:00
Vitor PamplonaandGitHub 16b4bc9197 Merge pull request #4008 from vitorpamplona/claude/profile-card-kind-0-design-9intah
Add kind-0 profile card rendering in feed
2026-08-28 21:53:02 -04:00
Claude c857bfe064 fix(profile-card): audit fixes — preview collision, self-follow chip, ripple, allocations
Correctness:
- ProfileCardPreview reused NoteHeaderMarkersPreview's pubkeys and metadata
  event ids ("a"*64 / "e1"*32). LocalCache is process-wide across previews and
  consuming a kind:0 no-ops on a duplicate id or a non-newer createdAt, so
  whichever preview rendered first won and this one showed {"name":"Vitor"} —
  the exact layout it exists to check. Now uses keys nothing else claims.
- "Follows you" now hides on your own card. A self-follow in your own kind:3
  is common, and the chip had no isLoggedUser guard (the follow button did).
- The website chip passed `clickable` as Surface's outer modifier, above
  Surface's own shape clip, so the ripple painted a square over the pill.
  Clip first.
- Drop `profile_card_followers`; reuse the already-translated
  `number_followers` ("%1$s Followers") instead of shipping a new key.

Allocation / recomposition:
- `pubkeyDisplayHex()` hex-decodes and bech32-encodes the key, and ran on
  every recomposition whenever metadata hadn't arrived. Remembered.
- The banner's gradient Brush was rebuilt on every recomposition; remembered
  on the background color. Static modifier chains hoisted to file scope, and
  the "@handle" / "(pronouns)" concatenations remembered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D17W8C3bYwwo2mWGm3QCnS
2026-08-29 01:43:36 +00:00
Claude f40b80eb9c feat(threadview): render kind 0 as a profile card in NoteMaster too
`FullBleedNoteCompose` (the thread/detail renderer behind `NoteMaster`)
keeps its own kind dispatch, separate from `RenderNoteRow`, so a kind:0
opened there still fell through to the raw-JSON text fallback. That path
is reachable: an inline `nostr:naddr…` pointing at a kind:0 navigates to
`Route.Note(aTag)`, and a NIP-22 comment rooted on a profile loads the
kind:0 as the thread's root.

Same `RenderProfileCard`, added at the head of the chain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D17W8C3bYwwo2mWGm3QCnS
2026-08-29 01:00:17 +00:00
Vitor PamplonaandGitHub 029c40ebb4 Merge pull request #4006 from vitorpamplona/claude/amy-status-redesign-xjgo7u
feat(cli): redesign `amy status` around who is signed in and what they saved
2026-08-28 20:55:28 -04:00
Claude cd5441be44 feat(cli): surface relay config, follows, and account selection in amy status
Audit of everything amy keeps under `~/.amy/` against what `status` showed.
Six gaps, all verified against a real data dir rather than reasoned about.

**No account selected.** With a stale `current` pin, or several accounts
and no pin, every verb but `use`/`status` dies at account resolution
("pins 'ghost' but … doesn't exist", "multiple accounts … pick one") —
and status, the command you run to find out why, showed nothing wrong.
It now leads with the cause and the fix. New `current_exists` in JSON.

**Relay config was invisible.** kind 10002/10050 are the first thing
`amy relay add` writes and every account has them, yet status said
nothing about where the account talks. Now `3 relays (2 write, 2 read)`
and `DM inbox on 1 relay`. The read/write split follows NIP-65, where a
bare `r` counts for both, so the two can exceed the total.

**Follows.** kind 3 — the other headline number of a nostr account.

All three come from the existing single multi-kind query on the account's
pubkey, so they cost no extra store round trips.

**"a published key package" was wrong.** It is backed by
`marmot/keypackages.bundle`, which is local private MLS material — the
old field name `key_package_published` had the same lie in it. Now "a
Marmot key package".

**Marmot messages.** `FileMarmotMessageStore` writes `<group>.messages`
in the `groups/` dir status already lists, so group chat history was
sitting there uncounted: `2 Marmot groups, 5 messages`. Counted by
streaming newlines, not by reading files in.

**The operator key.** `~/.amy/operator/` is a machine-level GrapeRank
signing identity that `listAccounts` skips as a reserved name — the one
thing under `~/.amy/` nothing reported. Now a footer line when present,
via a new read-only `OperatorKeys.peek` that needs no SecretStore and
mints nothing (the instance API creates a master on first use).

Considered and left out: decrypted DM counts (needs the signer, would
break the no-prompt promise); git repos, mute lists, bookmarks, search
relays (long tail — each is its own verb, and adding them all rebuilds
the wall of zeros this redesign removed); store size (that's
`amy store stat`); nutzap info (always published with the wallet).

Gathering moves behind `StatusReport.overview()`, which now returns an
`Overview` carrying selection state and the operator alongside the
accounts, so the command stays parse-call-emit.

JSON: adds `current_exists`, `operator`, and `saved.{follows, relays,
relays_write, relays_read, dm_relays, marmot_messages}`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtKhnNBSr9CWnZyjTWu7dL
2026-08-28 23:26:29 +00:00
Vitor PamplonaandGitHub 16ef96279f Merge pull request #4005 from vitorpamplona/claude/trusted-list-ptag-score-qprze4
Trusted Lists: a 0–100 member score and the kind-10040 Treasure Map entry
2026-08-28 19:15:51 -04:00
Claude 81e39fe29b feat(feed): render kind 0 as a profile card
A kind:0 in the feed fell through to the generic text renderer, so it
showed up as the raw profile JSON — and tapping it opened the bare note
view. Both are now handled:

- New `RenderProfileCard` (amethyst/ui/note/types/ProfileCard.kt) renders
  the metadata event the way the profile screen it opens does: banner
  faded into the card background, a ringed avatar overhanging the banner,
  the follow/unfollow (or unhide) action beside it, display name with
  custom emoji + pronouns, the @handle, the NIP-05/status line, a
  4-line bio, and a chip row for follower count, "follows you", website,
  lightning address and the bot flag. Chips only appear when the profile
  actually carries the data, so a name-only kind:0 stays clean. Tapping
  anywhere on the card opens the profile.
- `routeForInner` now maps `MetadataEvent` to `Route.Profile`, so quotes
  and `nostr:naddr` deep links to a kind:0 land on the person instead of
  the generic note screen.

Everything reuses existing pieces (BannerImage, BaseUserPicture,
ObserveDisplayNip05Status, ShowFollowingOrUnfollowingButton) — the card
adds layout only, no new profile plumbing.

Adds a `ProfileCardPreview` over real notes seeded into LocalCache
(full profile / name-only / bot) so the layout can be reviewed in both
themes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D17W8C3bYwwo2mWGm3QCnS
2026-08-28 23:07:39 +00:00
Claude 340fbee132 refactor(cli): give each amy status saved item its own line
A busy account lists six or seven footprint items. Joined with `·` and
wrapped at 78 columns they read as one run-on sentence that has to be
parsed; a column of short lines scans in one pass:

  saved: 128 events (newest 2h ago)
         3 contacts
         2 Marmot groups
         a published key package

Drops the wrap machinery (`appendWrapped`, the fixed WIDTH) for a plain
hanging indent. `saved: nothing yet` is unchanged, and so is `--json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtKhnNBSr9CWnZyjTWu7dL
2026-08-28 22:53:59 +00:00
Claude 1da2c32df3 feat(cli): redesign amy status around who is signed in and what they saved
The old output was a generic key/value dump: 12 fields per account, most
of them `no` or `0`, plus a `store` block that was wrong on the default
backend — it always walked the FS store path (`shared/events-store`),
so a SQLite install (the default since AMY_STORE landed) reported
`events: 0` no matter how full the database was.

`status` now answers two questions and drops everything else:

  alice (current)
    Alice Jones · alice@example.com
    npub1hje47kz5qeneyqrxc9nzgmz06ml6l9lguqv0qtsz4rkwqkmf636qvg4sz3
    local key, in the login keychain
    saved: 128 events (newest 2h ago) · 3 contacts · 2 Marmot groups

WHO: the profile name/NIP-05 amy holds locally (read from the account's
own kind:0 in the store — new), the npub, and one plain-English sentence
for the signer instead of three fields (`signer` + `key_storage` +
`can_sign`). Plaintext key storage is called out in yellow.

WHAT'S SAVED: the account's own events in the store and when the newest
one landed (new), contacts, Marmot groups, key package, Concord
communities (new — never reported before), Cashu wallet, DM cursor.

The rule that keeps it short is "absent is silent": anything an account
doesn't have is omitted rather than printed as `no`/`0`, so a fresh
account is four lines and says `saved: nothing yet`. Two accuracy fixes
fall out of that: the self-alias `init` writes is no longer counted as a
saved contact, and the Cashu wallet is detected from a real kind:17375
in the store rather than from `cashu.json`, which only ever held NUT-13
counters. A directory whose `identity.json` won't parse now says so
instead of suggesting `init`, which would mint a new key over it.

Event-store size, backend and kind histogram move out entirely — that is
`amy store stat`, which had its own (correct, backend-aware) version all
along.

Mechanics:
- `Output.emit(result) { color -> … }`, an internal overload for a command
  with a purpose-built human rendering. JSON mode is untouched.
- `StoreFactory.openExistingShared(root)` opens the cross-account store
  only if it already exists, so this read-only command never leaves an
  empty database behind — covered by a test.
- `StoreCommands.fsStat` now calls `StoreStats.of`, which it had
  duplicated line for line; `status` was `StoreStats`' only caller and no
  longer needs it. Same output, ~50 fewer lines.
- Split into StatusCommand (dispatch) / StatusReport (data + JSON
  contract) / StatusText (rendering) to stay under the module's file-size
  convention.

JSON contract change (per DEVELOPMENT.md principle 5): `store` and
`account_count` are gone; `hex` is now `pubkey` per the documented
convention; per-account footprint fields move under `saved`; adds
`profile_name`, `nip05`, `saved.events`, `saved.newest_event_at`,
`saved.concord_communities`. No in-tree consumer read the old shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtKhnNBSr9CWnZyjTWu7dL
2026-08-28 22:25:08 +00:00
Claude cdfc7ddb17 test(quartz): drop a redundant safe call the compiler flagged
assertTrue(entry?.isGeneric == true) smart-casts entry to non-null, so
the next line's ?. was dead and the build warned on it. Assert
non-nullness once up front and read the fields plainly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp
2026-08-28 22:17:18 +00:00
Claude 42c297104c fix(quartz): address the audit of the Trusted List work
Three bugs, each one where a write could produce a tag the matching read
refuses -- so the entry can never be found again, and every later write
appends instead of replacing.

replaceTrustedListProvider matched only generic entries but wrote
whatever it was handed. A named write therefore deleted the kind's
generic delegation -- a live delegation, gone irrecoverably, since 10040
is replaceable -- while never finding its own entry, so it duplicated on
every call. Replace and remove now address an entry by kind AND name, the
pair the first element encodes.

TrustedListProviderTag and ServiceProviderTag both let a constructor
write a kind their own parse rejects: outside 30392-30395 for the first,
outside NIP-85's 30382-30385 for the second. Both now require it, making
the unreadable state unrepresentable rather than silently accumulating.

That second bound, added in the previous commit on the read side only,
had regressed `amy graperank register --service 30392:podcaster`: the
dedup probe reads through the parser, so it appended a fresh duplicate
per run, and unregister could never match one. The CLI now rejects a
non-assertion kind with bad_args instead of writing a 10040 that grows a
tag per invocation.

Performance: the member scans that return one entry per tag -- members(),
memberValues(), linkedPubKeys/EventIds/AddressIds -- go through a
presizing fastMapNotNullDense instead of the stdlib mapNotNull, whose
capacity-10 start costs ~20 array copies on a 5k-member list. Deliberately
NOT applied to the sparse scans beside them: picking two discovery tags
out of thousands would allocate a thousands-wide array to hold two, which
is worse than the growth it avoids. The operator's KDoc says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp
2026-08-28 21:39:03 +00:00
Claude dfddf35e40 feat(quartz): carry Trusted List Map entries in both halves of the 10040
A 10040 keeps half its delegations NIP-44 encrypted in content -- who you
trust to rank the network is itself sensitive -- and the previous commit
only reached the public tags. The parsing was never the gap: it is
TagArray-level, so a caller merging the halves (commons'
PrivateTagArrayEventCache, which is how the app reads NIP-85 providers)
already got private entries out of trustedListProviders(). What was
missing was the event-level surface.

Reading now splits explicitly. publicTrustedListProvider(kind) is the
public tags alone; trustedListProvider(kind, signer) merges both halves
and falls back to the public half with anyone else's signer rather than
failing, matching TrustProviderListEvent.privateTags. Public tags are
searched first, so a Map that violates the invariant across halves
resolves to its public entry.

Writing takes isPrivate and maintains the invariant ACROSS halves: at
most one generic entry per kind is a property of the Map, not of one
half, so the write also drops the entry from the other side. Moving a
delegation between public and private is one call instead of a two-step
that strands a twin -- shadowed on read, republished forever after.

That costs the property the earlier version had of never needing
decryption: a public write on a Map with a private half must open it,
because we cannot drop a twin we cannot read. It throws
UnauthorizedDecryptionException rather than publish a Map that breaks the
invariant. A Map with no private half needs no decryption either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp
2026-08-28 20:37:47 +00:00
Claude 2ffc5ff49d feat(quartz): read the Trusted List entry in a NIP-85 Treasure Map
A 10040 delegates each assertion kind+metric with
["30382:rank", <pubkey>, <relay>]. Trusted Lists extend the Map with a
generic bare-kind entry, ["30392", <pubkey>, <relay>] (Tapestry ADR
tl-treasure-map/0001), where one entry delegates every list of that kind
and names are never enumerated. Quartz could not see it at all: parsing
went through ServiceType, which requires a `:`, so the entry fell out as
unparseable and the delegation was invisible.

Two further gaps came out of probing the same path:

An entry whose relay hint is the empty string -- what a publisher writes
when it has no relay configured, keeping the three-element shape -- was
dropped whole, taking the pubkey with it. The pubkey is the part a
consumer cannot do without, so relayUrl is nullable here and the
delegation stands without a hint.

A reserved named entry, ["30392:podcaster", ...], splits into two
segments exactly like "30382:rank" and was being handed to NIP-85
consumers as a live provider -- the one thing the spec says readers must
not do with them. ServiceProviderTag.parse is now bounded to NIP-85's own
assertion kinds (30382-30385), so those entries route to the Trusted List
parser instead of the rank/follower-count lookups. Nothing is lost, only
sorted: named entries parse, carry isGeneric = false, and drive nothing.

Readers resolve duplicate generic entries first-occurrence-wins, so two
readers of one Map pick the same publisher. Writers go through
replaceTrustedListProvider, which swaps the entry in place, collapses
duplicates for that kind, and preserves every other tag verbatim -- 10040
is replaceable, so anything dropped on an update is gone from the Map for
good. Content is carried across untouched, so the write needs no
decryption permission.

Kept in experimental/trustedLists/treasureMap rather than the NIP-85
package: this is a pre-NIP extension riding on that kind, and a NIP-85
consumer should stay unaware of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp
2026-08-28 20:05:20 +00:00
Claude 9fae1e526e feat(quartz): give the trusted-list member score a 0-100 scale
The member tag has carried its score at index 3, right after the relay
hint, since the family landed -- but as a bare Int with no domain. A
number nobody agreed on the ceiling for cannot be compared across two
publishers, or even across two metrics of one publisher, which is the
whole reason a list carries scores instead of just membership.

Pin it to a percentage: an integer 0..100 inclusive, named once in
MemberTagFields.SCORE_RANGE and shared by `p`, `e`, `a` and `i`.

Write clamps into the range, so we never emit a value we would refuse to
read. Read drops anything outside it rather than clamping: a publisher
counting on some other scale (0..1, 0..1000, a raw endorsement tally) is
reporting a quantity this field cannot carry, and pinning 950 to 100
would rank that member above every honestly-scored peer. The member
itself still stands -- it is simply unscored, the same state as a tag
that carries no score at all.

Both bounds are real scores, not sentinels: 0 means "scored, and the
publisher has no confidence in this member", which is not the same as
unscored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp
2026-08-28 19:26:10 +00:00
Vitor PamplonaandGitHub e5e6076039 Merge pull request #4004 from davotoula/fix/logging-hygiene
perf(logging): defer message construction to the lambda overload
2026-08-28 13:59:09 -04:00
David KasparandGitHub 163b272ae8 Merge pull request #4003 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-28 19:36:46 +02:00
davotoula b06093e9a2 fix(logging): finish the sweep the line-anchored patterns missed
The audit that produced the previous two commits used line-anchored greps, so
a call formatted across several lines was invisible to it. That selected for
short calls rather than expensive ones, and it shows: BootRelayDiagnostics had
three one-line banner calls converted while two Log.d calls in forEach loops
immediately below them — 25 and 20 iterations per census, each concatenating
five interpolated segments with nested joinToString — were left eager. Those
are the larger cost by a wide margin, and Log.d is dropped in every build.

Convert them, plus the multi-line census header and one in
AccountConcordActions. The three multi-line calls left in AccountCacheState
pass a throwable and carry static messages, so the eager form is correct there.

Also from review: extract the duplicated sort+join chain the two census
summaries shared; drop "${e.message}" from two AccountCacheState calls that
already pass the throwable (the inverse of the bug the first commit fixed, and
pre-existing); hoist refusalReason() in BlossomPaymentHandler, which computed
it twice on the same branch.

Record the rule in CONTRIBUTING-WITH-AI.md's existing Logging section, which
already owns the lambda-Log guidance — the previous commit put it only in the
skill, which is read only when the skill is invoked. Add a comment to
Amethyst's init block explaining why Log.minLevel is set there and not in
onCreate: init runs in every process, including the :napplet sandbox whose
onCreate early-returns, so moving it would leave that process at DEBUG.

The skill gains the multi-line step, and its own errors are fixed: it said
"Two" above a three-item list, Step 3 still used the anchored pattern and
short name list that Step 2 had just been corrected for, and the verify
command used grep -c, which counts lines and so undercounts. Step 4 becomes
Step 0 and moves above Step 1 — it gates the others, and saying so five times
in a document that ordered it last was the symptom.
2026-08-28 19:29:55 +02:00
davotoula 1d420dc406 docs(skill): correct find-non-lambda-logs from a real audit
Three things the 2026-08-28 pass got wrong because the skill told it to.
2026-08-28 19:28:09 +02:00
davotoula ccdcf433e1 Refactor logging
refactor(logging): move the last android.util.Log users onto the quartz wrapper
fix(logging): use the lambda overload, and keep the throwable in a catch log
2026-08-28 19:27:46 +02:00
davotoulaandgithub-actions[bot] ec27db70cc chore: sync Crowdin translations and seed translator npub placeholders 2026-08-28 16:05:01 +00:00
davotoula 8293cbfb7f update cs,se,de,pt 2026-08-28 18:00:25 +02:00
Vitor PamplonaandGitHub 2735d6612b Merge pull request #4002 from davotoula/fix/nwc-nip44-and-lnurl-dedup
fix(zaps): deduplicate LNURL endpoint fetches; stop NWC NIP-04 downgrade on a cold cache
2026-08-28 09:54:15 -04:00
davotoula 9bd85d0cbc Code review: release awaiters + cap the NIP-44 negotiation wait
fix(nwc): release awaiters when the account scope is already dead
fix(nwc): cap the NIP-44 negotiation wait and keep the fetch off the caller
2026-08-28 11:27:49 +02:00
davotoula 4f3e9fd1cd fix(nwc): stop downgrading to NIP-04 on a cold info cache
NIP-47 says a client "should always prefer nip44 if supported by the wallet
service", so prefersNip44() returning false has to mean "the wallet does not
offer NIP-44" — not "we have not asked yet". It meant both.

NwcInfoCache is per-account and in memory only, so it starts empty on every
app launch, and prefersNip44 read it without waiting. The first transaction
to each wallet after each launch therefore went out as NIP-04 even against a
wallet advertising nip44_v2 — a silent downgrade to deprecated encryption on
a payment request. The startup warm-up narrows the window but does not close
it: it only covers the default wallet, and it races the user's tap.

Add currentOrFetch(), which waits only when nothing at all is cached and
returns a stale entry as-is — staleness never caused the downgrade, since a
stale entry already says what the wallet advertises, so waiting on it would
buy nothing. prefersNip44 becomes suspend and uses it; both call sites were
already suspend.

Funnel every fetching path through one request per wallet. getFresh() went
straight to the network with no deduplication — only the background refresh
was guarded, and by a plain key set that could not be awaited. Without this,
making the payment path wait would have had it race the startup warm-up and
issue a second concurrent fetch for the same wallet.

Verified by mutation: reverting currentOrFetch to the old non-waiting read
fails the cold-cache tests, and removing the single-flight fails the
deduplication tests. The prefersNip44 call site itself is a two-line swap
covered by those cache tests — NwcSignerState has no test harness and
building one for it was out of proportion to the change.
2026-08-28 10:37:19 +02:00
davotoula b29519e4e6 refactor(zaps): move LNURL fetch dedup onto LnurlEndpointCache
Single-flight landed inside OkHttpLnurlEndpointResolver, which put the two
halves of one mechanism — "resolve this URL exactly once" — in two modules.
The flight map had to call LnurlForm.normalizeUrl purely to match a keying
detail private to LnurlEndpointCache in quartz. Nothing documented or
enforced that: if the cache changed its canonicalisation, the map would
silently stop deduplicating and no test would fail.

Move it onto the cache as getOrFetch(url, fetch). The key is now computed
once and shared by the lookup, the flight map and the store, so they cannot
disagree. Dedup also becomes process-wide, matching the resource it
protects — a stranger's /.well-known/ endpoint — rather than being scoped to
one resolver instance; clear() resets both maps. The resolver drops to a
one-line delegation and keeps only the HTTP half. Same shape as NwcInfoCache,
which already pairs a cache with an in-flight map and an injected fetch.

Mechanism tests move to quartz beside the cache, using delay() rather than
a blocking sleep. The commons test keeps the one claim it uniquely makes:
that the resolver really routes through the cache over a real OkHttp client.

No behaviour change. Verified by mutation: removing single-flight, keying
the flight map on the raw URL, never releasing the slot, and making the
resolver bypass the cache each fail exactly the test that covers them.
2026-08-28 10:37:19 +02:00
davotoula 319348de9a fix(zaps): single-flight the LNURL endpoint resolver
A zap-receipt burst hands OkHttpLnurlEndpointResolver one resolve() call
per receipt, each on its own coroutine from LocalCache.consume(LnZapEvent).
The resolver's read-through cache only helps once a fetch has landed, so
the whole burst missed together: N receipts for one lightning address made
N requests to that provider's /.well-known/lnurlp/ endpoint. A
lightning-address server observed ~20 per user action, with no zap sent.

Hold one CompletableDeferred per in-flight URL and let the rest await it.
The entry is keyed through LnurlForm.normalizeUrl, matching how
LnurlEndpointCache keys itself, so host case and a trailing slash share a
flight rather than starting two. The winner releases the slot in a finally
after the cache is populated, so a failed fetch is retried by the next
caller instead of being remembered as null, and awaiters are unblocked
even if the winner is cancelled.

The cache itself is unchanged.

Tested with a burst whose callers are released through a shared gate. The
gate is load-bearing: asserting "one fetch" while relying on every coroutine
reaching putIfAbsent before the winner's fetch returns makes a slow machine
fail the test rather than a regression. The burst test failed 20/1 before
this change.
2026-08-28 10:37:19 +02:00
Vitor PamplonaandGitHub 97919fd460 Merge pull request #3996 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-27 22:41:46 -04:00
vitorpamplonaandgithub-actions[bot] d44e1ff1d3 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-27 23:52:37 +00:00
Vitor PamplonaandGitHub 866897022f Merge pull request #4001 from vitorpamplona/claude/gif-insertion-post-comment-3z9ozg
fix(composer): wire keyboard GIF insertion into the last four composers
2026-08-27 19:50:05 -04:00
Claude c8bc0f6378 fix(composer): wire keyboard GIF insertion into the last four composers
Auditing every text-field call site against the view models that can accept
media turned up four more composers with an upload button and a full media
pipeline, but no `onContentReceived` — so a GIF inserted from the keyboard
silently did nothing there too:

- New public message: already called MessageFieldRow, which gained the
  parameter in the previous commit; it just never passed one.
- Nests audio-room chat.
- Long-form markdown editor.
- Minichat, which routes through ChatFileUploadState instead of the view
  model, so it also mirrors the gallery button's encryptFiles choice.

Two composers are deliberately left out. NewHighlightScreen has no media
pipeline at all, so a received GIF would have nowhere to go. EditPostView
uses OutlinedThinPaddingTextField, which has no contentReceiver — supporting
it there means changing that component, not passing an argument.

Only the keyboard commitContent path is addressed here. The chat composers
still lack the onNewIntent listener that catches a share-intent GIF (as
SwiftKey sends it), so sharing one from a chat continues to navigate out to
a new short-note composer; that is a larger change and is left for its own
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMyN6Y7DsXFdPxDxLfgo3g
2026-08-27 23:17:35 +00:00
Vitor PamplonaandGitHub dd98d9da9d Merge pull request #4000 from vitorpamplona/claude/gif-insertion-post-comment-3z9ozg
fix(composer): restore GIF insertion on the comment reply screens
2026-08-27 19:11:16 -04:00
Claude 519b76c708 fix(composer): restore GIF insertion on the comment reply screens
Replying to a kind-1 note opens ShortNotePostScreen, which wires both GIF
delivery paths. Replying to a comment (or a hashtag/geohash/url scope) opens
GenericCommentPostScreen, which wired neither, so GIFs silently did nothing:

- Gboard-style `commitContent` reaches the field only when the caller passes
  `onContentReceived`; ThinPaddingTextField attaches the `contentReceiver`
  modifier just for those, and MessageField defaults the parameter to null.
  The comment composer never passed one.

- SwiftKey delivers a GIF as a fresh ACTION_SEND. ShortNotePostScreen catches
  it with its own onNewIntent listener; the comment composer had none, so the
  global share router in AppNavigation handled it instead — and since its
  guard only recognised Route.NewShortNote, it answered a GIF by starting a
  brand-new short-note composer and discarding the reply in progress.

Wire both paths into GenericCommentPostScreen, which covers all four of its
entry points (comment, hashtag, geohash and url replies), and widen the
onNewIntent guard via consumesSharesInPlace() so a redelivered share no longer
throws away the draft. The launch-intent guard is left alone: a share that
starts the activity has no composer listening yet, so it must still navigate.

The root cause is copy-paste drift between composers, so also pull the four
identical addToMessage() bodies up into IMessageField as a default, and pass
onContentReceived on the other two composers with a working media pipeline
(new product, new group DM). NewHighlightScreen has no media pipeline and
EditPostView uses OutlinedThinPaddingTextField, which has no content receiver
— both left as-is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMyN6Y7DsXFdPxDxLfgo3g
2026-08-27 21:46:02 +00:00
Vitor PamplonaandGitHub f1b36ec4a5 Merge pull request #3954 from carmin777/feat/android-screen-share
feat: add Android screen sharing to calls
2026-08-27 01:13:18 -04:00
Vitor PamplonaandClaude Opus 5 d651ad3b1e fix(call): stop reopening the camera while the call is being torn down
Hanging up during a screen share opened the front camera for ~350ms before
closing it again. CallMediaManager.dispose() calls stopScreenShare(), which
restores the pre-share camera state, and only then calls stopCamera(). On an
SM-T220:

  22:07:46.389 MediaProjection: Dispatch stop to 0 callbacks
  22:07:46.432 CameraCapturer: startCapture: 1280x720@30
  22:07:46.438 Camera2Session: Opening camera 1
  22:07:46.433 CameraCapturer: Stop capture: Waiting for session to open
  22:07:46.765 Camera2Session: Stop done

so the user sees the camera privacy indicator flash on hangup, and the teardown
blocks waiting for the capture session it just started. It also churned the
local video track and source through recreateCameraResources() purely to
dispose them a few lines later.

stopScreenShare() takes restoreCamera, defaulting to true so the user-initiated
stop is unchanged; dispose() passes false.

Verified on device. Hangup while sharing: camera opens once for the call, closes
when sharing starts, and is never reopened during teardown — no startCapture and
no "Opening camera" in the teardown window. Stopping the share with the button
still restores it (startCapture + CAMERA_STATE_ACTIVE, preview returns).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Awrm1ro4eQXSaXDoH8EW3z
2026-08-27 00:28:24 -04:00
Vitor PamplonaandClaude Opus 5 8059dd7898 fix(call): release the screen-capture Surface instead of leaking it
Every screen-share session leaked one android.view.Surface, surfacing as a
StrictMode LeakedClosableViolation shortly after teardown:

  Explicit termination method 'Surface.release' not called
    at android.view.Surface.<init>
    at org.webrtc.ScreenCapturerAndroid.createVirtualDisplay(ScreenCapturerAndroid.java:193)

The library's ScreenCapturerAndroid builds the capture Surface inline and keeps
no reference to it:

  virtualDisplay = mediaProjection.createVirtualDisplay(
      ..., new Surface(surfaceTextureHelper.getSurfaceTexture()), ...);

so nothing can ever call Surface.release(). VirtualDisplay.release() does not
cover it — the Surface belongs to the caller — so it survived a clean in-app
stop and was reclaimed only whenever the finalizer next ran.
changeCaptureFormat() leaked another one per call (i.e. per rotation).

createVirtualDisplay() and the virtualDisplay field are both private, so this
cannot be fixed by subclassing. Replaces it with ScreenShareCapturer, a
derivative of the upstream class (© 2016 The WebRTC project authors,
BSD-style license) that holds the Surface and releases it together with the
virtual display, in stopCapture(), changeCaptureFormat() and — for the failure
path where startCapture() has no matching stop — dispose().

Verified on an SM-T220: two full share/stop cycles, two VirtualDisplay
create/destroy pairs, three forced GCs via `am dumpheap` (97MB dumps, so the
finalizer really ran) and zero LeakedClosableViolations. The same flow on the
previous build produced the violation three separate times. Screen sharing
still reaches the peer, confirmed by the remote rendering the shared screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Awrm1ro4eQXSaXDoH8EW3z
2026-08-27 00:16:33 -04:00
Vitor PamplonaandGitHub 09cddc59f1 Merge pull request #3998 from vitorpamplona/claude/github-json-carmin777-mapping-2ofbxn
Add carmin777 to contributors list
2026-08-26 23:37:04 -04:00
Vitor PamplonaandClaude Opus 5 965d55779c fix(call): keep calls alive when the system destroys MainActivity
A call that was up died as soon as Android reclaimed the backgrounded
MainActivity — reproducible on a Samsung SM-T220 a few hundred ms after
CallActivity enters picture-in-picture on HOME. Any screen share went
with it. Two independent causes:

1. Call state was owned by an Activity-scoped ViewModel.
   AccountViewModel.onCleared() -> CallSessionBridge.clear() ->
   CallManager.reset() -> CallState.Idle -> CallSession.close().
   CallManager also ran on viewModelScope, so a surviving call would
   still have been half-dead (signaling publishes silently no-oping).
   CallSessionBridge.clear() assumed onCleared meant "logout or account
   switch"; it fires on every MainActivity destruction.

2. CallForegroundService.onTaskRemoved hung up on the wrong task.
   It fires for every task of the app, and MainActivity is
   singleInstance while CallActivity launches with FLAG_ACTIVITY_NEW_TASK
   — so they live in different tasks. The service treated the system
   reclaiming MainActivity's task as the user swiping the call away
   (transitionToEnded reason=HANGUP).

Fixes:
- Account owns callManager, built on account.scope, so it outlives the
  UI and dies with the account.
- AccountViewModel references account.callManager; onCleared only drops
  the ViewModel reference.
- CallSessionBridge exposes the app-scoped Account and splits teardown:
  clearViewModel() (activity destroyed) vs clear() (real logout/switch).
- CallActivity binds its session to the Account; only its UI uses the
  ViewModel.
- AccountSessionManager calls CallSessionBridge.clear() on switch/logoff,
  mirroring the existing NestBridge.clear() hooks.
- AccountCacheState.removeAccount disposes callManager, whose watchdog
  scope is independent of account.scope.
- onTaskRemoved only hangs up for CallActivity's own task; a null root
  intent still hangs up so a swiped-away app cannot strand a call.

Verified on device: HOME during a call now keeps the call up (it ends
only on the legitimate 30s ring timeout), and a connected call with
screen sharing keeps streaming to the peer after the sharing device is
backgrounded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Awrm1ro4eQXSaXDoH8EW3z
2026-08-26 23:24:57 -04:00
Vitor PamplonaandClaude Opus 5 1e26fb8d5a fix(call): make ScreenShareResources public so the module compiles
CallMediaManager.stopScreenShare() returns ScreenShareResources and
disposeScreenShareResources() takes it, but the class was declared
internal, so :amethyst:compilePlayDebugKotlin failed:

  'public' function exposes its 'internal' return type 'ScreenShareResources'
  'public' function exposes its 'internal' parameter type 'ScreenShareResources'

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Awrm1ro4eQXSaXDoH8EW3z
2026-08-26 23:24:39 -04:00
Claude 7ffb5a9556 docs(changelog): map carmin777 to their npub
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01St3rihb3T8PqW6USwo9zkt
2026-08-27 02:26:08 +00:00
Vitor Pamplona 551ded1cf9 Merge branch 'main' into test-3954 2026-08-26 21:30:13 -04:00
Vitor PamplonaandGitHub 3a057f47b7 Merge pull request #3994 from greenart7c3/fix/deleted-list-uuid-in-top-bar
fix(lists): stop deleted lists showing their UUID in the feed filter picker
2026-08-26 20:48:13 -04:00
Vitor PamplonaandGitHub 040c4c9f01 Merge pull request #3995 from vitorpamplona/fix/tor-bootstrap-stall-and-ondemand
fix(tor): stop fresh installs stranding on a Tor bootstrap, and start them on clearnet defaults
2026-08-26 20:46:13 -04:00
Vitor PamplonaandClaude Opus 5 18b0dafec6 feat(tor): route the app's stand-in relays like the user's own until their lists arrive
A brand-new install routes 100% of its relay traffic over Tor by construction,
and that is a chicken-and-egg rather than a preference: `trustedRelays` is empty,
so `TorRelayEvaluation` falls through to `newRelaysViaTor` (default true) for
every url — and the kind:10002 that would populate it can only be fetched over
Tor. Measured on a Samsung SM-T220, same account, same login timing, fresh
install each: the first relay socket opened 2.3-2.9s *after* Tor became ready,
whenever that happened to be, and Arti's directory download ran 12.6-51.7s.

While an account's own lists are unknown, the defaults the app is already
dialling are now also classified for Tor purposes — as `assumed` relays, the
last branch before `newRelaysViaTor`:

  first relay socket, vs when Tor became ready (n=3 each, counterbalanced)
    before:  login+5.87s / +7.89s   — always 2.3-2.9s AFTER Tor Active
    after:   login+1.21s / +1.24s / +1.29s — independent of Tor entirely
  events ingested by the 20s census, non-overlapping
    before:  0 / 892 / 1159 / 2590
    after:   3719 / 3997 / 4081 / 5311 / 6051

It resolves to `trustedRelaysViaTor`, not to a hardcoded false: the app's
stand-in for a list gets the policy the user chose for their own list, so
anyone who set that preference keeps Tor here with nothing new to discover. And
it sits below .onion, money-operation and DM in the precedence chain, so those
keep their own policy for free — the branch can only capture urls that would
have been treated as strangers.

The guess ends by itself. `assumedDefaults` keys on the *event* being absent —
never on a list being empty, which is a choice we honor — so each list's
contribution empties the moment that event lands, with no window, timeout or
per-account bookkeeping. Device log: `Guessed relays: 15 -> 10 -> 5 -> 0 (own
lists arrived; released to their real Tor policy)`, after which 28 relays
re-dialled and their connect latency moved from a median 116ms to 503ms — the
handover onto Tor circuits, visible in the timings.

Deliberately NOT merged into `TrustedRelayListsState`. That feeds
`Account.isInMyRelayList` -> `RelayAuthPermissionLedger` -> `RelayAuthResolver`,
i.e. the NIP-42 AUTH decision. Guessed relays must never make the app sign an
AUTH challenge as though they were the user's own; that would turn a timing
signal into a signed identity assertion. Tor routing is the only consumer.

Two supporting changes, both of which pay for themselves here:

`RelayClassification` groups the four category sets into one value. The
reconnect trigger in `RelayProxyClientConnector` used to compare them field by
field, so a new category meant remembering another `||` — and I had forgotten
it, which is exactly the silent failure it invites: relays keep a socket on a
transport the policy has already moved them off. It is now one structural
comparison. That also removes a `Pair` that existed only to squeeze past
`combineTransform`'s five-source limit. Regression test covers the case that
made the omission reachable: an *empty* arriving list, where `trusted` does not
change while `assumed` empties.

`AccountsTorStateConnector.unionAcrossAccounts` replaces four ~30-line copies of
the same per-account fold. The copies had already drifted — two carried an
`if (isEmpty)` guard that could never fire, since `ifEmpty` had just guaranteed
otherwise.

Verified byte-identical to the build these numbers were measured on, and
re-measured after the refactors: first socket 1.24s median vs 1.21s before,
fully overlapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKYGEp22uGSzWrBDg8fAQ9
2026-08-26 20:24:08 -04:00
Vitor PamplonaandClaude Opus 5 e7bcb88d30 fix(relays): only substitute default relays when we have no event, not when the list is empty
There are three states, and two of them were collapsed:

  | we have                | effective list                        |
  |------------------------|---------------------------------------|
  | no event for the user  | app defaults — we do not know          |
  | an event, empty list   | **empty** — they told us: nothing      |
  | an event with relays   | those relays                           |

Every `WithBackup` helper keyed its fallback on the list being *empty* rather
than the event being *absent*, because `readRelaysNorm()`/`writeRelaysNorm()`
end in `.ifEmpty { null }` and the indexer/search helpers wrote
`?.ifEmpty { null } ?: DEFAULTS` outright. So a user who publishes a kind:10002
carrying only write relays silently acquired `Constants.bootstrapInbox` as their
*inbox* list, and a deliberately empty search or indexer list was replaced by
ours. That is the app overriding an explicit choice.

Only `normalizeNIP65AllRelayListWithBackup` was correct, and only by accident:
`relays()` has no `ifEmpty`, so its `?:` could fire only for a missing event.

The rule is now one named, tested primitive rather than an expression
open-coded at four call sites — three of which got it wrong the same way:

    relayListOrDefaultsWhenUnknown(event, defaults) { it.readRelaysNorm()?.toSet() }

`Account.indexRelays()` loses its `.ifEmpty { DefaultIndexerRelayList }` too;
it re-applied the substitution a layer up and would have undone the fix.

Two things deliberately left alone. The `Precached` variants keep substituting
defaults: they read only *already decrypted* tags, so empty there can mean "not
decrypted yet" — an unbounded window for a NIP-46 signer — rather than "the user
chose nothing", and the primitive's KDoc records that as a non-goal. And the
`NoDefaults` flows keep returning `emptySet()` for both cases, since their job is
to show what the user published.

Note for callers: the indexer and search flows previously documented themselves
as **never empty** and that contract is gone. A user who publishes an empty
kind:10007 now gets no search relays, which is what their event says. The same
applies to NIP-65 write relays, where the old fallback meant posts went to six
hardcoded relays; if a safety net is wanted there it belongs at the publish site
as a visible decision, not as a silent list substitution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKYGEp22uGSzWrBDg8fAQ9
2026-08-26 20:22:32 -04:00
Vitor PamplonaandClaude Opus 5 b266f1c403 fix(tor): keep retrying a stuck bootstrap, and stop calling a downloading Tor "Active"
A brand-new install could stop connecting to Tor entirely. Not slowly —
permanently: exactly two bootstrap attempts, then silence. Reproduced on a
Samsung SM-T220 (benchmark build, fresh install, log in, 150s offline, network
back): Tor never reached Active in the following 600s, no profile, no relay
lists, "Feed is empty." With the fix, the same scenario recovers at net+51s.

Root cause: on a native bootstrap timeout `TorService.start()` deliberately
leaves status at Connecting and delegates the retry to `TorManager`'s watchdog,
but that watchdog was `status.transformLatest { if (Connecting) { delay(45s);
emit() } }` — it fires once per Connecting *span*, and a timeout produces no
status change, so no new span ever began and the signal was never re-armed.
Nothing else covered it: `onNetworkChange` fires only on a networkId *change*
and `AppModules` drops the first non-null one, so even a network arriving from
offline did not rescue it.

Lifecycle fixes:
  - the watchdog re-arms while stuck instead of firing once per span;
  - it skips an attempt that is genuinely running, so a reset can no longer
    queue behind the blocking JNI call and tear down a client that just
    succeeded;
  - an install that has never bootstrapped retries on a 30s cooldown rather
    than the 5-minute one meant to protect working state;
  - `service.start()` is no longer awaited before `emitAll(service.status)`, so
    the app observes Connecting when the attempt starts rather than when it
    ends (on device the watchdog moved 105s -> 90s);
  - a hard init failure and port exhaustion no longer set the terminal Off,
    where neither the watchdog nor the failure dialog arms; both leave
    Connecting to be retried. The init path also no longer wipes all Arti data
    on any failure, which turned a transient "no network" into a lost guard
    sample — with an escalation after 3 fruitless gentle resets so corrupt
    state on a fresh install is still recovered.

Arti now bootstraps on demand. `create_bootstrapped` blocked the JNI call — and
the Kotlin lifecycle lock it holds — for the whole directory download (12.6s to
51.7s measured), during which `activePortOrNull` was null so every Tor-routed
dial fell back to 127.0.0.1:9050, the Orbot default, where nothing listens.
`create_unbootstrapped_async` + `BootstrapBehavior::OnDemand` returns in 124ms
and lets each stream wait for its own circuit. It does not make first paint
faster — the download is the real gate — but it removes the dead-port window
and the up-to-60s lock hold that also made "turn Tor off" appear frozen.

That forced a state split, and it is the load-bearing part. `Active` was
carrying two facts that used to coincide: "proxy routable" and "circuits
buildable". Android's `TorServiceStatus` gains `Bootstrapping(port)` plus
`socksPort` / `isFullyBootstrapped`, so callers state which they mean instead of
matching a variant that looks right for both. Commons gets the accessors only —
the desktop backend drives an external Tor and never sees the window, and a
variant nothing emits is dead weight.

Watchdogs are judged on forward progress, not elapsed time. Measured cold
downloads ran 12.6, 13.4, 14.0, 15.6, 17.9, 19.7, 19.8, 20.0, 34.4 and 51.7s on
one device and network, so no fixed patience separates slow from stalled: short
enough kills healthy downloads — and a reset discards the partial consensus, so
firing early can stop one ever finishing — while long enough sits uselessly on a
hang. A new `bootstrapProgressPermille()` exports `as_frac()`, and a download is
reset only after 60s with no movement at all, never with a state wipe. Device
run: a 51.7s download completed untouched where the previous code would have
reset and wiped its cache at 45s. `blocked()` is deliberately unused; Arti
documents it as best-effort and warns it misreports in both directions.

Readiness is read live (`bootstrap_status().ready_for_traffic()`) rather than
latching the one background `bootstrap()` result, which would report "not
bootstrapped" forever against a Tor that a later stream had already recovered.

`canDial` and `TorCircuitHealthTracker.isTorActive` gate on readiness, not
routability. Dialling on routability alone put ~190 relays into a backoff that
is never forgiven — the port is identical either side of Bootstrapping -> Active
so the transport never "changes" and `resetBackoff()` never runs — and it cost
nothing to wait: time-to-first-socket was unchanged by dialling early (n=3).

Both jniLibs ABIs rebuilt and verified reproducible from an upstream clone
(arm64 b53d20d2..., x86_64 36d41793...). `build-arti.sh`'s JNI symbol check
gained the new exports; it is a hardcoded list, and without them it silently
passed a stale .so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKYGEp22uGSzWrBDg8fAQ9
2026-08-26 17:26:26 -04:00
Vitor PamplonaandClaude Opus 5 4261124402 feat(logging): let the benchmark build emit the boot narrative
The `benchmark` build type is a release build (R8 + AOT) that exists purely to
be measured and is never shipped, but `DEFAULT_LOG_LEVEL` keyed on
`BuildConfig.DEBUG` and so pinned it to WARN. That dropped every INFO milestone
a boot narrative is made of — account load timings, Tor status transitions, the
BootRelayDiagnostics census — leaving the one variant whose numbers are
trustworthy as the one variant we could not read.

Key it on `isDebug` instead, which already covers the benchmark type
(DebugUtils.kt) and is what gates `BootRelayDiagnostics` itself, so the census
and the log level that lets it through can no longer disagree. Release is
unaffected and stays at WARN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKYGEp22uGSzWrBDg8fAQ9
2026-08-26 17:25:53 -04:00
greenart7c3 e0e2e427c4 fix(lists): stop deleted lists showing their UUID in the feed filter picker
Deleting a NIP-51 people list (kind 30000) or follow pack (kind 39089)
left a null-event AddressableNote behind — and the persisted per-screen
TopFilter that still pointed at the address re-created that shell on
every start via getOrCreateAddressableNote, so the deleted list kept
showing in the top-bar feed filter, its name falling back to the dTag
(UUID) once the event was gone.

- PeopleListsState / FollowListsState: exclude addressables without an
  event from the picker options (generalizes the earlier block-list-only
  filter to every list, and adds it for follow packs).
- deleteFollowSet() now resets any persisted default*FollowList that
  still points at the deleted address back to that screen's default, so
  no dangling filter survives a restart.

Fixes #3949
2026-08-26 18:23:23 -03:00
David KasparandGitHub ef4e7075be Merge pull request #3993 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-26 21:21:45 +02:00
davotoulaandgithub-actions[bot] 51d3485b53 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-26 19:20:42 +00:00
David KasparandGitHub 1b569eec7f Merge pull request #3992 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-26 21:17:46 +02:00
davotoulaandgithub-actions[bot] 3e459ec5ad chore: sync Crowdin translations and seed translator npub placeholders 2026-08-26 19:10:38 +00:00
davotoula 60d84b8d75 upgraded agp 2026-08-26 20:52:21 +02:00
davotoula deee5e5cde update cs,pt,de,sv 2026-08-26 20:50:19 +02:00
Vitor PamplonaandGitHub d06b83bd53 Merge pull request #3991 from vitorpamplona/claude/slow-image-loading-feed-9b5leo
Move Blossom read-auth signing off OkHttp threads
2026-08-26 11:37:29 -04:00
Vitor PamplonaandClaude Opus 5 8114f054d4 Merge PR: fix(desktop): surface macOS notification-permission OS errors + timeout the request
Merges nostr proposal 12762d29 into main:
- NotificationDispatcher gains lastRequestError so the OS's own message
  (e.g. UNErrorDomain "Notifications are not allowed for this application")
  reaches the settings UI instead of a generic "denied".
- NucleusNotificationDispatcher bounds requestPermission with a 90s timeout,
  so an auto-dismissed macOS permission banner no longer parks the coroutine
  and the "Requesting..." spinner forever.
- sendMac waits up to 10s for the UNUserNotificationCenter.add ack and
  reports Failed on a non-blank OS error instead of a phantom Delivered.
- NotificationSettingsScreen surfaces the error text and offers "Ask again".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PUzAFtZJYBUr8wvFdqM2Mb
2026-08-26 11:25:46 -04:00
David KasparandGitHub c291026d5a Merge pull request #3990 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-26 17:06:33 +02:00
vitorpamplonaandgithub-actions[bot] e7b7211625 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-26 14:45:29 +00:00
Vitor PamplonaandGitHub a3623ceffd Merge pull request #3987 from davotoula/fix/nwc-silent-refusals
Fix nwc silent refusals
2026-08-26 10:42:22 -04:00
David KasparandGitHub 00ccef7277 Merge pull request #3989 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-26 13:55:38 +02:00
davotoulaandgithub-actions[bot] b935f4c99c chore: sync Crowdin translations and seed translator npub placeholders 2026-08-26 11:35:46 +00:00
davotoula a1edb597bc update cs,pt,de,sv 2026-08-26 13:31:58 +02:00
David KasparandGitHub 8d79145d2a Merge pull request #3988 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-26 13:30:03 +02:00
davotoulaandgithub-actions[bot] 9aa4a9536c chore: sync Crowdin translations and seed translator npub placeholders 2026-08-26 10:11:37 +00:00
davotoula b9ff65290b refactor(napplet): remove inline FQNs and duplicated MIME literals.
remove inline fully-qualified names
drop the redundant Dns wrapper and IOException qualifier.
Define a constant instead of duplicating literals
2026-08-26 12:02:28 +02:00
davotoula e1df0f0c45 fix(nwc): disambiguate the replay warning, and localize the wallet error text 2026-08-26 11:05:25 +02:00
davotoula 22c170c510 Code reviews:
fix(nwc): close the last silent path and drop the success-type guessing hazard
refactor(nwc): fold the repeated failure-message logic into shared helpers
2026-08-26 11:05:12 +02:00
davotoula 24a8540ad9 fix(nwc): never let a NIP-47 refusal or timeout reach the user as silence
Field report (BrollyZapper, 2026-08-25): a QUOTA_EXCEEDED on pay_invoice and a
RESTRICTED on list_transactions both reached the phone and showed nothing at
all — no toast, no dialog, no error state. The action simply looked like it had
not happened. Three separate defects produce that symptom.

1. The zap path had no user-visible timeout. NwcSignerState's 60s safety net
   only dropped the relay subscription: it never cleaned the tracker entry and
   never told anyone. A response lost in transit (the same trip measured
   relay.damus.io refusing 40% of websocket upgrades) was therefore permanent
   silence. The timeout now retires the request and fires an onTimeout callback
   that every interactive caller renders. NwcPaymentTracker.cleanup returns
   whether it was the one to remove the entry, so a timeout racing a real
   response stays quiet rather than overwriting the wallet's own answer.

2. WalletTransactionsScreen never read walletViewModel.error. The ViewModel set
   it correctly on both the refusal and the timeout paths; the view branched on
   isLoading/isEmpty only and rendered "No transactions yet" over the top of it.

3. Consumers matched on PayInvoiceErrorResponse, which the deserializer only
   produces when result_type == "pay_invoice". NIP-47 does not require a wallet
   to echo result_type on an error, and an error for any other method takes the
   generic NwcErrorResponse branch — so those refusals were dropped without a
   word, and the DVM screen went as far as thanking the user for a payment that
   had just been refused. All of them now match IErrorResponseLike, and the
   remaining else branches report an unreadable response instead of nothing.

Also: errorMessage() falls back to the code name when a wallet sends `code`
without `message` (message is optional in NIP-47), and stale wallet errors are
cleared when a transaction fetch or page load succeeds.
2026-08-26 11:04:42 +02:00
Vitor PamplonaandGitHub fb0d8bd857 Merge pull request #3986 from vitorpamplona/claude/amethyst-file-upload-issue-hs9f7b
fix(browser): open a file picker for HTML file inputs
2026-08-26 01:00:54 -04:00
Vitor PamplonaandClaude Opus 5 baae40e5fc fix(browser): stop deleting the video a capture just returned
`accept="video/*" capture` handed the page a 0-byte file. The recording was
fine — we deleted it before the page could read it.

parseResult assumes a camera signals success by filling the EXTRA_OUTPUT file
and returning no URI. ACTION_IMAGE_CAPTURE does exactly that.
ACTION_VIDEO_CAPTURE on GoogleCamera does not: it writes the file *and* echoes
the output URI back in the result. That echo lands in `picked`, which makes
`captured` null, and the cleanup loop then treats every capture as unused:

    if (capture !== captured) NappletCaptureFiles.discard(context, capture.file)

So the one file whose URI was on its way to the page was the one file deleted.
The page opened it, found nothing, and a "successful" upload carried no bytes.

Captures whose URI is being returned are now excluded from the discard sweep,
whichever way they got there — echoed back in the result, or found by the
fill check. Untouched capture files are still deleted immediately, so a
dismissed or unused camera option leaves nothing behind.

An echoed URI is also no longer trusted on its face: if the file behind it is
empty the URI is dropped, and the request falls through to the same emptiness
rules as before rather than reporting a capture that never happened. URIs that
are not ours are never second-guessed.

Verified on device (Pixel 8 / Android 17), after the fix:
- video: 28,135,304-byte mp4 delivered and readable, was 0 bytes before
- image: 781,853-byte jpeg with EXIF intact — unchanged, no regression
- grants on the capture authority: 0 before, 1 while the camera holds it,
  0 again once the result is in, for both media

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-26 00:42:25 -04:00
Vitor PamplonaandClaude Opus 5 f11bdb8e49 fix(browser): stop the embedded file pick from killing the app
Completing a pick on either embedded surface crashed the whole app, every
time. parseResult resolved the picked URIs through
`WebChromeClient.FileChooserParams.parseResult`, which is a WebView *static*:
it boots Chromium in whichever process calls it. WebFileChooserActivity — the
main-process chooser host that exists precisely because the `:napplet`
providers are windowless and have no Activity to launch a picker from —
declares no `android:process`, so that call ran in main while `:napplet`
already held the WebView data directory. AwDataDirLock then threw

    Using WebView from more than one process at once with the same data
    directory is not supported

as a FATAL EXCEPTION on main. Reproduced on a Pixel 8 / Android 17: the
picker opens, the user selects, and on Done the process dies before the page
is ever handed its file.

The two Activity-owning hosts never hit it because they are themselves
`android:process=":napplet"`, where WebView is already initialised — which is
why the full-screen browser picked files correctly throughout. Cancelling did
not hit it either, so "the picker opened" was never enough to catch this.

The URIs are now read off the result Intent directly. The platform
implementation reads exactly the same two fields (ClipData items, else the
data URI, only on RESULT_OK), so behaviour is unchanged for the single-URI,
multi-select and camera shapes; it just no longer drags WebView into a
process that must not have it.

This also plugs a grant leak. releaseGrants runs *inside* parseResult, after
the line that was throwing, so every crashed capture left the camera apps
holding a live write grant on the capture URI that nothing would ever revoke.

Verified on device after the fix:
- embedded pick: no crash, page reads back all 94,976 bytes of the chosen
  PNG with its header intact — so a URI granted to the main process is
  readable by the WebView in `:napplet` with no re-granting, as designed
- camera capture: 892,681-byte JPEG with EXIF intact (full resolution, so
  EXTRA_OUTPUT is doing its job), delivered under the same name as the
  granted URI
- grant/revoke: 0 outstanding grants on the capture authority, 1 while the
  camera holds it, 0 again once the result is in

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-26 00:05:13 -04:00
Claude f1c461dcfa fix(blossom): bring read-auth tokens into line with BUD-11
Two deviations from BUD-11, both predating the read-auth rework and both
carried forward by it.

The `x` tag defeated the per-host token cache. BUD-11 lists `x` as
optional for `GET /<sha256>`, but its Tag scoping rule is strict about
what including one means: "When `x` tags are present, the token is only
valid for operations on the specified blob hashes." Tokens are cached per
host and replayed for every blob on it, so from the second image onward we
were sending a token scoped to some other blob's hash. The old comment had
the reasoning backwards — it kept `x` "for servers that check it", which is
precisely the case that rejects a reused token. createGetAuth now takes a
nullable hash, and the read-auth path passes null: the `server` tag alone
scopes the token, which is what makes reuse legitimate. That widens the
grant from one blob to any blob on the host for the token's hour, which is
the inherent price of caching and is the shape BUD-11 sanctions.

The token encoding was standard Base64. BUD-11: "MUST be encoded as Base64
URL-safe without padding (Base64url, as used by JWTs)". In practice the
alphabets coincide — a token's JSON is printable ASCII and a sextet only
reaches 62/63 when the third byte of its group is `>`, `~`, `?` or DEL, so
`+` and `/` never appeared across 600 sampled tokens — but padding did, on
52% of them. NIP-98's encoder is deliberately left alone; it specifies no
variant.

Nothing in the tree decodes a Blossom auth header, so the encoding change
is client-side only.

Tests pin both rules at the event level and end-to-end on the token this
path actually mints, with several content lengths for the padding case
since whether padding appears depends on the JSON length mod 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYrDf5Z8TE4uivADuFwPFz
2026-08-26 03:32:13 +00:00
Vitor PamplonaandGitHub bdb2990846 Merge pull request #3985 from vitorpamplona/claude/pollresponsescache-deprecations-xaw9y2
Replace mutable collection methods with immutable equivalents
2026-08-25 23:05:57 -04:00
Claude 2f1e1d546c perf(images): stop blocking an OkHttp thread to sign Blossom read-auth
BlossomReadAuthInterceptor bridged the suspend signer with runBlocking so
it could retry an auth-gated blob with a signed BUD-01 token. intercept()
runs on an OkHttp dispatcher thread, so that wait held one of the 16
per-host slots for the whole signing window — up to the 8s timeout, and
with a NIP-55 external signer a real IPC round trip. A feed's first burst
against a gated host could occupy every slot and stall every other image
from it.

Interceptor.intercept() is synchronous by contract, so the wait cannot be
made cheap in place; it has to move to a caller that already suspends.
Coil's Fetcher.fetch() is that caller:

  - BlossomReadAuthTokenProvider.header() is now suspend, and signs on an
    injected scope. Concurrent callers collapse onto one CompletableDeferred,
    so a cold burst mints one signature instead of N — the token cache alone
    could not do that, being populated only after a signature returned.
    cachedHeader() stays a pure map read for callers that cannot suspend.
  - BlossomReadAuthFetcher carries the anonymous -> 401 -> signed retry,
    catching the HttpException that Coil's NetworkFetcher raises for a
    non-2xx and re-issuing with Authorization injected into options'
    httpHeaders. Wrapped around all three network-backed Coil factories.
  - The interceptor now only attaches an already-cached token for a
    known-gated host and fires the mint off-thread, so video and other
    non-Coil callers still pick a token up on their next request.

Measured on the same signer and host, signature latency 2000ms:
waiting for it cost 2003ms on the calling thread, intercept() now returns
in 0ms. With 16 concurrent callers and a 300ms signature: 1 signature,
all callers done in 303ms.

Behaviour for images is unchanged — anonymous first, signed retry, host
learned so later blobs are signed up front. The one narrowing: a gated
host reached first by the video datasource cannot mint its own token and
must wait for the warm to land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYrDf5Z8TE4uivADuFwPFz
2026-08-26 01:35:19 +00:00
Claude cc13664816 fix: clear compiler warnings in poll tally, relay auth and cache stub
Swap the deprecated persistent-collection mutators in PollResponsesCache
for their kotlinx-collections-immutable 0.5 replacements (add -> adding,
remove -> removing, put -> putting), drop two safe calls on receivers the
compiler already smart-casts to non-null, and rename the test stub's
override parameter to match ICacheProvider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B22CbhBupxD3DZcac8jMi
2026-08-26 01:25:39 +00:00
Vitor PamplonaandGitHub de9a41ddb9 Merge pull request #3984 from vitorpamplona/claude/ime-padding-back-gesture-8xeyjn
Fix stranded IME inset that freezes keyboard padding
2026-08-25 20:54:04 -04:00
Claude cb5ddd7a77 perf: share one SafeImeInsets per window instead of one per call site
isStranded describes the window's insets listener, not any single layout, so
every call site in a window has to read the same flag. Each one built its
own, with its own IME_STRAND_GRACE_MS timer, and nothing made two of them
agree.

DisappearingScaffold is where that bit. It held two: one behind the root
modifier's padding and one whose value is subtracted from the nav-bar
reservation, with a comment asserting "the two have to agree" that the code
did not back. It also called imePaddingSafe() inside both arms of
`if (canHideBars)`, putting the call in two composition groups — so a
window-size-class change disposed and rebuilt the instance, dropping
isStranded back to false and putting the stale gap back on screen until a
fresh watchdog re-detected it.

SafeImeInsets is now cached per view, keyed exactly the way Compose keys
WindowInsetsHolder itself, and its constructor is internal so the cache
cannot be bypassed. Keying on the view is also what keeps a Dialog on its
own window's reading — a CompositionLocal would have handed it the host
activity's, which is why one was rejected earlier. The scaffold resolves the
instance once above the branch and passes it to ScaffoldLayout, so the value
it pads with is the same object the subtraction reads.

Call sites still park a watchdog each. They now write one shared flag from
the same two sources, so they cannot disagree; collapsing them to a single
watchdog would need either a scope outliving every call site (strongly
holding the view, defeating the weak cache) or a hand-off when the owning
site leaves the composition — both cost more than the coroutine they save.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bqpAeyLAxHzw5XsnRUtjD
2026-08-26 00:50:22 +00:00
Claude 33a64ce73a Merge remote-tracking branch 'origin/claude/ime-padding-back-gesture-8xeyjn' into claude/ime-padding-back-gesture-8xeyjn
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt
2026-08-26 00:28:48 +00:00
Vitor PamplonaandClaude Opus 5 17c9357b2f fix: stop HiddenWords holding the keyboard gap open forever
This was the last screen still reading the raw animated IME inset, so it was
the one place `imePaddingSafe()`'s recovery could not reach. `union` takes the
max per side: with the inset wedged at the keyboard height and navigationBars
at ~48px, the union stays at the keyboard height and the bottom bar sits a
keyboard up with no keyboard on screen — permanently, because nothing else
pulls it back down.

The lift itself is correct and stays: `AddMuteWordTextField` has to clear the
keyboard. Only the source of the IME term changes.

Confirmed on a Pixel 8 that the wedge is real and does not self-correct: with
the workaround disabled the inset pinned at 957px for 85s while the window
reported the keyboard gone. See b/552500419 and SafeImeInsets.

No raw `WindowInsets.ime` reads remain in amethyst/ or commons/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-25 20:24:22 -04:00
Claude ed2b3ef8d7 fix: correct the IME inset union in HiddenWordsScreen too
The first pass swapped Modifier.imePadding() call sites, which missed this
one: it reaches WindowInsets.ime through a union with the nav-bar inset
instead. A stranded inset leaves the add-word bar floating a keyboard's
height above the navigation bar, the same symptom by a different route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bqpAeyLAxHzw5XsnRUtjD
2026-08-26 00:20:55 +00:00
Claude d28afe0677 Merge remote-tracking branch 'origin/main' into claude/ime-padding-back-gesture-8xeyjn 2026-08-26 00:09:55 +00:00
Claude 6becc6efbe fix(browser): close five holes found auditing the file-input path
Self-audit of the picker and camera work. Four correctness bugs and one
robustness gap, none of them reachable by the happy path, all of them reachable.

A malformed accept entry became the picker's filter. `accept="image/"` passed
the "contains a slash, so it is a MIME type" test and went straight into
Intent.setType, where it matches no provider — an empty picker with nothing to
choose and no way out. A slashed token is now only a MIME type when both halves
are actually present; otherwise it is unnameable and widens to everything, the
same as an unknown extension. Test first, watched it fail.

The main-process chooser host never reported when the system destroyed it
without finish() — a low-memory kill while the picker is on top. The page's
file input would then wait forever on a result nobody was left to send (dead for
the life of the page), and the coordinator would hold the reply callback, and
the controller behind it, for good. Reporting from onDestroy covers it. A
recreated host now releases the input immediately too, instead of silently
swallowing a pick it can no longer route.

A second file input asking before the first pick returned overwrote the
in-flight request. The page's own callback was already released, but the
superseded request still owned camera scratch files and the URI grants handed
to every camera app — nothing would ever come back for them, so they sat until
the daily sweep. Superseding now runs the cancel path on the old request, and
the same cleanup runs when a host is torn down mid-pick.

Capture filenames were built from a clock and a per-object sequence. The main
and `:napplet` processes each hold their own copy of that object, so the
sequences run independently and two picks started in the same millisecond could
name the same file, one capture silently overwriting the other. createTempFile
removes the question.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxfdHeR9Ry4qALXHT5Sf1Q
2026-08-25 23:25:22 +00:00
Claude ff1ecde496 feat(browser): offer the camera for HTML file inputs
Completes the file-input support: a page that accepts photos or video can now
reach the camera, not only files already on the device. `accept="image/*"` on a
mobile browser means "take one or pick one"; until now Amethyst could only do
the second half, which is the wrong half for the common case of uploading a
photo.

How it decides, mirroring a mobile browser: a bare file input offers stills and
video, an image-only accept offers just the camera, a document accept offers
neither. Resolved by FileChooserAccept.captureMedia, pure and unit-tested.
Unlike the type filter this does NOT widen on an extension the platform cannot
name — widening there would put a camera in front of a page that never asked
for one.

Permission handling is the part worth reading. ACTION_IMAGE_CAPTURE throws
SecurityException for an app that declares CAMERA without holding it, and
Amethyst declares it, so the grant has to exist before the chooser is built.
When the page set `capture` the permission is requested first — the user tapped
a control whose entire purpose is to take a photo. Without `capture` the camera
is offered only if permission is already held, so opening a document upload
never raises a camera prompt out of nowhere. A denial is not a failure: the
picker still opens, minus the camera.

A camera needs somewhere to put a full-resolution shot (EXTRA_OUTPUT; without
one it returns a thumbnail, useless as an upload), so each option gets an empty
scratch file in cacheDir behind its own FileProvider — a dedicated one with its
own authority and paths file, exposing a single subdirectory rather than the
everything the app's general-purpose provider exposes. It needs its own
subclass because the manifest merger keys providers by android:name and would
otherwise collide with the app's.

A chooser entry supplied via EXTRA_INITIAL_INTENTS is started by the system,
not by us, and the URI grant flags on it are not reliably carried across that
hop, so every resolved camera package is granted write access up front — none
of them can be ruled out before the user chooses. That grant is taken back the
moment the outcome is known, for the kept capture as well as the discarded
ones, revoked per package rather than per URI so it cannot clip this app's own
read of its own provider. Unfilled scratch files are deleted immediately; a
kept one cannot be (the page may not read it until the form is submitted) and
is swept on a later request instead.

The three Activity-owning surfaces — the full-screen browser, the full-screen
napplet/nSite sandbox, and the main-process host that serves both embedded
surfaces — now share one WebFileChooserLauncher, so filtering, multi-select,
capture and the permission flow cannot drift between them. The embedded
providers pass the input's `capture` flag across the existing Messenger
contract rather than having the main process re-derive it.

Every path still ends in exactly one call to the page's filePathCallback,
including a denied permission, a dismissed camera, and a device with no camera
app at all.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxfdHeR9Ry4qALXHT5Sf1Q
2026-08-25 23:00:57 +00:00
Claude 2587294c55 perf(images): share one de-dupe strategy so concurrent fetches collapse
DeDupeConcurrentRequestStrategy coordinates through a map of in-flight
fetches that the strategy instance owns. All three network-backed Coil
factories built a fresh one inside create(), i.e. one per image request,
so the map never held more than the current caller: shouldWait was always
false and the de-dupe was inert. Coil's own NetworkFetcher.Factory holds
it as a field for exactly this reason.

The cost showed up wherever a feed asks for the same URL twice at once —
an author's avatar repeated down the rows, an image carried by both the
original note and its boost, or a row scrolled off and back on before the
first fetch had written to the disk cache. Every one of those was a full
second download competing for the same link instead of a waiter that
reads the cache once the leader lands.

Hoists a single strategy into ImageLoaderSetup.setup() and threads it
through OkHttpFactory, BlossomFetcher.Factory and
ProfilePictureFetcher.Factory, so a blob reached as an https URL, as a
`blossom:` URI, or as a profile picture all coordinate on one key. The
per-create CacheStrategy.DEFAULT wrappers are hoisted alongside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYrDf5Z8TE4uivADuFwPFz
2026-08-25 22:47:59 +00:00
Vitor PamplonaandClaude Opus 5 9eed37c5f4 fix: cut the stranded-IME grace to 120ms and pin the upstream cause
The grace was 400ms, and once a window wedges its IME animation that full
400ms is paid on *every* dismissal, on every screen — which reads as the
padding lagging behind the keyboard rather than as a bug being corrected.

400ms was never protecting against anything real. `collectLatest` + `delay`
already means "no movement for X ms", because each animation frame emits a
new sample and cancels the pending wait. So the grace only has to outlast
the dead time between the target flipping and the animation's first
onProgress — not the animation. Measured over 12 real Gboard transitions on
a Pixel 8: that dead time is 17-36ms (closes 24-36, opens 17-23), and every
frame after it lands within 11ms across a ~264ms animation. 120ms clears the
worst case by ~3.3x. Set too low this degrades to a cosmetic snap, never to
wrong padding, since the target is always the truthful reading.

Also records what the workaround is working around. The defect is upstream:
a cancelled IME animation never delivers onEnd, so
`InsetsListener.runningAnimation` stays set, `onApplyWindowInsets` matches
neither branch, and `composeInsets.update()` is never called again —
`WindowInsets.ime` is dead for the life of the window. Bisected to
foundation-layout 1.4.0 (1.3.0 updated unconditionally and could not wedge),
still present in 1.12.0 and 1.13.0-alpha01. Compose's self-heal is scoped to
`SDK_INT == R`, and `WindowInsetsHolder.resetState()` only runs when the
holder's accessCount goes 0 -> 1 — which never happens in a single-Activity
app whose shell always reads insets. Filed as b/552500419.

Confirmed on-device that the bug is real and permanent underneath: with both
treatments disabled the inset pinned at 957px for 85s while the window
reported the keyboard gone, and `imeAnimationTarget` stayed correct
throughout — which is why reading it works.

ComposeImeInsetWedgeTest reproduces that upstream state deterministically in
~3s and is the repro attached to the bug. The failing half is @Ignore'd so
CI stays green; re-run it by hand after a Compose upgrade, and when it
passes, SafeImeInsets can be retired. The passing half is left enabled on
purpose: it guards the premise this fix depends on, so if a future Compose
release stopped keeping imeAnimationTarget current we would hear about it
instead of silently reading a second dead value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-25 18:19:23 -04:00
Claude 3a53e2b965 fix(browser): never narrow the file picker below what the page accepts
Two fidelity gaps in the accept handling, both of which hid files a real
browser would have let the user pick.

An extension Android's MimeTypeMap cannot name was silently dropped from the
filter. That is harmless when it is the only entry (the filter is already
`*/*`), but `accept=".png,.sqlite3"` resolved to image/png alone — the picker
then showed PNGs and no way at all to reach the .sqlite3 the page also asked
for. MimeTypeMap is a fixed table and does not cover every extension a page
might list, so one unresolvable name now widens the whole filter to `*/*`.
`accept` is a hint in HTML, never an enforced restriction, so showing more than
asked is always recoverable and showing less is not.

MODE_OPEN_FOLDER (a `webkitdirectory` input) fell through to a single-file
pick. Android has no picker that hands a WebView the contents of a directory —
ACTION_OPEN_DOCUMENT_TREE returns a tree handle, not the file URIs the page's
callback takes — so it now opens a multi-select instead. The page loses
webkitRelativePath, but the user can finish the upload rather than being
capped at one file. Resolved in one shared helper so the two Activity hosts
and the two embedded providers cannot drift on it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxfdHeR9Ry4qALXHT5Sf1Q
2026-08-25 21:48:24 +00:00
Vitor PamplonaandGitHub 98f09f29c0 Merge pull request #3983 from vitorpamplona/claude/trusted-lists-searchable-events-7jb8wx
Make TrustedListEvent searchable by title (NIP-50)
2026-08-25 17:34:09 -04:00
Claude f5f8f605ec feat(quartz): index Trusted List titles for NIP-50 search
The Trusted List family (30392-30395) shipped with a `title` tag and no
`SearchableEvent`, so a list published as "Podcaster" could not be found by
name -- the only way to reach one was to already know its address. Nothing
recorded that as a decision; the feature commit wired the kinds into
EventFactory and KindNames and never touched search.

Implements SearchableEvent on the TrustedListEvent base, so all four kinds
inherit it, and indexes the title alone:

    override fun indexableContent() = title() ?: ""

Nothing else in the family is human-authored prose. `metric` names a
computation and `d` identifies the list -- machine ids, kept out so a search
for a common word in one doesn't return every list that ran the same job. The
member tags are hex ids and `content` is a JSON echo of the same membership,
so indexing either would put thousands of identifiers into the full-text
index for no lookup a #p/#e/#a/#i filter doesn't already serve better. A list
with no title indexes the empty string rather than throwing, since
indexableContent() runs inside the store's insert transaction.

The kinds are already registered in EventFactory, so the store's kind
pre-filter and the reindex scan pick them up with no further wiring.

Covered by unit tests over all four kinds (including the titleless case) and
a SQLite store test asserting the title is searchable while the metric, the
list id and the membership are not. Documents the indexing rule in the
package README and adds the rows to the searchable-kinds reference table that
external search engines mirror.

Note for existing databases: rows written before this change keep their
missing FTS text until IEventStore.reindexFullTextSearch() runs (`amy store
reindex-fts` drives it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1vXqHYHWXeni4xim66vvf
2026-08-25 20:46:38 +00:00
Claude 111f3392b4 fix(browser): open a file picker for HTML file inputs
Tapping `<input type="file">` anywhere in Amethyst was a silent no-op: no
picker, no error, nothing logged. An Android WebView shows no chooser of its
own — the app must override `WebChromeClient.onShowFileChooser`, and none of
the four WebView hosts did. The base implementation returns false, and for a
target of API 21+ there is no legacy fallback, so every file upload in the
in-app browser, in nSites and in napplets was impossible.

All four hosts now open the picker:

- NappletBrowserActivity (full-screen browser) and NappletHostActivity
  (full-screen napplet/nSite sandbox) own an Activity, so they run the picker
  directly through an ActivityResultLauncher.
- NappletBrowserService and NappletHostService render an embedded surface from
  a windowless Service in the keyless `:napplet` process and have no Activity
  to launch from. They send the request's *description* — accept list,
  multi-select, title — to the main process over the existing Messenger
  contract; WebFileChooserCoordinator builds the Intent there and collects the
  result in the throwaway WebFileChooserActivity. Shipping data instead of a
  ready-made Intent keeps the sandbox able to ask the trusted process for a
  file picker and for nothing else. URI read grants are per-UID, so the picked
  `content://` URIs are readable by the WebView in `:napplet` with no
  re-granting, and allowContentAccess stays off.

Two details that decide whether this actually works in practice:

- The page's `filePathCallback` must fire on every path. WebView keeps a file
  input busy until it does, so a dropped callback (user cancelled, session torn
  down, no app to handle the Intent) leaves that input permanently dead for the
  life of the page. PendingFileChooser guarantees exactly-once delivery and
  carries a request id so a result that outlived its request is dropped rather
  than fed to whichever input is waiting now.
- Android's own FileChooserParams.createIntent() keeps only the first `accept`
  entry and drops multi-select, so `accept="image/png,image/jpeg" multiple`
  would offer PNGs only, one at a time. FileChooserAccept resolves the whole
  list — extensions included — into a type plus EXTRA_MIME_TYPES, widening to a
  family wildcard rather than narrowing below what the page asked for. It is
  pure and unit-tested in commonMain.

NappletHostService had no chrome client at all, so it gains one. Its WebView is
built from a Service context with no window token to attach a dialog to, so the
new client also dismisses JS alert/confirm/prompt instead of opting into the
default dialog handling.

Camera capture (`accept` with `capture`) and getUserMedia still fall back to
the picker; `onPermissionRequest` remains unimplemented and is left for a
separate change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxfdHeR9Ry4qALXHT5Sf1Q
2026-08-25 20:44:50 +00:00
Vitor PamplonaandGitHub 58f144f0d1 Merge pull request #3982 from vitorpamplona/claude/picture-dialog-button-clickability-7q2whv
fix(viewer): full-screen viewer chrome — clickable buttons, PDF parity, and chrome that follows the system bars
2026-08-25 15:00:59 -04:00
Vitor PamplonaandClaude Opus 5 f4c130d7fe fix(viewer): follow the system bars instead of reserving a strip for them
The chrome reserved `systemBarsIgnoringVisibility` -- the space the bars would
occupy whether or not they were on screen. On a punch-hole device that is 142px
(54dp, not the usual 24dp: the status bar is sized to clear the camera), so the
controls sat ~64dp below the screen edge permanently, and the gap looked like a
bug because most of the time nothing was in it.

Reserving it was not gratuitous. `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE` paints
a peeked bar OVER the content and dispatches no insets at all: measured on a
Pixel-class emulator, `statusBars` reads 0 and `isVisible` reads false for the
entire time the bar is on screen, byte-identical to the hidden state. With no
signal to react to, permanently reserving the space is the only way to keep the
buttons from being covered -- which is why the previous code was written that
way, and why two attempts to shrink the inset while keeping transient bars both
failed on device.

So change the premise: ask for BEHAVIOR_DEFAULT. The bars then dispatch real
insets (statusBars 0 -> 142, navigationBars 0 -> 63, both `isVisible` flipping),
and the chrome can follow them:

- `animatedViewerChromeInset()` takes `systemBars` for the relevant edge, floors
  it at 16dp, and animates. Hidden: the row sits 16dp in (measured top=69).
  Shown: it moves clear of the bar (142). The floor is not arbitrary -- this
  display has 132px rounded corners, and a button whose left edge is x=39 needs
  y >= 38 to stay inside the visible area.
- The top display-cutout inset is dropped. Android reports it full-width, but
  the hole is `Rect(485,0,595,142)` -- 110px of 1080, dead centre. The
  edge-anchored buttons never overlap it; honouring it pushed them down by the
  height of a camera they are nowhere near. Horizontal cutout insets stay, for
  a landscape notch.

The animation snaps for 350ms after the chrome appears. Opening moves the inset
twice for reasons the user did not cause -- the window has not been told its
insets yet (they read 0, indistinguishable from "hidden"), and the immersive
effect hides the bars from a DisposableEffect that runs after composition --
and animating either played a slide on open.

Two things had to move because they were riding the same inset:

- The PDF page counter sat dead centre, which on a punch-hole device put it
  *under the camera*: measured overlap 56x36px against the lens circle. It now
  lives along the bottom edge, clear of the cutout, still screen-centred, and
  tracking the navigation bar.
- The image dialog's page dots used `navigationBarsPadding()`. That tracks the
  bar correctly but moves in a single frame, which read as a jump next to the
  top controls sliding. They now share the same animated inset.

`ViewerControlsRow`'s KDoc described the transient-bar behaviour and the
touch-swallowing it worked around. Neither is true of this code any more, so it
is rewritten rather than left to mislead.

One measurement that did NOT support this change, recorded so it is not
rediscovered as evidence: probing the reserved strip with injected taps found
12/12 points from y=8 to y=165 reaching the app, at all three button columns --
the "system swallows touches there" premise did not reproduce. But
`tappableElement` reports 142px, injected events are not a finger, and the
overlap problem above is reason enough on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-25 14:47:52 -04:00
Vitor PamplonaandGitHub 2ff7b7f199 Merge pull request #3981 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-25 11:46:53 -04:00
Claude 1c25bcb8d3 fix: repair the viewer chrome defects the audit turned up
Five fixes, all in the chrome the two viewers now share:

The PDF page swallowed its own tap. `zoomable` consumes the gesture before the
full-screen box underneath sees it, which is why the image path hangs its
toggle off `onTap` rather than a parent `clickable` -- so the page does too.
Without it the chrome auto-hid after two seconds and no tap could bring it
back, stranding the reader with no way out but the system gesture.

The auto-hide timer now races the controls going away instead of sleeping
through it: hiding and re-showing the chrome inside the two-second window used
to leave the original timer running, so it wiped controls the user had just
tapped back up. It also waits for the media to arrive (`armed`), because a PDF
that took longer than the delay to fetch rendered its first page with the
chrome already gone and nothing left to re-arm.

The save button ran on `rememberCoroutineScope` while living inside the
`AnimatedVisibility` that the auto-hide collapses two seconds later -- so the
chrome fading out cancelled the download it had just started, leaving no file
and no error. It now uses the view model's scope and the application context,
matching the download row in `ShareMediaAction`.

The page counter no longer slides sideways when the buttons fade: it sits in
its own centred row, anchored to the screen rather than to the space the
asymmetric button groups leave behind.

The back button also survives the loading and unreadable-PDF states, which had
inherited hidden system bars from the immersive effect without keeping a way
back out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PQscXLTMHXHwYyKh4xcKC
2026-08-25 15:19:03 +00:00
vitorpamplonaandgithub-actions[bot] 541b6116d7 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-25 15:14:41 +00:00
Vitor PamplonaandGitHub 3e383e77d7 Merge pull request #3980 from vitorpamplona/claude/pull-notification-card-expand-mg23fn
Make notification details opt-in via action button
2026-08-25 11:09:50 -04:00
Vitor PamplonaandGitHub c16e3f3c69 Merge pull request #3979 from vitorpamplona/claude/remove-ai-helper-post-zicyj2
feat: remove the AI writing helper from the post composer
2026-08-25 11:09:30 -04:00
Vitor PamplonaandGitHub 28e8af2564 Merge pull request #3978 from vitorpamplona/fix/homebrew-formulae
fix(homebrew): lint both formulae, and rename geode to geode-relay
2026-08-24 11:43:20 -04:00
Vitor PamplonaandClaude Opus 5 4ef3d12adf fix(homebrew): rename the geode formula to geode-relay to clear the name collision
`geode` can never be a homebrew-core formula: `formula_renames.json` maps
"geode" -> "apache-geode", so the token is permanently reserved and
`brew info --formula geode` resolves to Apache Geode. The previous commit
recorded that as a blocker; this removes it.

- `geode/packaging/homebrew/geode.rb` -> `geode-relay.rb`, `class Geode` ->
  `class GeodeRelay` (Homebrew requires the class to track the filename).
- `bump-homebrew-geode-formula.yml` follows the path, and the three sibling
  workflows' header comments now name the formula correctly.
- `geode/README.md` points at the new file and the new tap install line.

**The binary is still `geode`.** Users type `geode`, not `geode-relay`. That is
safe rather than sloppy: apache-geode installs `gfsh`, so nothing collides on
PATH. Formula token and binary name differ deliberately, which the header now
states so nobody "fixes" it later.

Verified: `brew style` clean on the renamed file (it validates class-vs-filename
agreement, so this catches a bad rename), `brew info --formula geode-relay`
resolves to this relay rather than Apache Geode, `ruby -c` passes, and replaying
the bump workflow's `sed` still changes exactly the two intended lines.

`geode/plans/2026-07-24-geode-release.md` is left alone — a dated design doc,
not live configuration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-24 11:32:15 -04:00
Vitor PamplonaandClaude Opus 5 e184db69c7 fix(homebrew): correct a style violation in both formulae, and record what blocks each submission
Checked while asking whether the amethyst-nostr cask's review feedback applied
to the two formulae. It does not — but running homebrew-core's own linter over
them turned up a real defect neither had been checked for.

**The style violation, in both files.** `brew style` flags

    Homebrew/FormulaPathMethods: Use formula_opt_prefix("openjdk")
      instead of Formula["openjdk"].opt_prefix

on the `write_env_script` line. Fixed in `amy.rb` and `geode.rb`; both now
report no offenses. It would have been raised on submission.

**A duplicated sentence.** amy.rb opened with "Reference Homebrew formula for
`amy`, the Amethyst CLI." twice, once on line 1 and again on line 3.

**Why they must NOT be made to match the cask.** The cask lost its `livecheck`
block and inline comments on review, so the obvious next step is to do the same
here. That would be wrong, and the header now says so with the evidence:
homebrew-cask and homebrew-core differ. Sampling the live core tap, 127 of 300
formulae with GitHub-release URLs declare `livecheck` (62 using
`:github_latest`), and 109 of 200 carry indented inline comments. `livecheck`
is load-bearing in core — it is what lets BrewTestBot open version-bump PRs, so
stripping it would disable exactly the automation the block exists for.

**geode cannot be submitted under that name.** homebrew-core's
`formula_renames.json` maps "geode" -> "apache-geode", so the token is
permanently reserved and `brew info --formula geode` resolves to Apache Geode.
Submitting needs a different token (`geode-relay`, `amethyst-geode`) plus a
matching change to bump-homebrew-geode-formula.yml. Recorded as a blocker in
the header rather than discovered at PR time.

**amy is unblocked but not ready.** The one-open-AI-PR limit that gated it is
cleared now the cask has merged; the ~70 MB bundle from `:commons` pulling
Compose/Skiko onto the CLI classpath is still the likely review objection, and
`brew audit --new --formula` has not been run end to end.

Verified the enlarged headers cannot confuse the bump workflows: both anchor on
`^  url ` / `^  sha256 ` at a two-space indent, each matches exactly once, and
replaying their `sed` changes those two lines only. `ruby -c` passes on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-24 11:14:36 -04:00
Vitor PamplonaandGitHub 7f0e1f2f90 Merge pull request #3974 from vitorpamplona/docs/sync-cask-reference
docs(homebrew): sync the reference cask to what actually merged upstream
2026-08-24 10:01:10 -04:00
Vitor PamplonaandGitHub 6ca19eab90 Merge pull request #3977 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-24 10:00:57 -04:00
vitorpamplonaandgithub-actions[bot] 36a74ae10c chore: sync Crowdin translations and seed translator npub placeholders 2026-08-24 13:22:46 +00:00
Vitor PamplonaandGitHub 78acc61318 Merge pull request #3976 from nrobi144/feat/desktop-live-media
feat(desktop): NIP-53 live streaming — consume & discover
2026-08-24 09:19:34 -04:00
nrobi144andClaude Opus 4.8 1190b55dbf fix(desktop): address code-review findings on live media
Blocker + high-severity fixes from multi-agent review:
- liveNowForBar: route through LiveActivitySorting.sortDescending so the
  comparator reads a snapshotted rank, not the live channel.info var — the
  previous inline comparator could hit TimSort's "contract violation" crash
  when a 30311 was swapped from a relay thread mid-sort.
- LiveWatchScreen: stop playback (GlobalMediaPlayer.stopVideo) on close via
  DisposableEffect — audio/decoding was leaking after the overlay closed.
- LiveNowBar: take the follow Set (stable identity) instead of a fresh .toList()
  per recompose, so its subscription + snapshot don't churn.
- Chat auto-scroll keys on the newest message id, not size (kept working once
  the 500-cap prune holds size flat).
- Remove the dead profile-nav affordance in the watch header/chat (was wired to
  a no-op); real profile nav from the overlay is a follow-up.
- generateSubId appends a per-process atomic counter so same-millisecond subs
  can't collide (one unsubscribe tearing down another's REQ).
- stopVideo also cancels the in-flight open job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 81d5add807 fix(desktop): make live playback start reliably + log failures
"Sometimes lives don't start" had no trace because kdroidFilter reports
playback state only via Compose state, never the log.

- GlobalMediaPlayer.playVideo now cancels any in-flight openUri before starting
  a new one, so two rapid track switches can't interleave openUri on the single
  shared engine (the race that left the surface stuck/black).
- Reuse the engine only when it's on the same URL AND had no error; a prior
  transient error (dead segment / 403 / just-went-live) now re-opens instead of
  showing a stuck surface.
- Log playVideo (REUSE/OPEN), playback errors (url + reason), and each watch
  open (address, status, streaming/recording URL) so failures are diagnosable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 e4adae05de feat(desktop): live-mode video controls in the watch screen
Addresses watch-screen player feedback:
- Hide the seek slider for live streams (the HLS is non-seekable, so a scrubber
  was inert/misleading). VOD recordings keep the normal seekable bar.
- Replace the "time / duration" readout with a single LIVE pill + one elapsed
  timer for live streams (no fixed end to show).
- Watch top bar: more top margin, less start margin (tighter to the X).

DesktopVideoPlayer/VideoControls gain an isLive flag; LiveWatchScreen sets it
from the 30311 status (live vs recording).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 c3f7bf52e9 feat(desktop): live watch screen (player + chat) + open-from-anywhere
Clicking a live stream (Discover card or the per-column bar) now opens a
full-window watch overlay: HLS player left, live chat right.

- LiveWatchController: app-level singleton holding the watched stream address;
  overlay rendered once at the composition root in Main.kt (mirrors
  GlobalFullscreenOverlay), so any live surface opens it without threading a
  callback through the deck/single-pane tree.
- LiveWatchScreen: DesktopVideoPlayer for the HLS stream + header (LIVE badge,
  host, viewer count, summary) + reactive kind-1311 chat (reverseLayout,
  auto-scroll at bottom) + composer that signs & publishes a 1311 with the
  stream's root `a` tag.
- FeedScreen/DiscoverScreen onOpenLive defaults now open the overlay.

UI polish from testing feedback: Discover "LIVE NOW" shows only genuinely-live
streams (no planned/ended), capped at 2 rows so "From the pack" stays visible;
feed live bar gets rounded inset + breathing room.

Follow-ups: live-vs-VOD seek suppression + stall watchdog, zap-the-stream,
mute/block chat filtering, online-probe downgrade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 daa6022adc docs(desktop): make live-media testing sheet build-state aware
Tags each section LIVE / PARTIAL / PENDING so it's usable against the current
branch, with concrete step→expected tables for the testable Discover + live-bar
surfaces and a "test right now" quick path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 18d28cf8ed feat(desktop): per-column "live now" bar on Following/Global feeds
Pins a compact bar at the top of the Following and Global feed columns showing
the single most-watched live host in that column's audience, with a "+N live ›"
dropdown for the rest. Hidden when nobody in scope is live.

- LiveNowBar: own 30311 subscription scoped to the column (follows for Following,
  global for Global); reads the shared liveNowForBar ranking (viewers-first);
  click opens the watch screen via onOpenLive.
- FeedScreen: pinned above the feed LazyColumn for FOLLOWING/GLOBAL modes only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 9c8c11a0d7 feat(desktop): Discover "Live now" section with ranking + search
Surfaces NIP-53 live streams in Discover: subscribes to kind 30311 while
visible, ranks via the shared LiveActivitySorting (live > planned > ended,
follow-participation, viewers), and filters client-side by title/host/hashtag.

- FilterBuilders.liveActivities / liveActivityChat + createLiveActivitiesSubscription
  / createLiveChatSubscription.
- LiveActivityRanking: maps channels to the shared snapshot rank; liveNowForBar
  (viewers-ranked) prepared for the per-column bar.
- LivesSection: subscription + search box + responsive card grid (thumbnail,
  LIVE/scheduled badge, host, viewer count). Card click -> onOpenLive(address)
  (wired to the watch screen in the next commit).

Online-probe downgrade (OnlineChecker) still to be wired; ranks treat all
status=live as online for now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:37 +03:00
nrobi144andClaude Opus 4.8 16aae86791 feat(desktop): route NIP-53 30311/1311 into DesktopLocalCache
Stands up Desktop's first channel cache (liveChatChannels) so live streams
and their chat have somewhere to live (getAnyChannel returned null before).

- getOrCreateLiveActivityChannel + LiveActivitiesChannel per stream address.
- Route kind 30311: replaceable supersession in addressableNotes, attach info
  to the channel, bump liveActivityVersion (drives Lives grid / live bar).
- Route kind 1311: attach to its stream channel by root `a` tag; cap retained
  chat at 500 via pruneOldMessages (Desktop had no pruning).
- Skip 1311 write-through to the local relay store (avoid unbounded chat replay
  on next launch); 30311s still hydrate.
- getAnyChannel resolves a 1311/30311 note back to its channel.
- snapshotLiveActivities() for reactive recomputation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:37 +03:00
nrobi144andClaude Opus 4.8 6614b0bd60 feat(commons): add NIP-53 LiveActivitySorting + plan/testing docs
Foundation for Desktop Live Media (NIP-53) consume+discover feature.

- LiveActivitySorting: pure, CLI-safe status-order / freshness / ranking
  helpers with a snapshot-map sort API so Android + Desktop order live
  streams identically and no comparator reads volatile state mid-sort
  (avoids the TimSort "contract violation" the Android filters guard against).
- Unit tests (green): status ordering, offline-live downgrade, 15-min
  live-bar freshness, overdue-planned detection, multi-key sort + tiebreaks,
  and stability under concurrent key mutation.
- Deepened plan (7 review agents) + brainstorm + full manual testing sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:40:09 +03:00
Vitor PamplonaandClaude Opus 5 b1a03a083d docs(homebrew): sync the reference cask to what actually merged upstream
The cask went live in Homebrew/homebrew-cask on 2026-08-24. A maintainer
removed three things during review that this reference copy still carried, so
the file now documents a shape Homebrew rejected:

- the `livecheck do url :url; strategy :github_latest end` block, which is
  redundant — Homebrew infers the strategy from a GitHub release URL
- the inline `conflicts_with` comment
- the inline `zap` rationale comment

The body below the header is now byte-identical to upstream, so `diff`-ing the
two is meaningful again.

The removed rationale was worth keeping, just not upstream, so it moves into
the header — which `scripts/bump-winget.sh`-style stripping never applies here
anyway, because this file is only ever read, never copied. Notably the `zap`
paths, re-derived from source rather than trusted from the old comment:
`AccountManager.kt` for `~/.amethyst` (accounts and KEYS), `DesktopTorManager.kt`
for the Application Support path, and `DesktopImageLoaderSetup.kt` whose macOS
`cacheDir()` branch resolves to `~/Library/Caches`. Also why the shared Java
prefs plist is deliberately excluded: `java.util.prefs` writes every Java app's
preferences into that one file.

The header also corrects a scope claim. It implied this file is what ships;
it is not. `scripts/bump-homebrew-cask.sh` bumps upstream through
`brew bump-cask-pr`, which edits the upstream cask in place and only reads
version + sha256 from here.

Verified the enlarged header cannot confuse either bumper: both anchor on the
two-space indent (`^  version "` / `^  sha256 "`), each matches exactly once,
and replaying the workflow's `sed` against this file changes those two lines
and nothing else. `ruby -c` passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-24 00:39:59 -04:00
Claude 9dc209cdfe feat: align the PDF viewer chrome with the image viewer
Both viewers open the same way -- tap a media card in a feed -- so the
difference in how their chrome behaved was arbitrary from the user's side, and
a PDF is a reading surface where controls parked over the page cost more than
they do over a photo.

The PDF viewer now goes immersive, toggles its controls on tap, auto-hides
them, anchors the share sheet to its button instead of the window root, and
gains the save-to-gallery button the image viewer already offered for PDFs.

The page counter is wayfinding rather than a control, so it does not simply
vanish with the buttons: it also flashes on its own for a moment after every
page turn, which is why the shared row holds a button's height whatever it
carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PQscXLTMHXHwYyKh4xcKC
2026-08-23 21:26:41 +00:00
Claude a82035c601 fix: keep the full-screen viewer controls out of the hidden system-bar strip
The zoomable dialog goes immersive, which drops the status-bar inset to zero
and lands the back/share/save buttons against the top edge of the screen. That
strip stays owned by the system while BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE is
set -- it is the area watching for the swipe that peeks the bars back -- so
touches there never reach the buttons and only their lower halves respond.
There is no API to turn that region off, so reserve the space the bars would
occupy even while they are hidden (systemBarsIgnoringVisibility, unioned with
the display cutout for notched devices in landscape). As a bonus the controls
no longer jump when the user swipes the bars back in.

Extracts the chrome the PDF viewer is about to share: the immersive effect, the
auto-hiding visibility state (which collapses the dialog's two duplicate
auto-hide effects into one), the control row, and the three buttons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PQscXLTMHXHwYyKh4xcKC
2026-08-23 21:26:24 +00:00
Claude a5e2aae960 feat: remove the AI writing helper from the post composer
The on-device AI writing assistant (ML Kit GenAI proofreading/rewriting via
Gemini Nano) proposed tone rewrites under the text field on the new post,
reply and quote screens. Removes the feature end to end:

- deletes the WritingAssistant abstraction and its play (ML Kit) and fdroid
  (no-op) implementations, the mock, and the AiWritingHelp panel/button
- strips the AI state, precompute job and lifecycle wiring out of
  ShortNotePostViewModel and ShortNotePostScreen
- drops the genai-proofreading, genai-prompt and genai-rewriting
  dependencies, which nothing else used

The composer was the only reader of the "Propose text improvements"
setting, so that goes too: the Compose Settings tile, the
automaticallyProposeAiImprovements field in UiSettings/UiSettingsFlow, the
ui.propose_ai_improvements DataStore key, and the ai_writing_*/ai_tone_*
strings in every locale.

The ML Kit image-description service that backs alt-text suggestions lives
in the same package and is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvNaegVegSy5tZ4y3M7B4b
2026-08-23 19:32:33 +00:00
Claude 41fff6b9b3 fix: keep the always-on notification card at the bare relay count
Android auto-expands a notification when it is the only one in the shade,
and offers no way to opt out. The per-job relay breakdown was attached as a
BigTextStyle on every post, so for anyone whose shade was otherwise empty
the full list of what each relay is doing *was* the default view — the
opposite of the "expanded only" intent it was written with.

The breakdown is now opt-in: the notification is built with no expanded
style at all, so the card is always just "Connected to X relays", and a
"Show details" action posts it back with the breakdown plus a "Hide
details" action that returns to the bare count. As a side effect the
per-relay request walk only runs while the details are on screen, instead
of once a second whether or not anyone is looking.
2026-08-23 19:28:03 +00:00
David KasparandGitHub dad7fccaf2 Merge pull request #3972 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-23 06:15:06 +02:00
vitorpamplonaandgithub-actions[bot] c4babf0b6e chore: sync Crowdin translations and seed translator npub placeholders 2026-08-23 01:54:17 +00:00
Vitor PamplonaandGitHub 5d0802661b Merge pull request #3973 from vitorpamplona/docs/release-doc-corrections
docs: correct three release-doc claims the v1.14.0 release disproved
2026-08-22 21:51:29 -04:00
Vitor PamplonaandClaude Opus 5 1aeb194368 docs: correct three release-doc claims the v1.14.0 release disproved
All three were found by following the docs during the v1.14.0 release and
hitting reality instead.

1. The Homebrew cask bootstrap command cannot work. BUILDING.md told the
   maintainer to run `brew bump-cask-pr amethyst-nostr` for the *one-time
   initial PR*, but that subcommand updates an existing cask. Against a name
   not in the tap it fails outright:

     Error: Cask 'amethyst-nostr' is unavailable: No Cask with this name exists.

   Verified by dry-run. A first submission is a new-cask PR — `brew create
   --cask`, `brew audit --new --cask`, then a hand-opened PR — so the section
   now documents that flow, notes the notarized+stapled precondition Homebrew
   enforces, and says where `bump-cask-pr` *does* apply (the later bumps).
   This is plausibly why the bootstrap never happened.

2. RELEASE_OPS claimed the release holds 31 assets. It holds 47. The windows-
   arm64 and linux-arm64 legs added this cycle took desktop 8 -> 14, amy 5 ->
   10 and geode 5 -> 10. BUILDING.md had already been updated; RELEASE_OPS had
   not, in two places (the § 2 breakdown and the § 6 checklist). A maintainer
   following it would read a correct release as broken. The breakdown now
   points at BUILDING.md, which carries the per-leg detail and the reasons for
   the two gaps, rather than restating it and drifting again. Also drops the
   geode Docker image from the count — it goes to the registry, not the
   release.

3. RELEASE_OPS § 3 said to verify "Intel + ARM DMGs are both present" while
   § 2 and § 6 said macOS is arm64-only. Only the arm64 DMG exists, so § 3 was
   the wrong one.

Also replaces the "neither has ever been submitted upstream" line with a
per-channel table: Winget is now submitted (microsoft/winget-pkgs#422752,
pending CLA), both Homebrew packages are not. Since that is a snapshot that
will age, it carries the one-call check that answers it live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-22 20:31:53 -04:00
Claude 416cd32eb3 fix: recover IME padding when Compose's insets listener freezes
After a while, the back gesture would dismiss the keyboard but leave a
keyboard-sized gap behind it, app-wide and permanently — only killing the
activity cleared it.

Compose keeps one InsetsListener per window in WindowInsetsHolder. It sets
runningAnimation in onPrepare and clears it only in onEnd, plus an
onApplyWindowInsets fallback gated to API 30. While that flag is set,
onApplyWindowInsets deliberately skips update(insets) and waits for
onProgress instead. An IME animation that is prepared and then cancelled
without ever delivering onEnd — which the back gesture can cause, since the
predictive-back window animation races the IME's own close animation —
leaves the flag set for good, and every WindowInsets in the window freezes
at its last animated value.

Nothing recovers from that on its own: the listener is only reset when the
holder's access count goes 0 -> 1, and the app reads WindowInsets.ime
continuously, so the count never reaches zero while the activity lives.

Nav's ImeSettler already prevents this for in-app navigation, but the
system's own back gesture never reaches Nav — the first back press with a
keyboard up is consumed by the IME — so prevention alone can't close it.

The escape hatch is that onApplyWindowInsets publishes imeAnimationTarget
before it consults that flag, so the target keeps tracking reality while the
animated value is frozen. SafeImeInsets watches both: when they disagree and
then stop moving for longer than any real animation frame gap, the animated
value is stale and the target is the truth. That corrects the freeze in both
directions — a gap left behind by a keyboard that is gone, and missing
padding under a keyboard that has come back.

Modifier.imePadding() is replaced with imePaddingSafe() across the app, and
keyboardAsState(), rememberImeSettler() and DisappearingScaffold's nav-bar
subtraction now read the corrected inset too — the stuck reading also left
the bottom navigation bar hidden and made every navigation burn the full
settle timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bqpAeyLAxHzw5XsnRUtjD
2026-08-22 23:08:21 +00:00
mstrofnone 94976dbe74 fix(desktop): surface macOS notification-permission OS errors + timeout the request
The "Enable OS notifications" button still fails on macOS even after
e9475dd0 + the auto-enable follow-up, and the failure mode gives the
user nothing to act on:

1. Nucleus's requestAuthorization callback carries the OS error string
   (UNErrorDomain), but the dispatcher discarded it ({ granted, _ -> })
   and mapped every non-grant to PermissionState.Denied. The settings UI
   then showed "Enable in System Settings → Notifications → Amethyst" —
   a dead end when macOS refused the request outright ("Notifications
   are not allowed for this application"), because a refused app never
   gets a System Settings entry.

2. On recent macOS the permission prompt is an auto-dismissing banner.
   If the user misses it, UNUserNotificationCenter may never invoke the
   completion handler, leaving requestPermission()'s
   suspendCancellableCoroutine parked forever and the UI stuck on
   "Requesting…".

Fixes:

- requestPermission() now captures the OS error string and exposes it
  via NotificationDispatcher.lastRequestError (new interface property,
  null-defaulted so other implementations are unaffected).
- The request is wrapped in withTimeoutOrNull(90s); on timeout the
  coroutine returns, the spinner clears, and lastRequestError tells the
  user to watch for the banner and retry.
- The Denied branch of NotificationSettingsScreen gains an "Ask again"
  button (re-request re-surfaces the banner) and both branches render
  the raw OS error when one is present.
- sendMac() now uses Nucleus's add(request, callback) overload and
  reports SendResult.Failed with the OS error instead of unconditionally
  returning Delivered for a request the notification center may have
  rejected. Timeout without an ack still counts as delivered (the
  request was queued).

Reproduced the hang + the silent-error path on macOS 26.4 with a
minimal Nucleus harness: first requestAuthorization call from a
freshly-installed bundle never fired its callback (30s timeout),
subsequent calls returned granted=false with "Notifications are not
allowed for this application" — neither observable from the Amethyst
UI before this change.
2026-08-23 08:35:14 +10:00
Vitor PamplonaandGitHub 10149d7150 Merge pull request #3968 from vitorpamplona/chore/bump-amy-formula-v1.14.0
chore: sync amy Homebrew formula to v1.14.0
2026-08-22 14:05:56 -04:00
Vitor PamplonaandGitHub 4ed50f5ec0 Merge pull request #3971 from vitorpamplona/chore/bump-winget-manifest-v1.14.0
chore: sync winget manifests to v1.14.0
2026-08-22 14:05:50 -04:00
Vitor PamplonaandGitHub 853d0c8be7 Merge pull request #3970 from vitorpamplona/chore/bump-amethyst-cask-v1.14.0
chore: sync amethyst-nostr cask to v1.14.0
2026-08-22 14:05:44 -04:00
Vitor PamplonaandGitHub 70e57abc08 Merge pull request #3969 from vitorpamplona/chore/bump-geode-formula-v1.14.0
chore: sync geode Homebrew formula to v1.14.0
2026-08-22 14:05:35 -04:00
vitorpamplonaandgithub-actions[bot] 9ab8af72dc chore: sync winget manifests to v1.14.0 2026-08-22 17:54:52 +00:00
vitorpamplonaandgithub-actions[bot] 2b25364c2c chore: sync amethyst-nostr cask to v1.14.0 2026-08-22 17:54:38 +00:00
vitorpamplonaandgithub-actions[bot] 00597751e4 chore: sync geode Homebrew formula to v1.14.0 2026-08-22 17:54:31 +00:00
vitorpamplonaandgithub-actions[bot] 7404f6db7b chore: sync amy Homebrew formula to v1.14.0 2026-08-22 17:54:29 +00:00
Vitor PamplonaandGitHub e1ba25df55 Merge pull request #3967 from vitorpamplona/chore/release-1.14.0
chore(release): v1.14.0 changelog and version bump
2026-08-22 13:13:30 -04:00
Vitor PamplonaandClaude Opus 5 68c1e4d1fe docs(changelog): credit the translators Crowdin recorded without a language
scripts/translators.sh buckets contributors whose sinceLastTag entry carries
an empty languages list under "(unknown language)". Dropping that bucket, as
the first draft did, silently uncredited 18 people who did translate this
cycle — the language is what is missing, not the contribution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-22 13:04:48 -04:00
Vitor PamplonaandClaude Opus 5 43f0fcf038 chore(release): bump to 1.14.0
app 1.13.1 -> 1.14.0, appCode 456 -> 457. That single edit drives Android's
versionName/versionCode, Desktop and CLI packageVersion, quartz's Maven
version and geode's RelayInfo.VERSION. RELEASE_NOTES_ID is repointed, which
RELEASE_OPS notes happens on x.y.0 releases and not on patches.

Also syncs the docs that state a version rather than illustrate one. quartz
and geode both read libs.versions.app, so their install snippets were stale
claims about what Maven Central and the release assets actually carry:

- README.md, quartz-integration SKILL.md and its gradle-setup.md reference
  -> quartz 1.14.0
- geode/README.md install commands -> geode 1.14.0

The Homebrew/Winget "not bootstrapped" notes in RELEASE_OPS.md and
BUILDING.md were stamped v1.13.1. Re-verified before moving the stamp rather
than re-stamping blind: Homebrew/homebrew-cask has no amethyst-nostr.rb and
microsoft/winget-pkgs has no VitorPamplona/Amethyst, both still 404, so the
claim holds. The bump-script invocations beside them named v1.13.2, a tag
that never existed, and are now copy-pasteable.

Left alone deliberately: everything under */packaging/ and translators.json's
tag, which the bump workflows and the Crowdin job write after the tag exists
(bumping by hand would commit wrong hashes and a dead URL); BUILDING.md's
asset-name and git-checkout samples, which are illustrations; and the
"invisible until v1.13.1" line in RELEASE_OPS.md, which is history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-22 13:04:02 -04:00
Vitor PamplonaandClaude Opus 5 2d14e85bf3 docs(changelog): add v1.14.0 release notes
Assembled from everything in v1.13.1..main: 158 PR merges (124 substantive,
34 Crowdin syncs), 5 ngit proposals merged outside the GitHub PR flow, and
15 direct-to-main commits.

Coverage was verified in both directions rather than assumed — every PR
resolves to a bullet that literally appears in the file, and every bullet is
claimed by some commit. That audit caught five defects in the first draft:

- A bullet crediting BUD-01 Blossom read-auth, which is PR #3789 and shipped
  in v1.13.1 — the GitHub search window overlapped the tag date.
- #3819's Concord fix (entities pinned across a Refounding) missing; only the
  log-quieting half of that PR had been written up.
- #3855's per-host strike-out / unreachability tracking missing.
- #3818's re-probe on the local Blossom toggle missing.
- The mention notification icon reshape (6fac964d), which has no PR at all and
  so was invisible to a PR-only sweep.

Six PRs are deliberately omitted, each for a stated reason: #3801/#3802/#3805
sync packaging to v1.13.1 and belong to the previous release, #3845 is
test-only, #3878 is behavior-preserving renames, #3956 is a CI lint fix.

Also records npubs for nrobi144, dmnyc, dskvr, alexgleason and mstrofnone in
github.json, and corrects mstrofnone's key in TEMPLATE.md. The five already
published changelogs keep the key they shipped with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-22 13:04:02 -04:00
David KasparandGitHub d585f0436d Merge pull request #3966 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-22 18:03:01 +02:00
vitorpamplonaandgithub-actions[bot] f73e8d2f0a chore: sync Crowdin translations and seed translator npub placeholders 2026-08-22 16:01:47 +00:00
Vitor PamplonaandGitHub 01892935c2 Merge pull request #3965 from vitorpamplona/claude/update-all-dependencies-2h0t1y
Upgrade dependencies: Firebase, Jackson, OkHttp, Gradle, and others
2026-08-22 11:58:52 -04:00
Claude 2c9f70e9e8 chore(deps): update dependencies and the Gradle wrapper
Sweep every dependency coordinate in the version catalog, the hardcoded
ones in the module build scripts, the Gradle wrapper and the GitHub
Actions against their upstream metadata, and take the newest release
that keeps each pin on the same stability channel it was already on.

Version catalog:
  firebaseBom           34.17.0     -> 34.18.0
  jacksonModuleKotlin   2.22.1      -> 2.22.2
  okhttp                5.4.0       -> 5.5.0
  sonarqubeGradlePlugin 7.3.1.8318  -> 7.4.0.8496
  spotless              8.9.0       -> 8.10.0
  vico-charts-compose   3.2.3       -> 3.3.0

vico had been held back at 3.2.3 because the only newer builds were the
3.3.0-next prereleases; 3.3.0 has since shipped stable, so the pin moves
without leaving the stable channel.

sonarqube-gradle-plugin is published on the Gradle plugin portal, not
Maven Central — the `3.3` that Central still serves for that coordinate
is a stale line unrelated to the current 7.x releases. It stays LGPL-3.0
and build-time only, gated behind the local.properties sonar opt-in in
the root build script, so nothing new enters a shipped artifact.

Gradle wrapper 9.7.0 -> 9.7.1, with distributionSha256Sum updated to the
checksum published for 9.7.1.

Already current, so untouched: every other catalog ref (AGP, Kotlin,
compose-multiplatform, the compose BOM, media3, coil, ktor, secp256k1,
camera, sqlite, ...), the hardcoded coordinates in the module build
scripts (tink-android 1.23.0, tracing-perfetto 1.0.1, opus-java 1.1.1,
jna 5.19.1, nucleus.notification-* 1.15.7, kotlinx-crypto-* 0.0.4), and
every GitHub Action — the floating major tags are all on their newest
major and setup-java is already pinned at v5.7.0, the newest release.

Left alone on purpose:
  - appfunctions stays at 1.0.0-alpha09: `appfunctions` and
    `appfunctions-compiler` publish alpha10 but `appfunctions-service`
    still stops at alpha09, and all three share the ref.
  - composeRuntimeAnnotation stays at 1.12.0 because it has to track
    whatever the compose BOM pins, and 2026.08.00 is still the newest.
  - The org.jetbrains.compose.material3 pin stays at 1.9.0 — everything
    above it is a 1.10/1.11/1.12 alpha.
  - The @moq/* npm pins in nestsClient/tests/browser-interop, which the
    directory's REV file ties to the moq-relay git rev in
    hang-interop/REV; bumping the 0.2.x (moq-lite-03) line is a
    wire-protocol change that has to move with the Rust relay pin.
  - quartz/tools/tsmls-vector-gen stays on ts-mls 2.0.0-rc.10. That
    generator emits the committed MLS KAT vector with fresh randomness
    each run, so moving it means regenerating and re-verifying the
    fixture, not a routine version bump. Its @noble/* deps already float
    on caret ranges.
  - The Docker base images (eclipse-temurin:21, rustc 1.95.0) are
    toolchain pins tied to the JDK target and the moq-relay pin.

No new dependencies are introduced, so no new licenses enter the build.

Verified: :amethyst:compilePlayDebugKotlin, :desktopApp/:cli/:geode/
:relayBench compileKotlin and spotlessCheck all pass on Gradle 9.7.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnezWP7LpA6amBCVxtGQ5b
2026-08-22 14:30:17 +00:00
Vitor PamplonaandGitHub 0e8a235bce Merge pull request #3964 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-21 18:05:19 -04:00
vitorpamplonaandgithub-actions[bot] 0d8d994609 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-21 21:23:12 +00:00
Vitor PamplonaandGitHub 4c524fd81b Merge pull request #3963 from vitorpamplona/claude/opengraph-preview-rendering-na1cul
Optimize MetaTagsParser: fix quote tracking in comments/scripts
2026-08-21 17:20:24 -04:00
Claude a7209a70b6 test: cover the meta-tag variants the suite never saw, and fix title/textarea
Reviewing what the tests actually reach turned up one live defect and a suite
that mostly did not run.

The defect is the comment bug again, in the elements whose content is text
rather than markup. `<title>5 < 6, that's math</title>` is ordinary HTML: the
`<` opened a phantom tag, the apostrophe opened a phantom attribute value, and
every tag after it -- the whole og: block -- was swallowed. Same for
`<textarea>`, and a `<meta>` written inside title text was parsed as a real one
and won over the page's own. Title and textarea now skip to their end tag like
script and style; the three tests for it fail without that change.

The suite: MetaTagsParserTest lived in `androidDeviceTest`, so every attribute
shape it covers -- unquoted values, single quotes, valueless attributes,
duplicate-attribute rejection, `</head>` inside a value -- was unguarded in CI.
Nothing in it is Android-specific, so it moves to commonTest. OpenGraphParser
and HtmlCharsetParser had no tests at all.

New coverage, all of it variants nothing exercised before:

- end of scan: `</HEAD>`, `</head >`, `</head>` inside an attribute value, and
  a document with no `</head>` at all.
- truncation: a body cut after a `/`, inside a quoted value, and right after a
  `<` -- the first is the crash the previous commit guarded and never pinned.
- tag shapes: uppercase `<META>`, `<meta/>`, `/>` inside a quoted value,
  `<![CDATA[]]>`, and meta tags inside `<noscript>` (which must still be read).
- attributes: a trailing valueless attribute, a value spanning lines, unknown
  attributes.
- character references: query-string `&` left intact (og:image URLs are full of
  them), astral references (`&#128512;`), unknown references left alone.
- laziness: the sequence stops when the consumer does.
- OpenGraphParser: property / name / itemprop sources, twitter and plain-name
  fallbacks, and that a field is taken in document order -- so a plain
  `<meta name="description">` above the og: one wins. That is why the
  brainstorm.world card shows the site description; pinned, not changed.
- HtmlCharsetParser / HtmlParser: charset attribute, http-equiv content-type,
  the UTF-8 default, the 1 KB sniff window, and the response-charset > BOM >
  document-declaration precedence.

49 tests in the package, from 8 that ran.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FumxeDJPEgPqX8mz3xkM6b
2026-08-21 20:16:12 +00:00
Claude 39797b2191 perf: drop the regex and the per-tag allocations from the meta scan
The tag-name check was a Regex match over a freshly cut substring, run for
every `<` in the document. Both are gone: names are compared in place against
the only four that matter (meta, head, script, style), ASCII-case-folded with
`code or 0x20`, so a non-meta tag now costs zero allocations -- no substring,
no Matcher, no RawTag. nextTag() reports a TagKind and leaves the attribute
span as two indices; only a real `<meta>` gets read, and parseAttrs() reads
that span straight out of the document instead of a copy of it.

The rest of the scan got the same treatment:

- `indexOf('<')` / `indexOf('>')` / `indexOf("-->")` instead of char-at-a-time
  predicate loops -- these are intrinsified and vectorized on the JVM.
- `Set<Char>.contains` for the attribute character classes boxed a Char per
  character of every meta tag; they are `when` branches now.
- one Pair, one Result and one lambda per attribute (`runCatching { add(Pair) }`)
  became a boolean-returning add -- a duplicate attribute no longer throws.
- `toImmutableMap()` rebuilt a persistent map for every meta tag; the Attrs
  builder is discarded at freeze(), so its own map is already private.
- the character-reference Regex only runs on values that contain an `&`.

Measured on a comment-free head, where this and the previous implementation
do identical work (same JVM, both warmed, `plainHead` corpus):

  1.1 KB head,  10 metas   12.5 us -> 5.1 us   ( 88 -> 215 MB/s)
   28 KB head, 204 metas    267 us -> 116 us   (105 -> 242 MB/s)

MetaTagsParserBenchmark joins the prodbench suite as the guard, on corpora
shaped like a Vite SPA head and a CMS head buried in analytics scripts: any
site we preview picks the input, so the scan has to stay linear in it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FumxeDJPEgPqX8mz3xkM6b
2026-08-21 19:58:08 +00:00
Claude b3d4cd924b fix: stop HTML comments and script bodies from hiding og: meta tags
MetaTagsParser's scanner treated `<!-- ... -->` like an element and ran the
attribute quote tracker over its text. A comment holding an odd number of
quote characters -- an apostrophe in "we don't" is enough -- left the scanner
inside a phantom quoted attribute value, so every tag that followed was
swallowed until the next matching quote character.

brainstorm.world hits this: its head opens with a theme comment containing
`don't`, `'dark'` and `'system'` (five apostrophes), and the scanner only
resurfaced at the apostrophe in `manifest's`, several comments later. The
whole og: block sat in between, so the parser saw 4 meta tags instead of 22
and none of them og:*. With no title/description/image, UrlInfoItem.fetchComplete()
is false, UrlCachedPreviewer stores Empty and the note renders a bare link.

Comments are now skipped to `-->`, declarations and processing instructions
(`<!DOCTYPE ...>`, `<?xml ...?>`) to the next `>` without quote tracking, and
script/style bodies to their end tag -- `for (i = 0; i < n; i++)` and quotes in
JS strings are raw text, not markup, and can hide the same way. Also guards a
peek() past the end of a body truncated right after a `/`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FumxeDJPEgPqX8mz3xkM6b
2026-08-21 17:08:21 +00:00
David KasparandGitHub 6560288fcb Merge pull request #3962 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-21 18:17:44 +02:00
davotoulaandgithub-actions[bot] 219f71bf17 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-21 16:16:49 +00:00
davotoula bd7e00c397 update cs,pt,de,sv 2026-08-21 18:02:08 +02:00
David KasparandGitHub 0c63687ad4 Merge pull request #3960 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-21 09:52:50 +02:00
vitorpamplonaandgithub-actions[bot] d92c94af95 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-20 22:31:03 +00:00
Vitor PamplonaandGitHub a2f0514b23 Merge pull request #3959 from vitorpamplona/claude/quartz-trusted-list-1wggln
Add Trusted Lists (kinds 30392-30395) implementation
2026-08-20 18:28:25 -04:00
Claude 509075abde fix(quartz): guard trusted-list member hints and drop member-scan allocations
Audit of the Trusted List family turned up one real bug, one indexing
inconsistency and two allocation problems.

Fabricated relay hints. RelayUrlNormalizer.normalizeOrNull("alice") returns
wss://alice/, so parsing index 2 of a member tag unconditionally turned any
non-url there -- a petname, a label, the empty-string padding's non-empty
cousins -- into a relay hint that then reached pubKeyHints()/eventHints() and
the hint indexer. Member tags now apply the same length + isRelayUrl gate PTag
uses on that slot. The trailing-field parsing that all four member types share
moves into MemberTagFields, which also removes the three copies of the score
parser.

AddressMemberTag.parseAsHint accepted any non-empty value, so a malformed `a`
tag produced an AddressHint keyed on a non-coordinate. It now requires the
value to look like a coordinate, matching ATag.parseAsHint.

memberValues() and memberCount() ran through members(), building one member
object per tag just to read a value or a length -- on lists that the spec
expects to carry thousands of entries. Both now read the tags directly via two
protected hooks each kind implements with its own isTag/parse-value pair, so
the objects are only built when a caller actually wants the hints and scores.
A test pins memberCount() == members().size, including over malformed tags,
since the two predicates have to stay in step.

Also aligns TrustedListContentMember.memberValue with the property form used
by TrustedListMemberTag, and emits list metadata ahead of the membership in
build() so a large list does not bury its own header tags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f6dt4Zo3g8TAayhCU4tTD
2026-08-20 21:48:30 +00:00
Claude c0f812e468 feat(quartz): implement the Tapestry Trusted List family (30392-30395)
Adds the pre-NIP Trusted List kinds as a companion to NIP-85 Trusted
Assertions. A Trusted List is an addressable event that publishes a curated
set of members computed under a point of view -- the aggregate analog of an
assertion, which states a computed result about a single subject.

The family binds to NIP-85's subject-type convention by the +10 rule, so the
kind's last digit denotes the member type and a reader never has to inspect
the tags to know what a list contains:

  30392 (= 30382 + 10) pubkeys            `p`  UserTrustedListEvent
  30393 (= 30383 + 10) events             `e`  EventTrustedListEvent
  30394 (= 30384 + 10) addressable events `a`  AddressableTrustedListEvent
  30395 (= 30385 + 10) external ids       `i`  ExternalIdTrustedListEvent

All four extend TrustedListEvent, which carries what the family shares: the
list identity in `d`, the title/metric labels, the observer / source-tag /
cutoff / min-rank provenance, the completeness signal, the retraction marker
and the optional JSON echo of the membership in `content`. members() is
narrowed per kind but always satisfies TrustedListMemberTag, so kind-agnostic
readers can take memberValue and score without branching.

Notable semantics:

- Member tags are [<tag>, <value>, <hint>, <score>] for every kind, so a
  publisher with a score but no relay hint pads index 2 with an empty string
  (as the reference publisher does). On `e` members index 3 is the score, not
  a NIP-10 marker.
- isTruncated() keys off the *presence* of the `truncated` tag, since its
  absence is what promises the list is exhaustive; a tag with a missing or
  unparseable total still reads as incomplete.
- Single-letter tags that are not the kind's member tag are relay-filterable
  discovery metadata, read through aboutAddresses()/aboutPubKeys() and never
  through members().

Package layout and tag classes follow the nip88Polls structure. Kinds are
registered in EventFactory and KindNames; the package README documents the
wire format. Covered by tests over the reference pinned-tag list event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f6dt4Zo3g8TAayhCU4tTD
2026-08-20 20:45:59 +00:00
David KasparandGitHub 2b86db485c Merge pull request #3948 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-20 13:17:31 +02:00
vitorpamplonaandgithub-actions[bot] 37ab7e49b4 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-20 01:01:35 +00:00
Vitor PamplonaandGitHub 159afc0423 Merge pull request #3958 from vitorpamplona/claude/quartz-issue-evaluation-odhxjr
Fix registry leak in subscription lifecycle
2026-08-19 20:58:30 -04:00
Vitor PamplonaandGitHub 3261539920 Merge pull request #3953 from nrobi144/feat/desktop-chat-composer-enrichment
feat(desktop): enrich the DM chat composer + message UX
2026-08-19 19:05:44 -04:00
Claude 18bd078a10 fix(quartz): release the sub row on unsubscribe even while the client is inactive
Audit of every subscription teardown path in the app found the previous commit's
release was reachable only while the client was ACTIVE: NostrClient.unsubscribe
gated the whole flush on isActive(), and that flush is what releases the row.
The Android app backgrounds constantly (disconnect() -> isActive() == false) and
tears feeds down while it is down there, so every teardown in the background
leaked a row for the whole stretch, and all of them were then scanned on every
relay connect once the app came back.

The flush now always runs; only the wire traffic stays gated. While inactive the
pool is disconnected, so sending is both impossible and pointless (the relay
dropped the subscription when the socket died), and routing through the pool
would materialize a relay client for a relay we deliberately stopped talking to.

Also hardens the release against a subscribe of the same id racing an
unsubscribe: sendToRelayIfChanged now (re)creates the row when the sub is
desired, and re-creates it after a removal that a concurrent subscribe undercut.
An empty row means "nothing in flight", so the REQ is still decided and sent —
without this, the loser of that race could leave a wanted feed permanently
silent, which is far worse than a leaked row.

Adds NostrClientSubscriptionLifecycleTest: ten end-to-end cases driving a real
NostrClient through a recording socket, each asserting BOTH the wire traffic and
the registry size, because it is easy to fix the leak by dropping a frame. They
cover subscribe/unsubscribe, teardown while inactive, background churn,
re-subscribing the same id (what SubscriptionController does when a feed's relay
set empties and refills), dismissal through SubscriptionController, filter
changes on a live sub, replay after reconnect, COUNT queries, multi-relay
CLOSEs, and survival of the relay-wide refusal block across re-subscribe cycles.

Nine of the ten fail on the pre-fix code; the tenth (a filter change on a live
sub) passes both ways and is there to catch the opposite regression. Verified
side by side that fixed and unfixed produce identical frames on the wire —
REQ, REQ, CLOSE, then only the live sub replayed on reconnect — with the
registry going from 2 requests + 1 count to 1 + 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHsz9fXDYysZcKZXhff4XY
2026-08-19 22:30:29 +00:00
Vitor PamplonaandGitHub 1ae9faacb5 Merge pull request #3957 from vitorpamplona/fix/geode-ci-sync-corpus
ci: shrink MirrorSyncThroughputTest's corpus to 100k on CI
2026-08-19 18:28:55 -04:00
Claude ca89b30fbe fix(quartz): release pool request/count state rows when a sub is removed
PoolRequests.remove() dropped the sub from `desiredSubs` and its listener, but
left its RequestSubscriptionState in `relayState` forever. Since
onConnecting/onDisconnected/onCannotConnect scan that whole map on every relay
lifecycle event, each connect was O(all subs ever created) rather than O(live
subs). A long-running pool driving many relays therefore spends more and more
dispatcher time in the connect path, starving everything else sharing the
client (reported downstream in NosFabrica/vespa-relay#154, where the monitor
plane completed zero passes in 3h while ~95% of dispatcher CPU sat in the
registry scan).

PoolCounts had the same leak, plus a cross-registry one: NostrClient fans every
frame out to both registries, so each REQ CLOSE materialized a permanent COUNT
row (and each COUNT id a permanent REQ row via sendToRelayIfChanged).

The registry deliberately models the *believed relay state*, separate from the
desired state, so a row must outlive `remove()` — the CLOSE frame is decided
from the filters it still holds. Dropping the row inside `remove()` looks like a
one-line fix but silently stops CLOSE being sent, leaking the subscription on
the relay instead. So the row is released after the CLOSE has been handed to the
socket, and the write paths no longer resurrect it:

- remove(): keeps the row (comment says why); drops it when the id isn't ours.
- sendToRelayIfChanged(): never creates a row; releases it once the sub is no
  longer desired and every affected relay has been told.
- onSent(): non-creating lookups. Both send paths pre-mark the row under the
  lock before the frame leaves, so a null means the sub is already gone.
- PoolCounts: same, via a liveState() helper gated on the query still existing.

While a sub is still desired nothing changes — its row survives connect,
disconnect and filter changes, so late events still link to the filters the
relay was running. Once removed, remove() has already dropped the listener in
the same call, so the surviving row had no consumer left anyway.

Adds INostrClient.registrySizes() so a host can alarm on registry growth;
registry size multiplies the cost of every relay lifecycle event, so a leak
shows up as connect-path CPU long before it shows up as memory.

Tests cover both directions: the registry must stay bounded across churn, and
CLOSE must still be sent for every removal (the guard that catches the naive
one-line fix).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHsz9fXDYysZcKZXhff4XY
2026-08-19 21:27:15 +00:00
Vitor PamplonaandClaude Opus 5 8bf1ac27d8 ci: shrink MirrorSyncThroughputTest's corpus to 100k on CI
test-geode has been dying at its 30-minute timeout on most runs since at least
18 Aug — six consecutive cancellations on main, both runs on #3955 — while the
runs that pass finish the whole job in under five minutes. It is not a job that
outgrew its budget; nothing lands in between.

The cause is MirrorSyncThroughputTest, which preloads a 1,000,000-event corpus.
The sink ingests slower than the in-process source serves, and MirrorWorker's
intake channel is deliberately unbounded (its listener callback cannot suspend,
so it trySends rather than block the shared OkHttp reader — see its kdoc). The
backlog therefore grows until the runner's heap is gone. From the CI log:

    …116777/1000000  (10,225 ev/s inst)
    …121961/1000000  ( 1,507 ev/s inst)
    …122793/1000000  (   247 ev/s inst)
    …123113/1000000  (    86 ev/s inst)
    Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler
               in thread "kotlinx.coroutines.DefaultExecutor"

That is a GC death spiral, then OOM. What turns it into a *timeout* rather than
a failure is where the OOM lands: on coroutine threads, reaching the
UncaughtExceptionHandler instead of the test thread. JUnit never sees a failure,
the JVM never exits, and the job produces no further output until GitHub kills
it — which is why the check has been red without ever saying why.

The test already takes -DsyncN (default 1,000,000) and geode/build.gradle.kts
already forwards it to the test JVM, so this is a workflow-only change. 100k
keeps a real ev/s measurement while bounding the worst-case backlog to a tenth
of what died. -DsyncN is unset everywhere else, so local and manual runs still
measure the full 1M.

This does not fix the underlying fragility — a bulk backfill can still exhaust
the heap, and an OOM on a coroutine thread will still wedge rather than fail.
Both are worth addressing separately; the MirrorWorker half is already noted in
relayBench/plans/2026-07-04-sync-throughput-1m.md.

Verified: :geode:test --tests "*MirrorSyncThroughputTest*" -DsyncN=100000 passes
in 7s and logs "preloaded 100000"; the full suite passes locally in 1m39s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:21:30 -04:00
Vitor PamplonaandGitHub e4184a3806 Merge pull request #3955 from vitorpamplona/claude/relay-auth-cache-91pa8f
Add session grants for relay auth to survive reconnects
2026-08-19 16:19:32 -04:00
Vitor PamplonaandGitHub df13abe1c4 Merge branch 'main' into claude/relay-auth-cache-91pa8f 2026-08-19 15:40:59 -04:00
Vitor PamplonaandGitHub 4e5b56d558 Merge pull request #3956 from vitorpamplona/fix/channel-invite-lint
fix: collect buzzWorkspaces instead of reading .value in composition
2026-08-19 15:39:43 -04:00
Vitor PamplonaandClaude Opus 5 80806df45f fix: collect buzzWorkspaces instead of reading .value in composition
Lint's StateFlowValueCalledInComposition fails the build on main, so
:amethyst:lintFdroidBenchmark aborts and takes test-and-build-android with it
on every branch.

Both sites read the workspace set as a snapshot inside a @Composable:

  val workspaces = accountViewModel.account.buzzWorkspaces.flow.value

That is not just a lint preference. The set is populated asynchronously — the
kind-13534 roster can land after the row is first drawn — and a .value read
subscribes to nothing, so the composable never recomposes when it arrives. An
invite drawn before its workspace was known keeps the value it saw, which for
these two is null from toMembershipNotice, and the renderer returns early: the
row simply never appears. Collecting fixes the staleness as well as the lint.

Both files already imported collectAsStateWithLifecycle and getValue, and it is
what the surrounding code uses (42 of 43 collections under ui/note/types).

The other buzzWorkspaces.flow.value reads — BuzzDmListViewModel,
BuzzDmDiscovery, BuzzMembershipEoseManager — are ViewModel and service code, not
composition, and are correct as they are.

Note CI only ever reported the first of these: lint prints "First failure" and
stops, so ChatroomHeaderCompose was invisible in the log until ChannelInvite was
fixed. Verified with :amethyst:lintFdroidBenchmark locally, which now passes with
0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:56:51 -04:00
Claude 4f8a887a7b fix(test): pass the now-required sessionGrants in RelayAuthReadFollowsTest
A semantic merge conflict: no textual overlap, but the two sides do not compile
together.

Making RelayAuthPermissionLedger.sessionGrants required (no default) updated
every ledger construction its author could see. RelayAuthReadFollowsTest was
not one of them — it arrived independently from main, so neither branch was
broken on its own and git had nothing to flag. Merging them produced
"No value passed for parameter 'sessionGrants'".

Passes a fresh RelayAuthSessionGrants() like the sibling suites. A private set
is right here: this test never exercises grants, it only needs a ledger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz
2026-08-19 17:14:12 +00:00
Claude 5a4313ba11 Merge remote-tracking branch 'origin/claude/relay-auth-cache-91pa8f' into claude/relay-auth-cache-91pa8f 2026-08-19 17:10:27 +00:00
Vitor PamplonaandClaude Opus 5 30b48de304 fix: keep the session-grant invariants with the state they protect
Three follow-ups from reviewing the session-grant feature. Each is a case where
the rule was right but lived somewhere it could not hold.

1. "Never log in" clears the grants, but that pairing was written in the
   settings screen's onClick, which made it a property of one screen rather
   than of the account. Any other caller of
   AccountSettings.changeDefaultRelayAuthPolicy would silently reintroduce
   grants that outlive the switch-it-all-off answer — and a grant outranks the
   policy, so they would authenticate. Moved to
   Account.changeDefaultRelayAuthPolicy, which owns both the setting and the
   grants; the screen now calls that. Stored Always/Never exceptions are still
   left alone, since those outrank the policy by design and are listed.

2. RelayAuthPermissionLedger.sessionGrants defaulted to a fresh instance.
   Account passes the shared one, so nothing was broken, but the default meant
   a ledger built without it got a private set instead of failing — and this is
   shared state by construction: the foreground screen and the background
   notification consumer decide off one ledger, so a split set would lose
   answers between them and bring the dialog back. Now required.

3. Blocking a relay did not drop its session grant. Blocking outranks
   everything while it is in force, so nothing leaked, but lifting the block
   resumed authenticating off an answer given before it — and the weaker "never
   allow" already drops the grant, so the stronger signal not doing so was
   backwards. Account now observes the block list and revokes through the new
   RelayAuthPermissionLedger.revokeSessionGrantsFor. Observed rather than hooked
   onto the local block action because kind 10006 is shared: a block published
   by another client arrives as a flow update with no call of ours behind it.

Verified on an emulator for 1 (selecting "never log in" still clears the grants
through the new path) and by unit test for 2 and 3. The block list has no editor
screen in this build — it is rendered from a published kind-10006 note — so 3's
wiring is covered by its test plus the fact that both sides key off
NormalizedRelayUrl.url, not by an end-to-end run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 13:04:55 -04:00
Claude 5ef59b1cec Merge remote-tracking branch 'origin/claude/relay-auth-cache-91pa8f' into claude/relay-auth-cache-91pa8f 2026-08-19 16:56:59 +00:00
Claude 99698d10ff Merge remote-tracking branch 'origin/main' into claude/relay-auth-cache-91pa8f
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
2026-08-19 16:49:25 +00:00
Vitor PamplonaandClaude Opus 5 99dc8c57ff fix: don't let undo restore a session grant under "Never log in"
The undo on "forget this login" re-granted unconditionally, so this sequence
put a grant back that the user had just switched off globally:

  forget a session grant -> select "Never log in" -> tap undo

The snackbar is the reason the window is wide enough to matter. It is shown
with an action label, and Material3 defaults that case to
SnackbarDuration.Indefinite, so it sits on screen until acted on — plenty of
time to change the policy above it first. Selecting NEVER clears the grants
that exist, but nothing stopped a new one being written afterwards, and the
grant is ranked above the policy in RelayAuthResolver, so the relay
authenticated again. That is exactly the claim the previous commit made about
NEVER being the switch-it-all-off answer.

Guarded in the ledger rather than in the composable that found it: the ledger
already owns globalPolicy and the precedence this protects, so every caller is
covered, not just this screen's undo.

The guard stops at the policy on purpose. A stored override written during the
same window is self-protecting — it outranks the grant, so an ALLOW or DENY
decides the relay whether or not the grant comes back — and so is the block
list. Only the policy sits below the grant, so only the policy could be
silently overridden.

grantForSession now reports whether it took, and the screen says so instead of
leaving a tapped undo looking like it did nothing.

Verified on an emulator against a NIP-42 relay that logs every frame: the
sequence above now leaves the policy on NEVER, no "Just for now" row, and no
AUTH on the wire; undo with the policy untouched still restores the grant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:29:31 -04:00
nrobi144andClaude Opus 4.8 84a031b931 feat(desktop): enrich the DM chat composer + message UX
Brings the desktop DM experience up to par with a modern chat client,
reusing existing commons/quartz infrastructure throughout.

Composer:
- Emoji picker (org.kodein.emoji, MIT) with search; unicode + NIP-30
  custom-emoji ":shortcode:" autocomplete in one strip
- @mention autocomplete (avatar + display name) inserting nostr:npub
- Reply-quoting: composer bar + a quoted chip on reply bubbles that taps
  to scroll-and-highlight the original (bounce + flash)
- Per-message image quality selector; send-in-progress spinner

Rendering:
- DM bubbles render rich text (mentions resolve to names; image/GIF/
  custom-emoji inline) instead of raw text

GIF search (Nostr-native, no third-party API):
- Query NIP-94 kind-1063 GIF metadata from a configurable relay list
  (nos.lol + relay.damus.io), dedup + client-side filter, animated preview

Uploads / privacy:
- Encrypted DM files upload as opaque application/octet-stream by default
  so the media server can't learn the media type; optional "Reveal media
  type" toggle for strict servers. Only reencode jpg/png/webp; GIF/video/
  AVIF/HEIC pass through. Actionable errors for servers that reject
  private uploads
- Reply/glyph: add a real Reply icon to the Material Symbols subset

Robustness:
- Preserve Messages state (selected room, draft, attachments) across the
  device lock by hoisting it above the lock gate
- Thread the send so the composer always clears and errors surface

Adds unit tests for the GIF merge/filter and caret insertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 10:59:21 +03:00
Vitor PamplonaandGitHub 89835da7e5 Merge pull request #3952 from vitorpamplona/claude/relay-auth-permission-bug-zesbwe
Make the "reading someone I follow" relay-auth toggle reachable, and scope Buzz per-account state to the account
2026-08-19 00:19:53 -04:00
Vitor PamplonaandClaude Opus 5 41fa4538c2 fix(buzz): open v2 invite links
The invite server now mints `v2.<opaque>` tokens, and the client could not
open them at all. BuzzInviteLink.parse requires `<payloadB64url>.<sigB64url>`
and reads the community out of the payload; for a v2 token the payload segment
is the literal `v2`, which decodes to one byte and fails the JSON parse, so
parse returned null.

Every entry point is gated on that one call, so the failure was total and
silent: the deep link fell through to the external browser (where nothing can
sign the claim with the user's key — the reason the in-app flow exists), a
pasted link in search did nothing at all, and a link inside a note rendered as
a plain url instead of an invite. `amy buzz join` refused it too.

Nothing is lost by admitting the shape. The join needs the host and the code,
both carried by the url itself: relayUrl() is `wss://$host`, httpBase() is
`https://$host`, and the claim response returns community_id and role — which
is why the screen never reads communityId. Expiry is the relay's call for a
token it alone can interpret.

Matched on the literal `v2` prefix rather than by relaxing the decode, so
`…/invite/anything.else` still fails to parse and a Concord naddr invite (no
dot) is still rejected. Tests cover the real v2 token end to end plus both
guards; all three fail against the unpatched parser.

Verified on device against a live workspace: the link now opens the join
screen, hands off to the window.nostr browser, and the claim enrolls the key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 00:07:08 -04:00
Claude 1c8968e737 Merge origin/main into claude/relay-auth-permission-bug-zesbwe
Resolves the conflict main's Buzz DM work created with the per-account stores.

BuzzDmDiscovery: took main's side wholesale. It rewrote discovery to derive the
whole membership picture from LocalCache observers each pass, which removed the
joined-relay set as a restart trigger — so the `joined` val this branch had
ported to `account.buzzWorkspaces` is simply gone, along with the import that
fed it.

The rest is the port main could not have known to make: three new call sites
reached for `BuzzWorkspaces` as a singleton, which no longer exists.

- BuzzMembershipEoseManager now reads `key.account.buzzWorkspaces`. This is the
  one that mattered — it is a PerUserEoseManager, so fanning each account's
  `#p=me` membership REQ across the *device-global* joined set (and
  pre-approving NIP-42 on every relay in it, per account) was the bystander leak
  this branch exists to close, reintroduced in a new file.
- membershipRelay/toMembershipNotice/toMembershipNotices/membershipNotices take
  the workspace set as a parameter instead of reading a singleton: which
  workspaces to prefer when resolving a notice's relay is a per-account
  question, so only the caller can answer it. No default — every caller has an
  account in hand, and a silent emptySet() would quietly degrade to "whatever
  relay delivered it".
- ChannelInvitesState takes the account's store and uses it as both the combine
  trigger and the data, keeping main's reasoning that a late restore-from-disk
  has to re-resolve which relay a notice belongs to.
- The two single-notice renderers (ChannelInvite, ChatroomHeaderCompose) read
  the set off their AccountViewModel. Faithful to main, including its staleness:
  the value is read at remember time, so a set restored afterwards does not
  re-resolve the row. Pre-existing, not introduced here.

Verified: 2874 tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest (main adds ~68); spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-19 02:24:16 +00:00
carmim777 7594d826a8 feat: add Android screen sharing to calls 2026-08-18 21:20:36 -03:00
Vitor PamplonaandGitHub 0fcfb853e8 Merge pull request #3951 from vitorpamplona/feat/invite-actions-in-relay
feat: keep invite replies, reactions and zaps inside the relay that sent them
2026-08-18 20:12:08 -04:00
Vitor PamplonaandClaude Opus 5 d93abf26fc feat: keep invite replies, reactions and zaps inside the relay that sent them
An invite card's actions behaved like actions on a public note: the reply routed to
a generic kind-1111 whose broadcast reached the account's outbox, and the like and
zap did the same. On a Buzz relay all three are room content — the room is the only
place they mean anything, and for a private or closed group publishing them
elsewhere advertises who is in which room.

- Group-scoped events (anything carrying an `h` tag) now publish to the room's host
  relays and nowhere else. `EventBroadcaster` resolves the hosts from the cached
  channels for that group id and returns them outright instead of unioning them
  with the outbox and broadcast lists; a room this cache doesn't know still stays
  off the broadcast list. This is the rule the group reply composer already applied
  on its own path, applied to every group-scoped event.
- Zap requests copy the room's `h` tag (reactions already did) and name the room's
  host as the relay for the receipt, so the kind-9735 lands where the message it
  pays for lives — and matches the recipient's `#h` notification query.
- Tapping an invite row opens the reply page. The room block inside the card keeps
  its own click and opens the room, so the two destinations each have a target.

The reply itself needed no change: it was already a kind-1111 carrying the room's
`h` tag, rooted on the kind-44100 (`E`/`K`/`P`) — only its delivery was wrong.

Device-verified against a local Buzz relay. Before: the comment reached nos.lol and
nostr.mom. After: the comment and the reaction exist on the workspace relay only,
absent from all three public relays checked, and the row-tap opens the composer
while the room block still opens the room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:40:49 -04:00
Vitor PamplonaandGitHub 75cec56a4a Merge pull request #3950 from vitorpamplona/fix/invite-roster-line-label
fix: give the invite roster line's member count its unit
2026-08-18 18:27:07 -04:00
Vitor PamplonaandClaude Opus 5 c4d68922bc fix: give the invite roster line's member count its unit
The line read "5 · 10.0.2.2:7447". Sitting immediately after the faces of people
you follow, a bare number reads as counting those faces — "5 people you follow" —
which is the one thing it does not mean.

Use the same `relay_group_member_count` plural every other group surface uses (the
workspace channel list, discovery, the parent picker), so it reads "5 members ·
10.0.2.2:7447" and matches the row the same channel gets elsewhere. Singular falls
out of the plural: "1 member".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:22:53 -04:00
Vitor PamplonaandGitHub 24be287bb4 Merge pull request #3940 from vitorpamplona/claude/concord-nip29-invitations-vx2wgj
Fix Buzz channel invite flickering by deriving from cache
2026-08-18 18:11:27 -04:00
Vitor PamplonaandClaude Opus 5 7c47a0495b fix: read the invite map the New Requests rebuild is driven by
Accepting or ignoring an invite from the Messages row left the row on screen: the
answer was applied (a manual pull-to-refresh dropped it, and the channel showed up
under Known) but the automatic rebuild did not see it.

`AccountFeedContentStates` invalidates dmNew when `pendingByEventId` emits, while
the filter read `channelInvites.flow` — a second StateFlow mapped off that one. At
the instant the rebuild runs, the derived flow can still hold the previous value,
so the answered invite is rebuilt right back in, and nothing emits again.

Read `pendingByEventId` directly. Ordering is irrelevant here — the feed sorts by
its own comparator afterwards.

Device-confirmed: Add to Messages and Ignore now clear the row immediately, and a
live invite still appears on both surfaces within seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:05:42 -04:00
Vitor PamplonaandClaude Opus 5 dac7bde2e1 fix: classify a buzz channel from the metadata event, not the channel it fills
An invite whose kind-39000 arrives after its kind-44100 never surfaced. The card
stayed hidden on the Notifications tab and in Messages > New Requests until an
unrelated membership notice happened to arrive, or the app was relaunched.

LocalCache.consume(GroupMetadataEvent) loads the event onto its addressable note
and wakes the cache observers FIRST, and only then copies it into the
RelayGroupChannel. So the recompute that the arriving directory triggers reads a
channel that is still empty, concludes UNKNOWN, and — with nothing left to emit —
never runs again. (The channel is also skipped entirely when the event arrived
without relay provenance.)

The trigger now carries the classification instead of a note count: the flow maps
the observed kind-39000 notes to their Buzz types, and classifyBuzzChannel falls
back to that map when the channel has not been filled in yet. Reading the event
that caused the emission cannot race with itself.

Device-confirmed on an emulator against a local Buzz relay: a kind-44100 followed
by its kind-39000 produced no card at all before this, and one unrelated kind-44101
made it appear instantly; after, the card appears on its own within seconds, on both
surfaces. BuzzDmDiscovery classified off the same racing read and gets the same fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:05:42 -04:00
Claude 554685dbb1 feat: load channel invites through the New Requests DAL
The invites were a pinned header above the New Requests list. That put them
outside the ordering: an invite from last week sat above a DM request from a
minute ago, and the list had two sections that were the same kind of thing.

ChatroomListNewFeedFilter now emits the kind-44100 itself as a row, so invites
sort in among the unaccepted DMs by when they landed. ChatroomHeaderCompose
matches the kind and draws it as the invited group's row — before the generic
group-scoped fallback, which would otherwise render the relay keypair's npub with
the raw JSON body as the preview and offer to leave a group never joined.

The state stays on the account. LocalCache is a process-wide `object` shared by
every logged-in account, and a kind-44100 is #p-gated — it is addressed to one
viewer. Hanging "pending invite" off the shared channel would show account A's
invite on account B's Messages; everything the channel does hold (39000 metadata,
39002 roster) is genuinely global, which is why membershipOf() takes the pubkey
as an argument rather than knowing who is asking. "Pending" also is not a
property of the notice alone: it means no later 44101 withdrew it, it is not on
my kind-10009, and I have not dismissed it — two of those are account state. That
is the same shape as joined groups, which the Known filter reads off
account.relayGroupList and sorts into the feed exactly this way.

Removes ChannelInvitesSection and the headerContent plumbing it needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-18 21:24:59 +00:00
Claude 5898dbde1c feat: render pending invites as group rows in Messages › New Requests
New Requests is a list of rooms awaiting a decision, and a pending invite is
exactly that — but it was drawn as a full note, a card the height of five rooms
sitting on top of a list of rooms. It got that shape by inheritance, not by
choice: the prompt was built for Notifications, where the unit of the feed
genuinely is a note, and Messages reused the same composable.

Extracts RelayGroupRow out of RelayGroupRoomCompose: one NIP-29 group as a
Messages row, with the "last message" line and the long-press menu as the only
two slots. Everything else — picture fallback to the host relay's NIP-11 icon,
the unread rule, where a tap goes — is fixed there, because every list that
shows a group has to agree on it.

A pending invite then renders as that row with the invitation as its newest
line: "Alice added you to this channel". The actor names it, not the signer — a
kind-44100 is signed by the relay keypair reporting the change it made, so the
author would be an npub. Deciding happens where it does for every other group:
tap opens the channel so it can be read first, and its top bar already offers
Add to Messages; long-press brings Add to Messages / Ignore / Leave to the row.

Notifications keeps the note card — same state holder, so the two surfaces still
cannot disagree about which invites are open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-18 21:12:21 +00:00
Claude 402bf412d5 feat: redesign the channel-invite body as a cover block
The kind-44100 body was a plain text line plus three buttons. It sat inside a
NoteCompose whose header, for this kind, is a bare npub — a kind-44100 is signed
by the relay keypair, which has no kind-0 and no NIP-05, so line one falls
through to npub1… and line two renders nothing. Everything that says what the row
is about therefore has to live in the body.

- Actor line: who added you, with their avatar, since the header names nobody.
- Cover block: the channel picture edge to edge at 84dp with its name reversed
  out over a lower-half scrim and a visibility badge. The picture draws over a
  gradient hashed from the group id rather than falling back to one, so a channel
  with no picture and a channel whose picture fails to load land on the same
  stable colour with no placeholder branch.
- Roster line: faces of people you already follow who are in the channel — a far
  better answer to "do I want to be here" than three strangers — plus the member
  count and host relay.
- Description, dropped entirely when blank.
- The whole block opens the channel, so it can be inspected before answering.

Accept keeps the fill but drops to text-button padding at 34dp: the stock 40dp
Button dominated the two choices beside it on a row that offers three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-18 20:32:28 +00:00
Claude eacb855bc2 Merge remote-tracking branch 'origin/main' into claude/concord-nip29-invitations-vx2wgj 2026-08-18 19:35:01 +00:00
Claude 8c1d75f354 refactor: render channel invites through NoteCompose like every other row
The invite row was a hand-assembled NoteComposeLayout with all five slots
filled in by hand. That predates this branch — promoting invites into the Card
pipeline renamed it and wired it into RenderCardItem without ever looking
inside it, so the plumbing became standard while the row itself stayed
bespoke, and it was missing everything NoteCompose provides: reply, boost,
like, zap, share, the 3-dot menu, and click-through to open what it is about.

NoteCompose already owns all of that chrome and RenderNoteRow is a `when` on
the event kind supplying only the body — the same extension point
RenderBadgeAward and ~30 others use. So kind 44100 gets a branch there,
RenderChannelInvite supplies the body, and the card and the Messages section
both just call NoteCompose. Nothing about the chrome is re-implemented.

The body carries what the row is actually about: the channel's picture, name,
member count, host relay and description, tappable through to the channel so
the viewer can look before deciding (Route.RelayGroup opens a group that is
not on kind-10009 yet, which is exactly this case), and the
Leave / Ignore / Add to Messages actions with Accept promoted to a filled
Button.

It also names the actor inline. A kind-44100 is signed by the relay keypair —
the relay is reporting a membership change it made — so NoteCompose's author
header shows the relay, which is correct but does not say who added you. That
moves into the body with their avatar and name; the hand-built row had cheated
by putting the actor in the author slot.

Note this now offers boost/zap/share on a relay-signed, `#p`-gated event:
boost republishes a membership notification to your followers, zap pays the
relay keypair rather than the actor, and share yields an nevent nobody else
can fetch. Raised before implementing; kept deliberately for consistency with
every other notification row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-18 19:29:19 +00:00
Vitor PamplonaandGitHub f257646915 Merge pull request #3947 from vitorpamplona/perf/concord-guestbook-fold-memo
perf: memoize guestbook envelope opens so the Concord fold stops re-decrypting the buffer
2026-08-18 14:29:57 -04:00
Vitor PamplonaandClaude Opus 5 dea37b3046 perf: memoize guestbook envelope opens so the fold stops re-decrypting the buffer
`refoldGuestbook()` re-projected the entire guestbook wrap buffer on every
arriving guestbook wrap, and projecting means opening each envelope from
scratch: two NIP-44 decrypts plus the wrap and seal signature verifies. The nth
arrival therefore re-opened all n wraps, making a boot quadratic in decryptions.

The Control Plane already avoids this via `editionByWrapId` ("a wrap is only
ever decrypted once no matter how many folds it participates in"); the guestbook
had no equivalent. This adds it, keyed on wrap id — safe because `guestbookKey`
is derived once at construction and never rotates in place (a rekey builds a new
session).

Measured on an emulator cold start (Soapbox Community, 12 channels), counting at
`Nip44v2.decrypt` — the choke point every NIP-44 caller funnels through:

    before   12,281 decrypts / 7,516 KB    6,229 opens over  448 unique (13x)
    after       724 decrypts /   705 KB      449 opens over  447 unique ( 1x)

94% fewer decrypts for the same set of envelopes, and the avoided opens skip two
signature verifies apiece on top. This was effectively all of the app's NIP-44
traffic at boot: giftwrap/NIP-17 DMs measured 0 calls and the NIP-51 private-list
"settings" 10 calls / 3 KB, so Concord refolding was the whole of it.

`guestbookMembers(wraps, key)` keeps its signature for existing callers and is
now the composition of the two halves it was split into, `guestbookEntry` (the
expensive open) and `projectGuestbook` (the trivial last-writer-wins fold).

Verified on device: the community still folds all 12 channels with unread counts,
and the Members roster renders Owner/Admins/Moderators plus the plain guestbook
members.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:39:02 -04:00
Vitor PamplonaandGitHub c89eb2e86b Merge pull request #3946 from vitorpamplona/fix/painter-res-cache
fix: cache the painter on its first miss in painterRes
2026-08-17 22:29:32 -04:00
Vitor PamplonaandClaude Opus 5 3587a4c858 fix: cache the painter on its first miss in painterRes
`painterRes` keeps an LruCache of per-size Painters per drawable, but on the first
miss for a resource it installed the inner per-size cache and returned `loaded`
without ever putting it in. A resource therefore had to be requested three times
before it could hit: once to install the empty inner cache, once to populate it,
once to read it -- so every drawable paid two extra `painterResource` loads.

Found while profiling Home<->Notifications tab switching; it is a correctness fix,
not a measurable win. The extra loads are two per (resource, size) pair for the
life of the process, which does not show up next to the GC and lock-contention
costs that actually dominate that switch.

Not unit-tested: `painterRes` is @Composable and the module has neither Robolectric
nor compose-ui-test, and `unitTests.isReturnDefaultValues = true` makes
android.util.LruCache a no-op on the JVM, so a test would need either new test
dependencies or a global android.util.LruCache stub affecting the other 154 test
files. Verified by inspection plus the existing suite (1282 tests green).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:26:56 -04:00
Claude b8b7be6e74 Merge remote-tracking branch 'origin/main' into claude/concord-nip29-invitations-vx2wgj 2026-08-18 01:37:48 +00:00
Vitor PamplonaandGitHub 4d7886a05d Merge pull request #3944 from vitorpamplona/perf/nip19-icu-free-scanner
perf: scan NIP-19 entities without ICU to stop the cold-start native-heap OOM
2026-08-17 21:36:33 -04:00
Vitor PamplonaandGitHub 2490c2fa97 Merge pull request #3945 from vitorpamplona/perf/hashtag-icu-free-scanner
perf: scan hashtags and #[n] references without ICU
2026-08-17 21:36:07 -04:00
Vitor PamplonaandClaude Opus 5 6d57f30307 perf: scan hashtags and #[n] references without ICU
`findHashtags` and `forEachIndexTag` jumped between `#` candidates with indexOf
and then anchored `Regex.matchAt` at each. On Android `java.util.regex` is
ICU-backed, and `Matcher.region()` -> `reset()` -> `MatcherNative.setInput()`
copies the ENTIRE input into native memory per call, so every candidate cost a
full native UTF-16 copy of the note's content. This is the same defect fixed for
the NIP-19 scanner in the OOM work, and measured over 2588 notes pulled off
production relays it is considerably worse, because a whitespace-preceded `#` is
far more common in prose than a NIP-19 prefix:

  scanner        Matchers   native bytes copied   worst single note
  nip19             7,871              1,752 MB    62.8 MB
  findHashtags     43,626              9,639 MB   279.6 MB  (a 119KB note)
  findIndexTags         0                     0    -

Both grammars are small, so they are matched directly instead. `hashtagSearch`
and `tagSearch` stay as the specification the scan is tested against.

Case handling is deliberate: `(?:\s|\A)` is Java's `\s`, which without
UNICODE_CHARACTER_CLASS is space plus 0x09..0x0D and nothing else, so the new
`isAsciiRegexSpace` is used rather than Char.isWhitespace() — the latter is
Unicode-aware and would accept U+00A0 before a `#`, which the regex rejected.
The same asymmetry runs the other way inside a tag: the excluded punctuation
class is entirely ASCII, so non-ASCII always continues a tag, and a tag made
only of U+00A0 is non-empty to the regex but still dropped by `isNotBlank()`.

Speed, medians of 5 RegexContentBenchmark runs (ns/op, lower better):

  case              bytes     regex   ICU-free    delta   ranges overlap
  hashtags m=5       4050      3267       1035   -68.3%      yes
  hashtags m=40     68072     56956      16033   -71.9%      yes
  hashtags m=120   767072    656535     185675   -71.7%      no
  TOTAL                      717550     203400   -71.7%
  idxTags TOTAL             112932.5    99937.5   -11.5%

Equivalence is pinned by a new test running both original regexes over a corpus
covering the punctuation class, ASCII-vs-Unicode whitespace either side of the
`#`, non-ASCII tag content and the minimum-one-character rules. Two mutations
(dropping `.` from the terminators, and swapping in Char.isWhitespace) fail both
it and the pre-existing ContentScanTest. Against 2588 real production notes the
scanners agree with the regexes on every one, 32,075 hashtags parsed.

`findIndexTags` shares the defect but never fires on real data — `#[0]` is the
legacy citation form no current client emits — so it is fixed for consistency
rather than impact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 20:22:04 -04:00
Vitor PamplonaandClaude Opus 5 86a9d3b780 perf: jump NIP-19 candidates with indexOf instead of testing every char
The scanner walked the content one character at a time looking for a candidate
prefix. `findHashtags` already showed the better shape for this: jump between
candidate positions with `indexOf`, which is an intrinsified, vectorised char
search, and only do real work where one lands.

'n'/'N' are two separate searches, so both are tracked and each is only
re-searched once consumed, amortising to about one indexOf per candidate.

Medians of 6 RegexContentBenchmark runs per arm (ns/op, lower better):

  case                 bytes   char-loop    indexOf    delta   overlap
  0 mentions             152       774.5      419.5   -45.8%     no
  0 mentions             608      1048.0      422.5   -59.7%     no
  0 mentions            4104      4704.5     2443.5   -48.1%     no
  0 mentions           68096     75678.0    38936.5   -48.5%     no
  0 mentions          767144    867870.5   434141.0   -50.0%     no
  m=120               767072   1097200.5   808300.5   -26.3%     no
  m=40                 68072    150933.5   125125.0   -17.1%    yes
  m=5                   4050     12317.0    10835.5   -12.0%    yes
  m=1                    222      3185.5     3367.0    +5.7%    yes
  m=2                    698      4453.5     7211.0   +61.9%    yes
  TOTAL                        2218165.5  1431202.0   -35.5%

Every no-match case is ~2x faster with non-overlapping ranges, and that is the
path that matters: of 2588 real notes sampled off production relays, only 160
contain a NIP-19 prefix at all, so 94% never leave the scan loop. The 767KB
mention-heavy tail -- the shape behind the OOM -- is 26% faster too.

The one arguable regression is a ~700B note with 2 mentions, +2.7us in absolute
terms with overlapping ranges across six runs. Accepted deliberately: it is
microseconds on the rarest shape, against halving the case that runs on nearly
every event.

Still 0 mismatches against both original regexes over the 2588-note production
corpus (7742 entities parsed), plus the synthetic equivalence corpus and
Nip19ScanTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 19:32:37 -04:00
Claude adca6e9666 fix: don't resurrect a removed NIP-OA attestation from the legacy store
Audit of this branch. The per-account migration added in the previous commit
made "removed" indistinguishable from "never migrated", so removing a held
attestation lasted exactly until the next launch.

persist(null) removed the account's key. restoreFromDisk treats an absent key as
"never migrated" and falls back to the pre-namespacing device-global list — which
nothing ever clears — so the credential the user just deleted was put back. It
survives every restart, because every restart repeats the same seeding.

The joined-workspace and starred-channel stores are not affected, but only by
luck of type: they persist a Set, and an empty Set reads back present, so their
cleared state suppresses the fallback on its own. Verified both halves of that
against a real PreferenceDataStore before fixing — a removed key reads back null,
an empty set does not. Comments now say so at both persist() sites, since the
correctness is entirely implicit and a later "cleanup" to remove() would be
silent.

The attestation store has no empty value to lean on, so it writes an explicit
tombstone. The restore decision moves into a pure internal restoreFrom(saved,
legacy, agent) — the store needs a Context and cannot be unit-tested on the JVM,
and this is the part with the sharp edge. Five tests cover the precedence,
including the regression (verified failing against the pre-fix logic).

Also from the audit: AgentAttestationScreen ignored put()'s new boolean. It is
unreachable today — parseHeldAttestation verifies against the same key the store
does — but a rejected paste would have cleared the field and shown success while
storing nothing. It now surfaces the shared failure message.

Verified: 2806 tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest; spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-17 23:24:22 +00:00
Claude 479b0a3e6d Merge remote-tracking branch 'origin/main' into claude/concord-nip29-invitations-vx2wgj
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt
2026-08-17 22:53:24 +00:00
Claude 6e8dd621fe refactor: move the held NIP-OA attestation onto Account and collapse its map
Not a leak — unlike the joined workspaces and the starred channels, this store
was already keyed by the agent pubkey each attestation authorizes, so no account
could ever read another's credential. It was a per-account store with extra
steps, and the steps were hiding a real gap.

Every caller only ever touched the entry for the account doing the AUTH:
AgentAttestationScreen put/removed `myPubkey`, AuthCoordinator read
`authTagFor(accountPubKey)`. So `Map<agentPubKey, OwnerAttestation>` was a
single-entry map behind a lookup that could not miss. BuzzHeldAttestations
becomes a class holding one nullable attestation for the key it is constructed
with, held as Account.buzzAttestation. The two CAS loops go with it — they
guarded concurrent writers to a shared map, and a single slot is last-write-wins
either way.

Owning the agent key lets put() do the verification its KDoc used to delegate
("The caller must have already confirmed attestation.verify(agentPubKey)"). That
obligation was discharged in two places and is now discharged in one, on the only
door into the store, so the paste path and the on-disk restore are gated
identically. BuzzAttestationPreferences drops its own re-verify loop as a result.

That gap is worth naming: the old tests stored `sig = "c".repeat(128)` and
asserted it came back out as an auth tag — an assertion that the store would hold
a credential no relay would accept, which is exactly what the store promises not
to do. They now sign real attestations with OwnerAttestation.sign and cover both
rejection paths (issued to another key, tampered conditions), including that a
rejected put leaves the held one intact.

Persistence is per account with the key namespaced by pubkey. The migration is
exact rather than best-effort: the legacy device-global list was already
agent-keyed, so this account picks out its own entry and no other can match.

Verified: 2801 tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest; spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-17 22:45:30 +00:00
Vitor PamplonaandClaude Opus 5 c5a6aa2090 refactor: move the bech32 alphabet test into Bech32 as isDataChar
The scanner added in the previous commit carried its own copy of the bech32
alphabet, duplicating `Bech32.ALPHABET`. "Is this a bech32 data character" is the
codec's own question, so it belongs on `Bech32` next to the alphabet it derives
from -- the same way `Hex` owns its parsing helpers.

`Bech32.map` could not be reused for it: it is an `Array<Byte>`, i.e. a boxed
`java.lang.Byte[]`, and this runs per character over whole note contents (up to
~767KB), so it would unbox on every char. `isDataChar` gets its own primitive
`BooleanArray` built from the same ALPHABET constants in the existing init block,
so there is still one source of truth.

Kept at parity with the private lookup it replaced, checked in the bytecode:

- as a plain member it compiled to an `invokevirtual` per character and measured
  ~1-2% slower across the scan benchmark, consistently signed across 8 of 10 cases
- `inline` removed that call, but property access to the table then compiled to a
  `getDATA_CHARS()` getter `invokevirtual` per character instead
- `@JvmField` on the table makes the call site `getstatic; iload; baload` -- the
  same three instructions the private array produced

Benchmark, medians of 3 runs of RegexContentBenchmark (nanoseconds, lower better):

              bytes    before   inlined
  0 mentions   4104      4473      4518
  0 mentions  68096     73398     74167
  0 mentions 767144    836400    835772
  m=120      767072   1029977   1030072
  TOTAL                2102097   2104899   (+0.1%)

The 767KB cases -- the tail that caused the OOM -- overlap run to run
(before [855512, 828137, 836400] vs inlined [833456, 839112, 835772]). The two
sub-microsecond cases swing ±80% between repeats of the *same* build, so they
carry no signal.

On-device native heap is unchanged from the previous commit: plateaus at
~169-182MB over two cold starts, zero lmkd kills.

Adds Bech32DataCharTest, which sweeps the whole BMP and requires isDataChar to
agree with ALPHABET exactly. Mutating the shared ALPHABET (adding 'b') now fails
both it and the NIP-19 equivalence test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:13:13 -04:00
Vitor PamplonaandClaude Opus 5 d13a54d832 perf: scan NIP-19 entities without ICU to stop the cold-start native-heap OOM
On a 2.9GB SM-T220 the release build grew to ~1.9GB RSS during a cold start and
was lmkd-killed ~32s in ("to free 1871628kB rss, 375492kB swap"). The growth was
entirely in the NATIVE heap -- the Java heap plateaued at its 512MB largeHeap
ceiling and GCed back down, while native ran 45MB -> 1372MB.

Cause: `forEachNip19Match` called `Regex.matchAt` once per candidate position.
On Android `java.util.regex` is ICU-backed, and `Regex.matchAt` builds a fresh
Matcher whose `region()` -> `reset()` -> `MatcherNative.setInput()` copies the
ENTIRE input into native memory. So scanning one note allocated a full native
UTF-16 copy of its content *per candidate `n`*, and note content reaches 767KB in
the tail. The Java Matcher object is tiny, so Java-heap-driven GC had no reason to
reclaim them promptly and native memory grew unbounded. heapprofd's top malloc
stack was exactly this path, under LocalCache.justConsume -> updateHintIndexes.
The file's own KDoc had already recorded the symptom from an earlier pass --
"2,541 of 4,573 live Matchers were running this regex" -- but that pass optimized
speed (the 9-23x anchoring win) and left the native retention in place.

The grammar is prefix + bech32 payload + trailing non-space, so it is matched
directly with char compares instead. Case folding is deliberately ASCII-only:
RegexOption.IGNORE_CASE maps to Pattern.CASE_INSENSITIVE, which is ASCII-only
unless UNICODE_CASE is set, so Kotlin's Unicode-aware `ignoreCase = true` would
have accepted inputs the regex rejected (U+212A folding to 'k'). Reusing a single
Matcher would NOT have fixed this: region() re-copies the input on every call.

Only the ingest hot path changes. `uriToRoute`/`tryParseAndClean`/`hasAny` still
use the regexes -- they run on short user input, not per ingested event.

Measured on device (release codegen, 3 runs), native heap RSS:
      t~9s   t~14s   t~22s   t~28s    t~43s
  before   45M    497M    626M   1372M   (killed at 31.9s)
  after    48M    127M    170M    175M    172M
Native now plateaus at ~170MB, total RSS falls back to ~500MB instead of climbing
to 1.88GB, and the process survives past 45s with zero lmkd kills.

Equivalence is pinned by a new test that runs both original regexes over the same
corpus and requires identical entity lists, targeting the exact-58 boundary, the
excluded bech32 chars, ASCII-only case folding and what `[\S]*` swallows. Both
mutations tried against it (58 -> 57, and admitting 'b' into the alphabet) fail
the test. The pre-existing `Nip19ScanTest` (23 tests) and commons'
`nip19MatchesReferenceScan` guard also still pass; full quartz suite 4288/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:33:04 -04:00
David KasparandGitHub c38124aac7 Merge pull request #3943 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-17 22:34:49 +02:00
vitorpamplonaandgithub-actions[bot] d334139923 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-17 20:16:58 +00:00
Vitor PamplonaandGitHub 151b5a1259 Merge pull request #3942 from vitorpamplona/feat/worker-thread-priority-governor
feat: demote relay/ingest worker threads below the UI thread (~40% faster cold-start first paint)
2026-08-17 16:14:11 -04:00
Vitor PamplonaandClaude Opus 5 c914787257 feat: enable the worker-thread priority governor by default at nice 10
Measuring on a release-codegen build (:amethyst:installPlayBenchmark -- R8 +
baseline-profile AOT) reverses the earlier debug-build conclusion: demoting the
relay/ingest workers is worth ~40% off time-to-first-paint there.

SM-T220, 5-round round-robin, 22s window, every run valid:

  workers at | starvation | rq wait | first paint | spread
  nice 0     |     26.7%  | 2300ms  |    11.1s    | 5.63s
  nice 5     |     22.0%  | 1774ms  |     8.0s    | 4.05s
  nice 9     |     17.1%  | 1280ms  |     8.4s    | 3.05s
  nice 10    |     14.6%  | 1234ms  |     6.2s    | 1.55s

nice 10 won 5/5 paired rounds (median 5.0s faster) and collapsed the run-to-run
spread from 5.6s to 1.6s, so DEFAULT_NICE is 10 and the governor now starts
without any setting. Re-validated end to end in the shipped configuration
(default-on vs explicitly disabled): 4/4 paired wins, first paint 10.4s -> 6.4s,
starvation 31.3% -> 20.5%.

The effect exists only in release. In a debug build the same sweep changes
nothing measurable, because there the main thread is ~70% busy saturated with
ART interpretation and scheduling was never the constraint (starvation 15% debug
vs 27% release). R8 collapses main's own work while leaving the relay storm
untouched, which is what promotes starvation to the binding constraint. Recorded
in the class doc so this is not re-validated on the wrong build type.

Settings.Global is now an override rather than the gate: it replaces the default
nice level, and any value <= 0 disables the governor entirely.

Also halves the governor's own cost, 7.1% -> 3.6% of one core over a cold start
(measured from the sweep thread's own utime+stime):
- each thread is now touched once for its lifetime, not once per sweep --
  denylisted threads are remembered instead of re-reading their comm every pass
- the interval backs off when a sweep finds nothing new, and resets when it does
- list() instead of listFiles() to avoid ~650 File allocations per sweep on an
  already GC-pressured heap
- exited tids are pruned so a recycled tid is re-evaluated

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:05:03 -04:00
Vitor PamplonaandClaude Opus 5 9d243b2420 feat: add opt-in worker-thread priority governor for cold-start diagnostics
A cold start dials ~190 relays at once and grows the process to ~500 threads
(OkHttp's TaskRunner pool, OkHttp dispatchers, the kotlinx scheduler, Arti's
tokio workers), every one of them born at nice 0. This adds a governor that
demotes them below the UI thread so the relay storm cannot starve main out of
its frames.

Disabled by default. It only runs when the `amethyst_worker_nice` global
setting exists, so it is inert as shipped:

    adb shell settings put global amethyst_worker_nice 9
    adb shell settings delete global amethyst_worker_nice

It sweeps /proc/self/task rather than installing thread factories because the
largest pool is OkHttp's TaskRunner backend, a process-wide singleton whose
factory OkHttp does not expose per client. A nice value is per-OS-thread and
survives renaming, so seeing a thread once is enough. A denylist protects the
threads that must keep their scheduling: main, RenderThread, hwuiTask, the ART
daemons (demoting HeapTaskDaemon would deepen the GC stalls this is meant to
reduce) and binder threads.

Measured on two rigs (4-round round-robin sweeps). The mechanism works
everywhere -- main-thread starvation tracks the CFS weight monotonically and
roughly halves (emulator 50.2% -> 24.8% at nice 9; SM-T220 15.2% -> 8.1%) --
but it does NOT reliably shorten time-to-first-paint on real hardware, which
is why it ships off. On a 4-core emulator main is only 27% busy and genuinely
starved; on the SM-T220 it is 70% busy and saturated with its own work, so
scheduling was never the constraint there. Raising priority on device handed
main more CPU (41.9s -> 47.6s on-cpu) and the stall did not move.

Kept as a diagnostic knob for the scheduling half of the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:54:43 -04:00
Vitor PamplonaandGitHub 3a29bcf00d Merge pull request #3939 from davotoula/feat/mute-public-chats
Mute public chats
2026-08-17 12:47:16 -04:00
Vitor PamplonaandGitHub 3564e989b2 Merge pull request #3941 from vitorpamplona/claude/cashu-wallet-balance-bug-gaik1b
Fix Cashu wallet balance truncation via proof backfill and history paging
2026-08-17 12:38:24 -04:00
Claude 64cb648447 fix: scope starred Buzz channels per account
Same class of bug as the joined workspaces, spotted by reading the neighbour:
BuzzChannelStars was a process-wide singleton on one device-global preference
key. Its own KDoc calls a star "personal" and "the client's own bookkeeping",
and the only justification offered for sharing it was "like BuzzWorkspaces,
this is a process-wide singleton" — which stopped being true one commit ago.

A star reorders and badges the community channel list, so while the set was
shared, one account pinning a channel reordered every other logged-in account's
list, and switching accounts silently rewrote the set they had in common. No
AUTH exposure here — this one is cosmetic — but it is the same mistake and the
plumbing was already in place.

BuzzChannelStars becomes a class held as Account.buzzChannelStars, and
BuzzChannelStarPreferences namespaces its key by pubkey with the same one-time
fallback to the pre-namespacing key, so upgrading doesn't unpin everything.
BuzzPinDropdownItem takes an AccountViewModel to read and toggle the right set.

AccountCacheState's per-account Buzz hook collapses from
startBuzzWorkspacePersistence(pubKey, workspaces, scope) to
startBuzzPersistence(account): two features needing the same wiring is the point
at which passing the account beats threading each piece of state through.

Verified: 2800 tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest, incl. 3 new BuzzChannelStars tests (one
pinning the cross-account isolation); spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-17 16:25:42 +00:00
Claude 4a1204ae57 refactor: delete the unused AuthDecisionResolver
AuthDecisionResolver has never had a production caller. Searching every commit
that touched amethyst/src/main for the symbol outside the object's own file
returns nothing, and `git log -S` against AuthCoordinator.kt is likewise empty:
it arrived unused and stayed that way, kept alive only by its own test.

The live equivalent is the per-account block in AuthCoordinator, which covers
every branch it modelled — ALLOW/DENY/ASK, and the full UserAuthChoice mapping
including the setDecision writes behind "always allow" and "never allow".

Two things made it worse than merely dead. It folded every logged-in account
into a single verdict, whereas the coordinator decides per account because one
socket is shared and an answer given for @a must not reveal @b. And its
"no verdicts -> authenticate" branch encoded the old any-account-allows fold
that was deliberately removed to fix the over-AUTH bug; there is no random-key
fallback any more. Left in place it reads as a template for policy the codebase
has since rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz
2026-08-17 16:24:24 +00:00
Claude ff71e3c674 fix: scope joined Buzz workspaces per account, correct the venue toggle label
Two follow-ups from auditing the rest of the isFirstParty path against what the
relay-auth screen options actually promise.

**Joined Buzz workspaces are now per account.** BuzzWorkspaces was a process-wide
singleton persisted to one device-global preference key. Everything else feeding
AuthCoordinator.isFirstParty is read off the account, so one account redeeming a
workspace invite made *every* other logged-in account first-party on that relay —
the bystander-account AUTH leak the per-account gate exists to prevent,
reintroduced on the line above the call to RelayAuthFirstParty.hasReason (which
is why RelayAuthFirstPartyTest could not catch it: the Buzz clause sits outside
the pure function it tests). Joining is a per-user act — the invite was redeemed
by one key and the relay grants membership to that key alone.

BuzzWorkspaces becomes a class held as Account.buzzWorkspaces; the dialect mark
stays global, since which protocol a relay speaks is a property of the relay and
not of who is asking. BuzzWorkspacePreferences namespaces its key by pubkey and
is constructed per account, mirroring the same move the relay-auth overrides made
from an app-wide file to a per-account one. AccountCacheState takes no Context, so
it gets a startBuzzWorkspacePersistence lambda the way it already takes
rootFilesDir and geolocationFlow. Restore falls back to the pre-namespacing key
once per account so an upgrade doesn't empty the workspaces hub — that set is
what every account already saw, and the first join after the upgrade writes to
the account's own key and takes over.

**The venue toggle's label was wrong, not its code.** "…it's my relay, or a room
I joined" undersold isTrustedVenue, which also covers venues reached through the
follow graph — the intent is joined, subscribed to, or favorited. Reworded to
"…it's my relay, or a room I joined or follow". Renamed the key rather than
reusing it (relay_auth_auto_my_relays → relay_auth_auto_my_relays_and_venues) so
a stale Crowdin translation cannot bind to the changed copy, and dropped the 7
now-orphaned translations.

Not addressed here: the write categories ("…I'm messaging …") are derived from
pending events with the author discarded, so they read as "somebody is messaging"
and lean on isFirstParty as an approximate stand-in. Fixing that needs purpose
attribution by event.pubKey and is left for a separate change.

Verified: 2797 tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest, incl. 2 new BuzzWorkspaces isolation tests;
spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-17 16:05:01 +00:00
Claude 06b49acf06 fix: make the "reading someone I follow" relay-auth toggle reachable
Under the CUSTOM ("decide per relay") policy, RelayAuthResolver AND-gated
every toggle behind isFirstParty:

    if (inputs.isFirstParty && customAllows(inputs)) ALLOW else fallThrough

isFirstParty (RelayAuthFirstParty.hasReason) is true only when we publish to
the relay, the relay is on our own list, or it hosts a room we joined. A
follow's outbox relay is none of those — it is theirs — so the readFollows
branch of customAllows could never be reached. With "…I'm reading someone I
follow" explicitly on, every follow's outbox relay still fell through to ASK,
producing one login prompt per follow. The only challenges the category ever
granted were ones myRelaysAndVenues already covered.

customAllows now checks readFollows ahead of the gate. Exempting just that
category keeps what the gate is for: the follow graph it consults is this
account's, so another account's traffic cannot conjure a match, and the other
three categories still require first-party — which is what stops a bystander
account being auto-authenticated (and billed) on a paid inbox relay because
another logged-in account's outgoing DM happened to name someone we follow.
Those three lose nothing by keeping it: our own relay list and our joined
rooms' hosts are first-party by definition, and a pending event of ours makes
its destination first-party too.

RelayAuthResolverTest pinned the old behaviour as intended
(nonFirstPartyAsksInsteadOfAutoAllowing), which is why this went unnoticed;
that assertion is replaced by readFollowsGrantsOnTheFollowsOwnOutboxRelay plus
readFollowsExemptionDoesNotLeakIntoTheOtherCategories, and a new
RelayAuthReadFollowsTest covers the same case end-to-end through the ledger.

Verified: 80 relay-auth tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest; spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-17 15:16:12 +00:00
Claude 91a3cd9fff fix: close two gaps in the relay auth session grant
Audit follow-up to the previous commit.

1. setDecision revoked the in-memory grant before awaiting the store write,
   but RelayAuthPermissionCache only publishes an override to memory after
   its disk write returns. A challenge landing between the two saw neither
   the grant nor the override, fell through to the policy, and re-prompted —
   a fresh dialog for a user who had just pressed "Always", which is the
   exact prompt the feature exists to remove.

   Fixed with asymmetric ordering, because the two decisions want opposite
   bias: ALLOW revokes last so the grant covers the window, while DENY
   revokes first so the window asks or denies but never signs — someone who
   just pressed "never allow" must not get one more AUTH out of the grant
   they are replacing. clearDecision needs no change: the old override stays
   readable across its window, so no gap exists.

   Covered by a gated store that suspends mid-write; the ALLOW case fails
   without the reorder.

2. Switching the global policy to "Never log in" left previously granted
   relays authenticating, since the grant is checked before the policy.
   Stored exceptions outranking the policy is deliberate and documented, but
   a casual one-tap grant surviving the switch-it-all-off answer is not the
   same claim. Clearing grants on NEVER also puts RelayAuthSessionGrants.clear()
   to use, which was otherwise unreferenced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz
2026-08-17 14:48:54 +00:00
Claude cb7ba7dbf7 feat: remember relay auth "log in" for the rest of the session
A NIP-42 challenge is not a one-off: relays re-challenge on every reconnect,
and the client reconnects constantly (network changes, doze, app switches).
Answering the prompt without the "remember" switch authorized only the single
in-flight challenge, so the same relay asked the same question again minutes
later — the prompt fatigue that pushes users into "always allow" on a relay
they only wanted to try once.

Adds RelayAuthSessionGrants: a per-account, in-memory set of relays approved
during this run of the app. It lives on Account, so it dies with the process
and at logout — that is what keeps it distinct from the stored ALLOW the
"remember" switch writes to disk.

Wiring:
- RelayAuthInputs/RelayAuthResolver gain hasSessionGrant, ranked below the
  stored override so a later "never allow" takes effect immediately, and
  below the block list which still wins outright. Not gated on isFirstParty:
  an explicit answer for this relay outranks any inference about it.
- AuthCoordinator records the grant on ALLOW_ONCE.
- setDecision/clearDecision drop the grant, so "follows your rules again"
  after removing an exception is true rather than silently still allowed.
- Relay auth settings lists the grants under "Just for now", each row
  promotable to a real exception or forgettable with an undo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz
2026-08-17 14:21:40 +00:00
Claude 6f14ddceba Merge remote-tracking branch 'origin/main' into claude/concord-nip29-invitations-vx2wgj 2026-08-17 13:45:02 +00:00
Claude 7106d7639b Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-balance-bug-gaik1b 2026-08-17 13:45:00 +00:00
davotoula e087ae8921 Manual testing and fixes:
test: cover the null-vs-empty mute merge, not just its decode
fix: make muting a public chat silence engagement too, and stop "Mute thread" from nuking a channel
docs: correct the one-way-filter comment in NotificationFeedFilter
2026-08-17 14:59:59 +02:00
davotoula eef95eeb6e Code review fixes:
fix: seed the row unread dot so it is right on the first frame
fix: widen public-chat unread/mute matching to metadata and create events
fix: announce muted public-chat state to screen readers
2026-08-17 14:59:59 +02:00
davotoula 1200519fe8 Mute button:
feat: add a mute button to the public chat header
feat: add mute notifications to the public chat row menu
feat: drop muted public chats from the Notifications feed
feat: stop push notifications from muted public chats
2026-08-17 14:59:59 +02:00
davotoula a23781de21 Initial commit
feat: suppress the unread dot for muted public chats
feat: expose the public-chat mute toggle on Account and AccountViewModel
feat: sync muted public chats via the NIP-78 settings blob
feat: persist muted public chats as local device state
feat: add the public-chat mute predicate
fix: make muted public chats a reactive signal in rowHasUnreadFlow
2026-08-17 14:59:59 +02:00
Vitor PamplonaandGitHub be2ed3b7f4 Merge pull request #3937 from vitorpamplona/fix/relay-auth-always
fix: honour "always log in" for relays the account does not use itself
2026-08-16 22:51:54 -04:00
Vitor PamplonaandGitHub c2584e856d Merge pull request #3929 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-16 22:51:46 -04:00
Vitor PamplonaandGitHub 210ff10141 Merge pull request #3936 from vitorpamplona/fix/memory-leaks-and-relay-auth
fix: reclaim memory under heap pressure and stop leaking sandbox Activities
2026-08-16 22:51:30 -04:00
Vitor PamplonaandClaude Opus 5 3426b83739 fix: honour "always log in" for relays the account does not use itself
RelayAuthResolver gated the ALWAYS policy behind isFirstParty:

    RelayAuthPolicy.ALWAYS -> if (inputs.isFirstParty) ALLOW else fallThrough(inputs)

so "Always log in" only auto-authenticated relays the account had its own
reason to be on. Any relay reached only through somebody else's traffic — a
followed author's outbox, another logged-in account — fell through to a prompt.
With the outbox model dialling 250+ relays and no stored per-relay decisions
yet, that is one prompt per third-party relay on a fresh install.

Narrowing to "only the relays I use" is what the "decide per relay" option
(CUSTOM plus RelayAuthCustomToggles) exists to express; applying it to ALWAYS
as well left no way to say "just authenticate everywhere", and contradicted
both the enum's own KDoc and the setting's description ("Every relay that
asks."). Make ALWAYS unconditional and keep the first-party gate on CUSTOM,
where it belongs.

Blocked relays (kind 10006) and explicit per-relay overrides still take
precedence — they are resolved before the policy.

The old behaviour was pinned by a test that documented it as intentional;
replaced with one asserting ALLOW either way, plus a new test keeping the
first-party gate covered under CUSTOM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 22:47:09 -04:00
Vitor PamplonaandClaude Opus 5 85cee362f0 fix: handle uiMode/fontScale/density in MainActivity instead of recreating
MainActivity declared only orientation|screenSize|screenLayout|smallestScreenSize,
so a dark-mode toggle or a font-size/display-size change destroyed and rebuilt
the Activity. Each rebuild strands one embedded browser session: the outgoing
SandboxedSdkView is dropped while privacysandbox still holds its
RemoteSessionClient over binder, which pins the old Activity's ViewRootImpl (and
the provider's WebView + SurfaceControlViewHost + EGL surface in :napplet) for
the life of the process.

Measured 1:1 on an emulator — four recreations, four leaked Activities and four
leaked WebViews, surviving repeated forced GCs. On a device that had been
running 3.4 days this showed up as 15 retained Activity objects against only 3
ActivityRecords, alongside 17 WebViews and 793MB of EGL surfaces in :napplet.

Compose handles these configuration changes natively (it reads
LocalConfiguration and recomposes), and NappletBrowserActivity in this repo
already declares the same wider set. Adding them removes the recreations
entirely: the same test goes from 1->5 to flat at 1, with zero session re-arms.

This does not fix the underlying privacysandbox retention — a session is still
stranded by recreations this does not prevent (locale change, account switch) —
but it removes the triggers users actually hit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 22:35:59 -04:00
Vitor PamplonaandClaude Opus 5 018c693077 fix: release the broker Messenger so a destroyed sandbox Activity can be freed
A full-screen napplet/browser surface ran onDestroy cleanly and was gone from
ActivityManager, yet the :napplet process kept the Activity, its window and its
WebView alive through repeated forced GCs. A heap dump gives the chain:

  ROOT(JNI_GLOBAL) android.os.Handler$MessengerImpl
    -> MessengerImpl.this$0  = android.os.Handler
    -> Handler.mCallback     = <lambda>
    -> lambda.f$0            = NappletBrowserActivity

`replyMessenger = Messenger(Handler(mainLooper, ::onBrokerReply))` makes the
Activity the handler's callback (a bound method reference captures `this`), and
a Messenger sent over IPC is a binder — so while the broker holds it, ART keeps
a JNI global reference to that Handler here in the sandbox. One retained
Messenger therefore pinned Activity -> PhoneWindow -> DecorView -> WebView, and
no GC in the sandbox could reclaim it; only killing the process could.

The broker keeps replyTo in long-lived structures (incBus subscriptions,
liveSubscriptions, identityWatch, foregroundLeases) and onDestroy only called
unbindService, which releases none of them.

Fix both halves:
  - MSG_RELEASE_CLIENT, sent first thing in onDestroy, so the broker drops the
    Messenger's inc-bus subscriptions and this surface's foreground lease. It
    goes directly on brokerMessenger rather than through sendToBroker, which
    queues while unbound — a queued release would never be sent.
  - The reply handler now holds the Activity through a WeakReference, so even a
    broker that never processes the release cannot pin a surface again.

Verified on an emulator: opening one full-screen page and pressing back left
Activities:1 WebViews:2 across three forced GCs before, and settles to
Activities:0 WebViews:1 after. Heap dump: NappletBrowserActivity instances drop
from 13 (the leaked Activity plus its captured lambdas) to 1 — the Companion,
which is a static singleton and correctly retained.

Note it takes two GC cycles to settle; one of the reference paths runs through
a Cleaner chain, so a single forced GC still shows the old numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 22:35:33 -04:00
Vitor PamplonaandClaude Opus 5 6420117f04 fix: reclaim memory on heap pressure instead of waiting for the OS
MemoryTrimmingService.run has exactly one caller, Application.onTrimMemory,
and since API 34 the OS only delivers two levels — UI_HIDDEN(20) and
BACKGROUND(40) — both of which require the app to be backgrounded. Every bulk
reclaim we have (Tier 2 pruning, feed trimming, the hard cache trims) is gated
on BACKGROUND, so two situations get no reclaim at all:

  1. Foreground use. The deprecated RUNNING_* levels are never delivered, so a
     long session simply grows until the heap is full.
  2. The always-on notification service. A process hosting a foreground service
     can never enter the cached state, so BACKGROUND is unreachable even while
     backgrounded — ActivityManager refuses it outright ("Unable to set a
     background trim level on a foreground process").

Measured: a 3.4-day session sat at 492MB of a 512MB heap (3% free), paying
685ms mark-compact GCs every ~10s with dozens of threads blocked in
WaitForGcToComplete, until an input-dispatch ANR. Reproduced independently on a
second device with no foreground service at all, where the app was simply in
the foreground.

Watch our own occupancy instead. Above 70% of maxMemory, run the app's existing
BACKGROUND reclaim — deliberately the same path rather than a parallel policy,
because at that occupancy "real reclaim pressure" is simply true. A 120s floor
between runs keeps a low-yield prune from spinning.

Verified by temporarily lowering the thresholds on an emulator: the watchdog
fires and drives the real Tier 2 functions (pruneHiddenEvents,
pruneHiddenMessages, pruneOldMessages, pruneRepliesAndReactions) that had never
once executed on a foreground or always-on install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 22:35:11 -04:00
Claude 1fc45e4bf6 feat(cli): amy cashu sync — page the wallet off the relays into the store
amy's cashu reads project the local event store and never touch the
network: that is the contract, and it is why `cashu balance` is instant
and works offline. The gap was that nothing in amy ever put the NIP-60
kinds INTO the store. CashuContext.snapshot() queries kinds 17375/7375/
7376/7374/10019/38000 out of ctx.store, and a grep for CashuTokenEvent.KIND
across cli/ returns exactly that one call site — a store query. So a
wallet created on the phone read as an empty wallet here, and `cashu
wallet show` answered "no kind:17375 wallet — run `cashu wallet create`",
which for a replaceable kind is advice that would have overwritten the
user's real wallet.

Adds `amy cashu sync`, plus `--sync` on `cashu balance` and `cashu wallet
show` for the common case. Kept opt-in rather than folded into the reads:
an implicit network round-trip inside a command documented as a local
projection is a contract change, and the offline read is worth keeping.

It pages rather than issuing one REQ, for the same reason the Android
backfill does: a relay answers an unbounded REQ with its own cap applied
to the newest matching events, and kind:7376 history outnumbers the
kind:7375 proofs by an order of magnitude on a wallet with any history,
so what falls off the bottom is the proofs at mints the user hasn't
touched lately — the balance reads low with nothing to indicate it.
drainAllPages walks each relay on its own until cursor to exhaustion.
Relay sets are split exactly as the Android subscription splits them:
own events from the outbox, inbound nutzaps from the inbox.

The filter builders move to commons per the thin-assembly rule, and
generalize what the Android backfill already had: cashuProofBackfillFilters
is now the kind:7375 narrowing of cashuOwnEventBackfillFilters, which
defaults to every kind the account authors. cashuInboundNutzapBackfillFilters
covers the #p half. A test pins that the own-event backfill covers every
kind the live subscription authors, so the gap can't reopen by someone
adding a kind to one and not the other.

Verified against a real binary, not just the compiler: `cashu sync`
emits its six JSON keys and exits 0 with no relays configured, `--sync`
parses on both readers, an unknown flag still exits 2, and the reworded
no_wallet error goes to stderr with exit 1.

New --json keys (additive): events_downloaded, token_events,
history_events on `cashu sync`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY
2026-08-17 01:14:15 +00:00
Claude 7a5193bcec feat: page the Cashu transaction list backward instead of showing a relay's suffix
The kind:7375 fix made the balance whole; the transaction list was still
whatever one uncapped REQ returned. CashuWalletEoseManager asks six kinds
of one relay in a single REQ with no limit, and kind:7376 history is the
most numerous of them, so the list every device shows is a recent-N
suffix chosen by that relay's cap — and no later REQ asks for the rest,
because the EOSE moves `since` forward.

Proofs and history want opposite fixes. A balance summed over a partial
proof set is wrong rather than incomplete, so those are walked to
exhaustion in one shot. History is display-only and unbounded, and the
user reads it newest-first, so pulling all of it at launch would be a
large download for something they may never scroll. That is exactly the
shape until+limit paging is for.

Built on the existing machinery rather than a new one: BackwardRelayPager
with cursors on the Account (Account.cashuHistory, beside
notificationHistory), modelled on AccountNotificationsHistoryEoseManager
for the loader and on the NIP-29 thread list for the two UI drivers —
a bootstrap that fills the first screen and a look-ahead that pulls
another page only while the user is scrolling toward the bottom. Nothing
is fetched while the wallet is off screen, and a relay that finished a
page parks at its cursor so another relay advancing doesn't re-REQ it.

One deliberate divergence from the DM and notification pagers: they floor
at `now - liveTail` because a separate live loader provably covers
everything newer. The wallet has no such guarantee — its live REQ carries
neither `since` nor `limit`, so how far back it reaches is whatever the
relay decided, which is the bug being fixed. Flooring at a fixed tail
would leave a band between the relay's cap and the tail boundary that
neither loader ever asks for. This pager floors at `now` and overlaps the
live subscription completely; duplicates are free (both LocalCache and
CashuWalletState.historyEvents are keyed by event id) and gaplessness is
worth more than the overlap.

The footer splits on stalledCount rather than reporting `exhausted` as
"all loaded": exhausted means nothing more is reachable right now, and a
relay that answered an auth CLOSE or went silent is stalled, not done.
Telling someone their transaction history is complete when part of it was
never served is a lie about their own money.

Page limit is 100, not the notification pager's 500 — every kind:7376 row
costs a NIP-44 decrypt to render, which on an external signer is an
out-of-process round-trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY
2026-08-17 00:43:56 +00:00
Claude 954560666a perf: stop the wallet paying per-bundle signer round-trips and per-mint rescans
Audit of the paths the proof backfill makes hot, plus two bugs it makes
reachable.

/v1/checkstate went out unchunked. scrubStaleProofs checks every proof
held at a mint in one call and that set is unbounded — it grows with the
wallet's history, and a client that pages its whole proof set back off
the relays reaches four figures in one sweep. Mints run the same Pydantic
list caps there that they do on /v1/restore, which this file already caps
at 500 for exactly that reason, so the sweep failed with a validation
error at the moment the wallet had the most to reconcile. Chunked, and
the hash-to-curve derivation now happens once per proof instead of twice
(it was computed separately for the request list and the response
lookup — a discarded EC operation per proof, every sweep).

The auto-redeem sweep paid two NIP-44 decrypts of kind:17375 before
checking whether it had anything to redeem, and p2pkPubkeyHex re-decrypts
the same event walletPrivkeyHex just read. That sweep fires from every
relevant cache bundle, so a wallet whose nutzaps were all redeemed months
ago still paid two out-of-process round-trips per bundle on a NIP-46
bunker or a NIP-55 external signer. The candidate filter needs no key, so
it now runs first, and the pubkey is derived from the privkey in hand.

A kind:7375 we cannot decrypt hides money exactly as effectively as one a
relay never delivered, and looked identical to an empty wallet.
recomputeUnspent caches only successes, so failures are retried — but
only when something else marks tokens dirty, which in a quiet wallet may
be never. Failures are now counted and logged, and a forced resync
retries them even when the relay walk found nothing new.

Two quadratic scans that were invisible while truncation kept the entry
list tiny: peekNutzapFunding filtered the whole entry list once per
shared mint, allocating a list each time, from inside a composable
remember (so per rendered note); and cleanupDuplicateProofs compared all
pairs before every Resync. Both are single-pass/indexed now — a superset
of B must share all of B's secrets, so only entries indexed under B's
first secret can cover it.

Finally, scanning every keyset made Resync N times slower by
construction: each keyset costs at least three /v1/restore round-trips
with 500-item bodies, so a mint that has rotated ten times turned a
three-request scan into thirty run end to end. The walks are independent
and read-only, so they run three at a time — bounded to stay polite to
the mint's rate limiter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY
2026-08-17 00:09:40 +00:00
Claude 3cb5b56ea2 fix: recover the whole Cashu balance instead of whatever a relay served
The NIP-60 balance is a pure function of the kind:7375 events the client
holds, and nothing ever checked that it held all of them.

The live wallet subscription sends one REQ per outbox relay with no
`limit`, asking for six kinds at once. Relays answer an unbounded REQ
with their own cap applied to the newest matching events, and kind:7376
history outnumbers the proofs by an order of magnitude on any wallet with
a few hundred transactions — so the proofs that lose that race are the
ones at mints the user hasn't touched recently, which is exactly the
balance they'd forgotten they had. Nothing recovers afterwards: a capped
page and a complete page both just EOSE, and PerUserEoseManager records
that EOSE as the `since` for every later REQ to the relay, so the events
below the cap are never asked for again. The subset is stable across cold
starts and differs per device, which is how one account reads three
different balances on three phones with none of them right.

Page the proof set instead of taking one REQ's word for it: a one-shot
fetchAllPagesFromPool walk over kind:7375 on the outbox relays, run at
startup once the relay list is known and again (forced) when the user
opens the wallet. Only kind:7375 — history and quotes are display-only,
and paging them would multiply the download without moving a balance.
Because a relay that ignores NIP-09 will hand back proofs the mint
already burned, a walk that recovers anything new finishes with the
NUT-07 scrub so the mint, not the relay, decides what is still unspent;
a walk that finds nothing new skips it and costs no mint traffic.

The seed-based recovery that should have been the fallback was blind in
the same direction. NUT-13 derives a counter chain per keyset, and
scanRecoverableProofs only ever scanned the mint's *active* keyset, so
proofs minted before the mint's last rotation sat on a derivation path
nothing walked — the restore reported an empty wallet rather than an
incomplete scan, since a scan that never asks looks like one that found
nothing. It now walks every keyset the mint lists for the unit, active
first, skipping (and logging) any that errors. fetchKeysetById resolved
ids through /v1/keys, which lists active keysets only, so an inactive
keyset was unresolvable even when asked for by id; it now tries NUT-01's
/v1/keys/{id} first and keeps the active-list lookup as fallback.
Counter bookkeeping still tracks the active keyset alone — an inactive
keyset can never receive another mint, so advancing its counter would
protect nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY
2026-08-16 23:01:33 +00:00
vitorpamplonaandgithub-actions[bot] 34dafb07f3 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-16 22:38:33 +00:00
Vitor PamplonaandGitHub c8a5d21ad3 Merge pull request #3935 from vitorpamplona/claude/pow-miner-memory-gc-xjilp3
Optimize PoWMiner hot loop for Android runtime
2026-08-16 18:35:45 -04:00
Claude de7e63ff58 perf(nip13): keep the PoW hot loop allocation-free without relying on the JIT
The nonce search enumerated a `List<Byte>` alphabet, which compiles to an
`ArrayList$Itr` allocation per recursion level plus a `Number.byteValue()`
unbox per candidate, and re-read `buffer.bytes` / `buffer.nonceEnds` through
their getters on every single candidate.

Measured on HotSpot, none of that showed up: escape analysis erased the
iterator and the loop already allocated ~0 B/hash. That is the problem — the
hot loop's allocation behaviour was left to the JIT, and mining runs on ART,
whose escape analysis makes no such guarantee.

Switching the alphabet to a ByteArray and hoisting the payload and the
last-index test out of the per-candidate path removes the iterator, the
unboxing and the getter calls from the bytecode outright, so the loop is
allocation-free by construction on every platform.

Verified: the iterator, `byteValue()` and per-candidate `MiningBuffer` getters
are gone from the disassembled `runDigit`; steady-state allocation stays at
~0.0002 B/hash and throughput went from 1.70M to ~1.75M h/s on a 510 B
payload. All 25 nip13Pow tests pass, including the determinism and created_at
invariants that pin the search order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vgXumByZ7E7ftfiLZCsRx
2026-08-16 20:01:21 +00:00
Claude c57108f46c fix: correct the invite projection's cache read and card removal
Audit of the branch turned up one root-cause bug and three consequences.

LocalCache.filter only yields addressables plus notes whose kind isRegular()
— and isRegular() is `> 0 && < 10_000`, so a Buzz 44100/44101 matches none of
its branches and observeNotes' initial snapshot for them is always empty.
Live arrivals were fine (the observer's new() applies no such gate), which is
why a cold start looked correct: the observer registers before the relay
answers. What broke was any projection built after the events had landed —
switching account and back builds a fresh one, and consumeRegularEvent never
re-notifies a duplicate, so it stayed empty for the session and no invite
could surface.

The observer is now only the arrival signal; the notices come from
LocalCache.membershipNotices(), the same shape NotificationFeedFilter.feed()
already uses over the same map. That scan is kept out of the projection's
combine so a dismissal or a kind-10009 edit doesn't re-walk the whole cache —
only a new verdict or a workspace-set change does.

Also fixed:

- Answering an invite could not remove its card. invalidateData() takes the
  additive path, finds no new notes (the 44100 *left* the feed) and bails on
  `if (newCards.isNotEmpty())`, leaving the answered invite pinned at the top
  by the invites-first order. The projection collector now clear()s first so
  the refresh rebuilds the whole list, which is the only branch that can
  shrink.
- BuzzDmListViewModel dropped every channel absent from a single observer
  pass, wiping rows the seed and the one-shot fetch had legitimately added.
  It now removes only channels with an actual kind-44101.
- leaveChannelInvite no longer records a persisted dismissal. That cleared
  the card a round-trip sooner but, being keyed by channel id and kept
  forever, would have swallowed a later legitimate re-add to the same
  channel. Ignore is the "don't ask again" action; Leave waits for the
  relay's own withdrawal.

Two comments corrected: NOTIFICATION_KINDS does not gate push
(NotificationDispatcher has its own set), and pendingInvites is a per-note
StateFlow read, not a once-per-conversion one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-16 19:46:56 +00:00
Claude 3603ddabb1 feat: render pending channel invites as notification Cards
The invite prompts reused NoteComposeLayout, so they looked like feed rows,
but they were not part of the feed: a bespoke composable in the card feed's
header slot, off a state holder hanging on AccountFeedContentStates. They
went through none of the Card pipeline, which is the structure the rest of
the tab is built on.

A pending invite is now a ChannelInviteCard, built by convertToCard and drawn
by the same RenderCardItem dispatch as every other row, so it inherits dedup
by id, last-read, backward paging, scroll-to-event from a push intent, and
trimToSize for free.

Ordering is a DAL concern: NotificationFeedOrderCard sorts unanswered invites
ahead of the dated rows, then newest-first as before. A plain created_at sort
would let a week of reactions bury a decision the user still has to make, and
page it off the end past limit(). Answering one drops it from the projection,
so nothing lingers at the top.

The projection moves from AccountFeedContentStates to Account.channelInvites
because the DAL reads it: acceptableEvent resolves a cached 44100 to "is this
still a live question" with a map lookup keyed on the event id, and
convertToCard attaches the resolved invite to the row. The 44100 kind joins
NOTIFICATION_KINDS — the contract test's envelope and subscription-coverage
rules both still hold, and push is unaffected since NotificationDispatcher
keeps its own kind set.

ChannelInvitesSection stays for Messages > New Requests, which is not a Card
feed. Its Notifications header slot is gone; the cards cover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-16 19:25:43 +00:00
Claude 97218b43ad refactor: derive Buzz channel invites from the cache, not a registry
The "somebody added you to a channel" prompts appeared and disappeared on a
loop. They were fed by BuzzChannelInvites, a process-wide mutable registry
that the DM discovery pass recorded into and its classification step deleted
from. The deletion was remembered nowhere, so any re-delivery of the same
kind-44100 re-added an invite that had already been withdrawn, and the two
steps fought each other. Both halves were derived from events LocalCache
already held, so the registry was only ever a second source of truth able to
drift from it.

Subscription. BuzzMembershipEoseManager joins the account loaders and owns
one `#p=me` REQ per joined workspace relay for 44100/44101 plus the 30622
hidden-DM snapshot. It needs a subscription of its own — the filter is
channel-less by nature (it is the query that discovers which channels exist),
and buzz downgrades a subscription carrying a channel-less filter to "global",
which is right for these kinds but wrong for anything channel-scoped sharing
the subscription. It pre-approves NIP-42 on each workspace relay; the
authenticator re-signs on the `auth-required:` refusal and syncFilters
re-drives the REQ, so no warm-auth one-shot is needed.

State. BuzzChannelInvites is now a pure projection: newest verdict per
channel, minus self-joins, dismissals, joined groups, and anything not yet
classified as a named channel. Withholding the unclassified case is what stops
every new DM flashing a channel-invite card until its kind-39000 lands. The
44101 handling moves out of LocalCache ingest and into the projection, which
makes out-of-order replay produce the same answer as ordered delivery.

Subscriptions removed. BuzzDmDiscovery and BuzzDmListViewModel each opened
their own identical `#p=me` 44100 REQ; both now observe LocalCache. Discovery
also recomputes and declares its whole DM set (BuzzDmChannels.replace) instead
of accumulating deltas it later has to undo.

21 new tests cover the projection and the filter shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-16 18:45:27 +00:00
Claude 81f852ad5d fix: keep the notifications header on screen in every feed state
The Notifications header slot carries standing prompts — pending channel
invites and the "you have no inbox relay" warning — but RenderCardFeed drew
it only in the Loaded branch. Arriving on the screen re-runs
checkKeysInvalidateDataAndSendToTop, and any refresh that momentarily
computes an empty list flips the feed through Empty/Loading, so the prompts
blinked out and back on every visit.

Draw the slot in the Empty, Loading and FeedError branches too. The padding
is applied at that point because only the Loaded branch has a LazyColumn to
carry the scaffold's inset as content padding — same shape
ChatroomListFeedView already uses for its own empty state.

This also fixes the relay prompt being hidden in the one state that needed
it: a missing inbox relay is the most likely reason the feed is empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-16 18:45:08 +00:00
Vitor PamplonaandGitHub 3ce6dbb82c Merge pull request #3932 from vitorpamplona/claude/ime-padding-browser-window-ksa26a
Handle IME insets in full-screen napplet hosts
2026-08-14 20:54:50 -04:00
Claude 582b7dfe2e fix: inset the full-screen browser and napplet host for the IME
windowSoftInputMode=adjustResize no longer resizes the window: it is a
no-op for apps targeting SDK 35+ on Android 15+, where edge-to-edge is
enforced and Theme.Amethyst does not opt out. The full-screen browser
padded its root by the system bars and display cutout only, relying on
that resize to keep focused inputs visible, so the soft keyboard simply
covered the bottom of the page.

Pad the root by the IME inset as well — max(bars, ime) on the bottom,
since an open IME sits over the navigation bar — and zero the consumed
types before they reach the WebView, which would otherwise apply them to
its own web content a second time. Shared as applyFullScreenHostInsets
between NappletBrowserActivity and NappletHostActivity, which carried the
same listener and the same defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwZuom4fFXjr85aGajJhSo
2026-08-15 00:51:25 +00:00
Vitor PamplonaandGitHub dfeefc4849 Merge pull request #3931 from vitorpamplona/claude/auth-default-new-logins-5j9pkj
feat: default new accounts to always AUTH with relays
2026-08-14 19:58:08 -04:00
Vitor PamplonaandGitHub 9eba7b9296 Merge pull request #3930 from vitorpamplona/claude/disable-high-usage-warning-asef8l
feat: disable the automatic high resource usage prompt
2026-08-14 19:47:15 -04:00
Claude c86f76b23e feat: default new accounts to always AUTH with relays
New logins started on RelayAuthPolicy.CUSTOM, which auto-authenticates only
for own relays/venues and follows and prompts for everything else. Start them
on ALWAYS instead, so a fresh account answers every relay that asks (still
gated by the first-party check and the blocked-relay list).

Only the AccountSettings constructor default moves, so this applies to
accounts created from here on. Existing accounts keep their saved policy, and
prefs written before the key existed still fall back to CUSTOM.
2026-08-14 23:24:05 +00:00
Claude 8139c3774b feat: disable the automatic high resource usage prompt
The "High resource usage detected" dialog no longer appears on app open.
It is gated behind a single flag in DisplayResourceUsageAlert instead of
being deleted, so it can be turned back on with a one-line change.

The ledger keeps recording and Settings > App resource usage still shows
the numbers and offers the same review-and-send report, so users who want
to send a usage report to the developers can still do so on demand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AxtcLx9ErKXUowC6obLaJv
2026-08-14 23:23:49 +00:00
David KasparandGitHub 120887e596 Merge pull request #3928 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-14 21:09:33 +02:00
vitorpamplonaandgithub-actions[bot] abb86357a8 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-14 16:25:35 +00:00
Vitor PamplonaandGitHub bec81ba6cb Merge pull request #3927 from vitorpamplona/claude/dependency-updates-hzw0u0
Bump dependencies and Gradle to latest stable versions
2026-08-14 12:22:52 -04:00
Claude 10336d8ed4 chore(deps): update dependencies, Gradle wrapper and CI actions
Sweep every dependency coordinate in the version catalog, the hardcoded
ones in amethyst/build.gradle.kts, the Gradle wrapper and the GitHub
Actions against their upstream metadata, and take the newest release
that keeps each pin on the same stability channel it was already on.

Version catalog:
  appcompat                1.7.1         -> 1.8.0
  benchmark                1.5.0-alpha07 -> 1.5.0-rc01
  biometricKtx             1.2.0-alpha05 -> 1.4.0-alpha02
  composeBom               2026.06.01    -> 2026.08.00
  composeRuntimeAnnotation 1.11.4        -> 1.12.0
  composemediaplayer       0.11.3        -> 0.11.4
  firebaseBom              34.16.0       -> 34.17.0
  fragmentKtx              1.8.9         -> 1.9.0
  ksp                      2.3.10        -> 2.3.11
  ktor                     3.5.1         -> 3.5.2
  media3                   1.10.1        -> 1.11.0
  secp256k1KmpJniAndroid   0.23.0        -> 0.24.0
  uiautomator              2.3.0         -> 2.4.0
  webkit                   1.16.0        -> 1.17.0

composeRuntimeAnnotation is not an independent choice: the 2026.08.00
BOM pins runtime/foundation at 1.12.0, so the standalone annotation
artifact has to move with it.

A shared version ref can only advance to the lowest release available
across every artifact that uses it. `appfunctions` is the one ref here
where the artifacts do not publish in lockstep: `appfunctions` and
`appfunctions-compiler` are at alpha10 but `appfunctions-service` stops
at alpha09, so the ref stays at alpha09 and a comment now records the
cap. Every other multi-artifact ref (media3, secp256k1, ktor, benchmark,
coil, camera, ...) agrees across all of its artifacts.

Hardcoded in amethyst/build.gradle.kts:
  tink-android              1.17.0 -> 1.23.0
  tracing-perfetto(+binary) 1.0.0  -> 1.0.1

Gradle wrapper 9.5.0 -> 9.7.0 (distributionSha256Sum updated to the
checksum published for 9.7.0), and actions/setup-java v5.6.0 -> v5.7.0
across build, create-release and smoke-test-desktop. Every other action
already floats on its current major tag.

Left alone on purpose:
  - vico stays at 3.2.3; the only newer build is the 3.3.0-next.2
    prerelease and the current pin is stable.
  - negentropy-kmp stays at v1.2.0, already the newest; the 1.0.1 that
    shows up in Maven metadata is an older artifact under a different
    tag scheme.
  - AGP, Kotlin, compose-multiplatform and the JetBrains material3 pin
    are all already the latest stable; newer builds are alphas/RCs.
  - The @moq/* npm pins in nestsClient/tests/browser-interop, because
    that directory's REV file ties the 0.2.x (moq-lite-03) line to the
    moq-relay git rev pinned in hang-interop/REV. Bumping to 0.3.x is a
    wire-protocol change that has to move with the Rust relay pin.

No new dependencies are introduced, so no new licenses enter the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UYmEazSQkEwsVCvrG96mB
2026-08-14 16:05:52 +00:00
David KasparandGitHub e4ea86fe02 Merge pull request #3925 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-14 17:51:45 +02:00
vitorpamplonaandgithub-actions[bot] 509656a726 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-14 15:39:25 +00:00
Vitor PamplonaandGitHub 313bb059f5 Merge pull request #3923 from davotoula/fix/community-comment-parent-context
fix: blank parent card above a top-level community post
2026-08-14 11:36:47 -04:00
Vitor PamplonaandGitHub 7e500f4266 Merge pull request #3926 from vitorpamplona/fix/dedup-cache-idle-trim
perf: bound the dedup decoder cache in size and in time
2026-08-14 11:33:22 -04:00
Vitor PamplonaandClaude Opus 5 b1f1190919 docs: record the tick sweep that picked 30s for the dedup age-out
30s was the one number in this change I picked rather than measured. Swept it
on device (10/30/60/120s).

The naive comparison is invalid: runs pull 3.7k-32k frames depending on what
the relays serve, so hit rate and heap at the end of a run are not comparable.
Comparing at a matched ~19k frames instead:

  tick    hit rate   parses   ids still cached at rest
   10s      44.9%    10,663      ~2
   30s      58.5%     7,569      ~2
   60s      60.4%     7,520      ~3
  120s      60.6%     7,555     7,438

Hit rate saturates by 30s, so a shorter tick buys only re-parses (10s costs 41%
more) and a longer one only holds memory -- at 120s just one tick fires in three
minutes, so the burst never drops at all. The knee is where the tick stops being
the binding constraint and capacity takes over: at ~400 frames/s that is
8192/400 ~= 20s, and 30s sits just past it.

Keeps 30s; documents why, and what to re-measure if capacity or frame rates
change. No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 ccebc3d344 fix: age the dedup cache on a clock, not on idleness
The idle-triggered release in the previous commit never fired. Measured on the
emulator: 240s of a completely flat heap, and every tick returned false.

Cause: relays keep pushing events down open subscriptions forever, so the
decoder is never idle in the sense of "saw no frames". Counting cache hits as
activity -- which the tests asserted, and which is right in isolation, since a
stream of pure duplicates is when the cache is earning its keep -- guaranteed
the idle clock was refreshed indefinitely. The unit tests proved the intended
behaviour; only the device showed the intent was wrong.

Replaced with unconditional clock-based aging: ageOutCache() retires the live
generation, so an id survives one tick and dies on the next, and a 30s tick
bounds any id's lifetime at ~60s. clearCache() still releases everything when
the host disconnects. Capacity keeps bounding cost during a burst; this bounds
how long it costs anything afterwards.

On-device A/B, same account and duration:
  idle-based (never fired):  flat 127-128 MB for 240s
  clock-based, run 1:  130 MB -> 71 MB when 7,436 ids were dropped
  clock-based, run 2:  143 MB -> 121 MB (3,153 ids) -> 96 MB (4,530 ids)
Each run contains its own control: the first tick only retires a generation,
drops nothing, and frees nothing.

Note ~7-8 KB released per cached id, an order above the ~600 B/entry the
recorded corpus suggested -- live traffic carries big kind-0/kind-3 events the
capture's first 150k frames under-represent. So capacity 8192 was costing
tens of MB on a real account, not the 4.7 MB the corpus implied.

Tests rewritten for the new semantics (no clock needed now, so they are fully
deterministic) and mutation-checked: a no-op ageOut fails 4, an ageOut that
clears both generations at once fails 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 1b8e264706 fix: release the dedup decoder cache once relay traffic goes quiet
CachingEventDecoder bounds what it holds with `capacity`, which caps the cost
during a burst but never ends it: the two generations only rotate inside an
insert, so a client that stops receiving frames pins up to `2 * capacity`
events for the rest of the process's life. Their dedup value decays within
seconds -- a duplicate is the same event arriving from another relay -- but
the memory did not decay at all. Raising capacity to 8192 doubled that cost
(4.7MB -> 9.4MB retained, worst case), which is what surfaced it.

Adds MessageDecoder.trimIfIdle(idleMillis, nowMillis), a no-op for stateless
decoders, and drives it two ways from NostrClient:

 - disconnect() trims eagerly, so backgrounding releases immediately instead
   of leaving a timer to do it later;
 - while active, a 30s tick releases anything idle for 60s.

The trim loop suspends on isActiveFlow exactly like keepAliveJob, so no timer
fires while the client is down.

Idleness counts cache HITS, not just inserts: a stream of pure duplicates
inserts nothing yet is precisely when the cache is earning its keep, and
keying off inserts would trim it out from under that traffic.

Racy by the same design as rotation -- clearing concurrently with an insert
can only lose an id, and a lost id costs a re-parse, never a wrong message.

Tests are in commonTest with an injected clock (no target needs a real one)
and were mutation-checked: dropping the hit-path refresh fails 1, ignoring the
idle threshold fails 2, never releasing fails 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 be330df1bc perf: size the dedup decoder cache from measured reuse distance (2048 -> 8192)
Connecting to every relay and pulling everything at once is the design; the
duplicate frames it produces are the price of that redundancy. Paying a full
JSON parse for each copy is not.

CachingEventDecoder already avoids that, but shipped at capacity 2048. Against
the recorded multi-relay startup capture (150k frames, 82% duplicates, median
reuse distance 1,430) that caught only 58.3% of duplicates. 8192 catches 80.5%
and halves offline decode time (354ms -> 169ms); 32768 would reach 96.5% but
for 4x the retained entries.

On-device A/B (emulator, cold start, ~15-18k frames): the share of frames
needing a full parse fell from 55.2% at 2048 to 38.7-44.2% at 8192. Process CPU
was NOT a usable signal at this sample size -- cold-start variance swamped it.
The gain scales with duplication, so accounts with many relays gain most.

No behaviour change: same relays, same aggression, same dispatch semantics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 f2eaa682e4 test: measure the dedup decoder cache at its production capacity
Connecting to every relay and pulling everything at once is the design, and the
82% duplicate frames it produces are the price of that redundancy. What is not
wanted is paying a full JSON parse for each copy -- which is what
CachingEventDecoder exists to avoid.

DedupDecodeBenchmark proves the mechanism, but at capacity = UNIQUE * 2 (40,000)
and with duplicates spaced 20,000 frames apart. Production ships the default
capacity of 2048. Measured against the real multi-relay capture in recorded
order, that catches only 58.3% of duplicates; 8192 catches 80.5% and halves
decode time (354ms -> 169ms over 150k frames).

Uses reuse distance so one pass yields the hit rate for every candidate capacity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 054c665696 test: pin that live websockets hold their OkHttp dispatcher slot forever
The app dials ~190 relays in one burst from RelayPool.connect() and spikes to
~640 threads, so capping the relay client's Dispatcher.maxRequests looks like a
one-line throttle.

It is not. Against a real relay, maxRequests=4 with 20 dials opens exactly 4;
the other 16 queue forever and never fail, so nothing surfaces the stall.
Setting maxRequests=N would cap the app at N relays permanently.

Pins the behaviour so the knob is not reached for again. Throttling has to
happen above OkHttp, in RelayPool, where a settled dial can release its permit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 e17f63428a test: profile ingest allocation rate against the real startup capture
Every prior memory measurement here looked at retained heap, which is blind
to garbage that never survives a GC. This measures allocated bytes per stage
with ThreadMXBean.getThreadAllocatedBytes, replaying the checked-in capture
of an account cold start.

Result: parse allocates 5.5x the wire bytes and retains 0.87x -- 4.8 KB per
event, 5.3 bytes of garbage per byte kept. Useful as a regression guard, but
it also rules the parse path out as the cause of the ingest CPU saturation:
device-wide allocation during ingest measures ~28-44 MB/s, which is not a
rate that stresses a GC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 66d43e73b3 test: measure the String.intern() memory/CPU tradeoff on ingest
`String.intern()` runs on every event id, pubKey and tag value, and an
on-device profile put art::InternTable::InternWeak at 3.5% of the ingest
workers' CPU. Measures what that CPU is buying, so the question does not
get re-litigated from intuition.

On 60k events / 840k strings: interning cuts retained heap 69.2MB -> 17.7MB
(~4x) for +200ms. Keeping it is the right trade on a memory-bound device.
Skipping the hex-shaped fields is not a shortcut (half the memory for half
the CPU -- referenced ids repeat via `e` tags). An app-level pool is the
only variant cheaper on CPU, but it holds strong references where ART's
table is weak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
David KasparandGitHub 1be3657237 Merge pull request #3924 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-14 17:22:20 +02:00
vitorpamplonaandgithub-actions[bot] 1b905c8521 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-14 13:57:07 +00:00
Vitor PamplonaandGitHub 332878b553 Merge pull request #3922 from nrobi144/feat/desktop-settings-search-accordion
feat(desktop): searchable settings accordion
2026-08-14 09:54:21 -04:00
davotoula dfcfbc3645 fix: decide top-level community posts from the parent, not the kind tag alone
isTopLevelCommunityPost() fell back to the root kind whenever no parent kind
(`k`) was present. That fallback exists for bridged posts that carry only the
uppercase root set, but it also swallowed nested replies that omit `k` -- those
name a parent *event*, so they answer a post inside the community, not the
community, and would have lost their parent card to a community card.

Decide from what the comment points at instead: an explicit `k` is
authoritative; failing that, a parent address that is a community means top
level and a parent event means it is not; only a comment naming no parent at
all falls back to the root kind.

Also pin two things the earlier commits changed but did not cover:

- communityAddress() against its pre-rewrite implementation as a reference
  oracle across eight tag shapes. Seven call sites outside this branch depend
  on it being unchanged, and nothing asserted that.
- isCommunityDefinition() and the lastOrNull selection RenderRepost now shares,
  which is the logic that changed there. The composable itself would need an
  instrumented test; the predicate is where the bug lived.

All three suites mutation-checked: reverting the predicate or reversing the
address scan order fails them.
2026-08-14 12:11:48 +02:00
davotoula 15327073cd refactor: name the resolved parent note, not the function that found it
The local shadowed the top-level replyingDirectlyTo it calls, which reads as
accidental recursion even though Kotlin resolves it correctly.
2026-08-14 11:48:42 +02:00
davotoula b03af026e5 Code review:
- refactor: tighten the community parent-context fix
2026-08-14 11:48:23 +02:00
davotoula 9954d2bc7d fix: blank parent card above a top-level community post
A NIP-22 comment posted to a NIP-72 community answers the community itself,
so there is no parent note to render. The reply-context resolver excluded the
community with `note.event?.kind != CommunityDefinitionEvent.KIND`, which only
holds once the definition event is in the cache: an uncached AddressableNote
has a null event, and `null != 34550`. The community shell was then handed to
ReplyNoteComposition, which rendered an empty card where the parent belongs.
2026-08-14 11:47:59 +02:00
nrobi144andClaude Opus 4.8 136983f130 feat(desktop): search + reveal for settings accordion
Add a pinned, auto-focused search field above the Settings accordion. Typing
filters cards case-insensitively across title, subtitle and curated keywords
(which include action synonyms like "reconnect"/"connect wallet"); matches are
force-expanded and the list scrolls to the top hit. Esc and the clear button
reset the query and collapse everything. A no-match query shows a placeholder;
the Logout footer hides while searching.

- Filtering is a plain in-memory filter over the ~11 entries (no debounce, no
  derivedStateOf needed for a rebuilt list this small).
- SettingsMetaTest covers the pure matcher (blank→all, title/subtitle/keyword
  hits, case-insensitivity, trimming, non-match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 11:03:02 +03:00
nrobi144andClaude Opus 4.8 0f0157b8f0 feat(desktop): settings accordion of collapsible cards
Replace the single long-scroll Settings screen (RelaySettingsScreen) with a
searchable-ready accordion of labeled cards. Each setting is a collapsible card
(icon + title + subtitle + chevron); Expand all / Collapse all toggle every card;
cards start collapsed, multiple can be open at once, and state resets each visit.

- New SettingsEntry/SettingsMeta (pure, testable matcher) + SettingsAccordionCard
  (slot-based header, hover + hand cursor).
- Rename RelaySettingsScreen -> SettingsScreen; drive it from an ordered entry
  list built each recompose (tiny list; avoids stale content-lambda capture).
- Extract the inline NWC and Relay blocks into WalletConnectSettingsSection and
  RelaySettingsSection; the relay list is now a plain Column (not a nested
  LazyColumn) so it can live inside the outer accordion LazyColumn.
- Drop now-redundant internal section titles (card header owns the title) from the
  five desktop-only sections; NamecoinSettingsSection (shared with Android) is
  untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 10:51:21 +03:00
davotoulaandClaude Opus 5 ebd3a68e3b docs: teach the translation skill the lint gate it was missing
A translation pass last night cleared every check the find-missing-translations
skill prescribes - no duplicate keys, well-formed XML, a green
convertXmlValueResourcesForCommonMain, a green compileFdroidDebugKotlin - and
still took CI red with three lint errors. The plural rules live in Android lint,
not in the resource compiler, and the skill never ran it. Six additions, each
from a failure in that pass:

- Step 6 now runs :amethyst:lintPlayBenchmark and reads the SARIF for zero
  errors, with a table of the rules that gate: MissingQuantity and
  ImpliedQuantity are errors, StringFormat* are warnings, and there is no lint
  baseline so abortOnError bites on the first one.
- Converting a <string> to <plurals> needs every locale's full CLDR category
  set. The "use other only, Crowdin fills the rest" shortcut fails
  MissingQuantity before any sync happens. res/CLAUDE.md step 3 advised exactly
  that shortcut, so it is corrected here too, and the declension trap is called
  out - the retained text is the plural form, so reusing it for "one" yields
  "1 odpowiedzi".
- tools:ignore belongs on the source entry, never a locale file. Crowdin
  propagates source attributes into its exports; an attribute added only to
  values-xx is absent from the next one. The tools:ignore="Typos" copies in
  cs/de/ar/eo/bn are the result of that propagation, not evidence that locale
  attributes survive - mistaking one for the other is what broke main.
- A new format-specifier parity and empty-item audit, for the class where the
  key is present and looks translated but the placeholder was dropped or
  escaped. The (?<!\\) lookbehind is mandatory: without it the scan matches
  %2$d inside \%2$d and certifies a broken string clean.
- The Crowdin section now states the actual rule: a repo-side edit to a
  translated value sticks only where Crowdin's database does not contradict it,
  including when it holds an empty string. One sync demonstrated both outcomes
  at once - pow_estimate_minutes[few] survived while nest_listener_count[many]
  was reverted to empty. Source-file changes are the exception and do stick.
- Six Common Mistakes entries, each with its date and concrete failure.

Docs only; no product code or resources change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uQksy5spXR8gC8Z5QfsRB
2026-08-14 08:13:43 +02:00
Vitor PamplonaandGitHub cdde6c4ef2 Merge pull request #3917 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-13 20:41:54 -04:00
Vitor PamplonaandGitHub 808770ee54 Merge pull request #3919 from vitorpamplona/build/baseline-profile-generator
build: add a baseline profile generator (:baselineprofile)
2026-08-13 20:39:37 -04:00
vitorpamplonaandgithub-actions[bot] 1cf1ff396f chore: sync Crowdin translations and seed translator npub placeholders 2026-08-14 00:24:18 +00:00
Vitor Pamplona 0e104e6843 Merge branch 'main' into build/baseline-profile-generator 2026-08-13 20:21:44 -04:00
Vitor PamplonaandGitHub 41c7f525a2 Merge pull request #3920 from vitorpamplona/perf/baseline-profile-flags
perf: drop the S (startup) flag from the hand-authored baseline profile
2026-08-13 20:21:32 -04:00
Vitor PamplonaandClaude Opus 5 a0166367cd perf: drop the S (startup) flag from the hand-authored baseline profile
The wildcards added in #3918 were written HSPL — hot + startup + post-startup —
on whole packages (quartz, LocalCache, Jackson, okhttp, okio, coroutines). The
S was wrong and potentially harmful.

S drives DEX layout: startup-flagged classes are grouped into classes.dex for
locality, and Android's docs warn that if startup code does not fit there it
"will overflow into the next DEX files". Claiming thousands of ingest methods
are startup-critical can push genuinely startup-critical code out of the first
DEX — hurting the thing the profile is meant to help. For scale, the generated
profile marks 32 of its 31,497 rules HSPL; this file claimed it for all 25 of
its rules, each covering an entire package.

Ingest runs AFTER startup, so HP is what this file actually knows. Startup
layout is left to the generated profile (#3919).

Re-measured on device (SM-T220, release build, simpleperf --app), share of
DefaultDispatcher worker CPU:

                     no profile     HSPL      HPL
  nterp                   42.9%     4.2%     4.0%
  app compiled            11.0%    20.8%    21.9%
  GC read barriers         9.4%     6.7%     6.6%
  class/method lookup      2.8%     0.3%     0.1%

Ingest throughput (RSS growth per unit of CPU):

  no profile   8.9 MB per core-second
  HSPL        14.5
  HPL         15.8   (n=4, range 14.6-18.4)

So dropping S costs nothing — as expected, since S affects DEX layout rather
than which methods get compiled. The compiled profile is marginally smaller
(16,341 -> 15,532 bytes).

The HPL-vs-HSPL numbers are within the noise of these arms (the HPL range alone
spans 14.6-18.4), so read this as "no regression", not as an improvement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:03:25 -04:00
Vitor PamplonaandClaude Opus 5 4c606c2571 build: add a baseline profile generator (:baselineprofile)
Follow-up to the hand-authored amethyst/src/main/baseline-prof.txt, which was a
stopgap: whole-package wildcards that compile methods which never run. This adds
the machinery to produce a real profile from a recorded journey.

  ./gradlew :amethyst:generateBaselineProfile

New :baselineprofile module (com.android.test + androidx.baselineprofile) with a
BaselineProfileRule journey, and :amethyst applies the plugin and consumes it.
Notes on the wiring, since three things needed working out:

- com.android.test must be applied WITHOUT a version. AGP is already on the
  buildscript classpath, so `alias(libs.plugins.androidTest)` fails with
  "already on the classpath with an unknown version".
- targetSdk belongs in defaultConfig, not testOptions, for a test module.
- :amethyst has a `channel` dimension, so the test module needs
  missingDimensionStrategy("channel", "play") or the dependency is ambiguous.

The generator ran on a connected device: Macrobenchmark 1.5.0-alpha07 supports
non-rooted generation on API 33+, so no root or AOSP emulator was needed. It
produced 31,497 rules (3.1 MB).

WHAT THE GENERATED PROFILE DOES AND DOES NOT COVER — it captures cold-start on a
LOGGED-OUT app, not the ingest burst. The generator installs the release
applicationId (com.vitorpamplona.amethyst), which is a fresh install with no
account, so the journey recorded a login screen. Measured on the output: zero
rules for justConsume and only 33 of 31,497 rules marked hot. Capturing ingest
needs a journey against a logged-in app, which needs a seeded test identity —
a design decision (a key in the repo is not acceptable), so it is left open.

Both profiles are therefore kept, because they cover different things: the
hand-authored one covers ingest (measured: nterp 42.9% -> 4.2% of ingest worker
CPU, ~1.6x more ingest per core-second), the generated one covers startup and
class loading.

Verified they both reach the shipping artifact by inspecting assets/dexopt/
baseline.prof across variants:

  11,425 bytes  neither profile
  16,341 bytes  hand-authored only        (benchmark build type)
  25,541 bytes  hand-authored + generated (RELEASE variant)

The generated profile only lands in `release`; the custom `benchmark` build type
gets just the hand-authored file, because the plugin wires generated profiles
into release variants. That is why the earlier ingest measurements — taken on
the benchmark build type — could not see it. The runtime effect of the generated
half is NOT measured here: the release APK is unsigned and could not be
installed on the test device.

The 3.1 MB generated file is committed on purpose (saveInSrc), so release builds
do not need a device attached at build time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 18:37:14 -04:00
Vitor PamplonaandGitHub f26b12958d Merge pull request #3918 from vitorpamplona/perf/baseline-profile
perf: AOT-compile the relay ingest path with a baseline profile
2026-08-13 18:11:01 -04:00
655 changed files with 68638 additions and 4650 deletions
@@ -77,6 +77,25 @@ What this means for this skill:
2. **Source-identical entries are a small, recognizable minority.** Brand terms (`Nowhere X`), single-word loanwords (`Apps` / `Feed` / `Issues`), and bare version/format strings (`v%1$s`) are the usual cases. Skip these by inspection rather than translating them to something identical.
3. **Don't add source-identical fallbacks.** Android falls back to `values/strings.xml` at runtime, so a key intentionally kept as English already renders correctly, and Crowdin's next sync would strip a local duplicate anyway.
4. **A repo-side edit to a translated value only sticks where Crowdin's database
doesn't contradict it.** Download replaces file content with Crowdin's current
export; it does not diff or merge. So a hand fix to a locale file survives only
if Crowdin happens to hold the same value (or holds nothing for that key). If
Crowdin holds a *different* value — including an **empty** one — the next sync
silently reverts you.
Observed 2026-08-13/14 in one pass, which is what makes the rule concrete:
`pow_estimate_minutes[few]` (pl) **survived** the sync because Crowdin's
approved value matched the fix, while `nest_listener_count[many]` (pl) was
**reverted to empty** two commits later because Crowdin stores an empty string
there. Same file, same commit, opposite outcomes.
Consequences: fixing a *value* durably means entering it in the Crowdin web UI
— no repo commit will hold it. Changes to the **source** file are different and
do stick, because that file is Crowdin's input, not its output: deleting a key
from `values/strings.xml` removes it project-wide, and attributes declared
there propagate into every export.
> **Historical note:** an earlier version of this skill tried to auto-filter the
> candidate list with a git "sync-timestamp" heuristic (skip any key added before
> the last `New Crowdin translations` commit). It was **dropped** because it
@@ -280,6 +299,64 @@ done
For each hit, warn the user that the entry is unreachable in that locale. The fix is to **remove the `<item quantity="zero">`** and, if the UX wanted distinct wording for count=0, add a separate `<string>` plus an `if (count == 0)` branch at the call site (see "Plurals: handle with care" below).
Also audit **format-specifier parity and empty items** across the locales you
touched. These are a different defect class from a missing key — the key is
present and looks translated, but the placeholder was dropped, escaped, or the
item left blank, so the number never reaches the user:
```bash
python3 - <<'PY'
import re, io, glob
keyre = re.compile(r'<string name="([^"]+)"[^>]*>(.*?)</string>', re.S)
plre = re.compile(r'<plurals name="([^"]+)"[^>]*>(.*?)</plurals>', re.S)
itre = re.compile(r'<item quantity="([^"]+)"[^>]*>(.*?)</item>', re.S)
# (?<!\\) is REQUIRED: \%2$d is an escaped literal, not a placeholder.
phre = re.compile(r'(?<!\\)%(?:(\d+)\$)?([sdf])')
sig = lambda t: sorted(m.group(0) for m in phre.finditer(t))
for base in ['amethyst/src/main/res', 'commons/src/commonMain/composeResources']:
d = io.open(f'{base}/values/strings.xml', encoding='utf-8').read()
dstr = {m.group(1): sig(m.group(2)) for m in keyre.finditer(d)}
dpl = {}
for m in plre.finditer(d):
s = set()
for it in itre.finditer(m.group(2)): s.update(sig(it.group(2)))
dpl[m.group(1)] = sorted(s)
for p in sorted(glob.glob(f'{base}/values-*/strings.xml')):
if '/values-ar' in p: continue # see caveat below
s = io.open(p, encoding='utf-8').read()
for m in keyre.finditer(s):
k, v = m.group(1), m.group(2)
if k in dstr and sig(v) != dstr[k]:
print(f'{p}\n {k} base={dstr[k]} loc={sig(v)}')
for m in plre.finditer(s):
k = m.group(1)
if k not in dpl: continue
for it in itre.finditer(m.group(2)):
if sig(it.group(2)) != dpl[k]:
print(f'{p}\n {k}[{it.group(1)}] base={dpl[k]} loc={sig(it.group(2))}')
PY
# Empty plural items render as nothing at runtime — always a bug.
grep -rn '<item quantity="[a-z]*"></item>' \
amethyst/src/main/res/values*/strings.xml \
commons/src/commonMain/composeResources/values*/strings.xml
```
Three things this scan taught us, all of which it now encodes:
- **The `(?<!\\)` lookbehind is not optional.** Without it the scan matches
`%2$d` *inside* `\%2$d` and scores a broken string clean. `\%` is not a
recognised Android escape, but lint reads it as one, so the placeholder is
reported missing. (2026-08-13: `nip46_signer_relays_some_down` in sl-rSI
survived a "clean" sweep exactly this way.)
- **Skip Arabic.** Its `zero`/`one`/`two` forms omit the numeral idiomatically
("دقيقتان" = "two minutes"), so ~30 hits there are correct translations, not
defects. Everything else is worth reading.
- **Repeated indices are legitimate.** `%1$s` appearing twice in a translation
where the base uses it once is normal — German repeats the name where English
says "They". Compare *sets*, and treat an arity difference as a question, not
a verdict.
Quick scan over the missing keys:
```bash
@@ -340,6 +417,37 @@ When adding or proposing **`<plurals>`** entries, follow these rules:
pluralStringResource(R.plurals.foo_items, count, dateLabel, count)
}
```
- **Converting an existing `<string>` to `<plurals>`: give every locale its FULL
category set, not just `other`.** You must convert it in every locale that
already had the `<string>` (aapt2 rejects a resource-type mismatch across
locales, and an orphaned locale `<string>` trips `ExtraTranslation`) — but
carrying the old text across as an `other`-only block, on the theory that
Crowdin backfills the rest, **fails `MissingQuantity` and breaks CI before
Crowdin ever gets a turn.** Supply `one`/`few`/`many` for pl, `one` for hu, and
so on, at conversion time.
Note this contradicts `amethyst/src/main/res/CLAUDE.md` step 3, which still
advises the `other`-only shortcut. That advice is wrong; prefer this.
Watch the declension when you do it: the retained text is usually the *plural*
form, so reusing it verbatim for `one` produces "1 odpowiedzi". (2026-08-13:
converting `poll_results_selections` with `other` only errored on both hu and
pl, and the retained pl text was the few/many form.)
- **A `tools:ignore` suppression must go on the SOURCE entry in
`values/strings.xml`, never on a locale file.** Crowdin propagates attributes
declared on the source into every translation it exports; an attribute you add
to `values-xx/strings.xml` alone is simply absent from the next export. That is
why the existing `tools:ignore="Typos"` entries survive — they are declared on
the source, and the copies in cs/de/ar/eo/bn are the *result* of propagation,
not evidence that locale-file attributes stick. (2026-08-13: an
`ImpliedQuantity` suppression added only to `values-pt-rBR` was stripped by the
next sync and took `main`'s CI red.)
Before reaching for a suppression at all, check whether the key is even used —
a `grep -rn "<key>" --include='*.kt'` that returns nothing means deleting the
key is the better fix than muting the rule that objects to it.
- Reference: [Android `<plurals>` docs](https://developer.android.com/guide/topics/resources/string-resource#Plurals) and [CLDR plural rules](https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html).
**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?"
@@ -380,6 +488,44 @@ When adding translated strings to locale files:
# ./gradlew :commons:convertXmlValueResourcesForCommonMain
```
- **Then run Android lint. This is the gate that actually matches CI, and the
checks above do NOT substitute for it.** Duplicate-key + well-formedness +
`convertXmlValueResourcesForCommonMain` can all pass on a change that still
takes CI red, because the plural rules live in lint, not in the resource
compiler:
```bash
./gradlew :amethyst:lintPlayBenchmark # the task CI runs (.github/workflows/build.yml)
```
There is no `lint-baseline.xml` in this repo and only `MissingTranslation` is
disabled (`amethyst/build.gradle.kts`), so `abortOnError` bites on the first
error. Three rules matter for a translation pass:
| Rule | Fires when | Severity |
|------|-----------|----------|
| `MissingQuantity` | a locale's `<plurals>` omits a CLDR category that locale uses | **error** for core categories — gates CI |
| `ImpliedQuantity` | a `quantity` item has no format argument in a locale where that category spans more than one number | **error** — gates CI |
| `StringFormatCount` / `StringFormatMatches` | a translation's placeholder count/type disagrees with the base entry | warning |
(2026-08-13: a pass that cleared the duplicate/XML gate above still failed
`lintPlayBenchmark` with 3 errors. Compiling is not evidence — `compileDebugKotlin`
passed on the same change.)
- **Confirm the report says zero errors, don't just trust BUILD SUCCESSFUL** of a
wider invocation:
```bash
python3 -c "
import json,io,collections
d=json.load(io.open('amethyst/build/reports/lint-results-playBenchmark.sarif',encoding='utf-8'))
r=d['runs'][0]['results']
print(dict(collections.Counter(x.get('level','warning') for x in r)))
for x in r:
if x.get('level')=='error': print('ERROR', x['ruleId'], x['locations'][0]['physicalLocation']['artifactLocation']['uri'])
"
```
## Common Mistakes
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commons/src/commonMain/composeResources`). A key extracted into `commons/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
@@ -392,6 +538,12 @@ When adding translated strings to locale files:
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
- **Declaring the pass done without running `:amethyst:lintPlayBenchmark`** — the duplicate-key + XML + `convertXmlValueResourcesForCommonMain` gate is necessary but nowhere near sufficient. `MissingQuantity` and `ImpliedQuantity` are errors, there is no lint baseline, and `abortOnError` is on, so a change that compiles and passes every check in Step 6's first half can still take CI red. Compiling is not evidence. (Happened 2026-08-13: 3 lint errors after a clean duplicate/XML gate and a green `compileFdroidDebugKotlin`.)
- **Converting a `<string>` to `<plurals>` with `other` only** — "Crowdin fills the rest" is false; `MissingQuantity` errors immediately and CI fails before any sync. Supply every category the locale uses at conversion time, and re-check the declension rather than reusing the old text for `one`.
- **Putting `tools:ignore` on a locale file** — Crowdin strips it on the next export. Suppressions belong on the source entry in `values/strings.xml`, which propagates. The `tools:ignore="Typos"` copies visible in cs/de/ar/eo/bn are the *result* of that propagation, not proof that locale-file attributes survive. (Happened 2026-08-13; it broke `main`.)
- **Suppressing a lint rule on a key nothing references** — check `grep -rn "<key>" --include='*.kt'` first. `poll_results_voters` was a bare noun with no count, zero call sites, and an unlocalizable shape; deleting it retired the problem outright where a suppression would only have muted it.
- **Comparing placeholders without a `(?<!\\)` guard** — `\%2$d` is an escaped literal to lint, but a naive `%\d+\$[sd]` regex matches the placeholder inside it and reports the string clean. A parity sweep missing this guard will certify a broken translation. Also treat a *repeated* index (`%1$s` twice where the base has it once) as legitimate — German does this where English says "They".
- **Reading an empty `<item quantity="…"></item>` as merely "untranslated"** — it renders as nothing at runtime, and for a category like Polish `many` (521, 2531, …) that is the common case, not an edge case. Grep for them explicitly; the missing-key diff will never surface one because the key is present.
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
+81 -15
View File
@@ -1,16 +1,17 @@
---
name: find-non-lambda-logs
description: Use when auditing or migrating Log calls — flags both interpolated Log.d/i/w/e that should use the lambda overload (allocation hygiene) and catch-block Log.w/e that interpolate ${e.message} but drop the throwable (lost stack traces)
description: Use when auditing or migrating Log calls — flags interpolated Log.d/i/w/e that should use the lambda overload (allocation hygiene), catch-block Log.w/e that interpolate ${e.message} but drop the throwable (lost stack traces), and files still importing android.util.Log (no lambda overload, bypasses Log.minLevel)
---
# Find Non-Lambda Log Calls
## Overview
Two related logging hygiene issues:
Three related logging hygiene issues:
1. **Lambda overload missing.** `Log.d/i/w/e` calls that use string interpolation without the lambda overload waste string allocation when the log level is filtered out in release builds.
2. **Throwable dropped in catch blocks.** `Log.w/e` calls inside `catch (e: ...)` blocks that interpolate `${e.message}` but don't pass `e` lose the stack trace, and log nothing useful when `e.message` is null (NPE, IOException with no message, etc.).
3. **Still on `android.util.Log`.** Files importing the platform logger bypass `Log.minLevel` and the `LogSink`, and have no lambda overload — so neither fix above can be applied to them. Step 0 finds these; the last section migrates them.
## When to Use
@@ -39,6 +40,54 @@ Log.d("Tag", "Initialization complete")
**Important:** Tags can be string literals (`"Tag"`) or variables (`tag`, `LOG_TAG`). Run both patterns for each step.
**The throwable-name alternation, used by Steps 2 and 3** — define it once and reuse it, rather than writing a shorter list in one step and a longer one in another:
```bash
THROWABLE='(e|t|it|ex|err|error|throwable|cause|tr)'
```
**Filter the noise before counting**, or the totals mislead: drop `/build/`, `/androidTest/` and `/src/test/` (release filtering doesn't apply to tests), and drop lines whose first non-space character is `//` or `*` — commented-out calls and KDoc examples both match these patterns. A `grep -vE ':[0-9]+: *(//|\*)'` handles the last one.
### Step 0: Find files still on `android.util.Log` (run this first)
**Two patterns — the fully-qualified one alone is a false negative.** Almost nobody writes `android.util.Log.w(...)` at the call site; they `import android.util.Log` and then write `Log.w(...)`, which is indistinguishable from the wrapper by call shape. The import is the reliable signal:
```bash
# the form that actually occurs
grep -rln --include='*.kt' '^import android\.util\.Log$' . | grep -v '/build/' | grep -v PlatformLog
# the rare fully-qualified call
grep -rnE --include='*.kt' 'android\.util\.Log\.(d|i|w|e|v)\(' . | grep -v '/build/' | grep -v PlatformLog
```
On 2026-08-28 the fully-qualified pattern reported **0** while the import pattern found **16 production files** (9 in `nappletHost`, the rest in amethyst's `favorites/` and `napplet/`). Exclude `PlatformLog.android.kt`, which is the wrapper implementation and must call `android.util.Log`.
These bypass the `Log.minLevel` filter and the `LogSink` indirection entirely, and — the practical consequence for this skill — **they have no lambda overload**, so Steps 13 cannot be applied to them until they are migrated. Subtract these files from the Step 13 candidate lists, or migrate them first (see the last section).
### Step 0b: The patterns are line-anchored — sweep multi-line calls separately
Every `pattern:` in Steps 13 matches a call written on one line. A call formatted as
```kotlin
Log.d(
TAG,
"WASTE ${url.url} dials=${r.tentatives.get()} " +
"fail=[${r.failures.entries.joinToString { … }}]",
)
```
is **structurally invisible** to them. That biases the audit towards short calls and away from expensive ones — the multi-line form is what long, heavily interpolated messages look like, and those are exactly the ones worth deferring. A 2026-08-28 sweep converted three one-line banner calls in `BootRelayDiagnostics.kt` while walking past two `Log.d` calls in `forEach` loops immediately below them, running 25 and 20 iterations per census with nested `joinToString` in each — strictly the larger cost, three lines away.
Catch them with the open-paren-at-EOL form, then read each hit:
```bash
grep -rnE --include='*.kt' 'Log\.[diwe]\($' . | grep -v '/build/'
# or, to see the whole call:
rg -U --multiline --type kotlin 'Log\.[diwe]\(\n[^)]*\$\{'
```
**Prioritise call sites inside loops over one-liners.** A `Log.d` in a 25-iteration `forEach` discards 25 built strings per pass; a one-line banner discards one.
### Step 1: Find interpolated Log.d/Log.i (highest priority — filtered in release)
```
@@ -61,7 +110,14 @@ pattern: Log\.(w|e)\(\w+,\s*"[^"]*\$
type: kotlin
```
Then **manually exclude** lines where a throwable is passed as third argument (ending with `, e)`, `, throwable)`, etc.). Check the actual line — a catch block catching `e` doesn't mean `e` is passed to the Log call.
Then **manually exclude** lines where a throwable is passed as third argument. Check the actual line — a catch block catching `e` doesn't mean `e` is passed to the Log call.
**`it` is the name you will miss.** `Result.onFailure { ... }` is the dominant shape in this repo, so most correct calls end `, it)`, not `, e)`. Excluding only `e`/`throwable` inflates the result badly — a 2026-08-28 pass reported 23 hits where the real number was 8, because 14 of them were `.onFailure { Log.w(TAG, "...", it) }` and already correct. Also note the throwable is not always last on the line (`}.onFailure { Log.w(...) }.getOrDefault(false)`), so anchoring the exclusion to `$` misses them:
```bash
grep -rnE --include='*.kt' 'Log\.(w|e)\([^,]+,\s*"[^"]*\$' . \
| grep -vE ",\s*$THROWABLE\)" # note: no $ anchor, and `it` included
```
### Step 3: Find catch-block Log.w/e that drop the throwable
@@ -70,23 +126,14 @@ Among the Step 2 hits, the calls that interpolate `${e.message}` (or `${t.messag
Quick filter:
```
pattern: Log\.(w|e)\([^)]*\$\{(e|t|throwable|cause)\.message\}[^)]*\)$
pattern: Log\.(w|e)\([^)]*\$\{(e|t|it|ex|err|throwable|cause)\.message\}
type: kotlin
```
Then for each hit, open the file and confirm the line is **inside a `catch (e: ...)` block** and **does not pass `e` (or the matching name) as a third argument**. False positives: extension functions / helpers that accept an `e: SomeError` parameter and forward it elsewhere.
Note this deliberately omits the `\)$` anchor and includes `it` — same reasons as Step 2. Then for each hit, open the file and confirm the line is **inside a `catch (e: ...)` block** and **does not pass `e` (or the matching name) as a third argument**. False positives: extension functions / helpers that accept an `e: SomeError` parameter and forward it elsewhere.
Both Step 2 and Step 3 may flag the same line — handle Step 3 first (different fix), then apply Step 2 to whatever remains.
### Step 4: Verify no android.util.Log leakage
```
pattern: android\.util\.Log\.(d|i|w|e|v)\(
type: kotlin
```
These bypass the `Log.minLevel` filter entirely. Exclude `PlatformLog.android.kt` which is the wrapper implementation.
## Fix Patterns
### Lambda overload (Step 1 + Step 2)
@@ -106,7 +153,7 @@ Switch to `(tag, msg, throwable)` — the lambda overload does **not** accept a
```kotlin
// Before — stack trace lost, prints "...failed: null" if e.message is null
try { groupManager.clearAllState() } catch (e: Exception) {
Log.w("MarmotManager") { "clearAllState failed: ${e.message}" }
Log.w("MarmotManager", "clearAllState failed: ${e.message}")
}
// After — full stack trace logged
@@ -120,6 +167,25 @@ Trade-off: the message string is allocated eagerly even when warn is filtered, b
## Do NOT Convert
- **To lambda:** calls passing a `Throwable` parameter — the lambda overload `(tag) { message }` has no throwable parameter.
- **To lambda: any call in a file that imports `android.util.Log`.** The platform `Log` has no lambda overload, so the conversion fails to compile with `None of the following candidates is applicable`. Either migrate the file first (below) or leave the call alone. (Hit on 2026-08-28: three edits in two files had to be reverted.)
- Static string calls with no `$` interpolation — no allocation benefit.
- Commented-out log calls.
- Informational/intentional log of `e.message` *outside* a catch block (rare; usually means the exception was already handled and only the message is meaningful).
## Migrating a file off `android.util.Log`
This is what unlocks Steps 13 for the files Step 0 finds. It is a behaviour change, so check it rather than assuming — but in this repo the check has come out safe, and here is the reasoning to redo:
1. **Which levels does the file use?** `grep -hoE 'Log\.[a-zA-Z]+' <files> | sort | uniq -c`. The wrapper has `d/i/w/e` only — **no `v`**, and no `getStackTraceString`. A `Log.v` call has no direct equivalent and needs a decision, not a rename.
2. **Would the gate drop them?** `LogLevel { DEBUG, INFO, WARN, ERROR }`, the gate is `minLevel <= <level>`, and `Amethyst.DEFAULT_LOG_LEVEL` is INFO in debug, **WARN in release** (deliberately — so relay-protocol refusals stay visible in the field). The wrapper's own default is `DEBUG`. So `Log.w` and `Log.e` survive in every build type and in every process, including before `Amethyst.init` runs — which matters for `:napplet`. `Log.d`/`Log.i` **would** go silent in release; those need a conscious call.
3. **Does the output move?** No. `PlatformLogSink` on Android delegates to `android.util.Log`, so lines land in logcat unchanged.
4. **Can the module see quartz?** `nappletHost` already has `implementation(project(":quartz"))`. Check before assuming.
Then: swap `import android.util.Log``import com.vitorpamplona.quartz.utils.Log`, run `./gradlew spotlessApply` (import order changes), and convert only the interpolated no-throwable calls to the lambda form. Calls that already pass a throwable keep the eager three-arg shape — the wrapper's `w(tag, msg, throwable)` matches exactly, so only the import moves.
**Verify the throwables survived**, since a careless rewrite can drop the third argument silently:
```bash
grep -hoE 'Log\.[diwe]\([^)]*,\s*(e|it)\)' <files> | wc -l # compare before/after
```
+3 -3
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.13.1` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.14.0` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.13.1"
quartz = "1.14.0"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
implementation("com.vitorpamplona.quartz:quartz:1.14.0")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.13.1
com.vitorpamplona.quartz:quartz:1.14.0
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.13.1"
quartz = "1.14.0"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
implementation("com.vitorpamplona.quartz:quartz:1.14.0")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
implementation("com.vitorpamplona.quartz:quartz:1.14.0")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
@@ -2,9 +2,9 @@
Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()`
expression. **Update this file in the same PR as any change to the searchable set or to an
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-04.
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-25.
Counts: 126 concrete classes covering 129 kind values (`GitStatusEvent` spans 4 kinds;
Counts: 130 concrete classes covering 133 kind values (`GitStatusEvent` spans 4 kinds;
kind 30063 has a collision — see the footnote). File paths are under
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
@@ -100,6 +100,10 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL |
| 30315 | StatusEvent | nip38UserStatus | `content` |
| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content |
| 30392 | UserTrustedListEvent | experimental/trustedLists/users | inherited `TrustedListEvent`: `title() ?: ""` — the label only; `metric`/`d` are machine ids and `content` is a JSON echo of the membership |
| 30393 | EventTrustedListEvent | experimental/trustedLists/events | inherited `TrustedListEvent`: `title() ?: ""` |
| 30394 | AddressableTrustedListEvent | experimental/trustedLists/addressables | inherited `TrustedListEvent`: `title() ?: ""` |
| 30395 | ExternalIdTrustedListEvent | experimental/trustedLists/externalIds | inherited `TrustedListEvent`: `title() ?: ""` |
| 30402 | ClassifiedsEvent | nip99Classifieds | `listOfNotNull(title(), summary(), content)` NL |
| 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL |
| 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL |
@@ -150,6 +154,7 @@ declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `Event
| `InteractiveStoryBaseEvent` | `listOfNotNull(title(), summary(), content)` NL | 30296, 30297 |
| `AddressableVideoEvent` | `listOfNotNull(title(), content)` NL | 34235, 34236 |
| `RegularVideoEvent` | `listOfNotNull(title(), content)` NL | 21, 22 |
| `TrustedListEvent` | `title() ?: ""` | 30392, 30393, 30394, 30395 |
## How to regenerate / verify this table
+51 -6
View File
@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v5.7.0
with:
distribution: 'temurin'
java-version: 21
@@ -69,7 +69,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v5.7.0
with:
distribution: 'temurin'
java-version: 21
@@ -94,6 +94,39 @@ jobs:
$CMD
fi
# This job runs five test suites (:quartz, :commons, :nestsClient, :cli,
# :desktopApp) but, unlike test-geode / test-quartz-ios /
# test-and-build-android, published nothing when one of them failed. The
# console line names the failing test and the exception class and stops
# there, so the message is lost with the runner. That is how the
# NostrClientNegentropySyncTest failure in run 10540 became
# undiagnosable: NegentropySyncException carries a `detail` naming which
# branch fired (connect timeout / idle silence / NEG-ERR / disconnect),
# and nobody could read it. Same action and pin as the Android job below.
- name: Desktop Test Report
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6.4.2
if: always()
with:
report_paths: '**/build/test-results/**/TEST-*.xml'
annotate_only: true
detailed_summary: true
fail_on_failure: true
# The HTML reports carry the full stack traces and stdout/stderr the
# annotations truncate. Named per-OS because the three matrix legs upload
# into the same run and artifact names must be unique.
- name: Upload Desktop Test Reports
uses: actions/upload-artifact@v7
if: failure()
with:
name: Desktop Test Reports (${{ matrix.os }})
path: |
quartz/build/reports/tests
commons/build/reports/tests
nestsClient/build/reports/tests
cli/build/reports/tests
desktopApp/build/reports/tests
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so testers on other Debian/Ubuntu
# releases can install the uploaded artifact.
@@ -126,7 +159,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v5.7.0
with:
distribution: 'temurin'
java-version: 21
@@ -136,8 +169,20 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# -DsyncN shrinks MirrorSyncThroughputTest's corpus from its 1,000,000-event
# default. At 1M the sink cannot keep up with the in-process source, and the
# MirrorWorker's deliberately unbounded intake channel buffers the backlog until
# the runner's heap is gone: throughput collapses (13,800 -> 86 ev/s) and an
# OutOfMemoryError lands on a coroutine thread, where the UncaughtExceptionHandler
# swallows it. JUnit never sees a failure, so the JVM wedges and the job burns to
# the timeout with no signal rather than failing. 100k keeps a real ev/s number
# while bounding the worst-case backlog to a tenth of what died.
#
# Only CI is shrunk: -DsyncN is unset everywhere else, so a local or manual run
# still measures the full 1M — the number written up in
# relayBench/plans/2026-07-04-sync-throughput-1m.md.
- name: Test geode (gradle)
run: ./gradlew :geode:test
run: ./gradlew :geode:test -DsyncN=100000
- name: Upload geode Test Reports
uses: actions/upload-artifact@v7
@@ -161,7 +206,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v5.7.0
with:
distribution: 'temurin'
java-version: 21
@@ -220,7 +265,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v5.7.0
with:
distribution: 'temurin'
java-version: 21
@@ -3,11 +3,11 @@ name: Bump Homebrew Formula (geode relay)
# Sibling of bump-homebrew-formula.yml (the amy CLI). Same mechanism, different
# artifact:
# - bump-homebrew-formula.yml -> Formula `amy` (the headless CLI)
# - this workflow -> Formula `geode` (the standalone relay)
# - this workflow -> Formula `geode-relay` (the standalone relay)
#
# After a stable release, download the published `geode-<version>-jvm.tar.gz`
# bundle, compute its sha256, and open a PR that syncs
# `geode/packaging/homebrew/geode.rb`'s url + sha256 to that release. Keeping the
# `geode/packaging/homebrew/geode-relay.rb`'s url + sha256 to that release. Keeping the
# in-repo reference formula accurate makes the eventual homebrew-core submission a
# copy-paste.
#
@@ -101,7 +101,7 @@ jobs:
- name: Update reference formula
run: |
set -euo pipefail
FORMULA=geode/packaging/homebrew/geode.rb
FORMULA=geode/packaging/homebrew/geode-relay.rb
URL="${{ steps.asset.outputs.url }}"
SHA="${{ steps.asset.outputs.sha256 }}"
# Rewrite the two indented lines in the formula block. Anchoring on the
@@ -119,11 +119,11 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-geode-formula-${{ steps.rel.outputs.tag }}
add-paths: geode/packaging/homebrew/geode.rb
add-paths: geode/packaging/homebrew/geode-relay.rb
commit-message: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `geode/packaging/homebrew/geode.rb` to the
Auto-synced `geode/packaging/homebrew/geode-relay.rb` to the
`${{ steps.rel.outputs.tag }}` release:
- `url` -> `${{ steps.asset.outputs.url }}`
@@ -158,7 +158,7 @@ jobs:
``,
`Recovery options:`,
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Manually update \`geode/packaging/homebrew/geode.rb\` (url + sha256) from the release asset`,
`2. Manually update \`geode/packaging/homebrew/geode-relay.rb\` (url + sha256) from the release asset`,
`3. Check the release actually published \`geode-${tag.replace(/^v/, '')}-jvm.tar.gz\``
].join('\n'),
labels: ['release-ops', 'bug']
+1 -1
View File
@@ -1,7 +1,7 @@
name: Sync Homebrew Cask Reference
# Sibling of bump-homebrew-formula.yml (amy) and bump-homebrew-geode-formula.yml
# (geode). Same mechanism, third artifact:
# (geode-relay). Same mechanism, third artifact:
# - this workflow -> Cask `amethyst-nostr` (the desktop GUI app / DMG)
#
# What it does: after a stable release, download the published macOS DMG, assert
+1 -1
View File
@@ -2,7 +2,7 @@ name: Sync Winget Manifest Reference
# Fourth sibling of the three Homebrew sync workflows, same shape:
# bump-homebrew-formula.yml -> Formula `amy`
# bump-homebrew-geode-formula.yml -> Formula `geode`
# bump-homebrew-geode-formula.yml -> Formula `geode-relay`
# bump-homebrew.yml -> Cask `amethyst-nostr`
# this workflow -> Winget `VitorPamplona.Amethyst`
#
+4 -4
View File
@@ -78,7 +78,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v5.7.0
with:
distribution: 'temurin'
java-version: 21
@@ -405,7 +405,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v5.7.0
with:
distribution: 'temurin'
java-version: 21
@@ -662,7 +662,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v5.7.0
with:
distribution: 'temurin'
java-version: 21
@@ -953,7 +953,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v5.7.0
with:
distribution: 'temurin'
java-version: 21
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v5.7.0
with:
distribution: 'temurin'
java-version: 21
@@ -66,7 +66,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v5.7.0
with:
distribution: 'temurin'
java-version: 21
+25 -7
View File
@@ -533,7 +533,7 @@ reads an optional per-release changelog from
## Bootstrap runbook (one-time)
> **Status as of v1.13.1: neither Homebrew nor Winget has been bootstrapped.**
> **Status as of v1.14.0:** Winget has been submitted — [microsoft/winget-pkgs#422752](https://github.com/microsoft/winget-pkgs/pull/422752), pending CLA + review. Neither Homebrew package (`amethyst-nostr` cask, `amy` formula) has been submitted yet.
> `https://formulae.brew.sh/api/cask/amethyst-nostr.json` and
> `microsoft/winget-pkgs/manifests/v/VitorPamplona/Amethyst` both 404, so
> **Amethyst does not currently ship through either channel.** The bump
@@ -584,7 +584,7 @@ The token then lives only in that maintainer's shell:
```bash
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.13.2
scripts/bump-homebrew-cask.sh v1.14.0
```
Create one at
@@ -600,7 +600,7 @@ Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives
runs fine from macOS or Linux:
```bash
scripts/bump-winget.sh v1.13.2
scripts/bump-winget.sh v1.14.0
```
CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the
@@ -618,15 +618,33 @@ that is needed.
### Homebrew cask (one-time initial PR)
> `brew bump-cask-pr` **cannot** do this step. It *updates* an existing cask —
> against a name that isn't in the tap yet it fails outright:
> `Error: Cask 'amethyst-nostr' is unavailable: No Cask with this name exists.`
> The first submission is a **new-cask** PR, which is a different flow:
```bash
brew bump-cask-pr amethyst-nostr \
--version 1.12.1 \
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amethyst-desktop-1.12.1-macos-arm64.dmg"
# 1. Scaffold from the published DMG (macOS arm64 — there is no Intel DMG)
brew create --cask \
https://github.com/vitorpamplona/amethyst/releases/download/v1.14.0/amethyst-desktop-1.14.0-macos-arm64.dmg \
--set-name amethyst-nostr
# 2. Fill in the cask body, then audit as a NEW cask (stricter than a bump)
brew audit --new --cask amethyst-nostr
brew install --cask amethyst-nostr # verify it actually installs
brew uninstall --cask amethyst-nostr
# 3. Open the PR against Homebrew/homebrew-cask by hand
```
The DMG **must be notarized and stapled** or Homebrew will reject it; verify
with `spctl -a -t open --context context:primary-signature -v <dmg>` before
submitting.
The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a
tiling window manager). After the first PR is merged, `bump-homebrew.yml`
auto-submits new version bumps on each stable release.
auto-submits new version bumps on each stable release — *that* is where
`brew bump-cask-pr` applies.
> **The desktop app is already on mainline Homebrew.** `homebrew/cask` *is* the
> mainline cask repo — GUI apps live in homebrew-**cask**, CLIs in
+7
View File
@@ -186,6 +186,13 @@ device. PRs that introduce any of them will be sent back.
body only runs when the log level is enabled. Plain
`Log.d("msg $x")` allocates the formatted string on every call,
including in feed and scroll hot paths.
- **Never `import android.util.Log`.** The platform logger bypasses
`Log.minLevel` and the `LogSink`, and it has no lambda overload, so
the rule above cannot be applied at those call sites. The one
legitimate user is `PlatformLog.android.kt`, which implements the
wrapper. A call that must pass a throwable uses the eager three-arg
form `Log.w(tag, "msg", e)` — the lambda overload takes no throwable,
and dropping it to keep the lambda loses the stack trace.
- **Strip diagnostic `Log.d` calls before commit.** Logs added
during on-device debugging — even lambda-form ones — must be
removed from the production diff. They survive R8 stripping only
+5 -5
View File
@@ -328,16 +328,16 @@ repositories {
Add the following line to your `commonMain` dependencies:
```gradle
implementation('com.vitorpamplona.quartz:quartz:1.13.1')
implementation('com.vitorpamplona.quartz:quartz:1.14.0')
```
Variations to each platform are also available:
```gradle
implementation('com.vitorpamplona.quartz:quartz-android:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-android:1.14.0')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.14.0')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.14.0')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.14.0')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
+33 -15
View File
@@ -101,15 +101,20 @@ git -c credential.helper= -c credential.helper='!gh auth git-credential' push up
```
When the `Create Release Assets` workflow finishes (~2530 min) the GH Release
holds **31 assets**, per the asset-name contract:
holds **47 assets**, per the asset-name contract:
- **Android (13):** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid
`.apks` set for Accrescent
(`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab` / `.apks`)
- **Desktop (8):** DMG (macOS **arm64 only** — there is no Intel DMG),
MSI + zip, DEB, RPM, AppImage, flatpak, tar.gz
- **CLI (5):** the `amy` artifacts
- **Relay (5):** the `geode` artifacts, plus the geode Docker image
- **Desktop (14):** macOS DMG (**arm64 only** — there is no Intel DMG), Windows
MSI (x64 only — **no arm64 MSI**) + portable zip (x64, arm64), and Linux
DEB/RPM/AppImage/flatpak/tar.gz in both x64 and arm64. BUILDING.md § Release
runbook has the per-leg breakdown and why the two gaps exist.
- **CLI (10):** the `amy` artifacts — the no-JRE `jvm.tar.gz`, macOS arm64,
Windows x64 + arm64, and Linux DEB/RPM/tar.gz in both x64 and arm64
- **Relay (10):** the `geode` artifacts, same matrix as `amy`. The geode Docker
image is **not** a release asset — it goes to the registry, so don't count it
here.
- **Maven Central:** `com.vitorpamplona.quartz:quartz:<version>` published.
`repo1.maven.org` lags the publish by tens of minutes — a 404 right after the
run is normal. Confirm the step's log says "Deployment is being published to
@@ -121,8 +126,9 @@ holds **31 assets**, per the asset-name contract:
## 3. Per-channel shipping
### GitHub Releases — automatic
Nothing to do beyond pushing the tag. Verify the asset count and that Intel +
ARM DMGs are both present (BUILDING.md § Verify).
Nothing to do beyond pushing the tag. Verify the asset count (BUILDING.md
§ Verify). macOS is **arm64-only** — there is no Intel DMG, so a single
`amethyst-desktop-<version>-macos-arm64.dmg` is the expected, correct result.
### Google Play — manual upload
1. Download `amethyst-googleplay-<version>.aab` from the GH Release.
@@ -177,7 +183,7 @@ when unset. To fan the release event out to more relays for discoverability,
set `RELAY_URLS` for the run:
```bash
RELAY_URLS="wss://relay.zapstore.dev,wss://relay.damus.io,wss://nos.lol,wss://vitor.nostr1.com" \
RELAY_URLS="wss://relay.zapstore.dev,wss://nos.lol,wss://nostr.mom,wss://vitor.nostr1.com" \
SIGN_WITH=<amethyst-nsec> zsp publish
```
@@ -188,10 +194,22 @@ itself reads from.
`bump-homebrew.yml` and `bump-winget.yml` are wired to open PRs against
`Homebrew/homebrew-cask` (cask `amethyst-nostr`) and `microsoft/winget-pkgs`
(`VitorPamplona.Amethyst`) — but **neither package has ever been submitted
upstream**, so both workflows detect that and skip with a `::warning::`. As of
**v1.13.1** these two channels deliver nothing; macOS and Windows users get the
desktop app from GitHub Releases only.
(`VitorPamplona.Amethyst`). Both can only *update* a package that already
exists upstream, so until the one-time bootstrap lands they detect the absence
and skip with a `::warning::`.
Bootstrap status:
| Channel | Upstream package | State |
|---|---|---|
| **Winget** | `microsoft/winget-pkgs` → `VitorPamplona.Amethyst` | **Submitted at v1.14.0** — [PR #422752](https://github.com/microsoft/winget-pkgs/pull/422752), pending CLA + review |
| **Homebrew cask** | `Homebrew/homebrew-cask` → `amethyst-nostr` | Not submitted |
| **Homebrew formula** | `Homebrew/homebrew-core` → `amy` | Not submitted |
Until each lands, that channel delivers nothing and macOS/Windows users get the
desktop app from GitHub Releases only. Re-check before assuming — the state
above is a snapshot, and `gh api repos/microsoft/winget-pkgs/contents/manifests/v/VitorPamplona`
(404 = still absent) answers it in one call.
Two separate faults kept this invisible until v1.13.1, both now fixed:
@@ -219,9 +237,9 @@ readable by anyone with push access here), so a maintainer runs the last step:
```bash
# after merging the sync PRs
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.13.2
scripts/bump-homebrew-cask.sh v1.14.0
scripts/bump-winget.sh v1.13.2 # no token — uses your `gh` auth
scripts/bump-winget.sh v1.14.0 # no token — uses your `gh` auth
```
Both scripts re-verify the published artifact's sha256 before submitting, and
@@ -283,7 +301,7 @@ Owner assignments and rotation reminders live with the team (issue tracker).
## 6. Post-release verification
- [ ] GH Release: 31 assets, sizes sane, and the asset-name set matches the
- [ ] GH Release: 47 assets, sizes sane, and the asset-name set matches the
previous release (see the `diff` one-liner in BUILDING.md § Release
runbook). macOS is arm64-only — do **not** look for an Intel DMG.
- [ ] Maven Central: `quartz:<version>` resolves (allow tens of minutes of
+19 -4
View File
@@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp)
alias(libs.plugins.androidxBaselineProfile)
}
fun getCurrentBranch(workingDir: java.io.File): String =
@@ -84,7 +85,7 @@ android {
.get()
.toInt()
versionName = generateVersionName(libs.versions.app.get(), rootDir)
buildConfigField("String", "RELEASE_NOTES_ID", "\"f54843af6397f78e39fa75dbe3b7f7de14eb18c4f9c56e60e7825a2c6715719b\"")
buildConfigField("String", "RELEASE_NOTES_ID", "\"00d306e01792e48b93638b73b57a7eb8b89622a338b1b4f529622150d46cd710\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -330,7 +331,7 @@ ksp {
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
configurations.all {
val tink = "com.google.crypto.tink:tink-android:1.17.0"
val tink = "com.google.crypto.tink:tink-android:1.23.0"
resolutionStrategy {
force(tink)
dependencySubstitution {
@@ -374,6 +375,17 @@ composeCompiler {
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
baselineProfile {
// One profile for the whole app rather than per-flavour: the ingest path being
// captured is identical in play and fdroid, and a shared profile is what the
// fdroid build needs — it never receives Play Cloud Profiles.
mergeIntoMain = true
// Keep the generated profile in source control so release builds do not depend on
// a device being attached at build time.
saveInSrc = true
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
@@ -384,8 +396,8 @@ dependencies {
// adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \
// -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver
debugImplementation("androidx.compose.runtime:runtime-tracing")
debugImplementation("androidx.tracing:tracing-perfetto:1.0.0")
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.0")
debugImplementation("androidx.tracing:tracing-perfetto:1.0.1")
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.1")
implementation(project(":quartz"))
implementation(project(":commons"))
@@ -402,6 +414,9 @@ dependencies {
// and no Cloud Profiles at all — there, this library is the only thing that gets
// the shipped baseline profile into ART.
implementation(libs.androidx.profileinstaller)
// Profile produced by :baselineprofile from a real cold-start + ingest journey.
baselineProfile(project(":baselineprofile"))
implementation(libs.androidx.activity.compose)
// Hardened WebView host for sandboxed napplet/nsite rendering (origin-restricted message bridge).
@@ -51,6 +51,12 @@ Shipped as designed. Where it diverged or went further:
connection forever. A second challenge for the same (relay, account) rides along
on the owner's answer with no deadline of its own — running one would let it
resolve the shared deferred and tear down a dialog mid-read.
- **Corrected later:** this plan left the decision model alone, including the
blanket `isFirstParty` gate on `CUSTOM`. That gate turned out to make
`readFollows` ("…I'm reading someone I follow") unreachable — a follow's outbox
relay is theirs, so it is never first-party for us, and every follow produced a
prompt with the toggle explicitly on. `RelayAuthResolver.customAllows` now
checks that one category ahead of the gate; the other three still require it.
- **Still not done:** what a timeout should *look like*. It is now an honest 60s of
visible time rather than a clock the user never saw, but it is still a dialog
that vanishes and an event left pending in the outbox with no feedback. That
@@ -0,0 +1,379 @@
# Upstream issue draft — Compose `WindowInsets.ime` permanently wedges after a cancelled IME animation
Target: Google IssueTracker → **component 612128 (Jetpack Compose)**.
The library-specific component the docs link to (856989, from the "Create a new issue" button on
the Compose Foundation release notes) does not grant public Create Issues permission, so this is
filed one level up with a routing request at the top of the body.
Status: **FILED as https://issuetracker.google.com/issues/552500419 (b/552500419)** on 2026-08-25,
against component 612128 with a routing request. Remaining open item: the AOSP commit that introduced `runningAnimation`
between 1.3.0 and 1.4.0-alpha01 has not been identified (android.googlesource.com returned 403
to automated fetch). Adding the commit link before filing would help triage.
---
## Title
`WindowInsets.ime` stops updating permanently when an IME animation is cancelled without `onEnd` (regression in 1.4.0, still present in 1.13.0-alpha01)
## Routing
Please reassign to the owner of **`androidx.compose.foundation` / `foundation-layout`**
(WindowInsets). Filing here because component 856989 — the target of the "Create a new issue"
button on the [Compose Foundation release notes](https://developer.android.com/jetpack/androidx/releases/compose-foundation)
— does not grant Create Issues permission to external accounts. That documented path being
unusable by the public is arguably a separate docs bug worth fixing.
## Affected versions
* **Broken:** `androidx.compose.foundation:foundation-layout` **1.4.0 → 1.12.0 (current stable) and 1.13.0-alpha01**
* **Not broken:** 1.3.0 and earlier
* Verified by inspecting published `-sources.jar` for 1.2.0, 1.3.0, 1.4.0-alpha01…rc01, 1.4.0,
1.5.0, 1.6.0, 1.7.0, 1.8.0, 1.9.0, 1.10.0, 1.11.0, 1.12.0, 1.13.0-alpha01.
`runningAnimation` and its guard are absent in 1.3.0 and present from 1.4.0-alpha01 onward,
textually unchanged since.
* Reproduced on a Pixel 8, Android 17 (API 37). The API-30-only self-heal (below) means API 31+
has no recovery path at all.
## Summary
If a `WindowInsetsAnimation` is prepared and started but never ended — what a cancelled IME
animation looks like — `InsetsListener.runningAnimation` stays `true` forever. From that point
`onApplyWindowInsets` matches neither of its two branches, so `composeInsets.update()` is never
called again and **`WindowInsets.ime` is frozen for the remaining life of the window**.
Every `Modifier.imePadding()` in the app then holds a keyboard-height gap open with no keyboard
on screen, permanently. `WindowInsets.imeAnimationTarget` keeps reporting correctly, because
`updateImeAnimationTarget()` is called outside the guard — that asymmetry is the only reason a
workaround is possible at all.
## Reproduction
Deterministic instrumented test, ~3s, no gestures and no timing dependence. It drives Compose's
own listener through the cancelled-animation sequence using **public** interfaces
(`WindowInsetsAnimationCompat.Callback`, `OnApplyWindowInsetsListener`); reflection is used only
to obtain the listener instance for the view. Inside the androidx codebase `InsetsListener` is
directly accessible, so `listenerFor()` can be deleted and the rest of the test used verbatim.
```
FAIL aCancelledImeAnimationMustNotWedgeTheAnimatedInset
expected:<0> but was:<957>
PASS theAnimationTargetSurvivesTheWedge
```
The second test is expected to pass and is included on purpose: it pins the asymmetry between the
two readings, and would catch a "fix" that broke `imeAnimationTarget` instead.
The full test source is attached below.
## Root cause
`compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/WindowInsets.android.kt`
```kotlin
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
prepared = true
runningAnimation = true // set here…
}
override fun onStart(animation, bounds): BoundsCompat {
prepared = false // …prepared cleared, runningAnimation left set
return super.onStart(animation, bounds)
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
prepared = false
runningAnimation = false // …cleared ONLY here
}
override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat {
savedInsets = insets
composeInsets.updateImeAnimationTarget(insets) // unconditional — stays correct
if (prepared) {
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) {
view.post(this) // self-heal, API 30 ONLY
}
} else if (!runningAnimation) {
composeInsets.updateImeAnimationSource(insets)
composeInsets.update(insets) // the animated inset — never reached when wedged
}
}
```
After a cancelled animation: `prepared == false` (cleared by `onStart`) and
`runningAnimation == true` (never cleared, because `onEnd` never came). Neither branch runs.
`composeInsets.update()` is dead.
### Why the existing self-heal does not help
`run()` exists precisely to handle a cancelled animation, but:
1. it is gated to `Build.VERSION.SDK_INT == Build.VERSION_CODES.R` (API 30 only), and
2. it is posted only from the `if (prepared)` branch, and returns early unless `prepared` is still
`true` — which `onStart` has already cleared.
So it covers "cancelled between `onPrepare` and `onStart`, on API 30". It does not cover
"cancelled after `onStart`", on any API level.
### Why applications cannot recover
The only reset is `insetsListener.resetState()`, called from `WindowInsetsHolder.incrementAccessors()`
when `accessCount` transitions `0 → 1`. `accessCount` is driven by `WindowInsetsHolder.current()`'s
`DisposableEffect`, so it only reaches 0 when *every* insets consumer leaves composition
simultaneously.
In a single-Activity app whose shell (scaffold / bottom bar / drawer) always reads insets, that
never happens — the holder is created once and lives for the whole process. There is no public API
to force the reset. `WindowInsetsHolder` is `internal`.
Multi-Activity apps mask this: a new Activity means a new `View`, a new holder, and fresh state, so
the wedge dies with the Activity and reads as a transient glitch.
### Regression point
1.3.0's `onApplyWindowInsets` had no such gate and could not wedge:
```kotlin
override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat {
if (prepared) {
savedInsets = insets
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) view.post(this)
return insets
}
composeInsets.update(insets) // unconditional once onStart cleared `prepared`
return
}
```
1.4.0 introduced `runningAnimation` and the `else if (!runningAnimation)` guard. Its own comment
states the intent:
> `// If an animation is running, rely on onProgress() to update the insets`
> `// On APIs less than 30 where the IME animation is backported, this avoids reporting`
> `// the final insets for a frame while the animation is running.`
i.e. a **one-frame** cosmetic flash on **API < 30** was fixed by making the update path conditional
on a flag that only `onEnd` clears — trading a single wrong frame on old devices for permanent
state corruption on all of them. The compensating recovery was never widened past `SDK_INT == R`.
## Real-world impact
Observed in a production Compose app (Amethyst, a Nostr client; single-Activity, `NavHost`,
77 `imePadding()` sites):
* On a Pixel 8 / Android 17, after ordinary manual use, `WindowInsets.ime` pinned at 957px while
the window reported `ime frame=[0,0][0,0]` — keyboard gone — and stayed pinned for 85+ seconds
until the process was restarted. Nothing in the app cleared it.
* Instrumented `WindowInsets.ime` vs `WindowInsets.imeAnimationTarget` across the failure:
```
17:12:58.803 animated=882 target=957 ← healthy open, 13 intermediate frames
17:12:58.902 animated=957 target=957
17:13:00.584 animated=957 target=0 ← dismissed; animated frozen
17:13:02.430 animated=0 target=957 ← reopened; snaps, no intermediate frames
17:13:03.479 animated=957 target=0 ← dismissed; frozen permanently
```
Note the loss of per-frame updates after the wedge: healthy transitions carry ~13 intermediate
values over ~264ms; post-wedge transitions carry none.
* Because the app never navigates away from its single Activity and its shell always reads insets,
`accessCount` never returns to 0, so the wedge is permanent for the session. Sessions in this app
routinely run for days.
The trigger for the underlying cancellation was not isolated — it is infrequent and required
extended manual use to hit. The defect being reported is not the cancellation itself but that
Compose enters a state it can never leave when one occurs. The attached test reproduces that state
directly and deterministically.
## Suggested fixes
Roughly in order of how targeted they are:
1. **Generalise the existing self-heal.** Post the `run()` reconciliation on all API levels, and
arm it after `onStart` as well as after `onPrepare`, so that an `onApplyWindowInsets` that
arrives with no intervening `onProgress` clears `runningAnimation` and applies `savedInsets`.
This preserves the API<30 one-frame behaviour the guard was added for, while bounding the
failure to a frame rather than forever.
2. **Reconcile on dispatch.** In `onApplyWindowInsets`, if `runningAnimation` is set but no
`onProgress` has been received since `onStart`, treat the animation as finished and update.
3. **Expose a reset.** A public way to reach `WindowInsetsHolder.resetState()` (or a documented
condition under which it runs) would at least let applications self-heal. Today they cannot,
short of reflection into an `internal` class — which R8 can rename or strip in exactly the
release builds where this occurs.
(1) or (2) is preferable: (3) only makes the bug survivable rather than fixing it.
## Environment
* `androidx.compose.foundation:foundation-layout` 1.12.0 (Compose BOM 2026.08.00)
* Pixel 8 (`shiba`), Android 17 / API 37, gesture navigation, Gboard, 120Hz
* Also inspected: 1.13.0-alpha01 — identical listener code
---
## Attachment — the failing test
```kotlin
package com.vitorpamplona.amethyst.ui.insets
import android.view.View
import android.view.animation.LinearInterpolator
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imeAnimationTarget
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
/**
* Upstream regression test for androidx.compose.foundation:foundation-layout.
*
* A `WindowInsetsAnimation` that is prepared and started but never ended — which is what a
* cancelled IME animation looks like — leaves `InsetsListener.runningAnimation` set forever.
* `onApplyWindowInsets` then matches neither of its two branches, so `composeInsets.update()`
* is never called again and `WindowInsets.ime` is dead for the life of the window.
*
* Introduced in 1.4.0 (absent in 1.3.0, where `onApplyWindowInsets` updated unconditionally
* once `onStart` had cleared `prepared`). Still present in 1.12.0 and 1.13.0-alpha01. The
* compensating self-heal (`view.post(this)` -> `run()`) is scoped to `SDK_INT == R`, so on
* API 31+ nothing clears the flag; `WindowInsetsHolder.resetState()` only runs when the
* holder's accessCount transitions 0 -> 1, which never happens in an app whose shell always
* reads insets.
*
* [aCancelledImeAnimationMustNotWedgeTheAnimatedInset] FAILS on every version from 1.4.0 on.
* [theAnimationTargetSurvivesTheWedge] documents the asymmetry that makes a workaround possible
* and is expected to PASS — `updateImeAnimationTarget` is called outside the guard.
*/
class ComposeImeInsetWedgeTest {
@get:Rule val rule = createComposeRule()
private val keyboardHeight = 957
private fun imeInsets(bottom: Int): WindowInsetsCompat =
WindowInsetsCompat
.Builder()
.setInsets(WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, bottom))
.setVisible(WindowInsetsCompat.Type.ime(), bottom > 0)
.build()
/** Compose's own listener for this view. Private class, but both interfaces it exposes are public. */
private fun listenerFor(view: View): Any {
val holderClass = Class.forName("androidx.compose.foundation.layout.WindowInsetsHolder")
val companion =
holderClass.getDeclaredField("Companion").run {
isAccessible = true
get(null)
}
val holder =
companion.javaClass
.getDeclaredMethod("getOrCreateFor", View::class.java)
.run {
isAccessible = true
invoke(companion, view)
}
return holderClass.getDeclaredField("insetsListener").run {
isAccessible = true
get(holder)!!
}
}
private fun anim() = WindowInsetsAnimationCompat(WindowInsetsCompat.Type.ime(), LinearInterpolator(), 250L)
private fun bounds() =
WindowInsetsAnimationCompat.BoundsCompat(
Insets.NONE,
Insets.of(0, 0, 0, keyboardHeight),
)
@OptIn(ExperimentalLayoutApi::class)
@Test
fun aCancelledImeAnimationMustNotWedgeTheAnimatedInset() {
var animated by mutableIntStateOf(-1)
lateinit var view: View
rule.setContent {
view = LocalView.current
val density = LocalDensity.current
animated = WindowInsets.ime.getBottom(density)
}
rule.waitForIdle()
val listener = listenerFor(view)
val onApply = listener as OnApplyWindowInsetsListener
val callback = listener as WindowInsetsAnimationCompat.Callback
// Baseline: with no animation in flight the inset tracks normally.
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
rule.waitForIdle()
assertEquals("baseline: the inset must follow a plain dispatch", keyboardHeight, animated)
// A cancelled animation: prepared and started, but onEnd never arrives.
rule.runOnUiThread {
callback.onPrepare(anim())
callback.onStart(anim(), bounds())
}
rule.waitForIdle()
// The keyboard is gone and the window says so. The animated inset must follow.
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
rule.waitForIdle()
assertEquals(
"WindowInsets.ime must still track the window after an animation was cancelled " +
"without onEnd; it is instead frozen at the keyboard height forever",
0,
animated,
)
}
@OptIn(ExperimentalLayoutApi::class)
@Test
fun theAnimationTargetSurvivesTheWedge() {
var target by mutableIntStateOf(-1)
lateinit var view: View
rule.setContent {
view = LocalView.current
val density = LocalDensity.current
target = WindowInsets.imeAnimationTarget.getBottom(density)
}
rule.waitForIdle()
val listener = listenerFor(view)
val onApply = listener as OnApplyWindowInsetsListener
val callback = listener as WindowInsetsAnimationCompat.Callback
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
rule.waitForIdle()
assertEquals(keyboardHeight, target)
rule.runOnUiThread {
callback.onPrepare(anim())
callback.onStart(anim(), bounds())
}
rule.waitForIdle()
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
rule.waitForIdle()
assertEquals(
"updateImeAnimationTarget is called outside the guard, so this reading stays truthful",
0,
target,
)
}
}
```
@@ -0,0 +1,182 @@
# Defaults stand in for the user's relay lists only while we have no event
**Status:** proposal — not implemented
**Goal:** first-login startup on a Tor-enabled install
**Related:** `fix/tor-bootstrap-stall-and-ondemand`, `[[fresh-install-routes-everything-via-tor]]`
## The rule
Three states, currently collapsed into two:
| we have | effective list | today |
|---|---|---|
| **no event** for the user | app defaults | defaults ✅ |
| event, **empty** list | **empty** — the user chose nothing | defaults ❌ |
| event with relays | those relays | those relays ✅ |
Everything below follows from separating "we don't know" from "we know, and it's nothing".
## Why the first login is slow
On a fresh install **100% of relay traffic is Tor-routed by construction**.
`TorRelayState.trustedRelays` is empty, so `TorRelayEvaluation.useTor()` falls through to
`newRelaysViaTor` (**default true**) for every URL — and the kind-10002 that would populate it can
only be fetched over Tor. Measured (SM-T220, same account, same ~app+8-10s login, fresh install
each; the Tor-OFF arm sets the pref, force-stops, then starts the timed run so Arti never boots):
| @20s census | Tor ON | Tor OFF |
|---|---|---|
| feed on screen | login+18s | **login+11s** |
| relays opened | 18/40 | **32/41** |
| relays serving events | 9 | **22** |
| events ingested | 2,830 | **6,134 / 7,641** |
≈7s of first paint and half the relay coverage.
## Finding 1 — every `WithBackup` helper keys on emptiness, not absence
This is a pre-existing bug against the rule above, and it must be fixed first because the whole
feature depends on the distinction being real.
```kotlin
// AdvertisedRelayListEvent
fun relays() = tags.mapNotNull(AdvertisedRelayInfo::parse) // [] when none
fun readRelaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseReadNorm).ifEmpty { null } // null!
fun writeRelaysNorm()= tags.mapNotNull(AdvertisedRelayInfo::parseWriteNorm).ifEmpty { null } // null!
```
| helper | fallback fires when | correct |
|---|---|---|
| `normalizeNIP65AllRelayListWithBackup` | event absent only | ✅ (by accident — `relays()` has no `ifEmpty`) |
| `normalizeNIP65Read/WriteRelayListWithBackup` | event absent **or list empty** | ❌ |
| `normalizeIndexerRelayListWithBackup` | `?.ifEmpty { null } ?: DefaultIndexerRelayList` | ❌ |
| `normalizeSearchRelayListWithBackup` | `?.ifEmpty { null } ?: DefaultSearchRelayList` | ❌ |
Consequence today: **a user who publishes a kind-10002 with only write relays gets
`Constants.bootstrapInbox` silently substituted as their inbox list.** Same for a deliberately empty
search or indexer list. The app overrides an explicit choice.
The mirror problem sinks the obvious implementation: the `NoDefaults` variants return `emptySet()`
for *both* "no event" and "empty event", so `trustedRelays.isEmpty()` cannot be used as the
"do we have data yet" signal.
**Fix:** make presence explicit, and never infer it from emptiness.
```kotlin
// absent -> defaults; present -> whatever it says, including nothing
fun readRelayList(note: Note): Set<NormalizedRelayUrl> =
nip65Event(note)?.let { it.readRelaysNorm()?.toSet() ?: emptySet() } ?: Constants.bootstrapInbox
```
Same shape for write/all, and drop the `?.ifEmpty { null }` from the indexer and search helpers.
Worth doing on its own merits even if the rest of this plan is dropped.
**This removes the need for any window or timeout.** The fallback becomes a pure function of "do we
have the event", so it ends the instant one arrives — even an empty one. No per-account bookkeeping,
no 30s backstop, no race to close.
## Finding 2 — do NOT put defaults into `TrustedRelayListsState`
Tempting (it already merges all nine lists) but wrong: `account.trustedRelays.flow` feeds
`Account.kt:454`
```kotlin
isInMyRelayList = { relayUrl -> ... it in trustedRelays.flow.value }
```
which feeds `RelayAuthPermissionLedger` -> `RelayAuthResolver` -> **the NIP-42 AUTH decision**.
Adding defaults there would make the app **auto-AUTH to the six hardcoded bootstrap relays as if
they were the user's own** — signing a challenge with the user's key and revealing the pubkey — at
exactly the moment we are also going clearnet. That converts a modest timing leak into a signed
identity assertion. See `[[relay-auth-always-was-gated]]` and `[[inbox-wine-notify-auth-billing]]`
for why AUTH is the sensitive edge.
(The `saveTrustedRelayList(trustedRelays + relay)` write path in `RelayGroupChannelListScreen:449`
is **not** a hazard — it reads `account.trustedRelayList` (the NIP-51 list), not the merged
`trustedRelays`. Checked.)
**Instead:** add a separate, purpose-named flow consumed only by Tor evaluation, e.g.
`Account.relaysAssumedWhileUnknown` — the union of the with-defaults views, non-empty only while the
corresponding events are absent. `AccountsTorStateConnector` feeds it into a new
`TorRelayState.assumedRelays`. Nothing else reads it.
## Where the check goes in `useTor()`
```
torType == OFF -> false
isLocalHost -> false
isOverlayNetwork -> false
isOnion -> onionRelaysViaTor
in moneyOpRelayList -> moneyOperationsViaTor
in dmRelayList -> dmRelaysViaTor
in trustedRelayList -> trustedRelaysViaTor
in assumedRelayList -> trustedRelaysViaTor <-- new, immediately above the fallback
else -> newRelaysViaTor
```
Landing immediately above the fallback means **.onion, money-operation and DM relays keep their own
policy for free** — the change can only ever affect URLs that would have been treated as "new".
Resolve to `trustedRelaysViaTor`, **not** a hardcoded `false`:
- default user (`false`) -> clearnet -> fast start;
- hardened user (`true`) -> stays on Tor, automatically, with no new setting to discover.
That is the difference between "the app overrides you" and "the app treats its stand-in list the way
you asked your own list to be treated".
## Privacy, for the PR body
The window correlates the user's **IP with their pubkey** at ~6 hardcoded relays, because the REQ
asks those relays for that pubkey's events. A first login is the most sensitive moment there is.
What makes it defensible: **`trustedRelaysViaTor` already defaults to false**, so the moment
kind-10002 lands the user's own relays are dialled over clearnet anyway. This moves an existing
disclosure slightly earlier, to a different well-known set. It is not a new class of exposure for
the default configuration — and it is *not* an AUTH disclosure, provided Finding 2 is respected.
If `trustedRelaysViaTor` ever becomes default-true, **this feature must be revisited in the same
commit** — its justification disappears. Leave a comment at the default linking the two.
Residual, worth verifying rather than assuming: `useTor()` is keyed by relay **URL**, and the pool
multiplexes every subscription for a URL over one socket. During the window, anything addressed to a
default relay rides that clearnet socket — including a kind-1059 giftwrap subscription, since the DM
list is also absent. Measure it (below) before deciding it is acceptable.
## Testing
Unit — the rule itself, per list type: absent event -> defaults; present-but-empty -> **empty**;
present-with-values -> values. The middle case is the regression guard and the one that fails today.
Unit (`TorRelayEvaluationTest`): an assumed relay resolves to `trustedRelaysViaTor` (both values);
.onion / money-op / DM keep their own policy while also listed as assumed; a non-assumed "new" relay
still resolves to `newRelaysViaTor`; an empty assumed set is byte-for-byte today's behaviour.
Unit: `isInMyRelayList` does **not** see assumed relays (guards Finding 2 permanently).
Device — the number that justifies the change. `relaytiming.sh` + `BootRelayDiag` census,
`VERBOSE_LOGS=true` benchmark build, fresh install each, counterbalanced, n>=3:
- primary: login -> first note; login -> own profile + follow list;
- secondary: relays opened / serving / events at the 20s census;
- guard: grep the verbose log for any request to a default relay during the window that is not for
the account's own pubkey, and for any AUTH sent to one.
Harness traps (all in `[[fresh-install-routes-everything-via-tor]]`): the tablet raises its lock
screen during long waits (`wm dismiss-keyguard`, not just `KEYCODE_WAKEUP`); the login layout shifts
when the IME opens, so dismiss it before tapping fixed coordinates; `BACK` on the home screen exits
the app; always assert the run left the login screen before trusting its timing.
## Expected outcome
Approach the Tor-OFF column: ≈**-7s to first paint, ~2x relay coverage** in the first 20s, with
everything after the first event behaving exactly as today.
If the gain is materially smaller, the likely cause is that the feed is gated on outbox-discovered
relays (which stay "new", hence Tor) rather than the user's own list — in which case the win is
limited to profile and follows, and may not be worth the privacy cost. Decide on the numbers.
## Order of work
1. Fix the absent-vs-empty bug in the four helpers + tests. Independently correct; ship separately.
2. Add `relaysAssumedWhileUnknown` + `TorRelayState.assumedRelays` + the `useTor()` branch.
3. Device A/B. Keep only if it earns its keep.
@@ -25,7 +25,10 @@ import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.ui.tor.TorService
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -58,7 +61,7 @@ import kotlin.system.measureTimeMillis
* 3. `./gradlew :amethyst:connectedPlayDebugAndroidTest -P android.testInstrumentationRunnerArguments.class=com.vitorpamplona.amethyst.tor.TorBootstrapInstrumentedTest`
*
* **What it covers that [TorManagerTest] does not:**
* - Real `ArtiNative.initialize` → `create_bootstrapped` → SOCKS listener bind.
* - Real `ArtiNative.initialize` → `create_unbootstrapped_async` → SOCKS listener bind.
* - Real rustls `CryptoProvider` install (regression check after the arti-v2.3.0 bump).
* - Real `destroy()` releasing the state file lock so a second `initialize()` succeeds.
* - OkHttp routing traffic through the SOCKS port and Arti exiting through the
@@ -73,7 +76,14 @@ import kotlin.system.measureTimeMillis
@Ignore("Tier-3 integration test — requires on-device network access to Tor. See class kdoc to enable.")
class TorBootstrapInstrumentedTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val torService = TorService(context)
/**
* [TorService] promotes Bootstrapping -> Active from a coroutine on this scope, so the test
* must own one and cancel it — without a live scope `status` would never reach Active and every
* assertion below would hang until its timeout.
*/
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val torService = TorService(context, scope)
@After
fun tearDown() =
@@ -81,11 +91,12 @@ class TorBootstrapInstrumentedTest {
// Drop the native client so this test's state file lock doesn't bleed into
// the next instrumented run on the same device.
torService.reset()
scope.cancel()
}
/**
* Cold-start bootstrap. The whole point of the custom Arti build is that this
* works at all — if create_bootstrapped panics (e.g., because we forgot to install
* works at all — if client creation panics (e.g., because we forgot to install
* a rustls CryptoProvider after an arti bump) the test catches it.
*/
@Test
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import java.io.File
import java.util.UUID
/**
* Shared harness for the MediaSaverToDisk instrumented tests: writes a small payload
* file, drives [MediaSaverToDisk.save] with the given MIME type, and asserts the save
* reported success. Package-level support object per the AvifInstrumentedTestSupport
* precedent.
*/
object MediaSaverTestSupport {
/** Drives one save and fails the test if it reported an error or never succeeded. */
fun saveAndAssertSuccess(
context: Context,
mimeType: String,
) {
val localFile = File(context.cacheDir, "media-saver-${UUID.randomUUID()}.bin")
localFile.writeBytes(ByteArray(2048) { it.toByte() })
var failure: Throwable? = null
var succeeded = false
try {
runBlocking {
MediaSaverToDisk.save(
localFile = localFile,
mimeType = mimeType,
context = context,
onSuccess = { succeeded = true },
onError = { failure = it },
)
}
} finally {
localFile.delete()
}
// Surfaces e.g. the #4009 IllegalArgumentException as the test failure message.
assertNull("save() reported an error: ${failure?.message}", failure)
assertTrue("save() never reported success", succeeded)
}
}
@@ -0,0 +1,155 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import android.os.ParcelFileDescriptor
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
/**
* Covers the pre-Q writer, which MediaStore never sees: below API 29 saveContentDefault
* writes straight to a public directory and lets the media scanner index it.
*
* That path used to hardcode Pictures for every content type, so videos, audio and PDFs
* were all filed under Pictures/Amethyst. It now routes through the same MediaStoreTarget
* as the MediaStore path. minSdk is 26, so this range ships.
*
* There is no JVM coverage of any of this: Build.VERSION.SDK_INT is 0 under
* returnDefaultValues, so unit tests can only reach the routing function, never the writer.
*
* **Running this suite:** below Q the storage grant must exist before the app process
* forks (external storage is mounted at fork time), and Gradle's connectedAndroidTest
* installs and instruments with no window to grant in between - so these tests skip
* under it. Drive them manually on an API 26-28 device:
* ```
* ./gradlew :amethyst:assemblePlayDebug :amethyst:assemblePlayDebugAndroidTest
* adb install -r -g amethyst/build/outputs/apk/play/debug/amethyst-play-arm64-v8a-debug.apk
* adb install -r -g amethyst/build/outputs/apk/androidTest/play/debug/amethyst-play-debug-androidTest.apk
* adb shell am instrument -w -e class com.vitorpamplona.amethyst.ui.actions.MediaSaverToDiskLegacyStorageTest \
* com.vitorpamplona.amethyst.debug.test/androidx.test.runner.AndroidJUnitRunner
* ```
*/
@RunWith(AndroidJUnit4::class)
class MediaSaverToDiskLegacyStorageTest {
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
/** Every directory production can write to, straight from the routing table. */
private val watchedDirs = MediaSaverToDisk.MediaStoreTarget.entries.map { it.relativeDirectory }
private val createdFiles = mutableListOf<File>()
@Before
fun onlyBelowScopedStorage() {
assumeTrue("saveContentDefault only runs below API 29", Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
// The legacy writer needs the runtime permission; no androidx.test:rules on the
// classpath, so grant it through the instrumentation shell instead. The output has
// to be drained: executeShellCommand runs asynchronously and closing the descriptor
// early kills the command before it applies.
val fd =
InstrumentationRegistry
.getInstrumentation()
.uiAutomation
.executeShellCommand(
"pm grant ${context.packageName} android.permission.WRITE_EXTERNAL_STORAGE",
)
ParcelFileDescriptor.AutoCloseInputStream(fd).use { it.readBytes() }
assertEquals(
"WRITE_EXTERNAL_STORAGE was not granted; the legacy writer cannot be exercised",
PackageManager.PERMISSION_GRANTED,
context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE),
)
// Holding the permission is not enough below Q: external storage is mounted into
// the process when it forks, so a grant to an already-running process never
// reaches it and every write fails with EACCES. Probe for real writability and
// skip rather than report a routing failure that is really a harness problem.
assumeTrue(
"External storage is not writable by this process; below API 29 the grant must " +
"exist at install time. See this class's KDoc for the exact run recipe.",
canWriteToPublicStorage(),
)
}
private fun canWriteToPublicStorage(): Boolean =
try {
val dir = amethystDir("Movies").apply { if (!exists()) mkdirs() }
val probe = File(dir, ".write-probe-${System.nanoTime()}")
val writable = probe.createNewFile()
probe.delete()
writable
} catch (e: IOException) {
false
}
@After
fun cleanUp() {
createdFiles.forEach { it.delete() }
}
@Test
fun videoGoesToMovies() = assertRoutes("video/mp4", "Movies")
@Test
fun imageGoesToPictures() = assertRoutes("image/jpeg", "Pictures")
@Test
fun audioGoesToMusic() = assertRoutes("audio/mpeg", "Music")
@Test
fun pdfGoesToDownloads() = assertRoutes("application/pdf", "Download")
/**
* Saves one file and asserts it appeared under [expectedDir]/Amethyst and nowhere else.
* Checking the other directories is the point: the bug was everything landing in Pictures.
*/
private fun assertRoutes(
mimeType: String,
expectedDir: String,
) {
val before = snapshot()
MediaSaverTestSupport.saveAndAssertSuccess(context, mimeType)
val added = snapshot().mapValues { (dir, names) -> names - before.getValue(dir) }
added.forEach { (dir, names) -> names.forEach { createdFiles.add(File(amethystDir(dir), it)) } }
val dirsThatGrew = added.filterValues { it.isNotEmpty() }.keys
assertEquals("$mimeType should land only in $expectedDir/Amethyst", setOf(expectedDir), dirsThatGrew)
assertEquals("expected exactly one new file", 1, added.getValue(expectedDir).size)
}
private fun amethystDir(publicDir: String) = File(Environment.getExternalStoragePublicDirectory(publicDir), "Amethyst")
private fun snapshot(): Map<String, Set<String>> = watchedDirs.associateWith { amethystDir(it).list()?.toSet() ?: emptySet() }
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* End-to-end regression test for issue #4009: drives the real ContentResolver, so it
* catches both symptoms of a collection/directory mismatch - Android 10 rejects the
* insert outright (the quoted rejection lives in [MediaSaverToDisk.MediaStoreTarget]'s
* KDoc), and later releases accept it and silently misfile the video.
*/
@RunWith(AndroidJUnit4::class)
class MediaSaverToDiskMediaStoreTest {
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
private val resolver: ContentResolver get() = context.contentResolver
/** Only rows this test inserted, as item Uris in the collection they went into. */
private val created = mutableListOf<Uri>()
@Before
fun requiresScopedStorage() {
assumeTrue("saveContentQ only runs on API 29+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
}
@After
fun cleanUp() {
created.forEach { resolver.delete(it, null, null) }
}
@Test
fun savingAVideoLandsInMoviesAndNotPictures() {
val relativePath = saveAndReadBackRelativePath("video/mp4", MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
assertEquals("Movies/Amethyst/", relativePath)
}
@Test
fun savingAnImageStillLandsInPictures() {
val relativePath = saveAndReadBackRelativePath("image/jpeg", MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
assertEquals("Pictures/Amethyst/", relativePath)
}
private fun saveAndReadBackRelativePath(
mimeType: String,
collection: Uri,
): String? {
// Anything at or below this id predates the test and must never be read or deleted:
// this suite is meant to be runnable on a real device holding real media.
val highWaterMark = maxIdIn(collection)
MediaSaverTestSupport.saveAndAssertSuccess(context, mimeType)
return rowInsertedAfter(collection, highWaterMark)
}
private fun maxIdIn(collection: Uri): Long {
resolver
.query(collection, arrayOf(MediaStore.MediaColumns._ID), null, null, "${MediaStore.MediaColumns._ID} DESC")
?.use { cursor ->
if (cursor.moveToFirst()) return cursor.getLong(0)
}
return -1L
}
/** Reads back the row the save just inserted and records it for cleanup. */
private fun rowInsertedAfter(
collection: Uri,
highWaterMark: Long,
): String? {
resolver
.query(
collection,
arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.RELATIVE_PATH),
"${MediaStore.MediaColumns._ID} > ?",
arrayOf(highWaterMark.toString()),
"${MediaStore.MediaColumns._ID} ASC",
)?.use { cursor ->
assertTrue("save() reported success but inserted no row into $collection", cursor.moveToFirst())
created.add(ContentUris.withAppendedId(collection, cursor.getLong(0)))
return cursor.getString(1)
}
return null
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.os.Environment
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.MediaStoreTarget
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* [MediaStoreTarget] spells its directories out as literals because Environment's
* DIRECTORY_* fields are plain statics that the unit-test android.jar leaves null.
* This is the other half of that trade: on a real device the literals are checked
* against the platform constants they stand in for.
*/
@RunWith(AndroidJUnit4::class)
class MediaStoreTargetInstrumentedTest {
@Test
fun directoriesMatchThePlatformConstants() {
assertEquals(Environment.DIRECTORY_PICTURES, MediaStoreTarget.IMAGES.relativeDirectory)
assertEquals(Environment.DIRECTORY_MUSIC, MediaStoreTarget.AUDIO.relativeDirectory)
assertEquals(Environment.DIRECTORY_MOVIES, MediaStoreTarget.VIDEO.relativeDirectory)
assertEquals(Environment.DIRECTORY_DOWNLOADS, MediaStoreTarget.DOWNLOADS.relativeDirectory)
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import android.os.Environment
import androidx.core.content.FileProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Pins what `res/xml/file_paths.xml` is allowed to hand out.
*
* The provider root used to be `<external-path path=".">`, i.e. the whole of
* `Environment.getExternalStorageDirectory()`. It is now the app-specific
* `<external-files-path>`, which is the only external location Amethyst ever
* shares from (camera/video capture). These tests fail if either half of that
* regresses: the capture paths must still resolve, and the external-storage
* root must not.
*/
@RunWith(AndroidJUnit4::class)
class FileProviderPathsTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val authority = "${context.packageName}.provider"
@Test
fun photoCaptureUriResolves() {
val uri = getPhotoUri(context)
assertEquals("content", uri.scheme)
assertEquals(authority, uri.authority)
assertTrue("expected the external_files root, got $uri", uri.path!!.startsWith("/external_files/"))
}
@Test
fun videoCaptureUriResolves() {
val uri = getVideoUri(context)
assertEquals("content", uri.scheme)
assertEquals(authority, uri.authority)
assertTrue("expected the external_files root, got $uri", uri.path!!.startsWith("/external_files/"))
}
@Test
fun cacheDirStillResolves() {
val file = File(context.cacheDir, "amethyst_share_probe.png")
val uri = FileProvider.getUriForFile(context, authority, file)
assertEquals(authority, uri.authority)
assertTrue("expected the cache root, got $uri", uri.path!!.startsWith("/cache/"))
}
@Test
fun externalStorageRootIsNoLongerShareable() {
@Suppress("DEPRECATION")
val outside = File(Environment.getExternalStorageDirectory(), "Download/not-ours.pdf")
try {
val uri = FileProvider.getUriForFile(context, authority, outside)
fail("FileProvider should not map $outside, but produced $uri")
} catch (expected: IllegalArgumentException) {
// Correct: no configured root contains it.
}
}
}
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.insets
import android.view.View
import android.view.animation.LinearInterpolator
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imeAnimationTarget
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import org.junit.Assert.assertEquals
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
/**
* Upstream regression test for androidx.compose.foundation:foundation-layout.
*
* A `WindowInsetsAnimation` that is prepared and started but never ended — which is what a
* cancelled IME animation looks like — leaves `InsetsListener.runningAnimation` set forever.
* `onApplyWindowInsets` then matches neither of its two branches, so `composeInsets.update()`
* is never called again and `WindowInsets.ime` is dead for the life of the window.
*
* Introduced in 1.4.0 (absent in 1.3.0, where `onApplyWindowInsets` updated unconditionally
* once `onStart` had cleared `prepared`). Still present in 1.12.0 and 1.13.0-alpha01. The
* compensating self-heal (`view.post(this)` -> `run()`) is scoped to `SDK_INT == R`, so on
* API 31+ nothing clears the flag; `WindowInsetsHolder.resetState()` only runs when the
* holder's accessCount transitions 0 -> 1, which never happens in an app whose shell always
* reads insets.
*
* Filed upstream as b/552500419.
*
* [aCancelledImeAnimationMustNotWedgeTheAnimatedInset] FAILS on every version from 1.4.0 on, so it
* is [Ignore]d to keep CI green. It is not a test of Amethyst code — it is the upstream repro we
* attached to the bug. **Re-run it by hand after every Compose upgrade**: when it passes, the
* upstream fix has landed and [com.vitorpamplona.amethyst.ui.insets.SafeImeInsets] can be retired.
*
* [theAnimationTargetSurvivesTheWedge] documents the asymmetry that makes a workaround possible
* and is expected to PASS — `updateImeAnimationTarget` is called outside the guard. It stays
* enabled, because it guards the premise [com.vitorpamplona.amethyst.ui.insets.SafeImeInsets]
* depends on: if a future Compose release stopped keeping `imeAnimationTarget` current, our
* fallback would silently start reading a dead value too.
*/
class ComposeImeInsetWedgeTest {
@get:Rule val rule = createComposeRule()
private val keyboardHeight = 957
private fun imeInsets(bottom: Int): WindowInsetsCompat =
WindowInsetsCompat
.Builder()
.setInsets(WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, bottom))
.setVisible(WindowInsetsCompat.Type.ime(), bottom > 0)
.build()
/** Compose's own listener for this view. Private class, but both interfaces it exposes are public. */
private fun listenerFor(view: View): Any {
val holderClass = Class.forName("androidx.compose.foundation.layout.WindowInsetsHolder")
val companion =
holderClass.getDeclaredField("Companion").run {
isAccessible = true
get(null)
}
val holder =
companion.javaClass
.getDeclaredMethod("getOrCreateFor", View::class.java)
.run {
isAccessible = true
invoke(companion, view)
}
return holderClass.getDeclaredField("insetsListener").run {
isAccessible = true
get(holder)!!
}
}
private fun anim() = WindowInsetsAnimationCompat(WindowInsetsCompat.Type.ime(), LinearInterpolator(), 250L)
private fun bounds() =
WindowInsetsAnimationCompat.BoundsCompat(
Insets.NONE,
Insets.of(0, 0, 0, keyboardHeight),
)
@OptIn(ExperimentalLayoutApi::class)
@Test
@Ignore("Fails by design until upstream fixes b/552500419 — re-run by hand on every Compose upgrade")
fun aCancelledImeAnimationMustNotWedgeTheAnimatedInset() {
var animated by mutableIntStateOf(-1)
lateinit var view: View
rule.setContent {
view = LocalView.current
val density = LocalDensity.current
animated = WindowInsets.ime.getBottom(density)
}
rule.waitForIdle()
val listener = listenerFor(view)
val onApply = listener as OnApplyWindowInsetsListener
val callback = listener as WindowInsetsAnimationCompat.Callback
// Baseline: with no animation in flight the inset tracks normally.
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
rule.waitForIdle()
assertEquals("baseline: the inset must follow a plain dispatch", keyboardHeight, animated)
// A cancelled animation: prepared and started, but onEnd never arrives.
rule.runOnUiThread {
callback.onPrepare(anim())
callback.onStart(anim(), bounds())
}
rule.waitForIdle()
// The keyboard is gone and the window says so. The animated inset must follow.
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
rule.waitForIdle()
assertEquals(
"WindowInsets.ime must still track the window after an animation was cancelled " +
"without onEnd; it is instead frozen at the keyboard height forever",
0,
animated,
)
}
@OptIn(ExperimentalLayoutApi::class)
@Test
fun theAnimationTargetSurvivesTheWedge() {
var target by mutableIntStateOf(-1)
lateinit var view: View
rule.setContent {
view = LocalView.current
val density = LocalDensity.current
target = WindowInsets.imeAnimationTarget.getBottom(density)
}
rule.waitForIdle()
val listener = listenerFor(view)
val onApply = listener as OnApplyWindowInsetsListener
val callback = listener as WindowInsetsAnimationCompat.Callback
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
rule.waitForIdle()
assertEquals(keyboardHeight, target)
rule.runOnUiThread {
callback.onPrepare(anim())
callback.onStart(anim(), bounds())
}
rule.waitForIdle()
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
rule.waitForIdle()
assertEquals(
"updateImeAnimationTarget is called outside the guard, so this reading stays truthful",
0,
target,
)
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.core.tween
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The feed's animated elements defer building their `Transition` until a value actually changes,
* because first composition has nothing to animate and building one per card per scroll is pure
* waste (measured: roughly half the composition cost of every reaction-row button).
*
* The whole point of deferring rather than removing is that the animation must still play. These
* tests pin that: they drive the clock manually and assert that the **first** change — the one that
* happens right after the transition is lazily created — still shows outgoing and incoming content
* simultaneously, which only a running animation does. A regression that turned the deferral into a
* plain snap would show exactly one of them and fail here.
*/
@RunWith(AndroidJUnit4::class)
class DeferredAnimationTest {
@get:Rule
val rule = createComposeRule()
@Test
fun deferredCrossfadeStillAnimatesTheFirstChange() {
val state = mutableStateOf("A")
rule.mainClock.autoAdvance = false
rule.setContent {
DeferredCrossfade(
targetState = state.value,
modifier = Modifier,
contentAlignment = Alignment.TopStart,
animationSpec = tween(DURATION_MS),
label = "test",
) { value ->
Text(value, modifier = Modifier.testTag("text_$value"))
}
}
// Before any change the transition has not been built, and only the current value renders.
rule.onNodeWithTag("text_A").assertIsDisplayed()
rule.onNodeWithTag("text_B").assertDoesNotExist()
state.value = "B"
rule.mainClock.advanceTimeByFrame()
rule.mainClock.advanceTimeBy(DURATION_MS / 3L)
// Mid-crossfade both are in the tree. This is the assertion that a snap would fail.
rule.onNodeWithTag("text_A").assertExists()
rule.onNodeWithTag("text_B").assertExists()
rule.mainClock.advanceTimeBy(DURATION_MS * 3L)
rule.onNodeWithTag("text_B").assertIsDisplayed()
rule.onNodeWithTag("text_A").assertDoesNotExist()
}
companion object {
const val DURATION_MS = 300
}
}
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.service.ai
class NoOpWritingAssistant : WritingAssistant {
override suspend fun checkAvailability(): WritingAssistantStatus = WritingAssistantStatus.Unavailable
override suspend fun requestDownload(): WritingAssistantStatus = WritingAssistantStatus.Unavailable
override suspend fun transform(
text: String,
tone: WritingTone,
@@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.service.ai
import android.content.Context
object WritingAssistantFactory {
/** Whether this flavor ships a real assistant. Drives the Settings tile. */
const val IS_SUPPORTED = false
@Suppress("UNUSED_PARAMETER")
fun create(context: Context): WritingAssistant = NoOpWritingAssistant()
}
+17 -3
View File
@@ -52,6 +52,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
@@ -115,7 +116,7 @@
android:exported="true"
android:launchMode="singleInstance"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|navigation|uiMode|fontScale|density"
android:supportsPictureInPicture="true"
android:theme="@style/Theme.Amethyst">
@@ -453,7 +454,7 @@
<service
android:name=".service.call.CallForegroundService"
android:foregroundServiceType="microphone|camera|phoneCall"
android:foregroundServiceType="microphone|camera|phoneCall|mediaProjection"
android:stopWithTask="false"
android:exported="false" />
@@ -559,7 +560,9 @@
<!-- Direct-WebView browser for a single web client. Runs in the isolated, keyless `:napplet`
process and hosts the WebView directly (not a streamed surface), so scroll/zoom/keyboard work
natively. adjustResize shrinks the window for the soft keyboard. Its own task/recents entry. -->
natively. The activity insets its own content for the soft keyboard (adjustResize only still
applies below Android 15, where the window is not forced edge-to-edge). Its own task/recents
entry. -->
<activity
android:name="com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity"
android:process=":napplet"
@@ -577,6 +580,17 @@
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Invisible host that runs the system file picker for an embedded WebView surface. The
`:napplet` providers are windowless services with no Activity of their own, so the main
process collects the pick and relays the URIs back to the sandbox. -->
<!-- Standard launch mode on purpose: two embedded surfaces can each have a pick in flight, and
singleTop would collapse the second onto the first and strand its page's file input. -->
<activity
android:name=".napplet.WebFileChooserActivity"
android:exported="false"
android:excludeFromRecents="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- First-connect "Connect to Nostr" dialog. -->
<activity
android:name=".connectedApps.consent.SignerConnectActivity"
+35 -26
View File
@@ -24,39 +24,48 @@
# strictly better — it would carry call counts and startup/post-startup flags
# that reflect real behaviour instead of whole-package guesses.
#
# Flags: H = hot, S = startup, P = post-startup.
# Flags: HP = hot + post-startup, deliberately WITHOUT S (startup).
#
# S drives DEX layout: startup-flagged classes are grouped into classes.dex for
# locality, and Android's docs warn that if startup code does not fit there it
# "will overflow into the next DEX files". These are whole-package wildcards for
# ingest, which runs AFTER startup — flagging them S would claim thousands of
# methods are startup-critical and could push genuinely startup-critical code out
# of the first DEX, hurting the thing it is meant to help. For comparison, the
# generated profile marks 32 of its 31,497 rules HSPL; this file should not claim
# more than that about startup. Startup layout is left to the generated profile.
# --- Quartz: protocol core, relay client, crypto, event kinds ---
HSPLcom/vitorpamplona/quartz/nip01Core/**->**(**)**
HSPLcom/vitorpamplona/quartz/nip10Notes/**->**(**)**
HSPLcom/vitorpamplona/quartz/nip19Bech32/**->**(**)**
HSPLcom/vitorpamplona/quartz/nip17Dm/**->**(**)**
HSPLcom/vitorpamplona/quartz/nip22Comments/**->**(**)**
HSPLcom/vitorpamplona/quartz/nip25Reactions/**->**(**)**
HSPLcom/vitorpamplona/quartz/nip18Reposts/**->**(**)**
HSPLcom/vitorpamplona/quartz/nip57Zaps/**->**(**)**
HSPLcom/vitorpamplona/quartz/nip65RelayList/**->**(**)**
HSPLcom/vitorpamplona/quartz/utils/**->**(**)**
HSPLcom/vitorpamplona/quartz/experimental/**->**(**)**
HPLcom/vitorpamplona/quartz/nip01Core/**->**(**)**
HPLcom/vitorpamplona/quartz/nip10Notes/**->**(**)**
HPLcom/vitorpamplona/quartz/nip19Bech32/**->**(**)**
HPLcom/vitorpamplona/quartz/nip17Dm/**->**(**)**
HPLcom/vitorpamplona/quartz/nip22Comments/**->**(**)**
HPLcom/vitorpamplona/quartz/nip25Reactions/**->**(**)**
HPLcom/vitorpamplona/quartz/nip18Reposts/**->**(**)**
HPLcom/vitorpamplona/quartz/nip57Zaps/**->**(**)**
HPLcom/vitorpamplona/quartz/nip65RelayList/**->**(**)**
HPLcom/vitorpamplona/quartz/utils/**->**(**)**
HPLcom/vitorpamplona/quartz/experimental/**->**(**)**
# --- Amethyst: the in-memory store and the relay wiring around it ---
HSPLcom/vitorpamplona/amethyst/model/**->**(**)**
HSPLcom/vitorpamplona/amethyst/service/relayClient/**->**(**)**
HSPLcom/vitorpamplona/amethyst/service/okhttp/**->**(**)**
HSPLcom/vitorpamplona/amethyst/commons/model/**->**(**)**
HSPLcom/vitorpamplona/amethyst/commons/richtext/**->**(**)**
HPLcom/vitorpamplona/amethyst/model/**->**(**)**
HPLcom/vitorpamplona/amethyst/service/relayClient/**->**(**)**
HPLcom/vitorpamplona/amethyst/service/okhttp/**->**(**)**
HPLcom/vitorpamplona/amethyst/commons/model/**->**(**)**
HPLcom/vitorpamplona/amethyst/commons/richtext/**->**(**)**
# --- JSON: every frame is parsed through Jackson ---
HSPLcom/fasterxml/jackson/core/**->**(**)**
HSPLcom/fasterxml/jackson/databind/**->**(**)**
HSPLcom/fasterxml/jackson/module/kotlin/**->**(**)**
HPLcom/fasterxml/jackson/core/**->**(**)**
HPLcom/fasterxml/jackson/databind/**->**(**)**
HPLcom/fasterxml/jackson/module/kotlin/**->**(**)**
# --- Transport: the socket read path under the relay client ---
HSPLokhttp3/internal/ws/**->**(**)**
HSPLokhttp3/internal/connection/**->**(**)**
HSPLokio/**->**(**)**
HPLokhttp3/internal/ws/**->**(**)**
HPLokhttp3/internal/connection/**->**(**)**
HPLokio/**->**(**)**
# --- Coroutines: every ingested event crosses the dispatcher ---
HSPLkotlinx/coroutines/scheduling/**->**(**)**
HSPLkotlinx/coroutines/channels/**->**(**)**
HSPLkotlinx/coroutines/flow/**->**(**)**
HPLkotlinx/coroutines/scheduling/**->**(**)**
HPLkotlinx/coroutines/channels/**->**(**)**
HPLkotlinx/coroutines/flow/**->**(**)**
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.amethyst.service.priority.WorkerThreadPriorityGovernor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
@@ -56,6 +57,9 @@ import java.io.File
*/
class Amethyst : Application() {
init {
// Deliberately in init, not onCreate: this runs in EVERY process, including the
// :napplet sandbox, whose onCreate early-returns. Moving it would leave that
// process on the wrapper's DEBUG default.
Log.minLevel = DEFAULT_LOG_LEVEL
Log.d("AmethystApp") { "Creating App $this" }
}
@@ -81,9 +85,14 @@ class Amethyst : Application() {
*/
val DEFAULT_LOG_LEVEL: LogLevel =
when {
!BuildConfig.DEBUG -> LogLevel.WARN
VERBOSE_LOGS -> LogLevel.DEBUG
else -> LogLevel.INFO
// `isDebug` also covers the `benchmark` build type — a release build (R8 + AOT)
// that exists purely to be measured and is never shipped. Treating it as a release
// build left it at WARN, which drops every INFO milestone the boot narrative is
// made of (account load timings, Tor status transitions, the relay census), so the
// one variant whose numbers are trustworthy was also the one we could not read.
VERBOSE_LOGS && isDebug -> LogLevel.DEBUG
isDebug -> LogLevel.INFO
else -> LogLevel.WARN
}
lateinit var instance: AppModules
@@ -119,6 +128,11 @@ class Amethyst : Application() {
instance = AppModules(this)
// Keeps the ~650 relay/ingest worker threads a cold start spawns from starving the UI
// thread out of its frames — worth ~45% off time-to-first-paint on a release build.
// Override or disable with the `amethyst_worker_nice` global setting.
WorkerThreadPriorityGovernor.start(this)
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
FavoriteAppsRegistry.init(this)
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst
import android.content.ComponentCallbacks2
import android.content.Context
import android.os.BatteryManager
import android.os.SystemClock
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
@@ -49,11 +50,13 @@ import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences
import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.sharedPreferencesDataStore
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
@@ -129,7 +132,6 @@ import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorService
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -189,6 +191,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.File
@@ -206,6 +209,19 @@ class AppModules(
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
/**
* Mints and caches BUD-01 read-auth tokens for auth-gated Blossom hosts.
* Shared by the OkHttp interceptor (which only reads the cache) and Coil's
* [com.vitorpamplona.amethyst.service.images.BlossomReadAuthFetcher] (which
* awaits a signature), so both see one token and one in-flight signature per
* host. Signing runs on [applicationIOScope], never on an OkHttp thread.
*/
val blossomReadAuthTokens =
BlossomReadAuthTokenProvider(
signerProvider = { sessionManager.loggedInAccount()?.signer },
scope = applicationIOScope,
)
private val _trimLevelEvents = MutableSharedFlow<Int>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val trimLevelEvents = _trimLevelEvents.asSharedFlow()
@@ -267,7 +283,7 @@ class AppModules(
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
}
private val torService = TorService(appContext)
private val torService = TorService(appContext, applicationIOScope)
val torManager = TorManager(torPrefs, torService, applicationIOScope)
// Network identity change (wifi↔cellular, regained from offline, captive portal
@@ -285,23 +301,16 @@ class AppModules(
}
}
// Restore + persist held NIP-OA attestations across restarts (device-global). Eager (not
// lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk.
val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope)
// Restore + persist the joined Buzz workspace relays across restarts (device-global). Eager so
// the app knows which relays to sync as workspaces on cold start (Buzz membership is
// server-side; there is no join event to rebuild the set from).
val buzzWorkspacePrefs = BuzzWorkspacePreferences(appContext, applicationIOScope)
// Restore + persist the user's starred Buzz workspace channels across restarts (device-global).
val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope)
// Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a
// deleted channel stays hidden across a restart even if the host relay re-announces a stale
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope)
// Restore + persist which drawer section headings the user has folded away, so the side menu
// opens the way they left it (device-global: a collapsed heading is a per-device view choice,
// not an account setting worth syncing, unlike the hidden rows beside it in the drawer).
val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(appContext.sharedPreferencesDataStore, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
@@ -335,6 +344,12 @@ class AppModules(
}
}
// Runs for the whole process lifetime (main process only — the sandbox never builds AppModules).
// See [startHeapPressureWatchdog] for why the OS trim callbacks cannot be relied on.
init {
startHeapPressureWatchdog()
}
// Shared cache populated by OnionLocationInterceptor from any HTTP/WebSocket
// response carrying an Onion-Location header. Consulted by OnionUrlRewriteInterceptor
// on Tor-enabled clients to transparently redirect to .onion addresses.
@@ -405,7 +420,11 @@ class AppModules(
init {
applicationIOScope.launch {
torService.status
.map { it is TorServiceStatus.Active }
// Battery ledger: Tor is doing work from the moment the client exists — the
// directory download is the most expensive part of a launch — so this tracks
// "running", not "bootstrapped". Keying it on Active alone would silently omit the
// 12-34s download from every cold start.
.map { it.socksPort != null }
.distinctUntilChanged()
.collect { torSession.setActive(it) }
}
@@ -452,9 +471,8 @@ class AppModules(
// tracks the logged-in account.
blossomReadAuth =
BlossomReadAuthInterceptor(
BlossomReadAuthTokenProvider(
signerProvider = { sessionManager.loggedInAccount()?.signer },
)::authHeader,
cachedHeaderProvider = blossomReadAuthTokens::cachedHeader,
onAuthRequired = blossomReadAuthTokens::warm,
),
)
@@ -639,7 +657,7 @@ class AppModules(
// proxy during bootstrap. RelayProxyClientConnector reconnects them (with
// ignoreRetryDelays=true) the instant Tor flips to Active.
canDial = { url ->
!torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isSocksReady()
!torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isTorReady()
},
)
@@ -708,7 +726,7 @@ class AppModules(
TorCircuitHealthTracker(
client = client,
isTorRouted = { torEvaluatorFlow.shouldUseTorForRelay(it) },
isTorActive = { torManager.isSocksReady() },
isTorActive = { torManager.isTorReady() },
isConnectivityActive = { connManager.status.value is ConnectivityStatus.Active },
onCircuitsDead = { torManager.onTorCircuitsDead() },
).also { it.register() }
@@ -897,6 +915,17 @@ class AppModules(
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
// Restore + persist the Buzz bookkeeping that has no Nostr event to rebuild from: the
// joined workspace relays (so the app knows which relays to sync as workspaces on cold
// start — Buzz membership is server-side) and the starred channels. Per account: the
// joined set makes a relay first-party for NIP-42, and a star is personal.
startBuzzPersistence = { account ->
BuzzWorkspacePreferences(appContext, account.scope, account.pubKey, account.buzzWorkspaces)
BuzzChannelStarPreferences(appContext, account.scope, account.pubKey, account.buzzChannelStars)
// Eager like the rest, so a held NIP-OA attestation is loaded before this account's
// first Buzz-relay AUTH rather than after it.
BuzzAttestationPreferences(appContext, account.scope, account.pubKey, account.buzzAttestation)
},
)
val sessionManager =
@@ -1076,6 +1105,7 @@ class AppModules(
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
thumbnailCache = thumbnailDiskCache,
backgroundScope = applicationIOScope,
readAuth = blossomReadAuthTokens,
)
}
@@ -1303,6 +1333,53 @@ class AppModules(
accountsCache.clear()
}
/**
* Self-triggered reclaim, because the OS-driven path cannot fire when we need it most.
*
* `onTrimMemory` is the ONLY caller of [trim], and since API 34 the OS delivers just two levels,
* both of which require the app to be backgrounded:
* - `UI_HIDDEN(20)` activities stopped. Only trims images; never touches [LocalCache].
* - `BACKGROUND(40)` the process is on the system LRU list, which is what gates every bulk
* reclaim we have (Tier 2 pruning, feed trimming, the hard cache trims).
*
* Two independent situations therefore get NO reclaim at all:
* 1. **Foreground use.** The deprecated `RUNNING_*` levels are never delivered, so a long session
* simply grows until the heap is full.
* 2. **The always-on notification service.** A process hosting a foreground service can never enter
* the cached state, so `BACKGROUND` is unreachable *even while backgrounded* ActivityManager
* refuses it outright ("Unable to set a background trim level on a foreground process").
*
* Measured consequence: a 3.4-day session sat at 492 MB of a 512 MB heap (3% free), paying 685 ms
* mark-compact GCs every ~10 s with dozens of threads blocked in `WaitForGcToComplete`, until an
* input-dispatch ANR. Reproduced independently on a second device with no foreground service at all.
*
* So we watch our own occupancy instead of waiting to be told. Above [HEAP_HIGH_WATER] we run the
* app's existing `BACKGROUND` reclaim deliberately the same path, not a parallel policy, because at
* this occupancy "real reclaim pressure" is simply true. [MIN_RECLAIM_INTERVAL_MS] keeps a prune that
* frees little from spinning.
*/
private fun startHeapPressureWatchdog() {
applicationIOScope.launch {
var lastRunAt = 0L
while (isActive) {
delay(HEAP_CHECK_INTERVAL_MS)
val runtime = Runtime.getRuntime()
val max = runtime.maxMemory()
val used = runtime.totalMemory() - runtime.freeMemory()
val ratio = used.toDouble() / max
val now = SystemClock.elapsedRealtime()
if (ratio >= HEAP_HIGH_WATER && now - lastRunAt >= MIN_RECLAIM_INTERVAL_MS) {
lastRunAt = now
Log.w("AppModules") {
"Heap at ${(ratio * 100).toInt()}% (${used / (1024 * 1024)}MB of ${max / (1024 * 1024)}MB) — " +
"self-triggering BACKGROUND reclaim; the OS will not deliver one here."
}
trim(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND)
}
}
}
}
fun trim(level: Int) {
_trimLevelEvents.tryEmit(level)
// Backgrounding is a natural moment to flush the usage ledger too.
@@ -1347,4 +1424,23 @@ class AppModules(
}
}
}
companion object {
/**
* Fraction of `Runtime.maxMemory()` above which we stop waiting for an OS trim that is never
* coming and reclaim ourselves. 70% leaves real headroom: the ANR-producing session was pinned at
* 96% (492 MB of 512 MB, 3% free), where every allocation already stalls behind a GC.
*/
private const val HEAP_HIGH_WATER = 0.70
/** Three `Runtime` reads; cheap enough to run often, slow enough to be invisible. */
private const val HEAP_CHECK_INTERVAL_MS = 60_000L
/**
* Floor between self-triggered reclaims. Pruning cannot free events the UI still holds, so a busy
* screen can sit above the high-water mark for a while; without this we would re-prune every
* check and burn CPU on a heap that has nothing left to give.
*/
private const val MIN_RECLAIM_INTERVAL_MS = 120_000L
}
}
@@ -214,6 +214,7 @@ private object PrefKeys {
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
const val DISMISSED_POLL_NOTE_IDS = "dismissed_poll_note_ids"
const val DISMISSED_CHANNEL_INVITES = "dismissed_channel_invites"
const val MUTED_PUBLIC_CHATS = "muted_public_chats"
const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids"
const val PENDING_ATTESTATIONS = "pending_attestations"
@@ -650,6 +651,7 @@ object LocalPreferences {
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value)
putStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, settings.dismissedChannelInvites.value)
putStringSet(PrefKeys.MUTED_PUBLIC_CHATS, settings.mutedPublicChats.value)
putString(
PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS,
JsonMapper.toJson(settings.viewedPollResultNoteIds.value),
@@ -789,6 +791,7 @@ object LocalPreferences {
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
val dismissedChannelInvites = getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null) ?: setOf()
val mutedPublicChats = getStringSet(PrefKeys.MUTED_PUBLIC_CHATS, null) ?: setOf()
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
@@ -1048,6 +1051,7 @@ object LocalPreferences {
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
dismissedChannelInvites = MutableStateFlow(dismissedChannelInvites),
mutedPublicChats = MutableStateFlow(mutedPublicChats),
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
@@ -1254,6 +1258,8 @@ private class InboxPrefs(
private fun SharedPreferences.readInboxPrefs() =
InboxPrefs(
// Missing key = an account saved before this setting existed. Those keep CUSTOM; only
// brand-new logins get the ALWAYS default from AccountSettings' constructor.
defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
@@ -21,12 +21,12 @@
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -24,7 +24,6 @@ import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
@@ -41,6 +40,7 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
import com.vitorpamplona.quartz.utils.Log
/**
* Turns a [FavoriteApp] back into a running app. The two cases map to the two launch paths in the
@@ -145,7 +145,7 @@ object FavoriteAppLauncher {
profile = HostProfile.WEBSITE,
)
else -> {
Log.w("FavoriteAppLauncher", "Favorited app not resolvable yet: $coordinate")
Log.w("FavoriteAppLauncher") { "Favorited app not resolvable yet: $coordinate" }
Toast.makeText(context, R.string.favorite_app_still_loading, Toast.LENGTH_SHORT).show()
}
}
@@ -21,12 +21,12 @@
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -31,10 +31,12 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSig
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
@@ -61,9 +63,11 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
@@ -74,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState
import com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
@@ -133,6 +138,7 @@ import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
import com.vitorpamplona.amethyst.model.nip89AppHandlers.AppRecommendationsState
import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
import com.vitorpamplona.amethyst.model.serverList.AssumedRelayListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithIndexRelayListsState
@@ -147,6 +153,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthSessionGrants
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthVenues
import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryTracker
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache
@@ -378,12 +385,16 @@ class Account(
// doubles as the attribution pubkey for ExplainedFilter.accountPubKeys.
override val userFinderPubkeyHex: HexKey get() = userProfile().pubkeyHex
override fun indexRelays(): Set<NormalizedRelayUrl> = indexerRelayList.flow.value.ifEmpty { DefaultIndexerRelayList }
// No ifEmpty here on purpose: an empty kind:10086 is the user asking for no indexers, and
// IndexerRelayListState already substitutes the defaults for the only case we may override —
// never having seen the event. Re-substituting here would undo that choice.
override fun indexRelays(): Set<NormalizedRelayUrl> = indexerRelayList.flow.value
override fun outboxHomeRelays(): Set<NormalizedRelayUrl> = nip65RelayList.allFlowNoDefaults.value + privateStorageRelayList.flow.value + localRelayList.flow.value
// searchRelayList.flow already applies the DefaultSearchRelayList fallback internally
// (SearchRelayListState.normalizeSearchRelayListWithBackup), so no ifEmpty needed here.
// searchRelayList.flow applies DefaultSearchRelayList internally when no kind:10007 has ever
// been seen (SearchRelayListState.normalizeSearchRelayListWithBackup); an empty published list
// stays empty. No ifEmpty here either way.
override fun searchRelays(): Set<NormalizedRelayUrl> = (trustedRelayList.flow.value + searchRelayList.flow.value).toSet()
override fun searchOnlyRelays(): Set<NormalizedRelayUrl> = searchRelayList.flow.value
@@ -406,6 +417,28 @@ class Account(
// answered without a disk read. Backed by a per-account file (see AccountCacheState).
val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope)
// The `block/buzz` workspaces THIS account joined. Per account, not per device: the invite was
// redeemed by this key and the relay grants membership to it alone — and this set makes the
// relay first-party for NIP-42 (see AuthCoordinator.isFirstParty), so a device-global set would
// hand every other logged-in account an automatic login on a workspace it never joined.
// Restored/persisted per account by BuzzWorkspacePreferences (see AccountCacheState).
val buzzWorkspaces = BuzzWorkspaces()
// The Buzz channels THIS account pinned. A star says which channels this user wants at the top
// of the community view, so a shared set let one account reorder and badge every other one's
// channel list. Restored/persisted per account by BuzzChannelStarPreferences.
val buzzChannelStars = BuzzChannelStars()
// The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay
// AUTH so the relay grants virtual membership. Restored/persisted per account by
// BuzzAttestationPreferences.
val buzzAttestation = BuzzHeldAttestations(pubKey)
// The relays this account approved by answering the NIP-42 prompt *without* the "remember"
// switch. Deliberately in-memory only: it dies with this Account (i.e. with the process, or at
// logout), which is what makes it a session grant rather than a stored ALLOW.
val relayAuthSessionGrants = RelayAuthSessionGrants()
// Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt),
// reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH
// path (foreground screen + background notification consumer) shares one instance, and so an
@@ -414,6 +447,7 @@ class Account(
RelayAuthPermissionLedger(
store = relayAuthPermissions,
globalPolicy = { settings.defaultRelayAuthPolicy.value },
sessionGrants = relayAuthSessionGrants,
customToggles = {
RelayAuthCustomToggles(
myRelaysAndVenues = settings.relayAuthTrustMyRelaysAndVenues.value,
@@ -434,6 +468,27 @@ class Account(
isVenueHostRelay = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in venueHostRelays() } ?: false },
)
/**
* Sets the global NIP-42 policy, dropping every session grant when it becomes
* [RelayAuthPolicy.NEVER].
*
* The two halves belong together, which is why they live here instead of in the settings screen
* that used to pair them: a session grant outranks the policy (see
* [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthResolver]), so "never log in" only
* means what it says if the casual one-tap answers go with it. As a composable's `onClick` that
* was a property of one screen rather than of the account, and any other caller of
* [AccountSettings.changeDefaultRelayAuthPolicy] silently reintroduced grants that outlive the
* switch-it-all-off answer.
*
* Stored Always/Never exceptions are deliberately left alone: those outrank the policy by
* design, and the settings screen lists them, so they are a standing answer rather than a
* casual one.
*/
fun changeDefaultRelayAuthPolicy(policy: RelayAuthPolicy) {
settings.changeDefaultRelayAuthPolicy(policy)
if (policy == RelayAuthPolicy.NEVER) relayAuthSessionGrants.clear()
}
/**
* Relays that exist here because *this account* joined a room on them: the host of every NIP-29
* relay group on its kind-10009 list, plus the relays of every Concord community on its
@@ -552,6 +607,21 @@ class Account(
val relayGroupListDecryptionCache = RelayGroupListDecryptionCache(signer)
val relayGroupList = RelayGroupListState(signer, cache, relayGroupListDecryptionCache, scope, settings)
/**
* Buzz channels somebody else added me to that I haven't answered yet, projected from the cached
* kind-44100/44101 verdicts. Account state rather than screen state because the notifications DAL
* reads it to decide whether a cached 44100 is still a live question.
*/
val channelInvites =
ChannelInvitesState(
me = signer.pubKey,
cache = cache,
buzzWorkspaces = buzzWorkspaces,
relayGroupList = relayGroupList,
dismissed = settings.dismissedChannelInvites,
scope = scope,
)
val concordChannelList = ConcordChannelListState(signer, cache, scope, settings)
/**
@@ -704,11 +774,18 @@ class Account(
// the history loader ([AccountNotificationsHistoryEoseManager]) binds its orchestrator to these.
val notificationHistory = RelayLoadingCursors()
// Per-relay backward-paging cursors for the NIP-60 spending history (kind:7376): how far back each
// outbox relay has been paged by until+limit. Same lifetime rule as notificationHistory — held here
// so paging progress survives leaving and re-entering the wallet screen; the history loader
// ([CashuWalletHistoryEoseManager]) binds its orchestrator to these.
val cashuHistory = RelayLoadingCursors()
val cashuWalletState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
pubKey = signer.pubKey,
signer = signer,
cache = cache,
client = client,
scope = scope,
outboxRelaysFlow = outboxRelays.flow,
inboxRelaysFlow = notificationRelays.flow,
@@ -737,6 +814,9 @@ class Account(
val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, indexerRelayList, proxyRelayList, trustedRelayList, broadcastRelayList, scope)
/** Relays guessed on the user's behalf until their own lists arrive. Read only by Tor routing. */
val assumedRelays = AssumedRelayListsState(nip65RelayList, searchRelayList, indexerRelayList, scope)
// Follows Relays
val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope)
@@ -768,6 +848,30 @@ class Account(
val newNotesPreProcessor = EventProcessor(this, cache)
/**
* Owns the WebRTC call state machine.
*
* Account-scoped on purpose: a call outlives the main UI. It runs in its own
* [com.vitorpamplona.amethyst.ui.call.CallActivity] (a separate task, since MainActivity is
* `singleInstance`) backed by a foreground service, so Android is free to destroy the
* backgrounded MainActivity while the call is up which it does routinely, e.g. a few hundred
* milliseconds after CallActivity enters picture-in-picture on HOME. While this lived on
* `AccountViewModel` (and ran on `viewModelScope`), that destruction cleared the ViewModel and
* reset the call to Idle, hanging up mid-conversation.
*
* Torn down with the account: [scope] is cancelled by
* `AccountCacheState.removeAccount`, which also calls [CallManager.dispose] for the
* independent watchdog scope.
*/
val callManager =
CallManager(
signer = signer,
scope = scope,
isFollowing = { isFollowing(it) },
publishEvent = { wrap -> scope.launch { publishCallSignaling(wrap) } },
isCallsEnabled = { settings.callsEnabled.value },
)
// Per-message publish acceptance (relay OKs), feeding the delivery ticks on
// own chat bubbles.
val chatDeliveryTracker = ChatDeliveryTracker(client)
@@ -1034,6 +1138,15 @@ class Account(
sendNewAppSpecificData()
}
/**
* Local state first, then publish. The local write is what every suppression point
* reads, so it must not wait on the signer publishing is best-effort sync.
*/
suspend fun toggleMutedPublicChat(channelId: String) {
settings.toggleMutedPublicChat(channelId)
sendNewAppSpecificData()
}
suspend fun updateZapAmounts(
amountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
@@ -3543,6 +3656,24 @@ class Account(
init {
Log.d("AccountRegisterObservers", "Init")
// Route incoming call signaling into the state machine as soon as the account exists, so
// offers are not missed while no UI is mounted.
newNotesPreProcessor.callManager = callManager
// Blocking a relay has to forget any "just for now" login to it, or unblocking later would
// silently resume authenticating off an answer given before the block. Blocking is the
// strongest signal available here — the weaker "never allow" already drops the grant via
// RelayAuthPermissionLedger.setDecision, so it would be odd for the stronger one not to.
//
// Observed rather than hooked onto the local block action because the kind-10006 list is
// shared: a block published by another client arrives as a flow update with no call of ours
// behind it.
scope.launch {
blockedRelayList.flow.collect { blocked ->
relayAuthLedger.revokeSessionGrantsFor(blocked.map { it.url })
}
}
// Start the Cashu wallet state observers AFTER all field initializers
// complete — auto-redeem can fire as soon as start() returns, and it
// calls back into sendLiterallyEverywhere which depends on
@@ -1381,7 +1381,7 @@ class AccountConcordActions(
val bannedHere = authority.isBanned(account.signer.pubKey)
val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
Log.i("Concord") { "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}" }
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged))
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
}
@@ -1521,11 +1521,10 @@ class AccountConcordActions(
val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = 30_000L)
val newest = events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
val entryCount = newest?.let { runCatching { it.decrypt(account.signer).size }.getOrElse { -1 } } ?: 0
Log.d(
"Concord",
Log.d("Concord") {
"importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " +
"newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}",
)
"newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}"
}
newest?.let { account.cache.justConsumeMyOwnEvent(it) }
}
@@ -1600,6 +1599,6 @@ class AccountConcordActions(
val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) }
var drained = 0
account.client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ }
Log.d("Concord", "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)")
Log.d("Concord") { "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)" }
}
}
@@ -330,6 +330,14 @@ class AccountSettings(
* still lists you, and Leave (kind 9022) is the separate action that actually removes you.
*/
val dismissedChannelInvites: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
/**
* NIP-28 channel ids the user has silenced. Local device state ON PURPOSE, even
* though it also syncs via NIP-78: the push dispatcher must answer "is this muted?"
* during a cold start, before (or without) the settings blob having been decrypted
* for a NIP-55 account that decrypt is an Amber IPC round-trip that may never
* complete in the background. See AppSpecificState.kt:70-75.
*/
val mutedPublicChats: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
@@ -338,7 +346,9 @@ class AccountSettings(
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
var callMaxBitrateBps: Int = 1_500_000,
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.CUSTOM),
// New accounts authenticate with every relay that asks. Existing accounts keep whatever they
// had saved (LocalPreferences falls back to CUSTOM for prefs written before this key existed).
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.ALWAYS),
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
val concordViewMode: MutableStateFlow<ConcordViewMode> = MutableStateFlow(ConcordViewMode.DEFAULT),
// Which conversation protocols the Messages inbox loads and shows. A disabled type is both hidden
@@ -778,6 +788,62 @@ class AccountSettings(
// list names
// ---
/**
* All per-screen persisted feed filters paired with their factory default.
* Deleting a list (NIP-51 people list / follow pack) must reset any screen whose
* filter still points at the deleted address otherwise the screen keeps a
* dangling [TopFilter.PeopleList] that re-creates an empty AddressableNote shell
* on every start and shows the list's dTag/UUID in the top bar instead of a name.
*/
private val feedFiltersWithDefaults: List<Pair<MutableStateFlow<TopFilter>, TopFilter>> =
listOf(
defaultHomeFollowList to TopFilter.AllFollows,
defaultStoriesFollowList to TopFilter.Global,
defaultNotificationFollowList to TopFilter.Selected,
defaultDiscoveryFollowList to TopFilter.Global,
defaultPollsFollowList to TopFilter.Global,
defaultPicturesFollowList to TopFilter.Global,
defaultNappletsFollowList to TopFilter.Global,
defaultNsitesFollowList to TopFilter.Global,
defaultWorkoutsFollowList to TopFilter.Global,
defaultGitRepositoriesFollowList to TopFilter.Global,
defaultHighlightsFollowList to TopFilter.Global,
defaultCalendarsFollowList to TopFilter.Global,
defaultProductsFollowList to TopFilter.AroundMe,
defaultShortsFollowList to TopFilter.Global,
defaultPublicChatsFollowList to TopFilter.Global,
defaultLiveStreamsFollowList to TopFilter.Global,
defaultNestsFollowList to TopFilter.Global,
defaultLongsFollowList to TopFilter.Global,
defaultArticlesFollowList to TopFilter.AllFollows,
defaultMusicTracksFollowList to TopFilter.Global,
defaultMusicPlaylistsFollowList to TopFilter.Global,
defaultPodcastEpisodesFollowList to TopFilter.Global,
defaultPodcastsFollowList to TopFilter.Global,
defaultSoftwareAppsFollowList to TopFilter.Global,
defaultBadgesFollowList to TopFilter.Mine,
defaultBrowseEmojiSetsFollowList to TopFilter.Global,
defaultCommunitiesFollowList to TopFilter.AllFollows,
defaultFollowPacksFollowList to TopFilter.Global,
defaultAppRecommendationsFollowList to TopFilter.Global,
defaultRelayGroupsDiscoveryFollowList to TopFilter.Mine,
)
/** Resets every persisted feed filter that points at the deleted list's address. */
fun resetFeedFiltersPointingTo(address: Address) {
var changed = false
feedFiltersWithDefaults.forEach { (flow, default) ->
val current = flow.value
if (current is TopFilter.AddressableTopFilter && current.address == address) {
flow.tryEmit(default)
changed = true
}
}
if (changed) saveAccountSettings()
}
fun changeDefaultHomeFollowList(name: FeedDefinition) {
changeDefaultHomeFollowList(name.code)
}
@@ -1513,6 +1579,14 @@ class AccountSettings(
backupAppSpecificData = appSettings
syncedSettings.updateFrom(newSyncedSettings)
// Null means an older client rewrote the blob without this key — leave the
// local set alone rather than treating "absent" as "unmute everything".
// The decision lives in mergeMutedPublicChats so it is unit-testable; this
// class cannot be constructed in a JVM test.
mutedPublicChats.tryEmit(
mergeMutedPublicChats(mutedPublicChats.value, newSyncedSettings.chats.mutedPublicChats),
)
saveAccountSettings()
}
}
@@ -1612,6 +1686,17 @@ class AccountSettings(
saveAccountSettings()
}
// ---
// muted public chats
// ---
fun toggleMutedPublicChat(channelId: String) {
mutedPublicChats.update {
if (channelId in it) it - channelId else it + channelId
}
saveAccountSettings()
}
// ---
// viewed poll results
// ---
@@ -90,7 +90,7 @@ class AccountSyncedSettings(
MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))),
)
fun toInternal(): AccountSyncedSettingsInternal =
fun toInternal(mutedPublicChats: Set<String>): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value, reactions.reactionRowItems.value),
zaps =
@@ -120,7 +120,12 @@ class AccountSyncedSettings(
),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }),
chats =
AccountChatPreferencesInternal(
chats.pinnedChatrooms.value.map { it.users.sorted() },
// sorted so the serialized form is deterministic
mutedPublicChats.sorted(),
),
proofOfWork =
AccountPoWPreferencesInternal(
proofOfWork.difficulty.value,
@@ -242,4 +242,14 @@ class AccountChatPreferencesInternal(
// pubkeys (hex) sorted ascending, so the serialized form is deterministic
// regardless of set iteration order.
var pinnedRooms: List<List<String>> = emptyList(),
// NIP-28 channel ids (hex) whose notifications are silenced, sorted ascending
// for the same determinism reason as pinnedRooms.
//
// NULLABLE ON PURPOSE. The default has to tell two cases apart:
// null = key absent — an older client rewrote the blob and dropped it, so
// the local mute set must be left alone.
// [] = an explicit "unmute everything" from a client that knows the field.
// A non-null default would collapse them and let an old client silently erase
// the user's mutes on every launch. See updateAppSpecificData.
var mutedPublicChats: List<String>? = null,
)
@@ -72,7 +72,15 @@ class AccountZapActions(
lnurl: String? = null,
) = LnZapRequestEvent.create(
zappedEvent = event,
relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
// Where the provider should publish the receipt. Zapping group content pins that to the room's
// host relay: the receipt belongs where the message it pays for lives, so the room can show it
// and the recipient's group query can find it — and, for a private or closed group, so a
// kind-9735 naming the room never lands on a relay outside it. Everything else keeps the
// ordinary NIP-65 inbox routing.
relays =
account.cache.relayGroupHostsFor(event).ifEmpty {
account.nip65RelayList.inboxFlow.value
} + (additionalRelays ?: emptySet()),
signer = account.signer,
pollOption = pollOption,
message = message,
@@ -91,18 +99,20 @@ class AccountZapActions(
suspend fun sendNwcRequest(
request: Request,
onTimeout: () -> Unit = {},
onResponse: (Response?) -> Unit,
) {
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onResponse)
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onTimeout, onResponse)
account.client.publish(event, setOf(relay))
}
suspend fun sendNwcRequestToWallet(
walletUri: Nip47WalletConnect.Nip47URINorm,
request: Request,
onTimeout: () -> Unit = {},
onResponse: (Response?) -> Unit,
): HexKey {
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onTimeout, onResponse)
account.client.publish(event, setOf(relay))
return event.id
}
@@ -119,12 +129,20 @@ class AccountZapActions(
*/
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
/**
* @param onTimeout invoked when no kind-23195 reply arrives before
* [NwcSignerState.NWC_RESPONSE_TIMEOUT_MS]. Pass one on any path with a user
* watching: without it a response lost in transit is indistinguishable from
* the action never having happened.
*/
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
onTimeout: () -> Unit = {},
metadata: Map<String, Any?>? = null,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onTimeout, metadata, onResponse)
account.client.publish(event, setOf(relay))
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
@@ -111,6 +112,11 @@ class EventBroadcaster(
val channelRelays = account.cache.getAnyChannel(event)?.relays()
if (channelRelays != null && channelRelays.isNotEmpty()) return false
// A group-scoped event whose room this cache doesn't know yet: it still must not go to the
// broadcast list. Its `h` tag names a room only its host can serve, so broadcasting it says
// "I am in this group" to relays that can do nothing with the content.
if (event.isGroupScoped()) return false
return true
}
@@ -143,6 +149,16 @@ class EventBroadcaster(
return emptySet()
}
// NIP-29 group content, and everything that refers to it — a kind-9 message, a kind-1111 comment,
// a like, a zap request — exists in a room on a host relay and nowhere else. The room's members
// read it there; the author's outbox and the broadcast list can neither serve it to them nor do
// anything else useful with it, and for a private or closed group publishing it there advertises
// who is in which room. So the host wins outright rather than being one more relay in the union.
// Same rule the group reply composer already applies (CommentPostViewModel), applied to every
// group-scoped event instead of just that one path.
val groupHosts = account.cache.relayGroupHostsFor(event)
if (groupHosts.isNotEmpty()) return groupHosts
val includeBroadcast = wantsBroadcastRelays(event)
val broadcastRelays = if (includeBroadcast) account.broadcastRelayList.flow.value else emptySet()
@@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
@@ -750,6 +749,21 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
return relayGroupChannels.filter { key, _ -> key.id == groupId }.singleOrNull()
}
/**
* Every host relay of the NIP-29 group [event] is scoped to (its `h` tag), or an empty set when the
* event carries no group scope or the group is unknown to this cache.
*
* Keyed by group id alone rather than by [GroupId]: an event about to be *sent* (a reaction, a zap
* request, a comment) knows which room it belongs to but not which relay hosts it that is exactly
* what this resolves. Group ids are relay-minted UUIDs, so the same id on two hosts is a
* theoretical case, and answering with both is the safe reading of it: the content reaches every
* host that claims the room, and none that don't.
*/
fun relayGroupHostsFor(event: Event): Set<NormalizedRelayUrl> {
val groupId = event.groupId() ?: return emptySet()
return relayGroupChannels.filter { key, _ -> key.id == groupId }.mapTo(mutableSetOf()) { it.groupId.relayUrl }
}
fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
fun getNoteIfExists(event: Event): Note? =
@@ -811,6 +825,12 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
false
}
/**
* Checks if a kind-5 event from the addressable's own author has deleted this
* address. Works for empty addressable shells whose event is not loaded yet.
*/
fun hasBeenDeleted(address: Address): Boolean = deletionIndex.hasBeenDeleted(address, address.pubKeyHex)
fun getOrAddAliasNote(
idHex: String,
note: Note,
@@ -1258,6 +1278,22 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
}
is GitPullRequestUpdateEvent -> {
// Link the update to its parent PR so it lands in the PR's
// replies collection (and picks up its target for threading).
// The repository ATag isn't a reply target — skip it.
listOfNotNull(event.parentPullRequestId()?.let { checkGetOrCreateNote(it) })
}
is GitStatusEvent -> {
// A status event roots itself at a patch/PR/issue via a
// marked-`root` `e` tag; link only that so the transition
// appears in the target's replies (GitStatusIndex reduces the
// observed stream separately and doesn't need this wiring, but
// ThreadFeedView and the notifications-tab reply chain do).
listOfNotNull(event.rootEventId()?.let { checkGetOrCreateNote(it) })
}
is TextNoteEvent -> {
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
}
@@ -2327,20 +2363,19 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
}
/**
* A kind-44101 "you were removed from a channel". Consumed like any other Buzz event, then used to
* withdraw any pending add-prompt for that channel: once the relay has taken the membership away
* there is nothing left to accept, so leaving the card up would offer an action that cannot succeed.
* A kind-44101 "you were removed from a channel". Stored like any other Buzz event and nothing more:
* withdrawing the matching add-prompt is not a side effect of ingest but a consequence of the stored
* event, since
* [com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites.pendingInvites] resolves each
* channel to its newest verdict. That ordering is what makes the two kinds arriving out of order
* routine on a re-subscribe, where the relay replays the whole history produce the same answer as
* them arriving in order.
*/
private fun consume(
event: MemberRemovedNotificationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val target = event.target() ?: return@also
val channelId = event.channel() ?: return@also
BuzzChannelInvites.remove(target, channelId)
}
): Boolean = consumeBuzzRegularEvent(event, relay, wasVerified)
/**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll,
@@ -3089,6 +3124,16 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
wasVerified: Boolean,
): Boolean {
val requestId = event.requestId()
// Duplicate delivery, checked before the tracker so the warnings below mean one
// thing each. Some NWC relays replay every cached kind-23195 whenever the REQ
// filter changes (see NWCPaymentFilterAssembler), so an already-answered response
// arrives again and again. Its first copy consumed the pending request, so the
// replays would otherwise be reported as "no pending request is registered" —
// the same line a genuinely late response produces, which made the two
// indistinguishable in the field.
if (getNoteIfExists(event.id)?.event != null) return false
val pending =
when (val match = paymentTracker.onResponseReceived(requestId, event.pubKey)) {
is NwcPaymentTracker.MatchResult.Matched -> {
@@ -3108,9 +3153,12 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
}
NwcPaymentTracker.MatchResult.NoMatch -> {
// Not a replay — those are filtered above — so this is the first time we
// have seen this response and nothing is waiting for it.
Log.w("LocalCache") {
"NWC response ${event.id} from ${event.pubKey} references request e=$requestId but no pending request is registered. " +
"The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag."
"The response arrived after the client gave up waiting, the user holds a stale subscription, " +
"or the wallet service set the wrong e tag."
}
return false
}
@@ -3124,7 +3172,8 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
// Backstop for a concurrent delivery that loaded the event between the replay
// check above and here. Same outcome, no warning: it is not a protocol problem.
if (note.event != null) return false
if (wasVerified || justVerify(event)) {
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
/**
* The NIP-28 channel an event belongs to, or null when [event] is not one of the three
* public-chat event types a channel row's newest event can be.
*
* This is THE definition of "which event identifies a public-chat room" the row dispatch
* (ChatroomHeaderCompose), the last-read route (ChatroomRowUnread.rowLastReadRoute), the
* feed's row de-duplication (ChatroomListKnownFeedFilter) and the mute predicate below all
* call it. It used to be copied into each of those by hand, and the copies drifted: one of
* them matched only [ChannelMessageEvent], so a channel whose latest activity was a topic
* edit silently lost its unread dot. Keep it a single function.
*
* Deliberately NOT `threadRootIdOrSelf()`. That returns the same channel id here a
* NIP-28 message's NIP-10 root marker IS its channel but it means something else
* (the NIP-51 "muted thread" key, which HIDES content). Keeping the two apart is what
* stops "mute notifications" and "mute thread" from bleeding into each other.
*
* Matched on concrete types rather than the IsInPublicChatChannel interface, which the
* channel-admin events ChannelHideMessageEvent/ChannelMuteUserEvent also implement: those
* must fall through to null rather than be treated as room activity.
*/
fun publicChatChannelIdOf(event: Event?): HexKey? =
when (event) {
is ChannelMessageEvent, is ChannelMetadataEvent -> event.channelId()
is ChannelCreateEvent -> event.id
else -> null
}
/** True when [event] is a public-chat message in a channel the user has silenced. */
fun isMutedPublicChatMessage(
event: Event?,
mutedChannels: Set<HexKey>,
): Boolean {
if (mutedChannels.isEmpty()) return false
val channelId = publicChatChannelIdOf(event) ?: return false
return channelId in mutedChannels
}
/**
* The inbound-sync decision for the mute set, kept separate from [AccountSettings] so it can be
* tested: [AccountSettings] builds a default `AccountSyncedSettingsInternal`, whose language
* preferences call `Resources.getSystem()`, so it cannot be constructed in a JVM unit test.
*
* [remote] is `null` when an older client rewrote the NIP-78 blob without the key. The local set
* must survive that and because `AppSpecificState` replays the cached backup event on every app
* start, treating absent as empty would re-clear the user's mutes on every single launch.
*
* An explicitly empty list is different: it is a real "unmute everything" from a client that knows
* the field, and is adopted.
*/
fun mergeMutedPublicChats(
local: Set<HexKey>,
remote: List<HexKey>?,
): Set<HexKey> = remote?.toSet() ?: local
@@ -73,6 +73,13 @@ class AccountCacheState(
val signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
/** App-global store of connected NIP-46 client display + relay info. */
val nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
/**
* Starts per-account persistence of the Buzz client-side bookkeeping that has no Nostr event to
* rebuild from the joined workspaces and the starred channels (restore now, mirror later
* changes). A lambda because those stores need an Android `Context` and this class deliberately
* takes none; no-op by default so tests and non-Android hosts build an Account without it.
*/
val startBuzzPersistence: (Account) -> Unit = { },
) {
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
@@ -83,6 +90,9 @@ class AccountCacheState(
accounts.update { existingAccounts ->
val oldValue = existingAccounts[pubkey]
oldValue?.scope?.cancel()
// CallManager keeps its own watchdog scope, independent of the account scope
// cancelled above, so it has to be disposed explicitly.
oldValue?.callManager?.dispose()
// Unregisters the tracker's persistent listener from the shared
// client; without this every removed account leaks a listener.
oldValue?.chatDeliveryTracker?.destroy()
@@ -104,7 +114,7 @@ class AccountCacheState(
loadAccount(accountSettings)
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
Log.w("AccountCacheState", "Failed to preload account ${savedAccount.npub}: ${e.message}", e)
Log.w("AccountCacheState", "Failed to preload account ${savedAccount.npub}", e)
}
}
}
@@ -137,7 +147,7 @@ class AccountCacheState(
fun deleteAccountFiles(pubkey: HexKey) {
val dir = File(accountsRootDir(), pubkey)
if (dir.exists() && !dir.deleteRecursively()) {
Log.w("AccountCacheState", "Failed to delete account directory ${dir.absolutePath}")
Log.w("AccountCacheState") { "Failed to delete account directory ${dir.absolutePath}" }
}
}
@@ -153,7 +163,7 @@ class AccountCacheState(
if (child.deleteRecursively()) {
Log.d("AccountCacheState") { "Pruned orphan account dir ${child.name.take(8)}" }
} else {
Log.w("AccountCacheState", "Failed to prune orphan account dir ${child.absolutePath}")
Log.w("AccountCacheState") { "Failed to prune orphan account dir ${child.absolutePath}" }
}
}
}
@@ -275,7 +285,7 @@ class AccountCacheState(
Dispatchers.IO +
SupervisorJob() +
CoroutineExceptionHandler { _, throwable ->
Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception: ${throwable.message}", throwable)
Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception", throwable)
},
),
mlsGroupStateStore = mlsStore,
@@ -286,6 +296,10 @@ class AccountCacheState(
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
).also { newAccount ->
// Per account, not per device: the joined set makes a relay first-party for NIP-42, so a
// shared one hands every other logged-in account an automatic login on a workspace it
// never joined, and a shared star set reorders everyone's channel list at once.
startBuzzPersistence(newAccount)
accounts.update { existingAccounts ->
existingAccounts.plus(Pair(signer.pubKey, newAccount))
}
@@ -0,0 +1,171 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.buzz
import com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification
import com.vitorpamplona.amethyst.commons.model.buzz.MembershipNotice
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz.MembershipNotificationKinds
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/*
* The cache-side view of the Buzz membership stream: everything that reads kind-44100/44101 out of
* LocalCache instead of asking a relay for its own copy.
*
* The relay subscription lives in BuzzMembershipEoseManager, mounted with the rest of the account
* loaders. Every consumer of the stream the Notifications feed's invite cards and Buzz DM discovery
* observes the cache through here, so there is exactly one `#p=me` REQ per workspace relay for all of
* them.
*
* This is model code, not UI: the notifications DAL reads it from `acceptableEvent`, so it must not
* live under `ui/`.
*/
/** Every membership verdict addressed to [me], as the cache observers want it. */
fun membershipNoticeFilter(me: HexKey) =
Filter(
kinds = MembershipNotificationKinds,
tags = mapOf("p" to listOf(me)),
)
/**
* The workspace relay that vouched for this notice.
*
* A note records every relay it was seen on, and a Buzz membership notification is only meaningful on
* the relay that issued it the channel UUID it names is that relay's. So prefer a relay we joined as a
* workspace; fall back to whatever else delivered it, which keeps a notice usable when the workspace set
* hasn't been restored from disk yet.
*
* [workspaces] is passed in rather than read from a singleton because the joined set is per account
* (`Account.buzzWorkspaces`): whose workspaces to prefer is a question only the caller can answer.
*/
private fun Note.membershipRelay(workspaces: Set<NormalizedRelayUrl>): NormalizedRelayUrl? {
val seen = relays
if (seen.isEmpty()) return null
return seen.firstOrNull { it in workspaces } ?: seen.first()
}
/** Flattens a cached kind-44100/44101 into a [MembershipNotice], or null when it isn't usable. */
fun Note.toMembershipNotice(workspaces: Set<NormalizedRelayUrl>): MembershipNotice? {
val relay = membershipRelay(workspaces) ?: return null
return when (val noteEvent = event) {
is MemberAddedNotificationEvent ->
noteEvent.channel()?.let {
MembershipNotice(noteEvent.id, it, relay, noteEvent.actor(), noteEvent.createdAt, removed = false)
}
is MemberRemovedNotificationEvent ->
noteEvent.channel()?.let {
MembershipNotice(noteEvent.id, it, relay, noteEvent.actor(), noteEvent.createdAt, removed = true)
}
else -> null
}
}
fun List<Note>.toMembershipNotices(workspaces: Set<NormalizedRelayUrl>): List<MembershipNotice> = mapNotNull { it.toMembershipNotice(workspaces) }
private fun Note.isMembershipNoticeFor(me: HexKey): Boolean =
when (val noteEvent = event) {
is MemberAddedNotificationEvent -> noteEvent.target().equals(me, ignoreCase = true)
is MemberRemovedNotificationEvent -> noteEvent.target().equals(me, ignoreCase = true)
else -> false
}
/**
* Every membership verdict for [me] currently in the cache.
*
* Scanned off [LocalCache.notes] rather than read from an `observeNotes` snapshot, because that
* snapshot cannot contain these kinds. `LocalCache.filter` only yields addressables plus notes whose
* `kind.isRegular()` and `isRegular()` is `> 0 && < 10_000`, so a Buzz 44100/44101 matches none of
* its branches and the seed comes back empty every time. Live arrivals are fine (the observer's `new()`
* applies no such gate), which is why a cold start looked correct: the observer registers before the
* relay answers. What broke was any projection built *after* the events had landed switching to
* another account and back builds a fresh one, and `consumeRegularEvent` never re-notifies a duplicate,
* so it would have stayed empty for the rest of the session.
*
* So the observer is kept purely as the change signal and this scan is the data. It is the same shape
* `NotificationFeedFilter.feed()` uses over the same map, for the same reason.
*/
fun LocalCache.membershipNotices(
me: HexKey,
workspaces: Set<NormalizedRelayUrl>,
): List<MembershipNotice> =
notes
.filterIntoSet { _, note -> note.isMembershipNoticeFor(me) }
.toList()
.toMembershipNotices(workspaces)
/**
* The Buzz type of every channel whose kind-39000 the cache already holds, keyed by group id.
*
* Built from the metadata events themselves rather than from the [RelayGroupChannel]s they populate,
* because the two are filled in at different moments. `LocalCache.consume(GroupMetadataEvent)` loads the
* event onto its addressable note and wakes the cache observers *first*, and only then copies it into
* the channel so a projection woken by that very emission reads a channel that is still empty, gets
* [ChannelClassification.UNKNOWN], and, because nothing emits a second time, stays wrong until an
* unrelated membership notice happens to arrive. (It also covers the case where the channel is never
* populated at all: `consume` only touches it when the event carried relay provenance.) Reading the
* event that caused the emission cannot race with itself.
*
* Keyed by group id alone, without the host relay: this is a fallback for [classifyBuzzChannel], which
* still prefers the relay-scoped channel whenever that one has already been filled in.
*/
fun buzzChannelTypes(metadataNotes: List<Note>): Map<String, ChannelClassification> {
val types = HashMap<String, ChannelClassification>(metadataNotes.size)
metadataNotes.forEach { note ->
val metadata = note.event as? GroupMetadataEvent ?: return@forEach
types[metadata.groupId()] =
if (metadata.isBuzzDmChannel()) ChannelClassification.DM else ChannelClassification.NAMED
}
return types
}
/**
* What [cache] currently knows about a channel's type, from its kind-39000.
*
* [ChannelClassification.UNKNOWN] until the directory lands callers decide what to do with that, and
* the invite projection deliberately withholds rather than guessing (see
* [com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites.pendingInvites]).
*
* [knownTypes] (from [buzzChannelTypes]) is consulted when the channel has no metadata yet, which is
* what makes the answer stable at the instant the directory lands see that function for why the
* channel alone is not enough.
*/
fun classifyBuzzChannel(
cache: LocalCache,
channelId: String,
relay: NormalizedRelayUrl,
knownTypes: Map<String, ChannelClassification> = emptyMap(),
): ChannelClassification {
val metadata =
cache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event
?: return knownTypes[channelId] ?: ChannelClassification.UNKNOWN
return if (metadata.isBuzzDmChannel()) ChannelClassification.DM else ChannelClassification.NAMED
}
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.buzz
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* The channels somebody else added the viewer to that are still awaiting a decision.
*
* A pure projection of what the cache already holds the relay's kind-44100/44101 membership verdicts
* addressed to me, the channels' kind-39000 types, my kind-10009 joined list, and my local dismissals.
* Nothing here asserts membership (the relay already granted that); it only decides whose call it is to
* surface the channel.
*
* ### Account state, not screen state
*
* This hangs off [com.vitorpamplona.amethyst.model.Account] rather than a feed holder because the
* notifications DAL reads it: `NotificationFeedFilter.acceptableEvent` consults [pendingByEventId] to
* decide whether a cached kind-44100 is still a live question, and `convertToCard` uses the same map to
* build the row. A projection only the UI could reach would have forced the DAL to re-derive it.
*
* ### Derived, not recorded
*
* This used to read a process-wide registry that the Buzz DM discovery pass wrote into and its
* classification step deleted from. Because the deletion was remembered nowhere, any re-delivery of the
* same kind-44100 re-added an invite that had already been withdrawn, and the prompt appeared and
* disappeared on a loop. Deriving from the cache removes the second source of truth: the same events
* always produce the same answer, in any order, however many times they arrive.
*/
@Stable
class ChannelInvitesState(
private val me: HexKey,
private val cache: LocalCache,
private val buzzWorkspaces: BuzzWorkspaces,
relayGroupList: RelayGroupListState,
dismissed: StateFlow<Set<String>>,
scope: CoroutineScope,
) {
/**
* What every group whose kind-39000 has landed turns out to be, which is what makes an
* [com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification.UNKNOWN] channel decidable.
* Without this the projection would never recompute when the directory arrives.
*
* It carries the classification rather than merely signalling that it changed, and that is the
* point: `LocalCache.consume(GroupMetadataEvent)` wakes this observer *before* it copies the event
* into the [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel], so a
* recompute that went back to the channel for the answer read one that was still empty, concluded
* UNKNOWN, and with nothing left to emit kept the invite hidden until an unrelated membership
* notice arrived. Device-confirmed: a kind-44100 followed 20s later by its kind-39000 produced no
* card at all, and one unrelated kind-44101 made it appear instantly.
*
* De-duplicated on the map, so a busy account's metadata traffic still only re-runs the projection
* when a group's type actually becomes known or changes.
*/
private val knownChannelTypes =
cache
.observeNotes(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
.map { buzzChannelTypes(it) }
.distinctUntilChanged()
/**
* The membership verdicts themselves, re-scanned only when something can actually change them.
*
* The scan walks every note in the cache, so it is deliberately NOT part of the combine below: the
* three inputs there (dismissals, my kind-10009, known channel types) change what the notices *mean*
* but never what they *are*, and folding them in would re-walk the whole cache on every list edit.
*
* The observer emission is the arrival signal it cannot be the data, because `observeNotes`'
* initial snapshot can't hold these kinds at all (see [membershipNotices]). The workspace set is the
* second trigger: a notice's relay is resolved by preferring a joined workspace over whatever else
* delivered it, and restore-from-disk can land after the cache already holds notices, changing which
* relay a channel resolves against and with it whether its kind-39000 is ever found.
*/
private val notices =
combine(
cache.observeNotes(membershipNoticeFilter(me)),
buzzWorkspaces.flow,
) { _, workspaces -> cache.membershipNotices(me, workspaces) }
/** Pending invites keyed by the kind-44100 that produced them — what the notifications DAL reads. */
val pendingByEventId: StateFlow<Map<HexKey, BuzzChannelInvite>> =
combine(
notices,
knownChannelTypes,
dismissed,
relayGroupList.liveRelayGroupList,
) { verdicts, knownTypes, dismissals, joined ->
BuzzChannelInvites.pendingInvitesByEventId(
viewer = me,
notices = verdicts,
dismissed = dismissals,
joined = joined.mapTo(HashSet()) { it.groupId },
classify = { channelId, relay -> classifyBuzzChannel(cache, channelId, relay, knownTypes) },
)
}.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
/** The same set as a newest-first list, for surfaces that render it directly. */
val flow: StateFlow<List<BuzzChannelInvite>> =
pendingByEventId
.map { it.values.sortedByDescending { invite -> invite.createdAt } }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
/** Whether this cached kind-44100 is still an unanswered question. Hot path — a map lookup. */
fun isPending(eventId: HexKey) = eventId in pendingByEventId.value
fun inviteFor(eventId: HexKey): BuzzChannelInvite? = pendingByEventId.value[eventId]
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -37,16 +38,23 @@ import java.util.concurrent.ConcurrentHashMap
* supported RPC methods, and whether it emits notifications.
*
* Entries expire after [ttlSeconds] (default 2 days) so a wallet that later
* changes its advertised capabilities is eventually re-checked. Reads never block
* on the network:
* changes its advertised capabilities is eventually re-checked. Four entry
* points, in increasing order of how much they will wait:
*
* - [current] returns whatever is cached (possibly stale, possibly null) with no
* side effect for the payment hot path.
* side effect and never blocks for callers that can act on "don't know".
* - [refreshIfStale] triggers a background fetch when the entry is missing or
* expired, and returns immediately call it right before using a wallet so a
* stale entry self-heals without holding up the transaction.
* - [getFresh] is the suspending variant for callers that can await (e.g. the
* notification watcher deciding whether to open a subscription).
* - [currentOrFetch] waits only when nothing at all is cached, and returns a
* stale entry as-is for callers where "don't know" and "no" are different
* answers, such as NIP-44 negotiation.
* - [getFresh] waits whenever the entry is missing *or* expired, for a caller
* that must not act on a stale answer. No production caller needs that today.
*
* Every fetching path funnels through one request per wallet, so the startup
* warm-up, a payment waiting on a cold cache and the notification watcher join
* the same call rather than racing each other.
*
* A completed fetch including a definitive "wallet published no info event"
* (null) is cached with a timestamp. A *failed* fetch (network error/timeout)
@@ -65,7 +73,9 @@ class NwcInfoCache(
)
private val cache = ConcurrentHashMap<HexKey, Entry>()
private val inFlight = ConcurrentHashMap.newKeySet<HexKey>()
// Fetches in progress, keyed like [cache]. Every fetching path goes through [fetchOnce].
private val inFlight = ConcurrentHashMap<HexKey, CompletableDeferred<NwcInfoEvent?>>()
private fun isFresh(entry: Entry): Boolean = now() - entry.fetchedAt < ttlSeconds
@@ -80,26 +90,85 @@ class NwcInfoCache(
fun refreshIfStale(uri: Nip47WalletConnect.Nip47URINorm) {
val entry = cache[uri.pubKeyHex]
if (entry != null && isFresh(entry)) return
if (!inFlight.add(uri.pubKeyHex)) return
scope.launch(Dispatchers.IO) {
try {
fetchAndStore(uri)
} finally {
inFlight.remove(uri.pubKeyHex)
}
}
scope.launch(Dispatchers.IO) { fetchOnce(uri) }
}
/**
* Suspends until a fresh-enough info event is available, fetching when the
* entry is missing or expired. Returns the last cached (possibly stale) value
* if the fetch fails.
*
* For a caller that must not act on a stale answer. No production caller needs
* that today; prefer [currentOrFetch], which only waits on a cold cache.
*/
suspend fun getFresh(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
val entry = cache[uri.pubKeyHex]
if (entry != null && isFresh(entry)) return entry.info
return fetchAndStore(uri)
return fetchOnce(uri)
}
/**
* Returns whatever is cached, waiting for a fetch only when there is nothing
* cached at all.
*
* This is the encryption-negotiation entry point. [current] answers "what does
* this wallet advertise" from memory, but on a cold cache it answers null, and
* a null there is indistinguishable from "no NIP-44" so the caller silently
* downgrades to NIP-04 on the first transaction after every app start. Waiting
* once, only when nothing is known, removes that.
*
* A stale entry is returned as-is without waiting: it still says which
* encryption the wallet advertises, and [refreshIfStale] self-heals it in the
* background for next time.
*/
suspend fun currentOrFetch(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
val entry = cache[uri.pubKeyHex] ?: return fetchOnce(uri)
refreshIfStale(uri)
return entry.info
}
/**
* Runs [fetchAndStore] for [uri] exactly once, however many callers ask at
* once; every caller awaits that one result.
*
* The fetch runs in this cache's own [scope], never in the caller's. A caller
* on the payment path is a `viewModelScope` coroutine that dies when the user
* backs out of the screen owning the fetch there would abandon it, leave the
* cache cold and make the next attempt pay the whole cost again. Here a caller
* giving up cancels only its own `await`, and the fetch it started still lands.
*/
private suspend fun fetchOnce(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
val key = uri.pubKeyHex
val ours = CompletableDeferred<NwcInfoEvent?>()
// putIfAbsent returns the previous entry, so a non-null result means
// someone else already started this wallet's fetch.
inFlight.putIfAbsent(key, ours)?.let { return it.await() }
val job =
scope.launch(Dispatchers.IO) {
var info: NwcInfoEvent? = null
try {
info = fetchAndStore(uri)
} finally {
// Non-suspending, so awaiters are released even if the fetch is cancelled.
inFlight.remove(key, ours)
ours.complete(info)
}
}
// A scope that was already cancelled — this account was logged off while a
// payment was in flight — never runs the body, so the finally above never
// releases anyone. Without this, the caller awaits forever and the abandoned
// slot makes every later call for this wallet do the same. Fires immediately
// when the job is already complete, and is a no-op on the normal path.
job.invokeOnCompletion {
if (!ours.isCompleted) {
inFlight.remove(key, ours)
ours.complete(null)
}
}
return ours.await()
}
private suspend fun fetchAndStore(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
@@ -37,11 +37,15 @@ import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectReque
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -60,6 +64,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/**
* Manages NIP-47 (Nostr Wallet Connect) related signing operations and decryption cache for a given account.
@@ -135,17 +140,58 @@ class NwcSignerState(
}
/**
* Non-blocking read of the negotiated encryption preference for a wallet.
* NIP-47 says a client "should always prefer nip44 if supported by the wallet
* service". Returns true only when the cached info event advertises `nip44_v2`;
* otherwise NIP-04 (the legacy default). Also nudges a background refresh so a
* stale/expired entry self-heals for the next transaction without blocking this
* one.
* The negotiated encryption preference for a wallet. NIP-47 says a client
* "should always prefer nip44 if supported by the wallet service", so a false
* here has to mean "the wallet does not offer NIP-44" not "we have not asked
* yet". [walletInfo] is what makes that distinction true.
*/
private fun prefersNip44(uri: Nip47WalletConnect.Nip47URINorm?): Boolean {
uri ?: return false
infoCache?.refreshIfStale(uri)
return infoCache?.current(uri)?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } ?: false
private fun prefersNip44(info: NwcInfoEvent?): Boolean = info?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } == true
/**
* The wallet's advertised capabilities: the one place a send waits on them, and
* it waits AT MOST ONCE.
*
* WAITING IS THE POINT. The info cache is per-account and in memory only, so it
* starts empty on every app launch, and reading it without waiting makes "not
* fetched yet" indistinguishable from "not supported". That shipped twice: the
* first transaction to each wallet after a launch fell back to NIP-04 against a
* wallet advertising `nip44_v2`, and a payment to a wallet that had been
* advertising NWC-06 for twenty minutes still went out bare with nothing, on
* either side, reporting an error.
*
* ONCE, because both questions read the same event. Each used to fetch for
* itself, which is free on a warm cache and doubles the stall on a cold one:
* [NwcInfoCache] deliberately does not cache a FAILED fetch, so with the relay
* down both waits ran in full and a 3s worst case became 6s.
*
* BOUNDED, because this sits in front of a payment the user has already tapped
* and the no-response timer does not start until it returns. On expiry the
* answer is null read as NIP-04 and as no-metadata, both of them the safe
* direction while the fetch keeps running in the cache's own scope so the next
* request gets the negotiated scheme. Never bound the fetch itself instead: a
* null from it is cached as a definitive "no info event" for the whole TTL,
* which would pin the wallet to NIP-04 for days.
*/
private suspend fun walletInfo(uri: Nip47WalletConnect.Nip47URINorm?): NwcInfoEvent? {
uri ?: return null
return withTimeoutOrNull(NIP44_NEGOTIATION_WAIT_MS) { infoCache?.currentOrFetch(uri) }
}
/**
* Strips NWC-06 `metadata` from a request bound for a wallet that never said it
* understands the field [MetadataCarrying] has the reason that matters.
*
* APPLIED WHERE THE REQUEST IS BUILT rather than at each call site, so populating
* `metadata` anywhere upstream is safe by construction.
*
* MUTATES the request in place see the callers' KDoc. Requests are built per
* send and not reused, and stripping a copy would mean rebuilding a params object
* whose field list would then drift from the original.
*/
private fun Request.dropMetadataIfUnsupported(info: NwcInfoEvent?) {
val carrier = metadataCarrier ?: return
if (carrier.metadata == null || info?.supportsExtension(ExtensionsTag.METADATA_CONVENTIONS) == true) return
carrier.metadata = null
}
fun hasWalletConnectSetup(): Boolean = settings.nwcWallets.value.isNotEmpty()
@@ -203,21 +249,30 @@ class NwcSignerState(
*/
suspend fun sendNwcRequest(
request: Request,
onTimeout: () -> Unit = {},
onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> = sendNwcRequestToWallet(defaultWalletUri.value, request, onResponse)
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> = sendNwcRequestToWallet(defaultWalletUri.value, request, onTimeout, onResponse)
/**
* Sends a generic NIP-47 request to a specific wallet.
*
* [request] MAY BE MUTATED: NWC-06 `metadata` is stripped in place when the
* wallet has not advertised support for it. Build a fresh request per send
* rather than retaining or re-reading this one.
*/
suspend fun sendNwcRequestToWallet(
walletUri: Nip47WalletConnect.Nip47URINorm?,
request: Request,
onTimeout: () -> Unit = {},
onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup")
val walletSigner = buildSigner(walletService) ?: signer
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(walletService))
val info = walletInfo(walletService)
request.dropMetadataIfUnsupported(info)
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(info))
val filter =
NWCPaymentQueryState(
@@ -234,14 +289,7 @@ class NwcSignerState(
// be missed.
assembler.subscribeAndFlush(filter)
// Safety net: drop the filter after 60s if the wallet never replies.
// The happy path (response arrives) cancels this job and unsubscribes
// through assembler.unsubscribeSoon, which debounces.
val timeoutJob =
scope.launch(Dispatchers.IO) {
delay(60000)
assembler.unsubscribe(filter)
}
val timeoutJob = launchGiveUpTimer(assembler, filter, event.id, onTimeout)
val responseCache = NostrWalletConnectResponseCache(walletSigner)
cache.consume(event, null, true, walletService.relayUri) {
@@ -255,15 +303,30 @@ class NwcSignerState(
/**
* Sends a zap payment request to the default wallet.
*
* [metadata] is NWC-06's per-payment blob and is dropped unless the wallet
* advertises `06`; see [dropMetadataIfUnsupported].
*/
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
onTimeout: () -> Unit = {},
metadata: Map<String, Any?>? = null,
onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup")
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value, useNip44 = prefersNip44(walletService))
val info = walletInfo(walletService)
val request = PayInvoiceMethod.create(bolt11, metadata)
request.dropMetadataIfUnsupported(info)
val event =
LnZapPaymentRequestEvent.createRequest(
request,
walletService.pubKeyHex,
nip47Signer.value,
useNip44 = prefersNip44(info),
)
val filter =
NWCPaymentQueryState(
@@ -278,14 +341,7 @@ class NwcSignerState(
// See sendNwcRequestToWallet above for the rationale.
assembler.subscribeAndFlush(filter)
// Safety net: drop the filter after 60s if the wallet never replies.
// The happy path (response arrives) cancels this job and instead
// hands off to assembler.unsubscribeSoon, which debounces.
val timeoutJob =
scope.launch(Dispatchers.IO) {
delay(60000) // waits 1 minute to complete payment.
assembler.unsubscribe(filter)
}
val timeoutJob = launchGiveUpTimer(assembler, filter, event.id, onTimeout)
cache.consume(event, zappedNote, true, walletService.relayUri) {
timeoutJob.cancel()
@@ -295,4 +351,59 @@ class NwcSignerState(
return Pair(event, walletService.relayUri)
}
/**
* Safety net for a wallet that never replies: drops the subscription filter and retires
* the request. The happy path cancels this job and unsubscribes through
* [NWCPaymentFilterAssembler.unsubscribeSoon] instead, which debounces.
*/
private fun launchGiveUpTimer(
assembler: NWCPaymentFilterAssembler,
filter: NWCPaymentQueryState,
requestId: HexKey,
onTimeout: () -> Unit,
) = scope.launch(Dispatchers.IO) {
delay(NWC_RESPONSE_TIMEOUT_MS)
assembler.unsubscribe(filter)
giveUpWaiting(requestId, onTimeout)
}
/**
* Retires a request whose response never arrived: removes the tracker entry so it
* does not leak, and tells the caller so the user hears about it. A silent give-up
* is the worst outcome for a payment UI the action just appears not to have
* happened, which is indistinguishable from a refusal the wallet did send.
*
* A `cleanup` that returns false means a response beat us to the tracker entry,
* so the response path is already reporting and this must stay quiet.
*/
private fun giveUpWaiting(
requestId: HexKey,
onTimeout: () -> Unit,
) {
val wasStillPending = cache.paymentTracker.cleanup(requestId)
if (wasStillPending) {
Log.w("NwcSignerState") {
"No NIP-47 response for request $requestId after ${NWC_RESPONSE_TIMEOUT_MS}ms; giving up and dropping the subscription."
}
onTimeout()
}
}
companion object {
/**
* How long a NIP-47 request waits for its kind-23195 reply before the client
* gives up. Exposed in seconds so the UI can name the number it shows the user.
*/
const val NWC_RESPONSE_TIMEOUT_SECONDS = 60
const val NWC_RESPONSE_TIMEOUT_MS = NWC_RESPONSE_TIMEOUT_SECONDS * 1000L
/**
* How long a request will wait for a cold info cache before falling back to
* NIP-04. Comfortably over a healthy single-relay round trip, far under the
* 30s the fetch itself would otherwise allow in front of a payment tap.
*/
const val NIP44_NEGOTIATION_WAIT_MS = 3_000L
}
}
@@ -58,7 +58,11 @@ class IndexerRelayListState(
fun indexListEvent(note: Note) = note.event as? IndexerRelayListEvent ?: settings.backupIndexRelayList
suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList
suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = indexListEvent(note) ?: return DefaultIndexerRelayList
// Fully decrypted here, so empty means the user listed nothing — not "not decrypted yet".
return decryptionCache.relays(event)
}
suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
@@ -73,12 +77,26 @@ class IndexerRelayListState(
*/
fun normalizeIndexerRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList
/** See `Nip65RelayListState.assumedDefaults`. Empty as soon as any kind:10086 exists. */
fun assumedDefaults(note: Note): Set<NormalizedRelayUrl> = if (indexListEvent(note) == null) DefaultIndexerRelayList else emptySet()
val assumedDefaultsFlow =
getIndexerRelayListFlow()
.map { assumedDefaults(it.note) }
.onStart { emit(assumedDefaults(indexerListNote)) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
assumedDefaults(indexerListNote),
)
/**
* The account's indexer relays, **never empty** [normalizeIndexerRelayListWithBackup]
* substitutes [DefaultIndexerRelayList] both when there is no kind:10086 and when the
* one we have decodes to zero relays. Callers assembling metadata / relay-list REQs read
* this and can rely on getting a usable set; use [flowNoDefaults] instead to show or diff
* what the user actually configured.
* The account's indexer relays. [normalizeIndexerRelayListWithBackup] substitutes
* [DefaultIndexerRelayList] when there is no kind:10086 at all but **not** when the one we
* have decodes to zero relays, which is the user saying "no indexers" and is honored. Callers
* assembling metadata / relay-list REQs must therefore tolerate an empty set; use
* [flowNoDefaults] to show or diff what the user actually configured.
*
* Seeded via [normalizeIndexerRelayListPrecached] rather than `emptySet()`, for the same
* reason as the search list: `flowOn(IO)` makes the first real emission asynchronous, so an
@@ -71,7 +71,15 @@ class FollowListsState(
) {
val user = cache.getOrCreateUser(signer.pubKey)
fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.KIND, user.pubkeyHex)
// Hides shells that a kind-5 deletion event from the list's author has already
// deleted (e.g. a persisted TopFilter re-creates an empty shell for the deleted
// address after a restart, and its name falls back to the dTag/UUID). Shells that
// are merely not loaded yet stay in the list so the UI can subscribe and fetch
// them from relays.
fun existingPeopleListNotes() =
cache.addressables
.filter(FollowListEvent.KIND, user.pubkeyHex)
.filter { it.event != null || !cache.hasBeenDeleted(it.address) }
val followListVersions = MutableStateFlow(0)
@@ -255,6 +263,10 @@ class FollowListsState(
val followListEvent = getPeopleList(identifierTag)
val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent)))
account.sendMyPublicAndPrivateOutbox(deletionEvent)
// Any screen whose persisted feed filter still points at this follow pack would
// keep re-creating an empty shell for its address (and render the dTag/UUID in
// the top bar) — reset those filters to their default.
account.settings.resetFeedFiltersPointingTo(followListEvent.address())
}
suspend fun addUserToSet(
@@ -69,10 +69,17 @@ class PeopleListsState(
) {
val user = cache.getOrCreateUser(signer.pubKey)
// Hides the fixed-dTag block-list shell when it is not loaded (it has no
// meaningful name until it exists) and shells that a kind-5 deletion event from
// the list's author has already deleted (e.g. a persisted TopFilter re-creates an
// empty shell for the deleted address after a restart, and its name falls back to
// the dTag/UUID). Shells that are merely not loaded yet stay in the list so the UI
// can subscribe and fetch them from relays.
fun existingPeopleListNotes() =
cache.addressables
.filter(PeopleListEvent.KIND, user.pubkeyHex)
.filter { it.dTag() != PeopleListEvent.BLOCK_LIST_D_TAG || it.event != null }
.filter { it.event != null || !cache.hasBeenDeleted(it.address) }
val peopleListVersions = MutableStateFlow(0)
@@ -262,6 +269,10 @@ class PeopleListsState(
val followListEvent = getPeopleList(identifierTag)
val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent)))
account.sendMyPublicAndPrivateOutbox(deletionEvent)
// Any screen whose persisted feed filter still points at this list would keep
// re-creating an empty shell for its address (and render the dTag/UUID in the
// top bar) — reset those filters to their default.
account.settings.resetFeedFiltersPointingTo(followListEvent.address())
}
suspend fun addUserToSet(
@@ -58,7 +58,11 @@ class SearchRelayListState(
fun searchListEvent(note: Note) = note.event as? SearchRelayListEvent ?: settings.backupSearchRelayList
suspend fun normalizeSearchRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList
suspend fun normalizeSearchRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = searchListEvent(note) ?: return DefaultSearchRelayList
// Fully decrypted here, so empty means the user listed nothing — not "not decrypted yet".
return decryptionCache.relays(event)
}
suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
@@ -74,16 +78,31 @@ class SearchRelayListState(
*/
fun normalizeSearchRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList
/** See `Nip65RelayListState.assumedDefaults`. Empty as soon as any kind:10007 exists. */
fun assumedDefaults(note: Note): Set<NormalizedRelayUrl> = if (searchListEvent(note) == null) DefaultSearchRelayList else emptySet()
val assumedDefaultsFlow =
getSearchRelayListFlow()
.map { assumedDefaults(it.note) }
.onStart { emit(assumedDefaults(searchListNote)) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
assumedDefaults(searchListNote),
)
/**
* The account's search relays, **never empty** [normalizeSearchRelayListWithBackup]
* substitutes [DefaultSearchRelayList] both when there is no kind:10007 and when the
* one we have decodes to zero relays. Callers assembling NIP-50 REQs read this and can
* The account's search relays. [normalizeSearchRelayListWithBackup] substitutes
* [DefaultSearchRelayList] when there is no kind:10007 at all but **not** when the one we
* have decodes to zero relays, which is the user saying "no search relays" and is honored.
* Callers assembling NIP-50 REQs must tolerate an empty set, and can
* rely on getting a usable set; use [flowNoDefaults] instead to show or diff what the
* user actually configured.
*
* Seeded via [normalizeSearchRelayListPrecached] rather than `emptySet()`: `flowOn(IO)` means
* the first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed
* left a window where `.value` contradicted the "never empty" contract above and search
* left a window where `.value` reported nothing before the event had been read at all, so search
* silently queried nothing. That window is unbounded for a NIP-46 signer whose list has
* private entries, since the first emission waits on a remote decrypt.
*/
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuProofBackfillFilters
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -35,6 +36,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -61,12 +64,14 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import java.util.concurrent.ConcurrentHashMap
@@ -99,6 +104,7 @@ class CashuWalletState(
private val pubKey: HexKey,
private val signer: NostrSigner,
private val cache: LocalCache,
private val client: INostrClient,
private val scope: CoroutineScope,
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
@@ -519,6 +525,150 @@ class CashuWalletState(
if (ids.isNotEmpty()) removeEvents(ids)
}
}
// Page the full proof set back, once, as soon as we know there is a
// wallet and where to ask about it. The live subscription above cannot
// do this on its own — see [resyncProofsFromRelays].
//
// Gated on a wallet existing so an Account object that is only resident
// to decrypt a pushed gift wrap never pages a wallet nobody has: that
// is the same reason the wallet's relay subscription lives in
// CashuWalletEoseManager rather than here.
jobs +=
scope.launch(Dispatchers.IO) {
val ready =
withTimeoutOrNull(BACKFILL_READY_WAIT_MS) {
_walletEvent.first { it != null }
outboxRelaysFlow.first { it.isNotEmpty() }
}
if (ready == null) {
Log.d("CashuWallet") { "No wallet + outbox relays within ${BACKFILL_READY_WAIT_MS}ms; skipping proof backfill" }
} else {
resyncProofsFromRelays()
}
}
}
// ============================================================
// Proof backfill — paging past the relay's REQ cap
// ============================================================
/**
* True once a paged proof walk has completed for this session. Guards the
* automatic backfill only; [resyncProofsFromRelays] with `force` ignores it.
*/
@Volatile private var proofBackfillDone = false
private val proofBackfillMutex = Mutex()
/**
* Re-download **every** kind:7375 this account ever published, by paging
* each outbox relay with `until` cursors, and then reconcile the result
* against the mints.
*
* ### Why this is needed
*
* [balanceSats] is a pure function of [_tokenEntries], which is a pure
* function of the kind:7375 events we happen to hold. Those arrive over the
* live wallet subscription, which sends one unbounded REQ per relay. A relay
* answers an unbounded REQ with its own cap (NIP-11 `limitation.max_limit`,
* or a hard-coded default) applied to the **newest** matching events and
* the same filter also asks for kind:7376 history, which outnumbers the
* proofs by an order of magnitude on any wallet with a few hundred
* transactions. The proofs that lose that race are the ones at mints the
* user has not touched recently, so what drops off the bottom is precisely
* the balance the user forgot they had.
*
* Nothing recovers from it afterwards: a capped page and a complete page
* both just EOSE, and [PerUserEoseManager] records that EOSE as the `since`
* for every later REQ to that relay, so the events below the cap are never
* asked for again. The subset is stable across cold starts (same filter,
* same cap, same events) but differs between devices whose relay set,
* arrival order or uptime differ which is why one account can read 39 sat
* on one phone, 1443 on another and 2522 on a third, with none of them
* being the wallet's actual balance.
*
* ### What this does
*
* `fetchAllPagesFromPool` walks each relay backwards page by page until a
* page comes back empty, so the cap bounds a page instead of the download.
* Events land in [LocalCache] through the client-wide `EventCollector`, but
* we also index what we receive directly rather than waiting on the bundled
* cache round-trip, so the balance is correct the moment the walk returns.
*
* ### Why the scrub afterwards
*
* Spent proofs are retired with a NIP-09 kind:5, and a relay that ignores
* deletions will happily hand those kind:7375 events back on a paged walk.
* Taken alone, this would trade an under-count for an over-count. So when
* the walk actually recovered something, we finish with the NUT-07
* [scrubLocallyStaleProofs] sweep: the mint not the relay decides which
* proofs are still unspent, and anything it calls SPENT is dropped and
* re-deleted. The sweep is skipped when the walk found nothing new, so a
* steady-state launch costs no mint traffic.
*
* Returns the number of kind:7375 events the walk delivered that we did not
* already hold, or null when it could not run (not started, no relays, or
* already done and not forced).
*/
suspend fun resyncProofsFromRelays(force: Boolean = false): Int? {
if (!started) return null
if (proofBackfillDone && !force) return null
return proofBackfillMutex.withLock {
if (proofBackfillDone && !force) return@withLock null
val relays = outboxRelaysFlow.value
// Don't latch on an empty relay set — the NIP-65 list may simply not
// have arrived yet, and the caller retries once it does.
if (relays.isEmpty()) return@withLock null
val filters = cashuProofBackfillFilters(pubKey)
// The callback runs on the relay reader thread and must not suspend,
// so collect first and index after the walk.
val collected = ConcurrentHashMap<HexKey, CashuTokenEvent>()
runCatching {
client.fetchAllPagesFromPool(
filters = relays.associateWith { filters },
idleTimeoutMs = BACKFILL_IDLE_TIMEOUT_MS,
) { event, _ ->
if (event is CashuTokenEvent && event.pubKey == pubKey) {
collected.putIfAbsent(event.id, event)
}
}
}.onFailure {
Log.w("CashuWallet", "Paged proof backfill failed", it)
}.onSuccess {
// Latch only on a walk that actually completed. A walk that
// blew up (offline at launch, every relay unreachable) has
// proved nothing about what the relays hold, and latching on it
// would leave the wallet showing the truncated balance for the
// rest of the session with no automatic second attempt.
proofBackfillDone = true
}
val fresh = collected.values.filter { !tokenEvents.containsKey(it.id) }
Log.i("CashuWallet") {
"Proof backfill over ${relays.size} relay(s): ${collected.size} kind:7375 seen, ${fresh.size} new"
}
if (fresh.isNotEmpty()) {
applyEvents(fresh)
// A relay that ignores NIP-09 just handed back proofs the mint
// already burned. Let the mint arbitrate before the user sees a
// number.
runCatching { scrubLocallyStaleProofs() }
.onFailure { Log.w("CashuWallet", "Post-backfill NUT-07 sweep failed", it) }
} else if (undecryptedTokenCount() > 0) {
// Nothing new off the relays, but we are still holding proofs
// we could not read. A decrypt failure hides money exactly as
// effectively as a missing event does, and the retry inside
// recomputeUnspent only fires when some *other* change marks
// the tokens dirty — which, in a wallet that has gone quiet, may
// be never. A user asking for a refresh is asking for that
// retry too.
recomputeUnspent()
}
fresh.size
}
}
fun destroy() {
@@ -704,8 +854,10 @@ class CashuWalletState(
private suspend fun recomputeUnspent() {
val all = tokenEvents.values.toList()
// Decrypt anything we haven't seen before; reuse cached TokenContent
// for events we've already decrypted. Decryption failures are
// skipped — the proof set rebuilds the next time a re-key happens.
// for events we've already decrypted. Only successes are cached, so a
// failure is retried on the next recompute rather than being pinned as
// "empty" for the session.
var undecryptable = 0
all.forEach { evt ->
if (!tokenContents.containsKey(evt.id)) {
val content =
@@ -715,7 +867,19 @@ class CashuWalletState(
"Failed to decrypt token ${evt.id.take(8)}: ${it.message}"
}
}.getOrNull()
if (content != null) tokenContents[evt.id] = content
if (content != null) tokenContents[evt.id] = content else undecryptable++
}
}
// A token we cannot decrypt is money we cannot see, and it drops out of
// the balance as silently as a token a relay never delivered. The
// retry above only fires when something else triggers a recompute, so
// say it out loud: with this counter, a wallet reading low because an
// external signer refused N decrypts is diagnosable from a log instead
// of looking identical to a wallet that is genuinely empty.
if (undecryptable > 0) {
Log.w("CashuWallet") {
"$undecryptable of ${all.size} kind:7375 event(s) failed to decrypt — balance excludes them"
}
}
@@ -723,6 +887,9 @@ class CashuWalletState(
_tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents)
}
/** Token events we hold but have never managed to decrypt. See [recomputeUnspent]. */
private fun undecryptedTokenCount(): Int = tokenEvents.keys.count { it !in tokenContents.keys }
private fun recomputePending() {
// Shared destroyed/expired filter with the headless reader.
_pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.values, historyEvents.values)
@@ -747,8 +914,14 @@ class CashuWalletState(
private suspend fun redeemPendingNutzapsSerialized() {
if (!redeemMutex.tryLock()) return // a sweep is already in flight
try {
val privkey = walletPrivkeyHex() ?: return
val pubkey = p2pkPubkeyHex() ?: return
// Establish there is work BEFORE touching the signer. This sweep
// fires from every relevant cache bundle, and the two key reads
// below are NIP-44 decrypts of kind:17375 — for a NIP-46 bunker or
// a NIP-55 external signer that is a round-trip out of the process
// (Amber even prompts on some configurations), paid on every bundle
// by a wallet whose nutzaps were all redeemed months ago. Nothing
// above the candidate filter needs a key, so hoist the filter.
if (nutzapEvents.isEmpty()) return
val skipIds = HashSet<HexKey>()
historyEvents.values.forEach { h ->
h.redeemedReferences().forEach { skipIds.add(it.eventId) }
@@ -759,6 +932,17 @@ class CashuWalletState(
val candidates = nutzapEvents.values.filter { it.id !in skipIds }
if (candidates.isEmpty()) return
val privkey = walletPrivkeyHex() ?: return
// Derived from the same key the line above just decrypted — pass it
// in rather than letting p2pkPubkeyHex() decrypt kind:17375 a
// second time for the identical bytes.
val pubkey =
runCatching {
Secp256k1
.pubKeyCompress(Secp256k1.pubkeyCreate(privkey.hexToByteArray()))
.toHexKey()
}.getOrNull() ?: return
for (ev in candidates) {
try {
ops.redeemNutzap(ev, privkey, pubkey)
@@ -918,11 +1102,26 @@ class CashuWalletState(
val sharedMints = info.mints().map { it.mintUrl }.filter { it in ourMints }
if (sharedMints.isEmpty()) return null
// One pass over the entries, not one per shared mint. This runs inside
// a composable `remember {}` on every zap chip, so it is per rendered
// note — and `_tokenEntries` is no longer the handful of events a
// truncated relay delivery used to leave behind, it is the wallet's
// whole proof set. The old filter-per-mint form was
// O(sharedMints × entries) with a throwaway list allocated per mint.
val entries = _tokenEntries.value
val satsPerMint = HashMap<String, Long>(sharedMints.size)
var totalWalletSats = 0L
entries.forEach { entry ->
val amount = entry.content.totalAmount()
totalWalletSats += amount
val mint = entry.content.mint
if (mint in ourMints) satsPerMint[mint] = (satsPerMint[mint] ?: 0L) + amount
}
var bestMint = sharedMints.first()
var bestMintSats = 0L
for (mint in sharedMints) {
val balance = entries.filter { it.content.mint == mint }.sumOf { it.content.totalAmount() }
val balance = satsPerMint[mint] ?: 0L
if (balance > bestMintSats) {
bestMintSats = balance
bestMint = mint
@@ -932,7 +1131,7 @@ class CashuWalletState(
return NutzapFunding(
target = NutzapTarget(mintUrl = bestMint, recipientP2pkPubkeyHex = recipientPubkeyHex),
bestSingleMintSats = bestMintSats,
totalWalletSats = entries.sumOf { it.content.totalAmount() },
totalWalletSats = totalWalletSats,
)
}
@@ -1203,11 +1402,26 @@ class CashuWalletState(
entry to entry.content.proofs.mapTo(HashSet()) { it.secret }
}
// Index secret → entries holding it. A superset of B must share every
// one of B's secrets, so the only entries that can possibly cover B are
// the ones indexed under B's first secret — which is a handful, not the
// whole wallet. The previous all-pairs scan was O(entries²) with a
// set-containment test inside; that was invisible while a truncated
// relay delivery kept the wallet at a few entries, and is not once the
// whole proof set is present.
val holdersOfSecret = HashMap<String, MutableList<Pair<TokenEntry, HashSet<String>>>>()
withSecrets.forEach { pair ->
pair.second.forEach { secret ->
holdersOfSecret.getOrPut(secret) { mutableListOf() }.add(pair)
}
}
val redundant = mutableListOf<TokenEntry>()
for ((entry, secrets) in withSecrets) {
if (secrets.isEmpty()) continue
val candidates = holdersOfSecret[secrets.first()] ?: continue
val isRedundant =
withSecrets.any { (other, otherSecrets) ->
candidates.any { (other, otherSecrets) ->
other.event.id != entry.event.id &&
otherSecrets.containsAll(secrets) &&
(
@@ -1567,6 +1781,22 @@ class CashuWalletState(
*/
const val DISCOVERY_TIMEOUT_MS = 8_000L
/**
* How long the startup proof backfill waits for a wallet event plus a
* non-empty outbox relay set before giving up. Both are restored from
* AccountSettings almost immediately on a returning launch; this window
* only matters on a first sign-in, where they have to come off the
* network before we know there is a wallet and where its events live.
*/
private const val BACKFILL_READY_WAIT_MS = 60_000L
/**
* Per-page idle window for the paged proof walk measured from the
* relay's last message, not from the page's start, so a relay actively
* streaming a long backlog is never cut off mid-page.
*/
private const val BACKFILL_IDLE_TIMEOUT_MS = 30_000L
private const val NOT_STARTED_MESSAGE = "CashuWalletState.start() not called"
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model.nip65RelayList
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.defaults.relayListOrDefaultsWhenUnknown
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -58,9 +59,9 @@ class Nip65RelayListState(
fun nip65Event(note: Note) = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList
fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: Constants.eventFinderRelays
fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = relayListOrDefaultsWhenUnknown(nip65Event(note), Constants.eventFinderRelays) { it.writeRelaysNorm()?.toSet() }
fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.readRelaysNorm()?.toSet() ?: Constants.bootstrapInbox
fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = relayListOrDefaultsWhenUnknown(nip65Event(note), Constants.bootstrapInbox) { it.readRelaysNorm()?.toSet() }
fun normalizeNIP65WriteRelayListNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: emptySet()
@@ -70,6 +71,27 @@ class Nip65RelayListState(
fun normalizeNIP65AllRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: emptySet()
/**
* The app defaults currently standing in for a user we have no kind:10002 for empty as soon
* as one exists, including an empty one.
*
* Uses the same `nip65Event(note) == null` predicate the substitution itself uses, so the two
* cannot drift: whatever is listed here is exactly what the app is guessing on the user's
* behalf. See [relayListOrDefaultsWhenUnknown].
*/
fun assumedDefaults(note: Note): Set<NormalizedRelayUrl> = if (nip65Event(note) == null) Constants.bootstrapInbox + Constants.eventFinderRelays else emptySet()
val assumedDefaultsFlow =
getNIP65RelayListFlow()
.map { assumedDefaults(it.note) }
.onStart { emit(assumedDefaults(nip65ListNote)) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
assumedDefaults(nip65ListNote),
)
val outboxFlow =
getNIP65RelayListFlow()
.map { normalizeNIP65WriteRelayListWithBackup(it.note) }
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.awaitCreatedAtToSupersede
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.utils.Log
@@ -33,6 +34,8 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.cancellation.CancellationException
class AppSpecificState(
@@ -52,12 +55,35 @@ class AppSpecificState(
fun getAppSpecificDataFlow(): StateFlow<NoteState> = amethystSettingsNote.flow().metadata.stateFlow
/**
* Serializes the state snapshot and the timestamp it is stamped with. Two rapid toggles publish
* from separate coroutines on the signer's dispatcher; without this they could read the same
* previous timestamp and collide again, or take timestamps in the opposite order to the state
* they captured. Signing and encrypting stay outside the lock those can wait on an external
* signer, and they don't affect ordering.
*/
private val stampOrder = Mutex()
/**
* The newest version this instance has published, which is not always in [amethystSettingsNote]
* yet: the cache is only updated once the event comes back through the broadcaster.
*/
private var lastPublishedAt = 0L
suspend fun saveNewAppSpecificData(): AppSpecificDataEvent {
val toInternal = settings.syncedSettings.toInternal()
val (toInternal, createdAt) =
stampOrder.withLock {
val snapshot = settings.syncedSettings.toInternal(settings.mutedPublicChats.value)
val stamp = awaitCreatedAtToSupersede(maxOf(lastPublishedAt, amethystSettingsNote.event?.createdAt ?: 0L))
lastPublishedAt = stamp
snapshot to stamp
}
return signer.sign(
AppSpecificDataEvent.build(
dTag = APP_SPECIFIC_DATA_D_TAG,
description = signer.nip44Encrypt(JsonMapper.toJson(toInternal), signer.pubKey),
createdAt = createdAt,
),
)
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.awaitCreatedAtToSupersede
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -31,7 +32,6 @@ import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
@@ -85,15 +85,15 @@ class AppRecommendationsState(
private val publishMutex = Mutex()
/**
* Returns a createdAt strictly greater than whatever AppRecommendationEvent
* currently sits in cache for this d-tag. Needed because
* LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't
* strictly greater, and TimeUtils.now() has only second resolution.
* The createdAt this d-tag's next version needs to supersede whatever is in cache for it. Waits
* out the second rather than stamping the future, so repeatedly toggling one recommendation
* cannot drift its `created_at` ahead of the clock. Runs under [publishMutex], which is what
* keeps two waits from racing each other onto the same second.
*/
private fun nextCreatedAt(supportedKind: String): Long {
private suspend fun nextCreatedAt(supportedKind: String): Long {
val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind)
val latest = cache.getAddressableNoteIfExists(address)?.event?.createdAt ?: 0L
return maxOf(TimeUtils.now(), latest + 1)
return awaitCreatedAtToSupersede(latest)
}
private fun currentRecommendations(supportedKind: String): List<RecommendationTag> {
@@ -37,25 +37,36 @@ import kotlinx.serialization.json.Json
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the NIP-OA attestations this device holds
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of
* needing to be re-pasted. Uses the app-wide [sharedPreferencesDataStore] like
* [NamecoinSharedPreferences] (not per-account the store is already keyed by the agent
* pubkey each attestation authorizes).
* Per-account persistence for the NIP-OA attestation this account holds
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of needing to be
* re-pasted. The key is namespaced by pubkey; the store used to be one device-global list because
* each entry carried the agent key it authorized, which made the file a per-account store with
* extra steps.
*
* On construction it loads the saved entries into the singleton **re-verifying each
* against its agent key**, so a tampered on-disk credential is dropped rather than trusted
* then mirrors every later change back to disk. Construct once, eagerly, at startup.
* On construction it loads this account's saved attestation and mirrors every later change back to
* disk. Re-verification on restore is no longer done here: [BuzzHeldAttestations.put] verifies
* against the agent key itself and rejects what fails, so a tampered on-disk credential is dropped
* by the same gate that rejects a mistyped one. Construct once per account, eagerly.
*/
@Stable
class BuzzAttestationPreferences(
private val context: Context,
private val scope: CoroutineScope,
private val pubKeyHex: HexKey,
private val attestation: BuzzHeldAttestations,
) {
private val json = Json { ignoreUnknownKeys = true }
private val key = stringPreferencesKey("$KEY_PREFIX$pubKeyHex")
@Serializable
private data class Entry(
val owner: HexKey,
val conditions: String,
val sig: HexKey,
)
/** The pre-namespacing on-disk shape: one list for the whole device, each entry agent-keyed. */
@Serializable
private data class LegacyEntry(
val agent: HexKey,
val owner: HexKey,
val conditions: String,
@@ -67,41 +78,76 @@ class BuzzAttestationPreferences(
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value
// present at collection start, which restoreFromDisk already wrote).
BuzzHeldAttestations.flow.drop(1).collect { persist(it) }
attestation.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val verified =
json
.decodeFromString<List<Entry>>(raw)
.mapNotNull { e ->
val attestation = OwnerAttestation(e.owner, e.conditions, e.sig)
// Only reinstate a credential that still verifies for its agent key.
if (attestation.verify(e.agent)) e.agent to attestation else null
}.toMap()
if (verified.isNotEmpty()) BuzzHeldAttestations.restore(verified)
val prefs = context.sharedPreferencesDataStore.data.first()
// put() verifies, so a credential that no longer checks out is dropped either way.
restoreFrom(prefs[key], prefs[LEGACY_KEY], pubKeyHex)?.let(attestation::put)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error reading held attestations: ${e.message}" }
Log.e("BuzzAttestationPrefs") { "Error reading held attestation: ${e.message}" }
}
}
private suspend fun persist(entries: Map<HexKey, OwnerAttestation>) {
private suspend fun persist(held: OwnerAttestation?) {
try {
val list = entries.map { (agent, a) -> Entry(agent, a.ownerPubKey, a.conditions, a.sig) }
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = json.encodeToString(list)
// Write [NONE] rather than removing the key: removing it is indistinguishable from
// never having migrated, which would let the legacy list re-seed a credential the
// user just deleted. See [restoreFrom].
prefs[key] = if (held == null) NONE else json.encodeToString(Entry(held.ownerPubKey, held.conditions, held.sig))
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error writing held attestations: ${e.message}" }
Log.e("BuzzAttestationPrefs") { "Error writing held attestation: ${e.message}" }
}
}
companion object {
private val KEY = stringPreferencesKey("buzz.heldAttestations")
private const val KEY_PREFIX = "buzz.heldAttestation."
/** The device-global key written before the store became per-account; read-only now. */
private val LEGACY_KEY = stringPreferencesKey("buzz.heldAttestations")
/**
* Tombstone for "this account has been migrated and holds nothing", which an *absent* key
* cannot express absent still means "never migrated" and is allowed to seed from
* [LEGACY_KEY]. Without it, removing a held attestation lasted only until the next launch,
* because nothing ever clears the legacy list. (The starred-channel and joined-workspace
* stores get this for free: they persist an empty *set*, which reads back present.)
*/
private const val NONE = ""
private val json = Json { ignoreUnknownKeys = true }
/**
* Which attestation to reinstate, given this account's saved value and the pre-namespacing
* device-global list. Pure, so the migration precedence is testable without a `Context`.
*
* [saved] wins whenever it is present, [NONE] included. Only a never-migrated account falls
* back to [legacy], and it takes just the entry issued to its own key that list was
* already agent-keyed, so no other account's credential can match. Nothing is verified here;
* [BuzzHeldAttestations.put] is the gate that rejects a tampered credential.
*/
internal fun restoreFrom(
saved: String?,
legacy: String?,
agentPubKey: HexKey,
): OwnerAttestation? {
if (saved != null) {
if (saved == NONE) return null
val entry = json.decodeFromString<Entry>(saved)
return OwnerAttestation(entry.owner, entry.conditions, entry.sig)
}
val list = legacy ?: return null
return json
.decodeFromString<List<LegacyEntry>>(list)
.firstOrNull { it.agent == agentPubKey }
?.let { OwnerAttestation(it.owner, it.conditions, it.sig) }
}
}
}
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
@@ -33,28 +34,39 @@ import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of starred Buzz workspace channels ([BuzzChannelStars]),
* so favorites survive a restart. Mirrors [BuzzWorkspacePreferences]: app-wide (not per-account),
* loads the saved ids into the singleton on construction, then writes every later change back.
* Construct once, eagerly.
* Per-account persistence for the set of starred Buzz workspace channels ([BuzzChannelStars]), so
* favorites survive a restart. Mirrors [BuzzWorkspacePreferences] in both shape and reasoning: the
* key is namespaced by pubkey because a star is personal it says which channels *this* user wants
* pinned and one device-global set meant one account's favorites reordered and badged every other
* logged-in account's channel list. Loads this account's saved ids into [stars] on construction,
* then writes every later change back. Construct once per account, eagerly.
*/
@Stable
class BuzzChannelStarPreferences(
private val context: Context,
private val scope: CoroutineScope,
private val pubKeyHex: HexKey,
private val stars: BuzzChannelStars,
) {
private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex")
init {
scope.launch {
restoreFromDisk()
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
BuzzChannelStars.flow.drop(1).collect { persist(it) }
stars.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
if (raw.isNotEmpty()) BuzzChannelStars.restore(raw)
val prefs = context.sharedPreferencesDataStore.data.first()
// Fall back to the pre-namespacing device-global key so an upgrade doesn't unpin
// everything. That set is what every account already saw; the next toggle writes to this
// account's own key and takes over. The legacy key is left for other accounts to seed
// from and is never written again.
val raw = prefs[key] ?: prefs[LEGACY_KEY] ?: return
if (raw.isNotEmpty()) stars.restore(raw)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error reading starred channels: ${e.message}" }
@@ -63,7 +75,10 @@ class BuzzChannelStarPreferences(
private suspend fun persist(ids: Set<String>) {
try {
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = ids }
// Always write the starred set, empty included — never remove the key. An absent key
// means "never migrated" and re-seeds from the legacy one above, so removing it
// would undo the user's last removal on the next launch.
context.sharedPreferencesDataStore.edit { prefs -> prefs[key] = ids }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error writing starred channels: ${e.message}" }
@@ -71,6 +86,9 @@ class BuzzChannelStarPreferences(
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.starredChannels")
private const val KEY_PREFIX = "buzz.starredChannels."
/** The device-global key written before the set became per-account; read-only now. */
private val LEGACY_KEY = stringSetPreferencesKey("buzz.starredChannels")
}
}
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
@@ -35,36 +36,56 @@ import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]),
* so the app knows which relays to connect + NIP-42-authenticate + run member-channel discovery
* against on a cold start Buzz membership is server-side (granted by the HTTP invite claim),
* with no NIP-51/kind-10009 join event to rebuild the set from. Uses the app-wide
* [sharedPreferencesDataStore] like [BuzzAttestationPreferences] (not per-account: a joined
* relay is workspace-wide, and restoring only marks relays to sync the relay still gates every
* read/write by the authenticated key).
* Per-account persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]), so the
* app knows which relays to connect + NIP-42-authenticate + run member-channel discovery against on
* a cold start Buzz membership is server-side (granted by the HTTP invite claim), with no
* NIP-51/kind-10009 join event to rebuild the set from.
*
* On construction it loads the saved relay URLs into the singleton (re-normalizing each, dropping
* any that no longer parse), then mirrors every later change back to disk. Construct once, eagerly.
* **Per account, not per device.** The set used to be one device-global key shared by every logged-in
* account, on the reasoning that restoring only marks relays to sync and the relay gates each
* read/write by the authenticated key anyway. That missed one consumer: the joined set also makes a
* relay first-party in `AuthCoordinator.isFirstParty`, so one account joining a workspace silently
* gave *every* other logged-in account an automatic NIP-42 login there the bystander-account leak
* the per-account gate exists to prevent. The key is namespaced by pubkey for the same reason the
* relay-auth overrides moved to a per-account file.
*
* Still on the app-wide [sharedPreferencesDataStore] file the namespacing, not the file, is what
* separates accounts, and one file avoids a second DataStore per logged-in account.
*
* On construction it loads this account's saved relay URLs into [workspaces] (re-normalizing each,
* dropping any that no longer parse), then mirrors every later change back to disk. Construct once
* per account, eagerly.
*/
@Stable
class BuzzWorkspacePreferences(
private val context: Context,
private val scope: CoroutineScope,
private val pubKeyHex: HexKey,
private val workspaces: BuzzWorkspaces,
) {
private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex")
init {
scope.launch {
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value present
// at collection start, which restoreFromDisk already wrote).
BuzzWorkspaces.flow.drop(1).collect { persist(it) }
workspaces.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val prefs = context.sharedPreferencesDataStore.data.first()
// Fall back to the pre-namespacing device-global key so an upgrade doesn't empty the
// workspaces hub. That set is whatever any account joined, which is exactly what every
// account already saw before this became per-account — so seeding from it changes
// nothing that was true yesterday, and the first join after the upgrade writes to this
// account's own key and takes over. The legacy key is left in place for the other
// accounts to seed from; nothing writes it again.
val raw = prefs[key] ?: prefs[LEGACY_KEY] ?: return
val relays = raw.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
if (relays.isNotEmpty()) BuzzWorkspaces.restore(relays)
if (relays.isNotEmpty()) workspaces.restore(relays)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzWorkspacePrefs") { "Error reading joined workspaces: ${e.message}" }
@@ -73,8 +94,11 @@ class BuzzWorkspacePreferences(
private suspend fun persist(relays: Set<NormalizedRelayUrl>) {
try {
// Always write the joined set, empty included — never remove the key. An absent key
// means "never migrated" and re-seeds from the legacy one above, so removing it
// would undo the user's last removal on the next launch.
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = relays.map { it.url }.toSet()
prefs[key] = relays.map { it.url }.toSet()
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -83,6 +107,9 @@ class BuzzWorkspacePreferences(
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
private const val KEY_PREFIX = "buzz.joinedWorkspaces."
/** The device-global key written before the set became per-account; read-only now. */
private val LEGACY_KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
}
}
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import androidx.compose.runtime.Stable
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
import com.vitorpamplona.amethyst.ui.navigation.drawer.drawerSectionIdsFromNames
import com.vitorpamplona.amethyst.ui.navigation.drawer.toNames
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the drawer section headings the user has collapsed, so the side menu
* comes back folded the way they left it instead of springing fully open on every launch. Which
* headings are folded is a per-device view choice, so unlike the hidden rows beside it in the drawer
* it is never published to relays.
*
* Mirrors [RelayGroupDeletionPreferences]: app-wide (not per-account), loads the saved names on
* construction, then writes every later change back. Takes the [DataStore] rather than a `Context`
* so the whole cycle is exercised by a plain unit test against a temp file.
*
* Construct once, eagerly. The restore is a fire-and-forget coroutine, not a barrier, so the flow
* reads as "nothing collapsed" until it lands; building this at app startup rather than on first use
* puts that read many frames ahead of the drawer's first composition (and the store's file has
* already been parsed by then, for `UiSharedPreferences`). Worst case if it ever lost that race is
* cosmetic a heading renders open and then folds which is why no one waits on it.
*/
@Stable
class DrawerSectionCollapsePreferences(
private val store: DataStore<Preferences>,
scope: CoroutineScope,
) {
private val collapsed = MutableStateFlow<Set<DrawerSectionId>>(emptySet())
/** The collapsed headings; the drawer collects this to decide which sections render their rows. */
val flow: StateFlow<Set<DrawerSectionId>> = collapsed.asStateFlow()
init {
scope.launch {
restoreFromDisk()
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
collapsed.drop(1).collect { persist(it) }
}
}
/** Collapses [section] if expanded, expands it if collapsed. Safe to call from the main thread. */
fun toggle(section: DrawerSectionId) = collapsed.update { if (section in it) it - section else it + section }
private suspend fun restoreFromDisk() {
try {
val raw = store.data.first()[KEY] ?: return
collapsed.value = drawerSectionIdsFromNames(raw)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("DrawerSectionCollapsePrefs") { "Error reading collapsed drawer sections: ${e.message}" }
}
}
private suspend fun persist(sections: Set<DrawerSectionId>) {
try {
store.edit { prefs -> prefs[KEY] = sections.toNames() }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("DrawerSectionCollapsePrefs") { "Error writing collapsed drawer sections: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("ui.drawer.collapsedSections")
}
}
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.serverList
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
/**
* The relays the app is **guessing** on the user's behalf because it has not seen their lists yet.
*
* Non-empty only while the corresponding event is absent never because a list is empty, which is
* a choice we honor (see `relayListOrDefaultsWhenUnknown`). It therefore empties itself, per list,
* the moment the user's own data lands; no window, no timeout, no bookkeeping.
*
* **Deliberately NOT merged into [TrustedRelayListsState].** That one feeds `Account.isInMyRelayList`
* -> `RelayAuthPermissionLedger` -> `RelayAuthResolver`, i.e. the NIP-42 AUTH decision. Guessed
* relays must never make the app sign an AUTH challenge as though they were the user's own that
* would turn a timing signal into a signed identity assertion. The single consumer of this flow is
* Tor routing.
*/
class AssumedRelayListsState(
val nip65RelayList: Nip65RelayListState,
val searchRelayList: SearchRelayListState,
val indexerRelayList: IndexerRelayListState,
val scope: CoroutineScope,
) {
val flow: StateFlow<Set<NormalizedRelayUrl>> =
combine(
nip65RelayList.assumedDefaultsFlow,
searchRelayList.assumedDefaultsFlow,
indexerRelayList.assumedDefaultsFlow,
) { nip65, search, indexer ->
nip65 + search + indexer
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
kotlinx.coroutines.flow.SharingStarted.Eagerly,
nip65RelayList.assumedDefaultsFlow.value +
searchRelayList.assumedDefaultsFlow.value +
indexerRelayList.assumedDefaultsFlow.value,
)
}
@@ -20,14 +20,17 @@
*/
package com.vitorpamplona.amethyst.model.torState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
@@ -35,112 +38,132 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
/**
* Pushes the relay classifications [TorRelayState] needs which relays are DM, trusted, guessed, or
* money-operation relays as a union across every logged-in account.
*
* All four are the same fold: pick one set per account, union them, publish. It used to be written
* out four times at ~30 lines each, and the copies had already drifted apart in trivial ways (an
* `if (isEmpty)` guard that could never fire, differently-named accumulators). Sharing one
* implementation is what keeps a fifth classification from being another 30 lines of the same
* thing and, more importantly, from being 30 lines that quietly forget a step.
*/
class AccountsTorStateConnector(
accountsCache: AccountCacheState,
torEvaluatorFlow: TorRelayState,
scope: CoroutineScope,
) {
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
val allDmRelayFlows: Flow<Set<NormalizedRelayUrl>> =
accountsCache.accounts
.debounce(200)
.transformLatest { snapshot ->
val dmFlows = snapshot.map { it.value.dmRelayList.flow }
val dmFlowReady =
dmFlows.ifEmpty {
listOf(MutableStateFlow(emptySet()))
}
if (dmFlowReady.isEmpty()) {
emit(emptySet())
} else {
emitAll(
combine(dmFlowReady) {
val dmRelays = mutableSetOf<NormalizedRelayUrl>()
it.forEach {
dmRelays.addAll(it)
}
dmRelays.toSet()
},
)
}
}.onEach {
torEvaluatorFlow.dmRelays.tryEmit(it)
}.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
/**
* Union of one relay set across all logged-in accounts, republished into [TorRelayState].
*
* `debounce(200)` rides out the burst of account churn at login; `transformLatest` drops the
* previous fan-in when the account set changes so a logged-out account cannot keep contributing.
* The seed is `emptySet()` for every classification: before any account exists, nothing is
* classified.
*
* Takes its collaborators as parameters rather than reading constructor properties because the
* call sites are property initializers, where non-`val` constructor parameters are in scope but
* member functions cannot see them.
*/
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val allTrustedRelaysFlow: Flow<Set<NormalizedRelayUrl>> =
private fun unionAcrossAccounts(
accountsCache: AccountCacheState,
scope: CoroutineScope,
select: (Account) -> Flow<Set<NormalizedRelayUrl>>,
publish: (Set<NormalizedRelayUrl>) -> Unit,
): StateFlow<Set<NormalizedRelayUrl>> =
accountsCache.accounts
.debounce(200)
.transformLatest { snapshot ->
val trustedRelayFlows = snapshot.map { it.value.trustedRelays.flow }
val trustedRelayFlowReady =
trustedRelayFlows.ifEmpty {
listOf(MutableStateFlow(emptySet()))
}
if (trustedRelayFlowReady.isEmpty()) {
emit(emptySet())
} else {
emitAll(
combine(trustedRelayFlowReady) {
val trustedRelays = mutableSetOf<NormalizedRelayUrl>()
it.forEach {
trustedRelays.addAll(it)
}
trustedRelays.toSet()
},
)
}
}.onEach {
torEvaluatorFlow.trustedRelays.tryEmit(it)
}.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
// Persistent money-operation relays across all accounts: NIP-47 wallet relays and saved CLINK
// Debits service relays. Feeds TorRelayState.moneyOpRelays so these connections honor the
// money-operations Tor preference instead of being classified as generic "new" relays.
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val allMoneyOpRelaysFlow: Flow<Set<NormalizedRelayUrl>> =
accountsCache.accounts
.debounce(200)
.transformLatest { snapshot ->
val perAccountFlows =
snapshot.map { (_, account) ->
combine(
account.settings.nwcWallets,
account.settings.clinkDebitWallets,
) { nwcWallets, clinkDebitWallets ->
val relays = mutableSetOf<NormalizedRelayUrl>()
nwcWallets.forEach { relays.add(it.uri.relayUri) }
clinkDebitWallets.forEach { relays.addAll(it.pointer.relays) }
relays.toSet()
}
}
val ready = perAccountFlows.ifEmpty { listOf(MutableStateFlow(emptySet())) }
val perAccount =
snapshot
.map { select(it.value) }
.ifEmpty { listOf(MutableStateFlow(emptySet())) }
emitAll(
combine(ready) { perAccount ->
val moneyOpRelays = mutableSetOf<NormalizedRelayUrl>()
perAccount.forEach { moneyOpRelays.addAll(it) }
moneyOpRelays.toSet()
combine(perAccount) { sets ->
sets.flatMapTo(mutableSetOf()) { it }
},
)
}.onEach {
torEvaluatorFlow.moneyOpRelays.tryEmit(it)
}.stateIn(
}.onEach(publish)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
/** NIP-17 DM relays: these follow the dedicated DM preference, never the generic "new" one. */
val allDmRelayFlows: StateFlow<Set<NormalizedRelayUrl>> =
unionAcrossAccounts(
accountsCache,
scope,
select = { it.dmRelayList.flow },
publish = { torEvaluatorFlow.dmRelays.tryEmit(it) },
)
/** Everything the user actually put in one of their own relay lists. */
val allTrustedRelaysFlow: StateFlow<Set<NormalizedRelayUrl>> =
unionAcrossAccounts(
accountsCache,
scope,
select = { it.trustedRelays.flow },
publish = { torEvaluatorFlow.trustedRelays.tryEmit(it) },
)
/**
* Relays the app is *guessing* while an account's own lists are unknown. Feeds
* [TorRelayState.assumedRelays] and nothing else see `AssumedRelayListsState` for why these
* must never reach the AUTH decision.
*
* Per account, so a second login cannot re-open the guess for an established one; each
* account's contribution empties itself as soon as that account's own lists land.
*/
val allAssumedRelaysFlow: StateFlow<Set<NormalizedRelayUrl>> =
unionAcrossAccounts(
accountsCache,
scope,
select = { it.assumedRelays.flow },
publish = {
logHandover(it)
torEvaluatorFlow.assumedRelays.tryEmit(it)
},
)
/**
* Persistent money-operation relays: NIP-47 wallet relays and saved CLINK Debits service
* relays, so these connections honor the money-operations preference rather than being
* classified as generic "new" relays.
*/
val allMoneyOpRelaysFlow: StateFlow<Set<NormalizedRelayUrl>> =
unionAcrossAccounts(
accountsCache,
scope,
select = { account ->
combine(
account.settings.nwcWallets,
account.settings.clinkDebitWallets,
) { nwcWallets, clinkDebitWallets ->
val relays = mutableSetOf<NormalizedRelayUrl>()
nwcWallets.forEach { relays.add(it.uri.relayUri) }
clinkDebitWallets.forEach { relays.addAll(it.pointer.relays) }
relays.toSet()
}
},
publish = { torEvaluatorFlow.moneyOpRelays.tryEmit(it) },
)
@Volatile private var lastAssumedCount: Int = -1
/**
* The handover is the whole contract of the guessed-relay feature: the moment a user's own
* lists arrive, every relay we were guessing about goes back to the policy they actually asked
* for. Logged at INFO because "did it hand over, and when" is not answerable from any other
* line the reconnect that follows looks identical to an ordinary one.
*/
private fun logHandover(relays: Set<NormalizedRelayUrl>) {
if (relays.size == lastAssumedCount) return
val released = if (relays.isEmpty()) " (own lists arrived; released to their real Tor policy)" else ""
Log.i("AccountsTorState") { "Guessed relays: $lastAssumedCount -> ${relays.size}$released" }
lastAssumedCount = relays.size
}
}
@@ -22,3 +22,5 @@ package com.vitorpamplona.amethyst.model.torState
// Canonical type now lives in commons
typealias TorRelayEvaluation = com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
typealias RelayClassification = com.vitorpamplona.amethyst.commons.tor.RelayClassification
@@ -46,6 +46,13 @@ class TorRelayState(
val dmRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val trustedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/**
* Relays guessed on the user's behalf while their own lists are unknown. Fed by
* [AccountsTorStateConnector]; see `AssumedRelayListsState` for why this is separate from
* [trustedRelays] rather than merged into it.
*/
val assumedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/**
* Relays known to be used for money operations from persistent configuration: NIP-47 wallet
* relays and saved CLINK Debits service relays. Fed by [AccountsTorStateConnector] across all
@@ -130,47 +137,49 @@ class TorRelayState(
currentSettings(),
)
val flow =
combineTransform(
torSettings,
private fun currentClassification() =
RelayClassification(
trusted = trustedRelays.value,
dm = dmRelays.value,
moneyOp = currentMoneyOpRelays(),
assumed = assumedRelays.value,
)
/**
* The four category sets as one value. Folding them here also keeps the evaluation flow below
* at two sources instead of six `combineTransform`'s typed overloads stop at five.
*/
private val classification =
combine(
trustedRelays,
dmRelays,
moneyOpRelays,
adHocMoneyOpCounts,
) {
torSettings: TorRelaySettings,
trustedRelayList: Set<NormalizedRelayUrl>,
dmRelayList: Set<NormalizedRelayUrl>,
moneyOpRelayList: Set<NormalizedRelayUrl>,
adHocMoneyOps: Map<NormalizedRelayUrl, Int>,
->
emit(
TorRelayEvaluation(
torSettings = torSettings,
trustedRelayList = trustedRelayList,
dmRelayList = dmRelayList,
moneyOpRelayList = moneyOpRelayList + adHocMoneyOps.keys,
),
assumedRelays,
) { trusted, dm, moneyOp, adHocMoneyOps, assumed ->
RelayClassification(
trusted = trusted,
dm = dm,
moneyOp = moneyOp + adHocMoneyOps.keys,
assumed = assumed,
)
}
val flow =
combineTransform(
torSettings,
classification,
) { torSettings: TorRelaySettings, classification: RelayClassification ->
emit(TorRelayEvaluation(torSettings, classification))
}.onStart {
emit(
TorRelayEvaluation(
torSettings = torSettings.value,
trustedRelayList = trustedRelays.value,
dmRelayList = dmRelays.value,
moneyOpRelayList = currentMoneyOpRelays(),
),
TorRelayEvaluation(torSettings.value, currentClassification()),
)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
TorRelayEvaluation(
torSettings = torSettings.value,
trustedRelayList = trustedRelays.value,
dmRelayList = dmRelays.value,
moneyOpRelayList = currentMoneyOpRelays(),
),
TorRelayEvaluation(torSettings.value, currentClassification()),
)
/**
@@ -178,13 +187,7 @@ class TorRelayState(
* snapshot. This makes ad-hoc money-op registration ([registerMoneyOpRelays]) take effect on the
* very next connection attempt, with no dependency on the combine pipeline having propagated yet.
*/
fun shouldUseTorForRelay(relay: NormalizedRelayUrl) =
TorRelayEvaluation(
torSettings = currentSettings(),
trustedRelayList = trustedRelays.value,
dmRelayList = dmRelays.value,
moneyOpRelayList = currentMoneyOpRelays(),
).useTor(relay)
fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = TorRelayEvaluation(currentSettings(), currentClassification()).useTor(relay)
fun okHttpClientForRelay(url: NormalizedRelayUrl): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForRelay(url))
}
@@ -31,7 +31,6 @@ import android.os.Message
import android.os.Messenger
import android.os.RemoteException
import android.os.SystemClock
import android.util.Log
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
@@ -50,6 +49,7 @@ import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
import com.vitorpamplona.amethyst.napplethost.NappletIpc
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
@@ -154,6 +154,21 @@ class NappletBrokerService : Service() {
private var foregroundLeaseWatchdog: Job? = null
private fun handleMessage(msg: Message): Boolean {
// A sandbox surface is being destroyed: drop every reference we hold to its Messenger. Holding a
// client's Messenger keeps a binder alive, which pins that surface's whole Activity (and its
// WebView) in the `:napplet` process past onDestroy — reclaimable only by killing the process.
if (msg.what == NappletIpc.MSG_RELEASE_CLIENT) {
msg.replyTo?.let { incBus.removeAll(it) }
// Release its foreground lease too; otherwise a destroyed surface keeps the main process
// pinned resumed until the lease watchdog expires it.
msg.data?.getString(NappletIpc.KEY_LAUNCH_TOKEN)?.let { token ->
synchronized(foregroundLeases) {
if (foregroundLeases.remove(token) != null) SandboxForegroundHold.release()
}
}
return true
}
// A sandbox surface (full-screen :napplet host) entered, renewed, or left the foreground. Hold the
// main process resumed while at least one is foreground, so opening it doesn't tear down Tor/relays.
if (msg.what == NappletIpc.MSG_SET_FOREGROUND) {
@@ -167,7 +182,7 @@ class NappletBrokerService : Service() {
// could still spam distinct keys to keep the network up. Bound the damage: refuse new
// lease keys past the cap. Real usage holds only a handful of foreground surfaces.
if (firstReport && foregroundLeases.size >= MAX_FOREGROUND_LEASES) {
Log.w("NappletBrokerService", "Foreground lease cap reached; ignoring new lease $token")
Log.w("NappletBrokerService") { "Foreground lease cap reached; ignoring new lease $token" }
return true
}
foregroundLeases[token] = SystemClock.elapsedRealtime()
@@ -359,7 +374,7 @@ class NappletBrokerService : Service() {
while (iterator.hasNext()) {
val entry = iterator.next()
if (now - entry.value > FOREGROUND_LEASE_TTL_MS) {
Log.w("NappletBrokerService", "Foreground lease ${entry.key} expired (host process gone?); releasing hold")
Log.w("NappletBrokerService") { "Foreground lease ${entry.key} expired (host process gone?); releasing hold" }
iterator.remove()
SandboxForegroundHold.release()
}
@@ -117,7 +117,7 @@ object SandboxForegroundHold {
synchronized(this@SandboxForegroundHold) {
// A surface may have re-acquired while we waited; only tear down if still released.
if (holdCount == 0) {
Log.d("SandboxForegroundHold", "No foreground sandbox surface for ${LINGER_MS}ms; releasing the resource hold")
Log.d("SandboxForegroundHold") { "No foreground sandbox surface for ${LINGER_MS}ms; releasing the resource hold" }
holdJob?.cancel()
holdJob = null
}
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.os.Bundle
import androidx.activity.ComponentActivity
import com.vitorpamplona.amethyst.napplethost.WebFileChooserLauncher
/**
* Invisible main-process host for one file pick made on behalf of an **embedded** WebView surface.
*
* The surface renders from the keyless `:napplet` process, which has no Activity to start a picker or
* a permission prompt from, so [WebFileChooserCoordinator] launches this instead. It exists only long
* enough to run the pick and report the result, and it reports on every exit a chosen file, a
* cancel, a system teardown because the page's `<input type="file">` stays busy until it hears
* something back.
*/
class WebFileChooserActivity : ComponentActivity() {
private var token: String? = null
private var reported = false
// Field, not a local: registerForActivityResult must run before this activity reaches STARTED.
private val chooser =
WebFileChooserLauncher(this) { uris ->
report(uris?.map { it.toString() }?.toTypedArray())
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val token = intent.getStringExtra(WebFileChooserCoordinator.EXTRA_TOKEN)
this.token = token
val ask = token?.let { WebFileChooserCoordinator.pendingFor(it) }
if (ask == null) {
// No pending request under this token: the surface went away, or the process restarted and
// the request died with it. Nothing to report to.
reported = true
finish()
return
}
// A recreated instance has lost the in-flight request that its result would be matched against
// (configChanges keeps this rare — a system kill, not a rotation), and relaunching would stack a
// second picker on the first. Release the page's input now rather than let it wait on a result
// that can no longer be routed anywhere.
if (savedInstanceState != null) {
finish()
return
}
chooser.launch(
acceptTypes = ask.acceptTypes,
allowMultiple = ask.allowMultiple,
captureEnabled = ask.captureEnabled,
pageTitle = ask.pageTitle,
)
}
/** Fail-open toward the page: any unreported teardown still releases its file input. */
override fun finish() {
report(null)
super.finish()
}
/**
* The system can destroy this host without ever calling [finish] a low-memory kill while the
* picker is on top. Without this the page's file input would wait on a result nobody is left to
* send, dead for the life of the page, and the coordinator would hold the reply callback (and the
* controller behind it) forever.
*/
override fun onDestroy() {
chooser.teardown()
report(null)
super.onDestroy()
}
private fun report(uris: Array<String>?) {
if (reported) return
reported = true
token?.let { WebFileChooserCoordinator.complete(it, uris) }
}
}
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import android.content.Intent
import com.vitorpamplona.quartz.utils.Log
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* Runs the system file picker on behalf of an **embedded** WebView surface.
*
* The two embedded providers ([NappletBrowserService][com.vitorpamplona.amethyst.napplethost.NappletBrowserService]
* and [NappletHostService][com.vitorpamplona.amethyst.napplethost.NappletHostService]) host their
* WebView in the keyless `:napplet` process as a windowless Service, so when a page taps
* `<input type="file">` there is no Activity there to start a picker from. They send the *description*
* of the request over Messenger instead; this holds it here in the main process and launches
* [WebFileChooserActivity], which runs the picker (and the camera, and the CAMERA permission prompt
* that a `capture` input needs) and reports back to the caller, which relays the URIs to the sandbox.
*
* Mirrors [NappletConsentCoordinator]: the pending request is keyed by a one-time token so the
* throwaway Activity carries nothing but that token. Every request completes exactly once a
* dismissed picker resolves to null, which is what releases the page's file input.
*
* URI read grants are per-UID, so the `content://` URIs granted to this process are readable by the
* WebView in `:napplet` without any re-granting.
*/
object WebFileChooserCoordinator {
/** The request as it arrived from the sandbox, plus where to send the answer. */
class Pending(
val acceptTypes: List<String>,
val allowMultiple: Boolean,
val captureEnabled: Boolean,
val pageTitle: String?,
val onResult: (Array<String>?) -> Unit,
)
private val pending = ConcurrentHashMap<String, Pending>()
/**
* Shows a picker for [acceptTypes] and calls [onResult] with the picked URIs as strings, or null
* when nothing was chosen. [onResult] always runs, including when no picker host could be started
* at all the page's file input is waiting on it.
*/
fun request(
context: Context,
acceptTypes: List<String>,
allowMultiple: Boolean,
captureEnabled: Boolean,
pageTitle: String?,
onResult: (Array<String>?) -> Unit,
) {
val token = UUID.randomUUID().toString()
pending[token] = Pending(acceptTypes, allowMultiple, captureEnabled, pageTitle, onResult)
val launch =
Intent(context, WebFileChooserActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_TOKEN, token)
runCatching { context.startActivity(launch) }
.onFailure { e ->
Log.w(TAG, "Could not start the file chooser host", e)
complete(token, null)
}
}
/** Called by [WebFileChooserActivity] to learn what to ask the user for. */
fun pendingFor(token: String): Pending? = pending[token]
/** Called by [WebFileChooserActivity] with the outcome; null = nothing chosen. Resolves at most once. */
fun complete(
token: String,
uris: Array<String>?,
) {
pending.remove(token)?.onResult?.invoke(uris)
}
const val EXTRA_TOKEN = "web_file_chooser_token"
private const val TAG = "WebFileChooser"
}
@@ -46,7 +46,6 @@ import okhttp3.Authenticator
import okhttp3.Call
import okhttp3.Callback
import okhttp3.CookieJar
import okhttp3.Dns
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
@@ -54,6 +53,7 @@ import okhttp3.Request
import okhttp3.Response
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.InterruptedIOException
import java.net.InetAddress
import java.net.URLDecoder
@@ -112,15 +112,13 @@ class NappletResourceFetcher(
.authenticator(Authenticator.NONE)
.proxyAuthenticator(Authenticator.NONE)
.callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.dns(
Dns { hostname ->
baseClient.dns.lookup(hostname).also { addresses ->
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
throw BlockedResourceException("Resolved address is not public.")
}
.dns { hostname ->
baseClient.dns.lookup(hostname).also { addresses ->
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
throw BlockedResourceException("Resolved address is not public.")
}
},
).addNetworkInterceptor { chain ->
}
}.addNetworkInterceptor { chain ->
chain.proceed(
chain
.request()
@@ -193,7 +191,7 @@ class NappletResourceFetcher(
is NProfile -> resolveReplaceable(0, entity.hex)
else -> null
} ?: return null
return NappletResource(event.toJson().encodeToByteArray(), "application/json")
return NappletResource(event.toJson().encodeToByteArray(), MIME_JSON)
}
/** A non-replaceable event by id: local cache first, then a bounded relay fetch. */
@@ -300,7 +298,7 @@ class NappletResourceFetcher(
meta
.removeSuffix(";base64")
.substringBefore(';')
.ifEmpty { "text/plain" }
.ifEmpty { MIME_PLAIN_TEXT }
.lowercase()
val bytes =
if (isBase64) {
@@ -323,8 +321,8 @@ class NappletResourceFetcher(
val type =
when {
sniffed in ALLOWED_SNIFFED_TYPES -> sniffed
declaredType == "application/json" && isJson(bytes) -> "application/json"
declaredType == "text/plain" && isPlainText(bytes) -> "text/plain"
declaredType == MIME_JSON && isJson(bytes) -> MIME_JSON
declaredType == MIME_PLAIN_TEXT && isPlainText(bytes) -> MIME_PLAIN_TEXT
else -> null
} ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.")
return success(NappletResource(bytes, type))
@@ -353,7 +351,7 @@ class NappletResourceFetcher(
message: String? = null,
): NappletResourceResult = NappletResourceResult.Failure(error, message)
private fun readBounded(input: java.io.InputStream): ByteArray? {
private fun readBounded(input: InputStream): ByteArray? {
input.use { source ->
val output = ByteArrayOutputStream()
val buffer = ByteArray(8 * 1024)
@@ -417,6 +415,8 @@ class NappletResourceFetcher(
private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme"
private const val ERROR_DECODE_FAILED = "decode-failed"
private const val ERROR_NETWORK = "network-error"
private const val MIME_JSON = "application/json"
private const val MIME_PLAIN_TEXT = "text/plain"
private val SHA256 = Regex("^[0-9a-f]{64}$")
private val ALLOWED_SNIFFED_TYPES =
setOf(
@@ -432,5 +432,5 @@ class NappletResourceFetcher(
private class BlockedResourceException(
message: String,
) : java.io.IOException(message)
) : IOException(message)
}
@@ -26,9 +26,10 @@ import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
@@ -159,15 +160,17 @@ class V4VPaymentHandler(
tlvRecords = tlvRecords,
)
account.zaps.sendNwcRequest(request) { response: Response? ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
response.errorMessage()
?: stringRes(context, R.string.error_parsing_error_message),
)
}
}
account.zaps.sendNwcRequest(
request = request,
onResponse = { response: Response? ->
response.nwcFailureDetail(context)?.let { detail ->
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
}
},
onTimeout = {
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
},
)
}
}
@@ -250,15 +253,18 @@ class V4VPaymentHandler(
is PaymentSource.Nwc -> {
var done = 0
payables.forEach { payable ->
account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
response.errorMessage()
?: stringRes(context, R.string.error_parsing_error_message),
)
}
}
account.zaps.sendZapPaymentRequestFor(
bolt11 = payable.invoice,
zappedNote = zappedNote,
onResponse = { response ->
response.nwcFailureDetail(context)?.let { detail ->
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
}
},
onTimeout = {
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
},
)
done++
onProgress(done.toFloat() / payables.size)
}
@@ -29,10 +29,12 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
@@ -60,6 +62,11 @@ class ZapPaymentHandler(
val info: MyZapSplitSetup,
val amountMilliSats: Long,
val invoice: String,
// The signed kind 9734 this invoice was fetched with, and the message on it.
// Carried so the NWC payment can name the payee (NWC-06 `metadata`); null for
// a NONZAP split, which has no zap request to send.
val zapRequest: LnZapRequestEvent? = null,
val message: String = "",
)
data class UnverifiedZapSplitSetup(
@@ -417,21 +424,30 @@ class ZapPaymentHandler(
account.zaps.sendZapPaymentRequestFor(
bolt11 = payable.invoice,
zappedNote = note,
// Dropped unless the wallet advertises NWC-06 — see NwcSignerState.
metadata =
NwcTransactionMetadata.build(
zapRequest = payable.zapRequest,
recipientIdentifier = payable.info.lnAddress,
comment = payable.message,
),
onResponse = { response ->
progress.step()
if (response is PayInvoiceErrorResponse) {
response.nwcFailureDetail(context)?.let { detail ->
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
stringRes(
context,
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
),
stringRes(context, R.string.wallet_connect_pay_invoice_error_error, detail),
payable.info.user,
)
}
},
onTimeout = {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
nwcTimeoutMessage(context),
payable.info.user,
)
},
)
progress.step()
@@ -551,6 +567,10 @@ class ZapPaymentHandler(
): Payable {
var progressThisPayment = 0.00f
// Only the request the provider actually accepted may be claimed as bound to
// this invoice; see lnAddressInvoice's onZapRequestSent.
var sentZapRequest: LnZapRequestEvent? = null
val invoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lud16,
@@ -564,6 +584,7 @@ class ZapPaymentHandler(
onProgressStep(step)
},
context = context,
onZapRequestSent = { sentZapRequest = it },
)
onProgressStep(1 - progressThisPayment)
@@ -572,6 +593,8 @@ class ZapPaymentHandler(
info = splitSetup,
amountMilliSats = zapValue,
invoice = invoice,
zapRequest = sentZapRequest,
message = message,
)
}
}
@@ -1,99 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.ai
import kotlinx.coroutines.delay
/**
* TODO: Remove before shipping. Debug-only mock for testing the AI writing help UI
* on devices without Gemini Nano / AICore support.
*/
class MockWritingAssistant : WritingAssistant {
override suspend fun checkAvailability(): WritingAssistantStatus = WritingAssistantStatus.Available
override suspend fun transform(
text: String,
tone: WritingTone,
): WritingResult {
delay(800)
val transformed =
when (tone) {
WritingTone.CORRECT -> {
correctMock(text)
}
WritingTone.REPHRASE -> {
"Here's another way to put it: $text"
}
WritingTone.SHORTER -> {
text
.split(".")
.firstOrNull()
?.trim()
?.plus(".") ?: text
}
WritingTone.ELABORATE -> {
"$text Furthermore, this point deserves deeper consideration and nuance."
}
WritingTone.FRIENDLY -> {
"Hey! $text Hope that makes sense! :)"
}
WritingTone.PROFESSIONAL -> {
"I would like to bring to your attention the following: $text"
}
WritingTone.MORE_DIRECT -> {
text.replace("I think ", "").replace("maybe ", "").replace("perhaps ", "")
}
WritingTone.PUNCHY -> {
text.uppercase().replace(".", "!")
}
WritingTone.EMOJIFY -> {
"$text \uD83D\uDE80\uD83D\uDD25\u2728"
}
}
return WritingResult(
originalText = text,
transformedText = transformed,
tone = tone,
)
}
private fun correctMock(text: String): String =
text
.replace("teh ", "the ")
.replace("dont ", "don't ")
.replace("cant ", "can't ")
.replace("wont ", "won't ")
.replace("i ", "I ")
override fun close() {
// no-op: mock holds no native handles or background workers to release.
}
}
@@ -25,6 +25,15 @@ import androidx.compose.runtime.Immutable
interface WritingAssistant {
suspend fun checkAvailability(): WritingAssistantStatus
/**
* Asks the platform to fetch the on-device model when [checkAvailability] reported
* [WritingAssistantStatus.Downloadable]. Returns the status after the attempt.
*
* Implementations must be safe to call repeatedly: only the first call per instance
* starts a download, later ones just report the current status.
*/
suspend fun requestDownload(): WritingAssistantStatus
suspend fun transform(
text: String,
tone: WritingTone,
@@ -40,8 +49,6 @@ enum class WritingTone {
ELABORATE,
FRIENDLY,
PROFESSIONAL,
MORE_DIRECT,
PUNCHY,
EMOJIFY,
}
@@ -50,6 +57,9 @@ sealed class WritingAssistantStatus {
data object Unavailable : WritingAssistantStatus()
/** The device supports the model but it has not been fetched yet. */
data object Downloadable : WritingAssistantStatus()
data object Downloading : WritingAssistantStatus()
}
@@ -53,6 +53,7 @@ class CallForegroundService : Service() {
const val ACTION_UPDATE = "com.vitorpamplona.amethyst.CALL_UPDATE"
const val EXTRA_PEER_NAME = "peer_name"
const val EXTRA_IS_VIDEO = "is_video"
const val EXTRA_IS_SCREEN_SHARING = "is_screen_sharing"
const val EXTRA_STATUS_TEXT = "status_text"
const val EXTRA_IS_RINGING = "is_ringing"
private const val HANGUP_REQUEST_CODE = 0x70001
@@ -75,6 +76,7 @@ class CallForegroundService : Service() {
ACTION_START, ACTION_UPDATE -> {
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
val isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false)
val isScreenSharing = intent.getBooleanExtra(EXTRA_IS_SCREEN_SHARING, false)
val isRinging = intent.getBooleanExtra(EXTRA_IS_RINGING, false)
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT)
val notification = buildNotification(peerName, statusText)
@@ -97,6 +99,9 @@ class CallForegroundService : Service() {
if (isVideo && hasCameraPermission) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
}
if (isScreenSharing) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
}
type
} else {
@@ -108,7 +113,11 @@ class CallForegroundService : Service() {
try {
val fallbackType =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
if (isScreenSharing) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
type
} else {
0
}
@@ -142,6 +151,23 @@ class CallForegroundService : Service() {
* it at 3 seconds as a safety net.
*/
override fun onTaskRemoved(rootIntent: Intent?) {
val removed = rootIntent?.component?.className
Log.d(TAG) { "onTaskRemoved root=$removed" }
// Only the call's own task going away means the user dismissed the call. This callback
// fires for *every* task of the app, and MainActivity lives in a separate one (it is
// `singleInstance`, and CallActivity is launched with FLAG_ACTIVITY_NEW_TASK). Android
// reclaims that backgrounded MainActivity task on its own while a call is running —
// notably a few hundred ms after CallActivity enters picture-in-picture on HOME — and
// hanging up on it ended calls the user never touched.
//
// A null root intent leaves the source unknown; treat it as a dismissal so a genuinely
// swiped-away app can't leave a call running with no UI to end it.
if (removed != null && removed != CallActivity::class.java.name) {
super.onTaskRemoved(rootIntent)
return
}
publishHangupBlocking()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service.call
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjection
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow
@@ -65,9 +67,20 @@ class CallMediaManager(
var localVideoTrack: VideoTrack? = null
private set
var screenVideoSource: VideoSource? = null
private set
var screenVideoTrack: VideoTrack? = null
private set
private var cameraCapturer: CameraVideoCapturer? = null
private var surfaceTextureHelper: SurfaceTextureHelper? = null
private var screenCapturer: ScreenShareCapturer? = null
private var screenSurfaceTextureHelper: SurfaceTextureHelper? = null
private var usingFrontCamera: Boolean = true
private var cameraWasEnabledBeforeScreenShare = false
private var stoppingScreenShare = false
var onScreenShareEnded: (() -> Unit)? = null
private val _localVideoTrackFlow = MutableStateFlow<VideoTrack?>(null)
val localVideoTrackFlow: StateFlow<VideoTrack?> = _localVideoTrackFlow.asStateFlow()
@@ -75,6 +88,9 @@ class CallMediaManager(
private val _isVideoEnabled = MutableStateFlow(false)
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
private val _isScreenSharing = MutableStateFlow(false)
val isScreenSharing: StateFlow<Boolean> = _isScreenSharing.asStateFlow()
private val _isFrontCamera = MutableStateFlow(true)
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
@@ -146,6 +162,136 @@ class CallMediaManager(
captureFps = fps
}
/**
* Starts Android's system screen capture using the one-shot permission result supplied by
* [android.media.projection.MediaProjectionManager]. The returned track is owned by this
* manager and must be attached to the peer senders before [stopScreenShare] releases it.
*/
@Synchronized
fun startScreenShare(permissionData: Intent): VideoTrack {
screenVideoTrack?.let { return it }
val factory = peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
val egl = sharedEglBase ?: throw IllegalStateException("EGL context not initialized")
val displayMetrics = context.resources.displayMetrics
val captureSize = screenShareCaptureSize(displayMetrics.widthPixels, displayMetrics.heightPixels)
cameraWasEnabledBeforeScreenShare = _isVideoEnabled.value
var source: VideoSource? = null
var track: VideoTrack? = null
var helper: SurfaceTextureHelper? = null
var capturer: ScreenShareCapturer? = null
try {
val createdSource = factory.createVideoSource(true)
source = createdSource
val createdTrack = factory.createVideoTrack("screen0", createdSource)
track = createdTrack
val createdHelper = SurfaceTextureHelper.create("ScreenCaptureThread", egl.eglBaseContext)
helper = createdHelper
val createdCapturer =
ScreenShareCapturer(
permissionData,
object : MediaProjection.Callback() {
override fun onStop() {
if (!stoppingScreenShare) {
onScreenShareEnded?.invoke()
}
}
},
)
capturer = createdCapturer
screenVideoSource = createdSource
screenVideoTrack = createdTrack
screenSurfaceTextureHelper = createdHelper
screenCapturer = createdCapturer
if (cameraWasEnabledBeforeScreenShare) {
stopCamera()
}
createdCapturer.initialize(createdHelper, context, createdSource.capturerObserver)
createdCapturer.startCapture(captureSize.width, captureSize.height, captureFps)
_isScreenSharing.value = true
_isVideoEnabled.value = true
_localVideoTrackFlow.value = createdTrack
return createdTrack
} catch (e: Exception) {
screenCapturer = null
screenSurfaceTextureHelper = null
screenVideoTrack = null
screenVideoSource = null
runCatching { capturer?.dispose() }
runCatching { helper?.dispose() }
runCatching { track?.dispose() }
runCatching { source?.dispose() }
if (cameraWasEnabledBeforeScreenShare) {
_isVideoEnabled.value = true
_localVideoTrackFlow.value = localVideoTrack
startCamera()
}
cameraWasEnabledBeforeScreenShare = false
throw e
}
}
/**
* Stops screen capture and restores the camera state from before sharing started. The
* returned resources remain alive until the caller has replaced every [RtpSender] track.
*
* Pass `restoreCamera = false` when the whole session is going away see [dispose]. Restoring
* a camera that is about to be torn down opens the device for a few hundred milliseconds
* (lighting the privacy indicator) and makes the following [stopCamera] block waiting for the
* capture session it just started.
*/
@Synchronized
fun stopScreenShare(restoreCamera: Boolean = true): ScreenShareResources? {
val track = screenVideoTrack ?: return null
val source = requireNotNull(screenVideoSource)
stoppingScreenShare = true
try {
screenCapturer?.let { capturer ->
runCatching { capturer.stopCapture() }
runCatching { capturer.dispose() }
}
screenSurfaceTextureHelper?.let { runCatching { it.dispose() } }
} finally {
screenCapturer = null
screenSurfaceTextureHelper = null
screenVideoTrack = null
screenVideoSource = null
_isScreenSharing.value = false
if (restoreCamera && cameraWasEnabledBeforeScreenShare) {
recreateCameraResources()
_isVideoEnabled.value = true
_localVideoTrackFlow.value = localVideoTrack
startCamera()
} else {
_isVideoEnabled.value = false
_localVideoTrackFlow.value = localVideoTrack
}
cameraWasEnabledBeforeScreenShare = false
stoppingScreenShare = false
}
return ScreenShareResources(track, source)
}
fun disposeScreenShareResources(resources: ScreenShareResources?) {
resources ?: return
runCatching { resources.track.dispose() }
runCatching { resources.source.dispose() }
}
private fun recreateCameraResources() {
val factory = peerConnectionFactory ?: return
// RtpSender.setTrack may release the previous native track wrapper, so restore a fresh
// camera track before binding it back to the senders.
runCatching { localVideoTrack?.dispose() }
runCatching { localVideoSource?.dispose() }
localVideoSource = factory.createVideoSource(false)
localVideoTrack = factory.createVideoTrack("video0", localVideoSource)
}
@Synchronized
fun startCamera() {
if (cameraCapturer != null) return
val source = localVideoSource ?: return
@@ -186,6 +332,7 @@ class CallMediaManager(
)
}
@Synchronized
fun stopCamera() {
try {
cameraCapturer?.stopCapture()
@@ -215,6 +362,10 @@ class CallMediaManager(
}
fun dispose() {
// Everything below tears the camera down, so don't let the screen-share stop bring it back
// up first — that opened the device mid-hangup and made stopCamera() wait on it.
val screenResources = stopScreenShare(restoreCamera = false)
disposeScreenShareResources(screenResources)
try {
stopCamera()
} catch (e: Exception) {
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service.call
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -34,33 +36,61 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
* whose lifetime is tied to the Activity's lifecycle.
*/
object CallSessionBridge {
/** Account-scoped: survives MainActivity being destroyed mid-call. */
var callManager: CallManager? = null
private set
/** Account-scoped: everything [CallActivity] needs (signer, settings, signaling publish). */
var account: Account? = null
private set
/**
* Activity-scoped, and therefore nullable at any time: MainActivity can be destroyed while a
* call is still running. Only consumers that genuinely need ViewModel-level helpers should
* read this, and they must tolerate null.
*/
var accountViewModel: AccountViewModel? = null
private set
fun set(
callManager: CallManager,
account: Account,
accountViewModel: AccountViewModel,
) {
this.callManager = callManager
this.account = account
this.accountViewModel = accountViewModel
}
/**
* Resets call state and clears all references. Called from
* [AccountViewModel.onCleared] during logout or account switch.
* Drops only the Activity-scoped [accountViewModel] reference. Called from
* [AccountViewModel.onCleared], which fires on every MainActivity destruction including
* while a call is in progress so it must leave [callManager] and [account] intact.
*
* While a call is up the reference is kept: [CallActivity] still renders its UI from this
* ViewModel and holds a strong reference to it either way, so clearing here would free
* nothing and would only break the running call's UI. It is replaced wholesale by [set] as
* soon as MainActivity comes back, and dropped by [clear] on logout / account switch.
*/
fun clearViewModel() {
if (callManager?.state?.value !is CallState.Idle) return
accountViewModel = null
}
/**
* Ends the current call and clears all references. Called on a real logout or account switch
* from `AccountSessionManager`, alongside `NestBridge.clear()`.
*
* Uses [CallManager.reset] (non-blocking, no mutex) instead of
* [CallManager.hangup] to avoid deadlocking on `stateMutex` if
* a cancelled coroutine on the dying `viewModelScope` still holds
* it. Hangup signaling to the remote peer is the responsibility
* of [CallActivity.onDestroy] and [CallForegroundService], not
* a cancelled coroutine still holds it. Hangup signaling to the remote peer is the
* responsibility of [CallActivity.onDestroy] and [CallForegroundService], not
* the bridge teardown.
*/
fun clear() {
callManager?.reset()
callManager = null
account = null
accountViewModel = null
}
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
import org.webrtc.VideoSource
import org.webrtc.VideoTrack
import kotlin.math.min
import kotlin.math.roundToInt
internal data class ScreenShareCaptureSize(
val width: Int,
val height: Int,
)
internal fun screenShareCaptureSize(
widthPixels: Int,
heightPixels: Int,
maxDimension: Int = 1920,
): ScreenShareCaptureSize {
val width = widthPixels.coerceAtLeast(2)
val height = heightPixels.coerceAtLeast(2)
val scale = min(1f, maxDimension.toFloat() / maxOf(width, height))
fun even(value: Int): Int = value.coerceAtLeast(2).let { it - it % 2 }
return ScreenShareCaptureSize(
width = even((width * scale).roundToInt()),
height = even((height * scale).roundToInt()),
)
}
data class ScreenShareResources(
val track: VideoTrack,
val source: VideoSource,
)
@@ -0,0 +1,219 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.view.Surface
import org.webrtc.CapturerObserver
import org.webrtc.SurfaceTextureHelper
import org.webrtc.ThreadUtils
import org.webrtc.VideoCapturer
import org.webrtc.VideoFrame
import org.webrtc.VideoSink
/**
* Captures the screen through [MediaProjection] and feeds it to WebRTC.
*
* Behaviourally a drop-in for `org.webrtc.ScreenCapturerAndroid` (a derivative of it original
* © 2016 The WebRTC project authors, BSD-style license), with one defect fixed: the upstream
* class builds the capture `Surface` inline,
*
* ```java
* virtualDisplay = mediaProjection.createVirtualDisplay(..., new Surface(helper.getSurfaceTexture()), ...);
* ```
*
* and keeps no reference to it, so `Surface.release()` is never called. `VirtualDisplay.release()`
* does not cover it the Surface belongs to the caller so every capture session leaked one,
* reclaimed only whenever the finalizer next ran. It showed up as a StrictMode
* `LeakedClosableViolation` pointing at `ScreenCapturerAndroid.createVirtualDisplay`, and
* `changeCaptureFormat` leaked another one per call. This version owns the Surface and releases
* it with the virtual display.
*/
class ScreenShareCapturer(
private val mediaProjectionPermissionResultData: Intent,
private val mediaProjectionCallback: MediaProjection.Callback,
) : VideoCapturer,
VideoSink {
private var width: Int = 0
private var height: Int = 0
private var virtualDisplay: VirtualDisplay? = null
/** The reference the upstream capturer drops on the floor. Released in [releaseDisplay]. */
private var surface: Surface? = null
private var surfaceTextureHelper: SurfaceTextureHelper? = null
private var capturerObserver: CapturerObserver? = null
private var mediaProjection: MediaProjection? = null
private var mediaProjectionManager: MediaProjectionManager? = null
private var numCapturedFrames: Long = 0
private var isDisposed = false
private fun checkNotDisposed() {
if (isDisposed) throw IllegalStateException("capturer is disposed.")
}
@Synchronized
override fun initialize(
surfaceTextureHelper: SurfaceTextureHelper,
applicationContext: Context,
capturerObserver: CapturerObserver,
) {
checkNotDisposed()
this.surfaceTextureHelper = surfaceTextureHelper
this.capturerObserver = capturerObserver
this.mediaProjectionManager = applicationContext.getSystemService(MediaProjectionManager::class.java)
}
@Synchronized
override fun startCapture(
width: Int,
height: Int,
ignoredFramerate: Int,
) {
checkNotDisposed()
this.width = width
this.height = height
val helper = surfaceTextureHelper ?: throw IllegalStateException("surfaceTextureHelper not set.")
val manager = mediaProjectionManager ?: throw IllegalStateException("capturer not initialized.")
val projection =
manager.getMediaProjection(Activity.RESULT_OK, mediaProjectionPermissionResultData)
?: throw IllegalStateException("MediaProjection permission data was rejected.")
mediaProjection = projection
// Let the MediaProjection callback use the SurfaceTextureHelper thread.
projection.registerCallback(mediaProjectionCallback, helper.handler)
createVirtualDisplay()
capturerObserver?.onCapturerStarted(true)
helper.startListening(this)
}
@Synchronized
override fun stopCapture() {
checkNotDisposed()
val helper = surfaceTextureHelper ?: return
ThreadUtils.invokeAtFrontUninterruptibly(helper.handler) {
helper.stopListening()
capturerObserver?.onCapturerStopped()
releaseDisplay()
mediaProjection?.let { projection ->
// Unregister the callback before stopping, otherwise the callback recursively
// calls this method.
projection.unregisterCallback(mediaProjectionCallback)
projection.stop()
}
mediaProjection = null
}
}
@Synchronized
override fun dispose() {
isDisposed = true
// Best-effort. On the failure path startCapture() can have created the display and Surface
// without a matching stopCapture(), and nothing else would hand them back.
releaseDisplay()
}
/**
* Changes the output video size, e.g. when the captured screen rotates.
*/
@Synchronized
override fun changeCaptureFormat(
width: Int,
height: Int,
ignoredFramerate: Int,
) {
checkNotDisposed()
this.width = width
this.height = height
// Capturer is stopped; the virtual display will be created by startCapture().
if (virtualDisplay == null) return
val helper = surfaceTextureHelper ?: return
// Recreate on the SurfaceTextureHelper thread to avoid interfering with frame processing,
// which runs on that same thread.
ThreadUtils.invokeAtFrontUninterruptibly(helper.handler) {
releaseDisplay()
createVirtualDisplay()
}
}
private fun createVirtualDisplay() {
val helper = surfaceTextureHelper ?: return
val projection = mediaProjection ?: return
helper.setTextureSize(width, height)
val newSurface = Surface(helper.surfaceTexture)
surface = newSurface
virtualDisplay =
projection.createVirtualDisplay(
"WebRTC_ScreenCapture",
width,
height,
VIRTUAL_DISPLAY_DPI,
DISPLAY_FLAGS,
newSurface,
null,
null,
)
}
/** Releases the virtual display first, so nothing is still drawing into the Surface. */
private fun releaseDisplay() {
virtualDisplay?.release()
virtualDisplay = null
surface?.release()
surface = null
}
/** Called on the internal looper thread of [SurfaceTextureHelper]. */
override fun onFrame(frame: VideoFrame) {
numCapturedFrames++
capturerObserver?.onFrameCaptured(frame)
}
override fun isScreencast(): Boolean = true
fun getNumCapturedFrames(): Long = numCapturedFrames
companion object {
private const val DISPLAY_FLAGS =
DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC or DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
/** DPI for the VirtualDisplay; does not appear to matter here. */
private const val VIRTUAL_DISPLAY_DPI = 400
}
}
@@ -78,7 +78,7 @@ class MemoryTrimmingService(
level: Int = ComponentCallbacks2.TRIM_MEMORY_BACKGROUND,
) {
if (isTrimmingMemoryMutex.compareAndSet(false, true)) {
Log.d("ServiceManager", "Trimming Memory (level=$level)")
Log.d("ServiceManager") { "Trimming Memory (level=$level)" }
try {
doTrim(account, otherAccounts, level)
} finally {
@@ -27,11 +27,12 @@ import coil3.annotation.ExperimentalCoilApi
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.network.CacheStrategy
import coil3.network.ConcurrentRequestStrategy
import coil3.network.ConnectivityChecker
import coil3.network.DeDupeConcurrentRequestStrategy
import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient
import coil3.request.Options
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.startsWithIgnoreCase
import okhttp3.Call
@@ -57,8 +58,15 @@ class BlossomFetcher(
class Factory(
val blossomServerResolver: () -> BlossomServerResolver,
val networkClient: (url: String) -> Call.Factory,
// Shared with every other network-backed factory on this ImageLoader --
// see the note in ImageLoaderSetup.setup(): the de-dupe only works when
// all fetchers coordinate through the same instance.
concurrentRequestStrategy: ConcurrentRequestStrategy,
private val readAuth: BlossomReadAuthTokenProvider? = null,
) : Fetcher.Factory<Uri> {
private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT }
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
private val concurrentRequestStrategyLazy = lazyOf(concurrentRequestStrategy)
override fun create(
data: Uri,
@@ -66,16 +74,20 @@ class BlossomFetcher(
imageLoader: ImageLoader,
): Fetcher? {
if (!isApplicable(data)) return null
// Wrapped per resolved url (not per Factory) because the server the
// blob actually lives on is only known once the resolver has run.
return BlossomFetcher(options, data, blossomServerResolver) { url ->
NetworkFetcher(
url = url,
options = options,
networkClient = lazy { networkClient(url).asNetworkClient() },
diskCache = lazy { imageLoader.diskCache },
cacheStrategy = lazy { CacheStrategy.DEFAULT },
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
)
readAuthAware(url, readAuth) { authHeader ->
NetworkFetcher(
url = url,
options = options.withAuthHeader(authHeader),
networkClient = lazy { networkClient(url).asNetworkClient() },
diskCache = lazy { imageLoader.diskCache },
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = concurrentRequestStrategyLazy,
)
}
}
}
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.images
import coil3.Extras
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.network.HttpException
import coil3.network.httpHeaders
import coil3.request.Options
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthInterceptor
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* Retries an auth-gated Blossom blob with a signed BUD-01 `t=get` token when the
* anonymous fetch comes back `401`.
*
* This is the half of the read-auth flow that has to wait for a signature.
* `BlossomReadAuthInterceptor` cannot: it runs on an OkHttp dispatcher thread,
* so waiting there holds one of the 16 per-host slots and stalls every other
* image from the same host. `Fetcher.fetch()` is `suspend`, so the wait costs a
* suspended coroutine and nothing else.
*
* [build] produces the underlying network fetcher, optionally carrying an
* `Authorization` header. Deliberately a `(String?) -> Fetcher` lambda rather
* than taking Coil's [Options] directly the retry decision is then testable
* without an Android `Context` to construct [Options] with.
*/
class BlossomReadAuthFetcher(
private val url: String,
private val auth: BlossomReadAuthTokenProvider,
private val build: (authHeader: String?) -> Fetcher,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
try {
return build(null).fetch()
} catch (e: HttpException) {
// Coil's NetworkFetcher throws HttpException for any non-2xx/304,
// which is how a 401 reaches us with its code intact.
if (e.response.code != HTTP_UNAUTHORIZED) throw e
val httpUrl = url.toHttpUrlOrNull() ?: throw e
// Gate only: read-auth applies to Blossom blob URLs, but the token is
// scoped to the host (BUD-11 `server` tag) and carries no `x` tag.
BlossomReadAuthInterceptor.blossomHashOrNull(httpUrl.encodedPath) ?: throw e
val header = auth.header(httpUrl.host) ?: throw e
return build(header).fetch()
}
}
companion object {
private const val HTTP_UNAUTHORIZED = 401
}
}
/**
* Wraps [build] in read-auth handling when a token provider is configured, and
* returns the plain fetcher when it isn't (tests, pre-configuration call sites).
*/
fun readAuthAware(
url: String,
auth: BlossomReadAuthTokenProvider?,
build: (authHeader: String?) -> Fetcher,
): Fetcher =
if (auth == null) {
build(null)
} else {
BlossomReadAuthFetcher(url, auth, build)
}
/**
* Copy of these options carrying [header] as `Authorization`, or the same
* options when there is no header. Coil's `NetworkFetcher` builds its request
* from `options.httpHeaders`, so this is how the retry gets authenticated.
*/
fun Options.withAuthHeader(header: String?): Options =
if (header == null) {
this
} else {
copy(
extras =
extras
.newBuilder()
.set(
Extras.Key.httpHeaders,
httpHeaders.newBuilder().set("Authorization", header).build(),
).build(),
)
}
@@ -33,6 +33,7 @@ import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.memory.MemoryCache
import coil3.network.CacheStrategy
import coil3.network.ConcurrentRequestStrategy
import coil3.network.ConnectivityChecker
import coil3.network.DeDupeConcurrentRequestStrategy
import coil3.network.NetworkFetcher
@@ -43,6 +44,7 @@ import coil3.svg.SvgDecoder
import coil3.util.Logger
import coil3.video.VideoFrameDecoder
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
@@ -60,7 +62,7 @@ class ImageLoaderSetup {
val debugLogger = if (isDebug) MyDebugLogger() else null
@OptIn(DelicateCoilApi::class)
@OptIn(DelicateCoilApi::class, ExperimentalCoilApi::class)
fun setup(
app: Context,
diskCache: () -> DiskCache,
@@ -69,7 +71,19 @@ class ImageLoaderSetup {
callFactory: (url: String) -> Call.Factory,
thumbnailCache: ThumbnailDiskCache,
backgroundScope: CoroutineScope,
// Signs the BUD-01 retry when a gated host answers 401. Null keeps every
// fetch anonymous (tests, pre-configuration call sites).
readAuth: BlossomReadAuthTokenProvider? = null,
) {
// ONE strategy for the whole ImageLoader. DeDupeConcurrentRequestStrategy
// coordinates through a map of in-flight fetches that it owns, so it only
// works when every fetcher shares the same instance -- a fresh one per
// request can never see anybody else's fetch and the de-dupe silently
// no-ops. Shared across all three network-backed factories so a feed
// image and the same blob reached through `blossom:` (or a profile
// picture) still collapse onto one download.
val concurrentRequests = DeDupeConcurrentRequestStrategy()
SingletonImageLoader.setUnsafe(
ImageLoader
.Builder(app)
@@ -87,13 +101,13 @@ class ImageLoaderSetup {
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(ThumbHashFetcher.Factory)
add(BlossomFetcher.Factory(blossomServerResolver, callFactory))
add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope))
add(BlossomFetcher.Factory(blossomServerResolver, callFactory, concurrentRequests, readAuth))
add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope, concurrentRequests, readAuth))
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add(ThumbHashFetcher.TKeyer)
add(ProfilePictureFetcher.BKeyer)
add(OkHttpFactory(callFactory))
add(OkHttpFactory(callFactory, concurrentRequests, readAuth))
}.build(),
)
}
@@ -132,9 +146,12 @@ class MyDebugLogger(
@OptIn(ExperimentalCoilApi::class)
class OkHttpFactory(
val networkClient: (url: String) -> Call.Factory,
concurrentRequestStrategy: ConcurrentRequestStrategy,
private val readAuth: BlossomReadAuthTokenProvider? = null,
) : Fetcher.Factory<Uri> {
private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT }
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
private val concurrentRequestStrategyLazy = lazyOf(concurrentRequestStrategy)
override fun create(
data: Uri,
@@ -145,15 +162,17 @@ class OkHttpFactory(
val url = data.toString()
return NetworkFetcher(
url = url,
options = options,
networkClient = lazy { networkClient(url).asNetworkClient() },
diskCache = lazy { imageLoader.diskCache },
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
)
return readAuthAware(url, readAuth) { authHeader ->
NetworkFetcher(
url = url,
options = options.withAuthHeader(authHeader),
networkClient = lazy { networkClient(url).asNetworkClient() },
diskCache = lazy { imageLoader.diskCache },
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = concurrentRequestStrategyLazy,
)
}
}
private fun isApplicable(data: Uri): Boolean = data.scheme == "http" || data.scheme == "https"
@@ -30,12 +30,13 @@ import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.network.CacheStrategy
import coil3.network.ConcurrentRequestStrategy
import coil3.network.ConnectivityChecker
import coil3.network.DeDupeConcurrentRequestStrategy
import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.Call
@@ -95,8 +96,16 @@ class ProfilePictureFetcher(
private val thumbnailCache: ThumbnailDiskCache,
private val networkClient: (url: String) -> Call.Factory,
private val backgroundScope: CoroutineScope,
// Shared with every other network-backed factory on this ImageLoader --
// see the note in ImageLoaderSetup.setup(). Avatars repeat constantly
// down a feed, so this is where a per-request instance cost the most:
// every row holding the same author's picture downloaded it again.
concurrentRequestStrategy: ConcurrentRequestStrategy,
private val readAuth: BlossomReadAuthTokenProvider? = null,
) : Fetcher.Factory<ProfilePictureUrl> {
private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT }
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
private val concurrentRequestStrategyLazy = lazyOf(concurrentRequestStrategy)
override fun create(
data: ProfilePictureUrl,
@@ -106,15 +115,17 @@ class ProfilePictureFetcher(
val diskCacheLazy = lazy { imageLoader.diskCache }
val netFetcher =
NetworkFetcher(
url = data.url,
options = options,
networkClient = lazy { networkClient(data.url).asNetworkClient() },
diskCache = diskCacheLazy,
cacheStrategy = lazy { CacheStrategy.DEFAULT },
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
)
readAuthAware(data.url, readAuth) { authHeader ->
NetworkFetcher(
url = data.url,
options = options.withAuthHeader(authHeader),
networkClient = lazy { networkClient(data.url).asNetworkClient() },
diskCache = diskCacheLazy,
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = concurrentRequestStrategyLazy,
)
}
return ProfilePictureFetcher(
data.url,
@@ -201,6 +201,13 @@ class LightningAddressResolver {
?: response.code.toString()
}
/**
* @param onZapRequestSent receives the zap request that was ACTUALLY sent to the
* callback, or null when it was not. A provider that does not advertise
* `allowsNostr` never sees [nostrRequest], and its invoice therefore commits to
* nothing about it so a caller must not go on to claim the two are bound. See
* the drop below.
*/
suspend fun lnAddressInvoice(
lnAddress: String,
milliSats: Long,
@@ -209,6 +216,7 @@ class LightningAddressResolver {
okHttpClient: (String) -> OkHttpClient,
onProgress: (percent: Float) -> Unit,
context: Context,
onZapRequestSent: (LnZapRequestEvent?) -> Unit = {},
): String {
val mapper = jacksonObjectMapper()
@@ -264,12 +272,19 @@ class LightningAddressResolver {
)
}
// NIP-57 binds a zap request to its invoice through `description_hash`, and a
// provider that ignores `nostr=` mints an invoice that commits to nothing about
// it. Report what actually went, so a caller cannot attach the event to a
// payment it was never bound to.
val sentZapRequest = nostrRequest?.takeIf { allowsNostr }
onZapRequestSent(sentZapRequest)
val invoice =
fetchLightningInvoice(
lnCallback = callbackUrl,
milliSats = milliSats,
message = message,
nostrRequest = if (allowsNostr) nostrRequest else null,
nostrRequest = sentZapRequest,
okHttpClient = okHttpClient,
context = context,
)
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.isMutedPublicChatMessage
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.notifications.renderers.ArticleNotification
import com.vitorpamplona.amethyst.service.notifications.renderers.BadgeNotification
@@ -75,6 +76,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
@@ -218,11 +224,20 @@ class EventNotificationConsumer(
// Don't push-notify events this account authored.
if (event.pubKey == account.signer.pubKey) return
// Drop reactions/zaps/reposts whose target note lives on a muted thread
// (matches the in-app feed, which mutes all four).
// Drop reactions/zaps/reposts whose target note lives on a muted thread, or in a
// public chat the user has silenced (matches the in-app feed, which mutes all four).
// Without the second check, muting a channel still let a like on your own message
// there notify you — the row's glyph promises silence, so it has to mean it.
if (event is ReactionEvent || event is LnZapEvent || event is RepostEvent || event is GenericRepostEvent) {
val target = LocalCache.getNoteIfExists(event)?.replyTo?.lastOrNull()
if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) return
if (target != null &&
(
account.isThreadMuted(account.resolveThreadRoot(target)) ||
isMutedPublicChatMessage(target.event, account.settings.mutedPublicChats.value)
)
) {
return
}
}
when (event) {
@@ -268,6 +283,11 @@ class EventNotificationConsumer(
is GitPatchEvent -> CodeNotification.notify(applicationContext, account, event)
is GitPullRequestEvent -> CodeNotification.notify(applicationContext, account, event)
is GitPullRequestUpdateEvent -> CodeNotification.notify(applicationContext, account, event)
is GitReplyEvent -> CodeNotification.notify(applicationContext, account, event)
is GitStatusOpenEvent -> CodeNotification.notify(applicationContext, account, event)
is GitStatusAppliedEvent -> CodeNotification.notify(applicationContext, account, event)
is GitStatusClosedEvent -> CodeNotification.notify(applicationContext, account, event)
is GitStatusDraftEvent -> CodeNotification.notify(applicationContext, account, event)
is LiveChessGameAcceptEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_challenge_accepted)
is LiveChessMoveEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_your_turn)
@@ -315,6 +335,12 @@ class EventNotificationConsumer(
event: ChannelMessageEvent,
account: Account,
) {
// Reads local device state, NOT the NIP-78 blob: on a push-driven cold start
// AppSpecificState may not have decrypted yet (and for a NIP-55 account that is
// an Amber IPC round-trip that can fail outright in the background). Losing this
// race would post exactly the notification the mute exists to prevent.
if (isMutedPublicChatMessage(event, account.settings.mutedPublicChats.value)) return
val note = LocalCache.getNoteIfExists(event.id) ?: return
if (NotificationFeedFilter.isNotifiablePublicChatReply(note, account.signer.pubKey)) {
@@ -47,6 +47,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
@@ -105,7 +110,9 @@ class NotificationDispatcher(
// consumeFromCache can't route it. It's delivered directly via
// [notifyWelcome] from processMarmotWelcomeFlow, which does know the
// recipient account.
private val NOTIFICATION_KINDS: Set<Int> =
// `internal` (was `private`) so the notification-kinds contract test
// can pin the push-side kind set against the in-app feed's kind set.
internal val NOTIFICATION_KINDS: Set<Int> =
setOf(
// Direct-arrival
PrivateDmEvent.KIND,
@@ -131,6 +138,14 @@ class NotificationDispatcher(
GitIssueEvent.KIND,
GitPullRequestEvent.KIND,
GitPullRequestUpdateEvent.KIND,
// NIP-34 threaded activity: legacy git-reply comment (1622)
// and the four status transitions (open/applied/closed/draft,
// kinds 1630-1633). Same push channel as issues/patches/PRs.
GitReplyEvent.KIND,
GitStatusOpenEvent.KIND,
GitStatusAppliedEvent.KIND,
GitStatusClosedEvent.KIND,
GitStatusDraftEvent.KIND,
HighlightEvent.KIND,
LongTextNoteEvent.KIND,
WikiNoteEvent.KIND,
@@ -92,6 +92,11 @@ class NotificationRelayService : Service() {
// Keeps notification updates well under Android's rate limit (~10/s).
private const val NOTIFICATION_REFRESH_MS = 1000L
// Toggles the expanded per-job breakdown on and off. Fired by the notification's own
// action button, so the details are always something the user asked for.
private const val ACTION_SHOW_DETAILS = "com.vitorpamplona.amethyst.SHOW_NOTIFICATION_SERVICE_DETAILS"
private const val ACTION_HIDE_DETAILS = "com.vitorpamplona.amethyst.HIDE_NOTIFICATION_SERVICE_DETAILS"
const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE"
fun start(context: Context) {
@@ -149,6 +154,9 @@ class NotificationRelayService : Service() {
/** Last non-empty per-job breakdown, kept so a reconnect does not blank the expanded view. */
private var lastBreakdown: List<String> = emptyList()
/** Whether the user asked for the per-job breakdown. Off until they tap "show details". */
private var detailsExpanded = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
@@ -165,6 +173,13 @@ class NotificationRelayService : Service() {
startId: Int,
): Int {
Log.d(TAG, "Starting service")
// The details toggle re-enters here through the notification's action button. It only
// flips the flag; the rebuild happens in the ensureForeground() below, which reposts the
// notification with (or without) the breakdown.
when (intent?.action) {
ACTION_SHOW_DETAILS -> detailsExpanded = true
ACTION_HIDE_DETAILS -> detailsExpanded = false
}
// Every startForegroundService() call re-arms Android's "must call
// startForeground() within the timeout" requirement — including the repeated
// calls MainActivity.onResume fires on each resume, even when the service is
@@ -359,9 +374,12 @@ class NotificationRelayService : Service() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
// Expanded only. The collapsed line stays the bare count it has always been — that is all
// most people want from an ongoing notification — and the per-job breakdown appears solely
// when someone deliberately expands it to ask why the phone is talking to N relays.
// Opt-in, never automatic. Android auto-expands a notification when it is the only one in
// the shade, and there is no way to opt out of that — so attaching the breakdown as a
// BigTextStyle up front made the per-job list the *default* view for anyone whose shade was
// otherwise empty, which is the opposite of what it is for. The card is therefore built
// with no expanded style at all until someone taps "show details"; that reposts it with the
// breakdown and a "hide details" action that puts it back to the bare count.
//
// Held across reconnects rather than recomputed blindly: the breakdown is derived from the
// *connected* relays, so a drop to zero (the "connecting…" state) would otherwise empty it and
@@ -369,9 +387,37 @@ class NotificationRelayService : Service() {
// looking at it. What each connection is *for* does not change while it is re-establishing,
// so the last known answer is still the right one; only the count above it goes stale, and
// that count is already labelled "connecting".
val fresh = RelayPurposeSummary.lines(this)
if (fresh.isNotEmpty()) lastBreakdown = fresh
val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() }
//
// Computed only while expanded: walking every connected relay's active requests once a
// second is wasted work when nobody has asked to see the result.
val breakdown =
if (detailsExpanded) {
val fresh = RelayPurposeSummary.lines(this)
if (fresh.isNotEmpty()) lastBreakdown = fresh
lastBreakdown.takeIf { it.isNotEmpty() }
} else {
null
}
val detailsIntent =
Intent(this, NotificationRelayService::class.java).apply {
action = if (detailsExpanded) ACTION_HIDE_DETAILS else ACTION_SHOW_DETAILS
}
val detailsPendingIntent =
PendingIntent.getService(
this,
if (detailsExpanded) 4 else 3,
detailsIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val detailsLabel =
getString(
if (detailsExpanded) {
R.string.always_on_notif_hide_details
} else {
R.string.always_on_notif_show_details
},
)
// Deliberately left ungrouped. This notification is ongoing and IMPORTANCE_LOW, so it
// sits in the shade's Silent section next to the low-importance content kinds
@@ -396,7 +442,8 @@ class NotificationRelayService : Service() {
.bigText(contentText + "\n\n" + it.joinToString("\n")),
)
}
}.setSmallIcon(R.drawable.amethyst_service)
}.addAction(0, detailsLabel, detailsPendingIntent)
.setSmallIcon(R.drawable.amethyst_service)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSilent(true)
@@ -32,9 +32,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* The per-job breakdown behind the always-on notification's relay count.
*
* **Only ever shown expanded.** The collapsed line stays exactly what it was a count because
* that is all most people ever want from an ongoing notification. This is for the moment someone
* taps to ask *why* their phone is talking to 40 relays.
* **Only ever shown on request.** The card stays exactly what it was a count because that is all
* most people ever want from an ongoing notification. This is for the moment someone taps
* "show details" to ask *why* their phone is talking to 40 relays. It is not attached to the
* notification otherwise: Android auto-expands a lone notification, so anything hung off the
* expanded view alone would be the default view rather than an opt-in one.
*
* A relay usually serves several jobs at once (measured: a typical relay carries four), so these
* counts deliberately **overlap and sum to more than the relay count**. They answer "how many relays
@@ -35,12 +35,26 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
/**
* Git / code notifications NIP-34 issues (1621), patches (1617), pull requests
* (1618) and PR updates (1619) on repos you maintain. Rendered as a slate card
* titled by the action ("X opened an issue" ) with the subject as the body.
* (1618), PR updates (1619), replies (1622, legacy), and status transitions
* (1630 open, 1631 applied/merged, 1632 closed, 1633 draft) on repos or threads
* you're p-tagged into. Rendered as a slate card titled by the action ("X opened
* an issue", "X merged a pull request", …) with the subject as the body.
* Author name + avatar enriched observably.
*
* Status kinds resolve their title from the *target* event's kind (patch/PR/issue)
* when it's in cache, so a merge on a PR reads "merged a pull request" but the
* same 1631 targeting a plain kind-1617 patch reads "applied a patch". Falls
* back to a generic wording when the target isn't yet resolved (rare: the
* notification lands after the target because the p-tag subscription pulls
* status events regardless of whether the target has been seen).
*/
object CodeNotification {
suspend fun notify(
@@ -67,6 +81,89 @@ object CodeNotification {
event: GitPullRequestUpdateEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitReplyEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_reply, event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitStatusOpenEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_open, event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitStatusAppliedEvent,
) = post(
context,
account,
event.id,
event.createdAt,
event.pubKey,
titleRes =
titleForStatusOnTarget(
event.rootEventId(),
pr = R.string.app_notification_code_channel_message_status_applied_pr,
patch = R.string.app_notification_code_channel_message_status_applied_patch,
issue = R.string.app_notification_code_channel_message_status_applied_issue,
fallback = R.string.app_notification_code_channel_message_status_applied,
),
subject = event.content,
)
suspend fun notify(
context: Context,
account: Account,
event: GitStatusClosedEvent,
) = post(
context,
account,
event.id,
event.createdAt,
event.pubKey,
titleRes =
titleForStatusOnTarget(
event.rootEventId(),
pr = R.string.app_notification_code_channel_message_status_closed_pr,
patch = R.string.app_notification_code_channel_message_status_closed_patch,
issue = R.string.app_notification_code_channel_message_status_closed_issue,
fallback = R.string.app_notification_code_channel_message_status_closed,
),
subject = event.content,
)
suspend fun notify(
context: Context,
account: Account,
event: GitStatusDraftEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_draft, event.content)
/**
* Pick a title string for a status event based on the *target*'s kind, so
* a 1631 on a kind-1618 PR reads "merged a pull request" while the same
* status kind on a kind-1617 patch reads "applied a patch". [rootId] is
* the marked-`root` `e` tag on the status event; when the target isn't in
* cache we return [fallback] which is deliberately generic.
*/
private fun titleForStatusOnTarget(
rootId: String?,
pr: Int,
patch: Int,
issue: Int,
fallback: Int,
): Int {
val targetKind = rootId?.let { LocalCache.getNoteIfExists(it)?.event?.kind } ?: return fallback
return when (targetKind) {
GitPullRequestEvent.KIND -> pr
GitPatchEvent.KIND -> patch
GitIssueEvent.KIND -> issue
else -> fallback
}
}
private suspend fun post(
context: Context,
account: Account,
@@ -58,7 +58,7 @@ object NwcPaymentNotifier {
val time = tx.settled_at ?: tx.created_at ?: TimeUtils.now()
val title = stringRes(context, R.string.app_notification_payments_channel_message, amount)
val comment = (tx.parsedMetadata()?.comment ?: tx.description)?.ifBlank { null }
val comment = tx.parsedMetadata()?.displayComment() ?: tx.displayDescription()
val body = comment ?: title
val accountNpub = NotificationRoutes.accountNpub(account)
@@ -49,22 +49,27 @@ import java.util.concurrent.ConcurrentHashMap
* - and at most one retry (an application interceptor's second `chain.proceed`
* runs the downstream chain again, it does not re-enter this interceptor).
*
* [authHeaderProvider] is `(host, sha256) -> header?`. It is synchronous by
* contract (the caller bridges the suspend signer), returns `null` when no
* signer is available or signing times out, and is only consulted on a real
* `401`, so an unauthenticated user simply keeps seeing the broken image
* rather than paying any signing cost.
* This interceptor never signs and never waits. [cachedHeaderProvider] is a
* pure cache read and [onAuthRequired] is fire-and-forget: `intercept` runs on
* an OkHttp dispatcher thread, where blocking would hold one of the 16 per-host
* slots for the whole signing window and stall every other image from that
* host. The signed *retry* therefore lives one layer up, in
* `BlossomReadAuthFetcher`, which is `suspend` and can await the signature
* without occupying a slot.
*
* The first blob from an auth-gated host costs an extra round trip (anonymous
* `GET` `401` signed retry), but that host is then remembered in
* [knownAuthHosts] so every later blob from it is signed **up front** one
* round trip, not two. This matters on a Buzz community feed where nearly every
* image comes from the same gated host: without it each image would keep paying
* the wasted 401 probe. The learned host also short-circuits to anonymous when
* no signer is available, so a logged-out user never re-probes needlessly.
* The first blob from an auth-gated host still costs an extra round trip
* (anonymous `GET` -> `401` -> signed retry by the fetcher), but the host is
* then remembered in [knownAuthHosts] so every later blob from it is signed
* **up front** from the cache one round trip, not two. This matters on a Buzz
* community feed where nearly every image comes from the same gated host.
* Callers that cannot retry (e.g. the media3 video datasource) get the token on
* their next request, once [onAuthRequired] has landed it in the cache.
*/
class BlossomReadAuthInterceptor(
private val authHeaderProvider: (host: String, sha256: HexKey) -> String?,
/** Pure cache read — must not sign, must not block. */
private val cachedHeaderProvider: (host: String) -> String?,
/** Fire-and-forget: starts a signature for a host we just learned is gated. */
private val onAuthRequired: (host: String) -> Unit,
) : Interceptor {
// Hosts observed to answer 401 to an anonymous Blossom GET. Small (a user
// follows a handful of auth-gated servers at most) and shared across all
@@ -81,14 +86,16 @@ class BlossomReadAuthInterceptor(
return chain.proceed(request)
}
val sha256 = blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request)
// The hash is a gate, not an input: read-auth applies only to Blossom
// blob URLs. The token itself is host-scoped and carries no `x` tag.
blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request)
val host = request.url.host
// Known-gated host: skip the anonymous probe and sign the first attempt.
// Falls through to anonymous only when we can't produce a token (no
// signer / timeout) — the server would 401 either way.
if (host in knownAuthHosts) {
authHeaderProvider(host, sha256)?.let { header ->
cachedHeaderProvider(host)?.let { header ->
return chain.proceed(request.withAuth(header))
}
}
@@ -99,12 +106,12 @@ class BlossomReadAuthInterceptor(
// Learn the host so its next blob is signed up front.
knownAuthHosts.add(host)
val header = authHeaderProvider(host, sha256) ?: return response
// Start the signature but do not wait for it: this thread holds a
// per-host dispatcher slot. BlossomReadAuthFetcher performs the signed
// retry for this very request from a coroutine.
onAuthRequired(host)
// Close the 401 body before replaying so the connection can be reused.
response.close()
return chain.proceed(request.withAuth(header))
return response
}
private fun Request.withAuth(header: String) =
@@ -21,29 +21,49 @@
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException
/**
* Signs and caches BUD-01 read-auth headers for [BlossomReadAuthInterceptor].
* Signs and caches BUD-01 read-auth headers for auth-gated Blossom hosts.
*
* The interceptor is synchronous (it runs on an OkHttp dispatcher thread) but
* signing is `suspend`, so [authHeader] bridges with [runBlocking] guarded by a
* timeout: an internal key signs instantly, while a remote (NIP-46) or external
* (NIP-55) signer that hangs or needs user interaction simply yields `null` and
* the download stays unauthenticated instead of pinning the thread.
* Signing never blocks a caller's thread. It runs on [scope]; callers either
* `suspend` on [header] or fire [warm] and pick the token up later. That
* matters because the consumer used to be [BlossomReadAuthInterceptor], which
* runs on an OkHttp dispatcher thread bridging the suspend signer with
* `runBlocking` there held one of the 16 per-host dispatcher slots for as long
* as the signer took (up to the timeout), so a feed's first burst against a
* gated host could occupy every slot and stall every other image from it.
*
* Tokens are cached per host, not per blob. A BUD-11 `server`-scoped token
* grants reads for every blob on the host (thumbnails included), so one signed
* event covers a whole feed's worth of images from an auth-gated host for the
* life of the token. The blob hash of the request that first triggered signing
* is still included as the `x` tag for BUD-01 servers that check it.
* One signature per host, however many callers. The token cache alone couldn't
* provide that: it is only populated *after* a signature returns, so a cold
* burst of N images all missed it and all signed concurrently with a NIP-55
* external signer that meant N IPC round trips (and potentially N prompts).
* [inFlight] is what collapses them; the leader signs and every follower awaits
* the same [CompletableDeferred].
*
* Tokens are cached per host, not per blob, and are therefore minted with a
* BUD-11 `server` tag and **no** `x` tag. BUD-11 lists `x` as optional for
* `GET /<sha256>` but is strict about what including one means: "When `x` tags
* are present, the token is only valid for operations on the specified blob
* hashes." A token carrying the hash of whichever blob happened to trigger
* signing would therefore be invalid for every other blob it was reused for.
* Server-scoped and hash-free, one signed event legitimately covers a whole
* feed's worth of images from the host for the life of the token.
*
* The tradeoff that buys: the token authorizes reading any blob on that host
* until it expires, rather than one. It is only ever sent to that host, over
* TLS, and BUD-11 sanctions the shape but it is a wider grant than a
* per-blob token, which is the price of caching at all.
*/
class BlossomReadAuthTokenProvider(
private val signerProvider: () -> NostrSigner?,
private val scope: CoroutineScope,
private val clock: () -> Long = { System.currentTimeMillis() },
) {
private class CachedToken(
@@ -52,31 +72,91 @@ class BlossomReadAuthTokenProvider(
)
private val cache = ConcurrentHashMap<String, CachedToken>()
private val inFlight = ConcurrentHashMap<String, CompletableDeferred<String?>>()
fun authHeader(
host: String,
sha256: HexKey,
): String? {
val now = clock()
/**
* The token already held for [host], or null. Pure map read safe to call
* from an OkHttp interceptor, and never signs.
*/
fun cachedHeader(host: String): String? = cache[host]?.takeIf { it.expiresAtMs > clock() }?.header
cache[host]?.let { if (it.expiresAtMs > now) return it.header }
/**
* The token for [host], signing one if none is cached. Suspends rather than
* blocking, so the caller must already be in a coroutine on the image path
* that is Coil's `Fetcher.fetch()`.
*/
suspend fun header(host: String): String? {
cachedHeader(host)?.let { return it }
return signOnce(host)?.await()
}
/**
* Starts a signature for [host] without waiting for it. For callers that
* cannot suspend (the interceptor) and only need the token to exist by the
* time some later request needs it.
*/
fun warm(host: String) {
if (cachedHeader(host) != null) return
signOnce(host)
}
/**
* Returns the in-flight signature for [host], starting one if this caller
* wins the race. Null when there is no signer to sign with.
*
* Leader/follower over [ConcurrentHashMap.putIfAbsent] rather than
* `computeIfAbsent`: the completion handler removes the map entry, and a job
* that finishes immediately would run that removal *inside* the mapping
* function, which `ConcurrentHashMap` forbids.
*/
private fun signOnce(host: String): CompletableDeferred<String?>? {
inFlight[host]?.let { return it }
val signer = signerProvider() ?: return null
val header =
runBlocking {
withTimeoutOrNull(SIGN_TIMEOUT_MS) {
BlossomAuth.createGetAuth(
hash = sha256,
alt = "Downloading media from $host",
signer = signer,
servers = listOf(host),
)
}
} ?: return null
val fresh = CompletableDeferred<String?>()
inFlight.putIfAbsent(host, fresh)?.let { return it }
cache[host] = CachedToken(header, now + CACHE_TTL_MS)
return header
scope
.launch {
val header =
try {
withTimeoutOrNull(SIGN_TIMEOUT_MS) {
BlossomAuth.createGetAuth(
// No `x` tag: this token is reused for every blob
// on the host. See the class kdoc.
hash = null,
alt = "Downloading media from $host",
signer = signer,
servers = listOf(host),
)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
if (header != null) {
cache[host] = CachedToken(header, clock() + CACHE_TTL_MS)
}
// Retire the entry *before* completing it. `invokeOnCompletion` fires when the
// job ends, which is after `complete()` resumes the awaiting caller — so a
// caller that returned from `header()` could come straight back, find this
// finished deferred still in the map, and be handed its already-signed token
// instead of signing a new one. A caller whose token has just expired does
// exactly that, and got the expired token back for as long as the window
// lasted — [refreshesAfterExpiry] closes it immediately and so hit it every run.
inFlight.remove(host, fresh)
fresh.complete(header)
}.invokeOnCompletion {
inFlight.remove(host, fresh)
// No-op when the job completed normally; releases followers when
// it was cancelled (scope torn down) instead of hanging them.
fresh.complete(null)
}
return fresh
}
companion object {
@@ -84,7 +164,8 @@ class BlossomReadAuthTokenProvider(
// refresh a little early to avoid handing over a token that dies mid-flight.
private const val CACHE_TTL_MS = 55L * 60L * 1000L
// Bounds how long an image download may block waiting on a slow signer.
// Bounds how long an image may wait on a slow signer. No thread is held
// for this window any more — only the waiting coroutine.
private const val SIGN_TIMEOUT_MS = 8_000L
}
}
@@ -0,0 +1,241 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.priority
import android.content.Context
import android.os.Process
import android.os.SystemClock
import android.provider.Settings
import com.vitorpamplona.quartz.utils.Log
import java.io.File
/**
* Demotes the app's network/ingest worker threads below the UI thread so a cold-start relay storm
* cannot starve the main thread out of its frames.
*
* **Why this exists.** On a cold start the outbox model dials ~190 relays at once and the process
* grows to ~650 threads (OkHttp's TaskRunner pool, OkHttp dispatchers, the kotlinx scheduler and
* Arti's tokio workers). Every one of them is born at nice 0. The main thread is nice -10, but a
* single -10 thread against dozens of simultaneously-runnable nice-0 threads still loses a large
* share of its schedulable time to the runqueue long enough that the first feed frame takes
* seconds and the "Loading account" screen stays on-screen well after the account itself has
* loaded.
*
* **Measured on a release-codegen build** (`:amethyst:installPlayBenchmark`, i.e. R8-minified +
* baseline-profile AOT), SM-T220, 5-round round-robin, every run valid:
*
* | workers at | starvation | runqueue wait | time to first paint | spread |
* |---|---|---|---|---|
* | nice 0 (off) | 26.7% | 2300 ms | 11.1 s | 5.63 s |
* | nice 5 | 22.0% | 1774 ms | 8.0 s | 4.05 s |
* | nice 9 | 17.1% | 1280 ms | 8.4 s | 3.05 s |
* | **nice 10** | **14.6%** | **1234 ms** | **6.2 s** | **1.55 s** |
*
* nice 10 beat the control in 5 of 5 paired rounds (median 5.0 s faster, ~45%) and collapsed the
* run-to-run spread from 5.6 s to 1.6 s, so [DEFAULT_NICE] is 10.
*
* **This effect only exists in a release build, so never re-validate it on a debug one.** In a
* debug build the same sweep changes nothing measurable: there the main thread is ~70% busy,
* saturated with ART interpretation, so scheduling was never the constraint (starvation is 15% in
* debug vs 27% in release). R8 collapses main's own work while leaving the relay storm untouched,
* which is what promotes starvation to the binding constraint. An emulator is equally misleading
* for the opposite reason its shared cores manufacture contention real hardware does not have.
*
* **Why a /proc sweep instead of thread factories.** The largest pool by far is OkHttp's
* `TaskRunner` backend, a process-wide singleton whose thread factory OkHttp does not expose per
* client, so there is no injection point to set a priority at creation time. Sweeping
* `/proc/self/task` catches every pool uniformly including threads OkHttp renames after the host
* they are currently serving. A nice value is per-OS-thread and survives renaming, so seeing a
* thread once is enough; the sweep only has to be frequent enough to catch newly-spawned ones.
*
* Note that [Thread.setPriority] does NOT map to a Linux nice level on Android only
* [Process.setThreadPriority] does. (`AudioTrackPlayer` documents the same trap for audio.)
*
* **Scope.** [DENYLIST] holds the threads that must keep their scheduling: the main thread,
* RenderThread (it draws the frames we are trying to protect), the ART daemons (demoting
* HeapTaskDaemon would make the GC pressure *worse*, not better) and binder threads (IPC replies
* the system waits on). Everything else is app work that should yield to the UI.
*
* **Cost.** Each thread is touched once, not once per sweep, and the interval backs off whenever a
* sweep finds nothing new the storm front-loads thread creation, so most sweeps after the first
* few seconds are empty. Threads that exit are pruned so a recycled tid is re-evaluated.
*
* **Runtime override.** [SETTING_KEY] overrides [DEFAULT_NICE] without a rebuild, and is also the
* off switch (any value <= 0 disables the governor entirely):
* ```
* adb shell settings put global amethyst_worker_nice 5 # demote to nice 5 instead
* adb shell settings put global amethyst_worker_nice 0 # disable
* adb shell settings delete global amethyst_worker_nice # back to DEFAULT_NICE
* ```
* Despite AOSP's `androidSetThreadPriority` calling `set_sched_policy(SP_BACKGROUND)` at nice >= 10,
* no cpuset/schedtune move was observed on real hardware (SM-T220 / Android 14): at nice 5, 9 and 10
* every worker kept main's exact membership (`schedtune:/top-app`, `cpuset:/top-app`, `cpu:/`) and
* only the nice value changed so 10 carries no hidden cgroup penalty over 9.
*/
object WorkerThreadPriorityGovernor {
/** `Settings.Global` key overriding [DEFAULT_NICE]; any value <= 0 disables the governor. */
const val SETTING_KEY = "amethyst_worker_nice"
/** Best measured value on a release-codegen build — see the table in the class doc. */
const val DEFAULT_NICE = 10
/** Sweep cadence while threads are still appearing. */
private const val MIN_INTERVAL_MS = 250L
/** Ceiling the interval backs off to while a sweep keeps finding nothing new. */
private const val MAX_BURST_INTERVAL_MS = 2_000L
/** How long to stay in the adaptive burst before settling at [IDLE_INTERVAL_MS]. */
private const val BURST_DURATION_MS = 120_000L
/** Steady-state cadence; relay reconnects still spawn threads long after boot. */
private const val IDLE_INTERVAL_MS = 5_000L
/**
* Threads whose scheduling must not be touched. Matched as prefixes against the kernel `comm`
* (which the kernel caps at 15 characters, so these are deliberately short).
*/
private val DENYLIST =
listOf(
// Draws the frames this whole exercise is meant to protect.
"RenderThread",
"hwuiTask",
"GPU completion",
// ART daemons — demoting the GC would deepen the very stalls we are fixing.
"HeapTaskDaemon",
"ReferenceQueueD",
"FinalizerDaemon",
"FinalizerWatchd",
"Signal Catcher",
"Jit thread pool",
"Runtime worker",
"perfetto_hprof",
// Debugger/profiler plumbing.
"ADB-JDWP",
"JDWP",
// Synchronous IPC the system framework blocks on.
"binder:",
)
@Volatile private var started = false
fun start(context: Context) {
if (started) return
val targetNice = resolveTargetNice(context)
if (targetNice == null) {
Log.i("ThreadPriority") { "Worker thread governor disabled via $SETTING_KEY" }
return
}
started = true
Log.i("ThreadPriority") { "Worker thread governor on, target nice=$targetNice" }
Thread({ sweepLoop(targetNice) }, "worker-nice-governor")
.apply {
isDaemon = true
start()
}
}
/** Returns the nice level to apply, or null when the governor should not run at all. */
private fun resolveTargetNice(context: Context): Int? {
val configured =
runCatching {
Settings.Global.getInt(context.contentResolver, SETTING_KEY, DEFAULT_NICE)
}.getOrDefault(DEFAULT_NICE)
// Only a demotion makes sense here; <= 0 is the documented off switch and anything above
// the nice ceiling is a typo we should not act on.
return configured.takeIf { it in 1..19 }
}
private fun sweepLoop(targetNice: Int) {
// The governor must keep running while the pools it polices saturate the CPU, so it runs
// slightly above default rather than as background work.
runCatching { Process.setThreadPriority(Process.THREAD_PRIORITY_FOREGROUND) }
val startedAt = SystemClock.elapsedRealtime()
val mainTid = Process.myPid()
// Tids already dealt with — demoted, or skipped because they are on the denylist. Both are
// permanent decisions, so keeping them here means a thread costs one `comm` read for its
// whole life instead of one per sweep. Seeded with our own tid so the sweep can't demote
// the governor itself.
val handled = HashSet<Int>().apply { add(Process.myTid()) }
var interval = MIN_INTERVAL_MS
while (true) {
val demoted = sweepOnce(mainTid, targetNice, handled)
if (demoted > 0) {
Log.d("ThreadPriority") { "Demoted $demoted thread(s) to nice $targetNice" }
}
// Thread creation is front-loaded into the connect storm, so once a sweep comes back
// empty the next one almost certainly will too — back off instead of spinning.
interval =
when {
SystemClock.elapsedRealtime() - startedAt >= BURST_DURATION_MS -> IDLE_INTERVAL_MS
demoted > 0 -> MIN_INTERVAL_MS
else -> (interval * 2).coerceAtMost(MAX_BURST_INTERVAL_MS)
}
runCatching { Thread.sleep(interval) }.onFailure { return }
}
}
private fun sweepOnce(
mainTid: Int,
targetNice: Int,
handled: MutableSet<Int>,
): Int {
// list() rather than listFiles(): this runs hundreds of times over a boot and the File
// objects would be pure garbage on an already GC-pressured heap.
val tidNames = File("/proc/self/task").list() ?: return 0
val live = HashSet<Int>(tidNames.size * 2)
var demoted = 0
for (tidName in tidNames) {
val tid = tidName.toIntOrNull() ?: continue
live.add(tid)
if (tid == mainTid || tid in handled) continue
// A thread can exit between listing and reading; treat any failure as "skip" and let
// the next sweep retry, since it is not yet recorded in `handled`.
val name =
runCatching {
File("/proc/self/task/$tidName/comm").readText().trim()
}.getOrNull() ?: continue
if (DENYLIST.any { name.startsWith(it) }) {
handled.add(tid)
continue
}
if (runCatching { Process.setThreadPriority(tid, targetNice) }.isSuccess) {
handled.add(tid)
demoted++
}
}
// Drop tids that have exited so the kernel recycling one into a new thread doesn't leave
// that thread permanently un-demoted.
handled.retainAll(live)
return demoted
}
}
@@ -20,13 +20,13 @@
*/
package com.vitorpamplona.amethyst.service.relayClient
import com.vitorpamplona.amethyst.commons.tor.RelayClassification
import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -101,9 +101,7 @@ class RelayProxyClientConnector(
// flipped relay would sit out its (now-irrelevant) backoff. We track these so such a relay can
// skip its retry delay on the next reconnect — scoped to onlyIfChanged, so only the relays that
// actually flipped re-dial and the rest of the pool's backoff is left untouched.
private var lastTrustedRelays: Set<NormalizedRelayUrl>? = null
private var lastDmRelays: Set<NormalizedRelayUrl>? = null
private var lastMoneyOpRelays: Set<NormalizedRelayUrl>? = null
private var lastClassification: RelayClassification? = null
@OptIn(FlowPreview::class)
val relayServices =
@@ -152,7 +150,7 @@ class RelayProxyClientConnector(
onTrigger(UsageKeys.TRIGGER_OFF)
client.disconnect()
}
if (infra.torStatus is TorServiceStatus.Active) {
if (infra.torStatus.isFullyBootstrapped) {
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
}
// disconnect() already cleared every relay's backoff. Forget the network
@@ -163,7 +161,7 @@ class RelayProxyClientConnector(
infra.connectivity is ConnectivityStatus.Active && !client.isActive() -> {
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
if (infra.torStatus is TorServiceStatus.Active) {
if (infra.torStatus.isFullyBootstrapped) {
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
}
@@ -174,9 +172,7 @@ class RelayProxyClientConnector(
lastTorSettings = torSettings
lastTorConnection = infra.torConnection
lastClearConnection = infra.clearConnection
lastTrustedRelays = infra.evaluator.trustedRelayList
lastDmRelays = infra.evaluator.dmRelayList
lastMoneyOpRelays = infra.evaluator.moneyOpRelayList
lastClassification = infra.evaluator.classification
}
else -> {
@@ -202,13 +198,13 @@ class RelayProxyClientConnector(
// so let onlyIfChanged pick out the flipped relay(s) and skip THEIR retry delay —
// without resetBackoff(), so the rest of the pool's backoff is untouched (these sets
// churn while relay lists load, and forgiving the whole pool then would be too much).
//
// One comparison over the whole classification, not one per category: this used to
// be a four-way `||` and adding a category meant remembering to extend it. Missing
// a term fails silently — the affected relays keep a socket on a transport the
// policy has already moved them off.
val classificationChanged =
lastTrustedRelays != null &&
(
infra.evaluator.trustedRelayList != lastTrustedRelays ||
infra.evaluator.dmRelayList != lastDmRelays ||
infra.evaluator.moneyOpRelayList != lastMoneyOpRelays
)
lastClassification != null && infra.evaluator.classification != lastClassification
val previousNetworkId = lastNetworkId
@@ -216,9 +212,7 @@ class RelayProxyClientConnector(
lastClearConnection = infra.clearConnection
lastNetworkId = networkId ?: lastNetworkId
lastTorSettings = torSettings
lastTrustedRelays = infra.evaluator.trustedRelayList
lastDmRelays = infra.evaluator.dmRelayList
lastMoneyOpRelays = infra.evaluator.moneyOpRelayList
lastClassification = infra.evaluator.classification
if (networkChanged) {
Log.d("ManageRelayServices") {
@@ -265,8 +265,8 @@ private fun RelayHeader(
horizontalArrangement = Arrangement.spacedBy(11.dp),
) {
RobohashFallbackAsyncImage(
robot = info?.id ?: prompt.relayUrl.displayUrl(),
model = info?.icon,
robot = info.id ?: prompt.relayUrl.displayUrl(),
model = info.icon,
contentDescription = null,
colorFilter = RelayIconFilter,
modifier = Modifier.size(34.dp).clip(MaterialTheme.shapes.small),

Some files were not shown because too many files have changed in this diff Show More