Compare commits

...
Author SHA1 Message Date
Claude 8b47588d37 perf(ai): bound in-flight GenAI work and stop blocking IO threads on it
Follow-up to the crash fix, which changed what cancellation costs.

Inferences can no longer be recalled: awaitDetached() detaches instead of
cancelling, because cancelling the future is what killed the process. So
abandoning a running batch no longer stops anything — it leaves seven rewrites
burning on-device compute for text the user has already moved past, and since
precomputeAiResults() runs on every keystroke, each later pause stacked seven
more on top with nothing bounding the pile.

The composer now coalesces instead of abandoning. The debounce window stays
freely cancellable (nothing has reached the model yet), but once a batch's
inferences are under way it is left to finish and the newest draft text is
stashed in aiPendingText, picked up when that batch ends. In-flight work is
bounded at one batch however fast the user types, and per-batch latency is
untouched — the seven tones still run concurrently.

MLKitImageLabelService moves off ListenableFuture.get() onto awaitDetached().
Describing an image takes seconds, and get() held an IO thread for all of it
uninterruptibly, so backing out of the composer left the thread pinned until
AICore answered. This needs a CancellationException rethrow ahead of the
existing catch-all: now that the awaits suspend, a cancelled caller lands there
and must not be swallowed as "no suggestion".

Also drops MIN_CONFIDENCE/MAX_LABELS, which nothing has referenced since the
keyword image-labeling path was removed, and corrects the class KDoc that still
described that fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mgCe29aaWUwGmGHKnFKo
2026-09-03 19:45:21 +00:00
Claude 08b64fb4f2 fix(ai): stop cancelling ML Kit GenAI futures, which crashes the app
Cancelling a `genai-rewriting` 1.0.0-beta1 inference future kills the process
from a thread we don't own:

    Thread: AiCoreClientWorker-thread-5
    java.lang.NullPointerException: Attempt to invoke interface method
      'void com...mlkit_genai_rewriting.zzp.zzd()' on a null object reference
        at com...mlkit_genai_rewriting.zzby.zzk
        at com...mlkit_genai_rewriting.zzbt.run
        at java.util.concurrent.ThreadPoolExecutor.runWorker

Disassembling the library pins it down exactly. `zzw.zzf` — the
`IMagicRewriteService` AIDL proxy — reads the returned `ICancellationCallback`
with `Parcel.readStrongBinder()`, which yields null when AiCore answers without
one, and passes that null on. `zzbh.attachCompleter` then registers it as the
future's cancellation listener with no null check
(`addCancellationListener(new zzbt(handle), ...)`), so cancelling the future
runs `zzby.zzk(null)` → `null.zzd()`. `zzk` catches only `RemoteException`, and
it all happens on ML Kit's own worker pool, so nothing we wrap can see it: the
NPE reaches the default uncaught handler and takes the app down.

The composer cancelled these routinely — a keystroke replaces the in-flight
batch of seven tones via `aiComputeJob.cancel()`, and leaving the composer
cancels `viewModelScope` — which turned a beta-library race into a routine
crash.

There is nothing to upgrade to: genai-rewriting, genai-proofreading and
genai-image-description have each published exactly one version. So the future
bridge now detaches instead of cancelling — `awaitDetached()` drops
`invokeOnCancellation { cancel(true) }` and skips reading the result once the
caller is gone. A cancelled batch's inferences finish with nobody listening,
which spends a little on-device compute where cancelling spent the process; the
composer's 1s debounce already keeps most stale batches from starting.

MLKitImageLabelService blocks on `.get()` and never cancels, so it is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mgCe29aaWUwGmGHKnFKo
2026-09-03 19:45:19 +00:00
Vitor PamplonaandGitHub 536e27dc8f Merge pull request #4041 from vitorpamplona/claude/payment-targets-zap-integration-q52kn6
NIP-A3 payment targets in zap picker — v1
2026-09-03 13:55:58 -04:00
Claude 12c6b7b08d feat(zap): gate the pay-to chip on discovery alone, default it on
The chip required the sender and the author to publish the same protocol,
capped the row at two, and shipped opt-out. All three go.

Symmetry was a proxy for "I can actually pay this way", and it is the wrong
proxy: paying a Monero address needs a wallet, not a published address of one.
What the sender happens to say about themselves never determined whether the
hand-off would work — the installed-app probe does, and it was already running.
So `PayToRailMatcher.match` no longer takes the sender's list, `selectFor`
drops the `senderTargets` gate, and `canOpen` becomes the substantive filter
with the rest as preconditions.

Dropping symmetry moves the probe set. It used to be the sender's own target
list, which is why `warm()` could replace the cache wholesale; it is now the
targets of whichever author's picker is open. So `warm()` merges instead of
replacing — replacing would evict what was learned about every other author the
moment a second picker opened — and the `LaunchedEffect` keys on the author's
observed kind:10133 rather than on `paymentTargetsState`.

MAX_CHIPS existed because symmetry could pass several protocols at once with
nothing else narrowing them. Discovery narrows them: a target with no installed
app never reaches the picker, so the cap was bounding a row that discovery
already bounds, and an arbitrary two-chip truncation would now hide a target
the user can genuinely pay.

`showPayToZapChip` defaults on for the same reason. The opt-out was justified
by fiat handles carrying legal names, but the chip only ever surfaces a target
its author chose to publish, to a device that can already open it.

The setting's copy said "when you and the author both publish the same payment
method" and the toggle read "Offer shared payment methods" — both described the
gate that no longer exists, so both are rewritten.

Tests follow the contract rather than the old shape: symmetry cases become
capability cases, `everyOpenableTargetIsOfferedWithNoCap` replaces the cap
assertion, and one new case pins the inverse of the rule that was removed — a
target the sender does not publish is still offered. The lazy-read test keeps
its guarantee, minus the sender-empty branch that no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-03 16:50:04 +00:00
David KasparandGitHub abbe98119e Merge pull request #4045 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-03 17:09:16 +02:00
vitorpamplonaandgithub-actions[bot] 63c3f55878 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-02 22:57:12 +00:00
Claude 5c661e046c Merge remote-tracking branch 'origin/main' into claude/payment-targets-zap-integration-q52kn6
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
2026-09-02 22:43:38 +00:00
Vitor PamplonaandClaude Opus 5 1685d7c0bf fix(strings): re-apply escape conversion after the Crowdin sync
The Crowdin sync (a7e7ace985) reintroduced Android escaping into composeResources:
2,068 escaped apostrophes and 749 escaped quotes across 20 locale files, every one
of which was clean at f3a72e26e0. Compose does not resolve \' or \", so those
strings render with a literal backslash.

The affected locales are the apostrophe-heavy regional variants -- uz-rUZ 949,
fr-rFR 341, fr-rCA 326, tr-rTR 189 -- while their base locales stayed clean.

Re-applies the conversion with --no-unwrap-quotes, since these files are already
migrated: escape conversion is idempotent, quote-unwrapping is not, and a second
unwrap would strip the real display quotes from strings like import_follows_tips.
Diff verified as pure escape conversion: 2,002 lines changed, none unexplained.

This will recur on every sync until the conversion moves into the Crowdin
pipeline. See tools/strings-migrate/fix_escapes.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 18:25:20 -04:00
Vitor PamplonaandGitHub 4daff2a03b Merge pull request #4042 from vitorpamplona/claude/zap-onchain-balance-check-gyc19z
Gate on-chain zaps on wallet balance and fee estimates
2026-09-02 18:20:05 -04:00
David KasparandGitHub eca09480c7 Merge pull request #4044 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-02 19:46:33 +02:00
vitorpamplonaandgithub-actions[bot] a7e7ace985 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-02 16:45:36 +00:00
Vitor Pamplona a602697afb Merge remote-tracking branch 'origin/main' into claude/zap-onchain-balance-check-gyc19z
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt
2026-09-02 16:39:36 +00:00
Vitor PamplonaandClaude Opus 5 f3a72e26e0 fix(strings): drop tools:ignore from compose resources
`tools:` attributes are an Android-lint construct. They arrived with strings moved
out of amethyst/src/main/res/, whose <resources> root declares xmlns:tools --
composeResources roots do not, so the prefix was unbound and the XML malformed:
97 occurrences across 49 locale files, none of them declaring the namespace.

Nothing was visibly broken, because Compose parses namespace-unaware and drops the
unknown attribute (no .cvr contains it). But nothing should rely on that, and
Android lint never runs on composeResources, so the attribute carried no meaning
there either.

migrate.py now strips tools: attributes as it moves each element, so the remaining
migration waves cannot reintroduce them.

Also fixes a hazard in fix_escapes.py found while doing this: quote-unwrapping is
NOT idempotent. Android wraps a value in quotes to protect whitespace, but once
\" has been converted to ", a legitimately quoted value is indistinguishable from
a wrapped one, and a second pass strips the real quotes -- it silently damaged 10
`import_follows_tips` translations before this was caught. Unwrapping is now
opt-out via --no-unwrap-quotes for repair runs over already-migrated files, and
documented as run-exactly-once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 12:27:44 -04:00
Claude 3a5688fc2e Merge remote-tracking branch 'origin/main' into claude/payment-targets-zap-integration-q52kn6
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt
2026-09-02 16:24:50 +00:00
Vitor PamplonaandGitHub efbfc85c50 Merge pull request #4025 from vitorpamplona/claude/amethyst-commons-migration-hm8vgm
Migrate model classes to commons module
2026-09-02 12:04:10 -04:00
Vitor PamplonaandClaude Opus 5 810e342bc1 fix(strings): convert Android escaping when moving strings to compose resources
Strings moved from res/values/ into composeResources/values/ kept Android's
escaping, which Compose does not interpret the same way, so the login screen
rendered `Don\'t have a Nostr account?` with a literal backslash and the terms
line showed stray quotes.

Compose 1.11.1 handleSpecialCharacters resolves only \uXXXX, \n and \t (and
collapses \\). It leaves \' \" \? \@ alone, and renders Android's quote-wrapping
-- used to preserve leading/trailing spaces, e.g. " Following" -- literally.

Convert those four escapes and unwrap the quotes, leaving \n, \t, \uXXXX and \\
untouched so Compose still resolves them. 3,717 entries across 56 locale files;
translations were hit far harder than English (Uzbek 964, French ~340 per
variant, Turkish ~208) because those languages use apostrophes heavily.

migrate.py now applies the same conversion as it moves each element, so the next
wave cannot reintroduce this; fix_escapes.py repairs what is already migrated and
is idempotent.

Verified on a Pixel 9 emulator: "Event is loading or can't be found in your relay
list" now renders with a real apostrophe, and no visible text node contains a
literal backslash escape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 11:46:35 -04:00
Claude 8e833d9b9e Merge origin/main (Amethyst icon font)
Icons.kt conflicts: kept main's AmethystIconGlyph calls with this
branch's migrated Res.string content descriptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-02 15:18:38 +00:00
Claude 1db2eeffaf Merge remote-tracking branch 'origin/main' into claude/zap-onchain-balance-check-gyc19z 2026-09-02 15:10:10 +00:00
Claude f51d5ced1c Merge remote-tracking branch 'origin/main' into claude/payment-targets-zap-integration-q52kn6 2026-09-02 15:10:04 +00:00
Vitor PamplonaandGitHub e1743f46d3 Merge pull request #4043 from vitorpamplona/perf/amethyst-icon-font
perf(icons): draw Amethyst's own icons from a generated icon font
2026-09-02 10:49:22 -04:00
Vitor PamplonaandClaude Opus 5 f54194f70d perf(icons): draw Amethyst's own icons from a generated icon font
Icon(imageVector = …) calls rememberVectorPainter, and a VectorPainter rasterises
its paths into a cached graphics layer per instance, so the feed re-rasterised the
same glyphs once per card. A font glyph is a blit from the shared text atlas,
shared across every call site for free.

tools/icon-font/build_icon_font.py converts the Kotlin ImageVector DSL to SVG paths
and builds a TTF with fontTools. Font metrics mirror the bundled Material Symbols
font (upem 960, ascent 1056, descent -96, advance 960) so glyphs align with existing
call sites; generated outlines land within a few units of Google's own.

Measured on the uniform-corpus feed benchmark (SM-T220, three arms A/B/A, 0.2%
identical-arm noise floor, gate 18/18/18 cards):

  frame duration P90   -10.7%
  frame overrun  P90   -17.4%
  DrawReactions        114.8 -> 76.7 ms/iteration

For reference, ablating the reaction icons entirely gives frame P90 -13.5%, so this
captures ~84% of the available headroom. It supersedes the shared-VectorPainter
approach (-8.2%), which needed CompositionLocal plumbing and hand-scoping to avoid
cross-size cache thrashing; glyphs are atlas-shared automatically.

Artwork is unchanged: this converts Amethyst's existing vectors rather than
substituting Google's glyphs. Verified on device by pixel comparison -- unconverted
icons are 0-diff, and the converted ones differ only by sub-pixel antialiasing
between the text and vector rasterisers.

Stroked icons are deliberately NOT converted. A glyph outline can only be filled, so
converting Zap (strokeLineWidth 1.2) turned a thin outline bolt into a solid one; the
build script now detects a stroke and skips the icon, leaving Following, Zap and
ZapSplit on their ImageVectors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 10:21:59 -04:00
Claude e9bc618f93 Merge branch 'main' into claude/payment-targets-zap-integration-q52kn6
Conflict in DisplayPaymentTargets.kt, where #4040 and this branch changed the
same hand-off path from opposite ends and converged on the same idea.

main extracted a shared PaymentTargetPill and routed every hand-off through one
new paymentTargetUri(target), still backed by the uriFor lambda on
PaymentTargetStyle. This branch had deleted that lambda, moving the scheme
table to commons so the zap picker and the installed-app probe could share it.

Kept main's structure — the pill and paymentTargetUri are the better shape, and
PaymentButton already calls the latter — and backed paymentTargetUri with
PaymentTargetTypes.uriFor. One hand-off entry point, one scheme table, no
behaviour change on either side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-02 01:39:31 +00:00
Claude ddef45304c fix: import NotifyRequest/NotifyRequestsCache from commons in main's new test
Main's NotifyRequestsCacheTest resolved both by same-package; on this
branch they live in commons.relayClient.notify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-02 01:37:30 +00:00
Vitor PamplonaandClaude Opus 5 f1b30ea26c refactor: fold the wallet card's balance fetch into OnchainWalletState
OnchainSection kept its own composable-local UTXO fetch and sum, so after
the zap picker gained a cached balance there were two paths to the same
number. Point the card at the shared account state instead.

- OnchainWalletState gains a `status` flow (UNAVAILABLE / LOADING / READY
  / ERROR) so the card keeps its four display states, and a `totalSats`
  for the figure it shows (settled + mempool, matching what it summed
  before). ERROR is reported only when there is no snapshot at all: once
  a balance is known, a failed refresh keeps the last good number on
  screen rather than blanking it.
- The card's private BalanceState enum is gone; it renders the model's
  status directly.

Opening the wallet screen now warms the balance the zap chips read, and
a send invalidates the snapshot for both surfaces at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jsupTzY576d2iWbqbzH4k
2026-09-02 01:35:29 +00:00
Claude 2438ab16c6 fix(zap): make the pay-to setting findable, and preview the chip
Two gaps from moving the toggle to the Zaps screen.

Settings search indexes the catalog entry's keywords, not the screen's
contents, so after the move nothing matched "venmo", "payto" or "paypal" — the
words someone would actually type to find this. Widened zaps_search_keywords.

ZapAmountChoicePopupPreview exercises four rail combinations but never
payToTargets, so the new chip had no preview at all in a file that otherwise
covers this component carefully. Adds a row with two hand-offs; since no app
resolves in a preview it also exercises the brand-coloured glyph fallback,
which is what a device without the app installed shows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-02 01:32:30 +00:00
Claude 88ad91459b Merge origin/main (notify block-relay button, payment-target dialog)
Conflicts:
- DisplayPaymentTargets.kt: main's model.User import superseded by the
  commons User import; setText import dropped (main's rewrite no longer
  uses it).
- strings.xml: kept only main's genuinely new notify_block_relay key;
  thread_title and send_the_seller_a_message already migrated to commons.
Also re-added the R import in NotifyRequestDialog.kt for the new
R.string.notify_block_relay usage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-02 01:30:18 +00:00
Claude 34e931a9b0 refactor(settings): move the pay-to toggle to the Zaps screen
It was on Profile settings, under a section literally titled "profile
sections" (badges, app recommendations, zap-received feed, followers feed).
The toggle has no profile-visible effect at all — it decides whether a chip
appears in the zap picker — so it was filed by association with
showOnchainWallet, which sits there for the same weak reason but at least puts
a chip on profiles.

The Zaps screen is where it belongs: it is the zap picker's configuration
surface, reached from the picker's own "change amount" action, and it already
renders previewRailsFor for the very chip row this setting adds to.

Wired through UpdateZapAmountViewModel rather than applied instantly, because
that screen is a Save/Cancel form: load() reads it, hasChanged() reports it,
sendPost() commits it and cancel() reverts it. An instant-apply switch on a
form with a Cancel button that did not revert it would read as a bug.

Strings move out of the profile_ui_ namespace to zap_payto_*, and the
explainer now states the two things the chip does not do: the other app asks
for the amount, and nothing is published, so the note's zap count is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-02 01:15:57 +00:00
Vitor PamplonaandGitHub 2df6221cd0 Merge pull request #4039 from vitorpamplona/claude/notify-block-relay-button-e6amne
Add relay blocking to NOTIFY payment prompts
2026-09-01 20:56:48 -04:00
Vitor PamplonaandGitHub 99d85a51b7 Merge pull request #4040 from vitorpamplona/claude/payment-target-dialog-format-yvfojv
Unify payment target UI across profile and dialog
2026-09-01 20:56:21 -04:00
Claude 6faa6556dd fix: keep the payment-target address visible and unify the wallet handoff
Audit follow-ups to the pill format in the payment-targets dialog:

- The row's three icon buttons left the pill 112dp on a 320dp dialog, which
  is exactly the width of the icon + type label: the shortened address was
  measured at 0dp and never drawn. The pill already pays on tap and copies
  on long-press (same as the profile), so the redundant bolt button goes and
  the address gets 45dp on a 320dp dialog, 97dp on a 372dp one.
- Cap the chip label at one line: a long type ("BITCOINCASH") wrapped the
  pill to two lines in narrow hosts.
- The dialog handed off to "payto://<type>/<authority>" for every type while
  the identical pill on the profile uses the type's own scheme, so the same
  pill reached a different app depending on where it was tapped. Both now go
  through paymentTargetUri(), which keeps payto:// as the unknown-type
  fallback.
- Drop FLAG_ACTIVITY_NEW_TASK|CLEAR_TASK from that handoff: CLEAR_TASK wiped
  whatever the wallet app already had open, and the dialog runs from an
  activity context that needs neither flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We35qZJEhp8bSbPUHo6Koc
2026-09-02 00:33:35 +00:00
Claude 77b96c4ab7 fix(zap): correct the pay-to probe, and stop redoing its expensive half
Audit of the previous commit. Four findings, all reachable in normal use.

The probe was stricter than the hand-off it predicts. It carried
CATEGORY_BROWSABLE and queried with flags=0, while the hand-off goes through
startActivity, which implies CATEGORY_DEFAULT and nothing else.
IntentFilter.matchCategories returns the first category on the *intent* the
filter lacks, so each category added to a query narrows the match: any app
declaring only DEFAULT was invisible to the probe and its chip was hidden even
though tapping it would have worked. The probe now carries no category and uses
MATCH_DEFAULT_ONLY, resolving exactly the set startActivity would. The
<queries> entries lose the category for the same reason — there it narrows
package visibility itself.

The chip snapshotted the probe result with remember(target.type), so it never
saw the probe finish. A web target is offered before any probe runs, since any
browser opens https, so that snapshot pinned the fallback glyph and the real
app icon could not appear until the picker was closed and reopened — the Venmo
and PayPal case the icon exists for. It now collects the availability flow.

peek() built the recipient's target list eagerly, walking the kind:10133 tag
array on every call, including the one-tap zap path with the feature switched
off. selectFor now takes it as a lambda behind the cheap gates, pinned by a
test that counts reads.

warm() runs on each picker open so resolution stays fresh when the user
installs an app and comes back, but it also re-read each APK's resources and
re-rasterised its icon to answer the same question. Decoded icons are now kept
across warms, keyed by package and size, and the browser control probe only
runs when a web target is actually present.

Also: the icon failure log kept its message but dropped the throwable; it now
passes it. Removes the unused clear().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-02 00:33:29 +00:00
Claude 5ba42b3439 test: verify the block actually mutates the kind-10006 correctly
The button had no test behind its central claim. NotifyRequestsCacheTest covered
the prompt bookkeeping and BlockedRelayFilteringClientTest the enforcement, but
nothing exercised BlockedRelayListState.addRelay — the step that decides what the
published block list contains.

That step is worth pinning because BlockedRelayListEvent.updateRelayList replaces
every relay tag with what it is handed, so addRelay must read the current list
before writing. Get it wrong and the second tap silently wipes the first block —
a data-loss bug the UI gives no sign of, since the dialog closes either way.

Drives the real thing: a real keypair, real NIP-51 encryption, LocalCache, and
the production decryption cache. AccountSettings is stubbed only because it reads
Resources.getSystem() for spoken languages, which is null outside an Android
runtime; Looper is mocked as the neighbouring LocalCache tests already do.

Covers: the list is created on the first block; the second block keeps the first;
re-blocking is idempotent; and the relays stay in encrypted private tags, never
public ones — a leak there would publish which paid relays the user walked away
from. Confirmed the wipe case fails when addRelay is reverted to writing only the
new relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T
2026-09-02 00:30:28 +00:00
Claude 6cb485cda8 feat(zap): offer a NIP-A3 pay-to hand-off in the zap picker
When the sender and the note's author both publish a payment target of the
same protocol, the zap picker now offers a chip that hands off to the app that
owns it. Gated on a new opt-in setting (default off), on the note carrying no
NIP-57 zap split, and on an installed app actually resolving the URI.

The chip carries no amount. Zap presets are sats and there is no rate anywhere
in the repo to turn them into a Venmo or IBAN figure, so no number is shown and
no RFC-8905 amount= is emitted; the receiving app asks. It ends in OpenInNew
rather than the send arrow every amount segment uses, and long-press copies the
authority instead of opening the sat-preset editor, which would mean nothing
here. Nothing is published, so the zap counter does not move and none of the
zap progress state is touched.

It renders beside the amount pills rather than inside each pill's rail toggle.
Carrying no amount, it would otherwise repeat identically once per preset, and
keeping it out of the toggle leaves ZapRail a plain enum instead of forcing it
into a data-carrying sealed interface.

Discovery needs the new <queries> entries: targetSdk is 37, so Android 11+
package visibility returns nothing from queryIntentActivities for an undeclared
scheme, and the chip would be invisible on every modern device. Unknown types
all fall back to payto://<type>/<authority>, so one payto entry covers the
open-ended tail of the vocabulary. Specific <intent> filters, never
QUERY_ALL_PACKAGES.

The mark is the resolved app's own icon, from the same ResolveInfo the probe
already holds, decoded once at the chip's size during the warm step and masked
round the way a launcher draws it. It falls back to the brand-coloured glyph
paymentTargetStyleFor already assigns when the hand-off would open a chooser or
merely a browser: https targets resolve to any browser, so a control probe
against an unownable host separates a real app handler from Chrome.

The availability cache is keyed on scheme plus host, not scheme, because an app
may declare host="iban" and a scheme-only hit would wrongly claim payto://upi
is handled. It is warmed from the sender's own target list when the picker
opens, so it is bounded by how many ways the user says they can be paid rather
than growing with the feed, and it is a StateFlow because a plain map write is
invisible to Compose.

Shared plumbing moves to commons: PaymentTargetTypes now owns the alias and
scheme tables that were duplicated inside the profile UI file, and
PayToRailMatcher holds the matching and the gate decision as pure functions,
free of Note, Context and the availability singleton so the gates are testable
on their own. RailCapability gains a defaulted payToTargets, and peek gains
defaulted parameters so zapClick's one-tap fast path stays Lightning-only.
PaymentTarget becomes a data class: without value equality it compares by
identity, which breaks list keys and dedupe.

25 new tests in commons; amethyst, commons and quartz suites all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-02 00:12:17 +00:00
Claude ea86c10adc refactor: wrap the Block Relay press in launchSigner at the call site
Replaces the onBlocked callback parameter added to AccountViewModel.blockRelay
with the pattern the rest of the app already uses for "sign, then clean up the
UI only if it worked" — accountViewModel.launchSigner { … } around both steps at
the call site, as in AwardBadgeScreen's launchSigner { sendPost(); popBack() }.
There are 187 such direct uses in ui/, so a bespoke callback parameter on the
ViewModel was the odd one out.

Behaviour is unchanged: blockRelay was itself defined as `= launchSigner { … }`,
so the press already ran inside one and the dismissal already waited on a
successful signature. This just drops a layer and the now-unused ViewModel
method rather than leaving dead API behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T
2026-09-01 23:48:46 +00:00
Vitor PamplonaandClaude Opus 5 3616a69656 feat: hide on-chain zap amounts the wallet can't fund
The zap picker offered the on-chain rail for every preset at or above
MIN_ONCHAIN_ZAP_SATS regardless of what our own Taproot address held, so
a user with an empty (or merely small) on-chain wallet was shown amounts
that could only end in an insufficient-funds failure at send time.

Gate the rail on the sender's balance:

- OnchainZapBuilder.maxSpendableSats() answers "what is the largest
  amount this UTXO set can actually pay?" against the exact greedy
  selection the builder uses — prefix sums of the value-descending list,
  plus the last-chance no-change branch — so an amount that clears it is
  one build() will not reject. Tests pin the boundary: max is buildable,
  max + 1 throws.
- OnchainWalletState caches that figure per account (one explorer round
  trip per minute at most, failures back off too, invalidated after a
  spend), computed at the fee rate the send dialog defaults to.
- RailCapability.canPayOnchain() folds the three gates — recipient
  payable, amount over the minimum, wallet can cover it — into one place
  the chip calls. An unknown balance stays optimistic: a flaky explorer
  should not silently remove a payment option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jsupTzY576d2iWbqbzH4k
2026-09-01 23:47:14 +00:00
Claude b8899ec7ff fix: block the relay before dismissing its prompts, and make the cache atomic
Follow-up to the Block Relay button, from a review of that change.

Dismiss-before-block. The button called blockRelay() fire-and-forget and then
dismissed every prompt from the relay. reportSignerErrors swallows a refused or
timed-out signature (ManuallyUnauthorizedException, TimedOutException,
CouldNotPerformException) with a log line and no toast, so rejecting the signer
prompt closed the dialog, left the relay unblocked, and gave the user nothing to
tell them so — and the prompts were in the dismissal set for good. The dismissal
now runs from a callback that only fires after account.blockRelay() returns;
leaving the prompt up is the feedback when it doesn't. This also shrinks the
race window, since sendMyPublicAndPrivateOutbox consumes the kind-10006 into
LocalCache synchronously before publishing.

Non-atomic cache mutations. NOTIFYs are filed from the relay's socket coroutine
while dismissals run from the UI, so addPaymentRequestIfNew's `value +=`
read-modify-write could drop one of two concurrent edits, and dismissAllFrom
read the pending set before updating it — a prompt arriving in between was
removed without ever being recorded as dismissed. Both now go through
update/getAndUpdate.

Also avoids a copy on a hot path in BlockedRelayFilteringClient: every REQ,
COUNT and publish went through filterKeys/minus whenever the block list was
non-empty, allocating a full copy of the targets just to reproduce them
unchanged. A blocked relay is by definition one the app has stopped aiming at,
so it now checks whether any target is actually blocked before copying. This
matters more now that blocking is one tap from the dialog rather than a trip to
the settings screen, so non-empty block lists become the norm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T
2026-09-01 23:42:43 +00:00
Claude 4e067a13d7 docs(amethyst): take the pay-to chip's mark from the installed app
Answers whether the chip can wear the icon of the app it hands off to: it can,
and the codebase already does it. ExternalSignerButton renders installed NIP-55
signers from loadLabel/loadIcon off getExternalSignersInstalled, which is the
same queryIntentActivities call discovery already makes, so the ResolveInfo we
keep to answer "can anything open this?" also carries the mark and the label.

Argues against bundling brand logos instead: a trademark question rather than a
licence one, an unbounded free-text type space no bundled set can cover, and a
call the codebase already made by pairing brand colours with a generic wallet
glyph. Keeps that pairing as the fallback.

Records four things the existing precedent gets away with and this would not:
loadIcon is I/O and belongs in the off-main warm step caching an ImageBitmap
rather than in a recomposing item; adaptive icons need sizing and a round mask
or the logo floats in launcher bleed at 18dp; a multi-handler URI resolves to
ResolverActivity and has no single app to name; and a full-colour raster cannot
join the tinted glyph scheme.

Promotes the https control probe from a later refinement into v1: it never
gated the chip, but without it a browser-only Venmo target resolves to Chrome,
and a Chrome icon on a Venmo chip is worse than no icon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-01 23:34:09 +00:00
Claude bb32822ab9 feat: add a Block Relay button to the relay NOTIFY dialog
A paid relay answers a rejected AUTH with a NOTIFY asking for payment, and
until now the only thing the prompt offered was "OK" — which dismisses it and
lets the same relay ask again on the next AUTH. The user's actual intent
("stop talking to this relay") had to be carried out by hand on the Blocked
Relays screen.

Adds a "Block Relay" action to the dialog that publishes the relay into the
account's NIP-51 kind:10006 blocked list. Enforcement is the existing one:
BlockedRelayFilteringClient strips blocked relays from every REQ, COUNT and
publish, so the pool drops the socket once the subscriptions that wanted the
relay are recomputed.

- BlockedRelayListState.addRelay / Account.blockRelay add one relay without
  rebuilding the list from a caller-held snapshot — the kind-10006 list is
  shared across clients and may have grown since.
- NotifyRequestsCache.dismissAllFrom drops every queued prompt from the
  blocked relay, not just the one on screen: a paid relay files one NOTIFY per
  rejected AUTH, so dismissing them singly would immediately re-open the dialog.
- NotifyCoordinator drops NOTIFYs from an already-blocked relay, closing the
  window where frames still in flight could re-open the prompt.
- The button is hidden for read-only accounts, which cannot sign the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T
2026-09-01 23:07:18 +00:00
Claude 7121b89e71 feat: show payment-target pills in the payment targets dialog
The dialog behind the payment-target button listed each target as a
titlecased type over the full wallet id on a second line. It now renders
the same pill the profile page uses — type icon, tinted type label and
the shortened authority, with long-press to copy the full value.

Extracts that pill as PaymentTargetPill and rebuilds PaymentTargetChip on
top of ProfilePaymentChip, so the profile rail and the dialog (both from
the profile button and from ReactionsRow) share one implementation
instead of duplicating the Surface/Row layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We35qZJEhp8bSbPUHo6Koc
2026-09-01 23:05:48 +00:00
Claude 8a4e37d41d docs(amethyst): scope the payment-targets zap chip down to an amount-less v1
Drops amounts, in-app payment and receipts from the first cut. The chip
carries no number and hands the amount to the external app, because there is
no FX service in the repo to convert a sat preset into a fiat figure.

Adds Intent-based discovery so only protocols an installed app can actually
handle are offered. Records the constraint that decides it: targetSdk 37 means
Android 11+ package visibility returns nothing from queryIntentActivities
without a <queries> declaration, and the current block covers only nostrsigner,
TTS, Health Connect and Tor. One payto entry covers every generic type, since
unknown types all fall back to payto://<type>/<authority>; https targets are
exempt because a browser always resolves them.

Keys the discovery cache on scheme+host rather than scheme, and warms it from
the sender's own target list instead of lazily per post: the symmetry gate
means only protocols the sender declares can ever be shown, so the probe set is
a handful of entries and feed rendering never triggers one. The cache has to be
a StateFlow, not a plain map, or the chip stays invisible until an unrelated
recomposition.

Moves the chip beside the amount pills instead of inside the per-amount rail
toggle: an amount-less rail would repeat identically in every pill, and keeping
it out of the toggle leaves ZapRail a plain enum, deleting the sealed-interface
refactor and its recompose-key breakage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-01 22:56:03 +00:00
Claude af5f23109e docs(amethyst): plan NIP-A3 payment targets as a zap-chip rail
Design doc for surfacing a NIP-A3 payment target as a selectable segment in
the zap amount chip when the sender and recipient share a pay-to protocol and
the note carries no NIP-57 zap split.

Anchors the design on what is already in the tree: kind:10133 already rides
in UserMetadataForKeyKinds beside kind:0, so no new subscription is needed;
RailCapabilityResolver.peek already computes the zap splits the gate needs;
and UnifiedZapAmountChip is already a segmented rail toggle.

Calls out the constraints that shape it: there is no FX service in the repo,
so the handoff segment carries no sat amount and emits no RFC-8905 amount=;
lightning/bitcoin payto types must map onto the existing rails rather than
render a second Bolt icon; ZapRail has to become a sealed interface to carry
which target; and the handoff produces no kind:9735, so it must stay out of
the zap state machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-01 22:46:46 +00:00
Vitor PamplonaandGitHub 9fa60eae10 Merge pull request #4031 from vitorpamplona/perf/feed-note-render
perf(feed): defer animation transitions until there is something to animate
2026-09-01 17:53:02 -04:00
Vitor PamplonaandGitHub 3598037ed4 Merge pull request #4038 from vitorpamplona/fix/splash-and-dead-nightmode
fix(theme): pin the launch splash colour and drop the no-op night-mode writes
2026-09-01 17:52:49 -04:00
Vitor Pamplona 726f3e39c3 fix(theme): pin the launch splash colour and drop the no-op night-mode writes
Two small independent fixes found while profiling the feed.

Splash colour
-------------
The system builds the launch splash from the manifest theme *before the process
starts*, resolving it against the system light/dark configuration. The in-app
ThemeType can be pinned to the opposite, so the splash flashed the wrong colour
before the UI appeared: white before a dark UI for someone who pins DARK on a
light system, black before a light UI for the reverse. No app-side code can fix
that first frame — it is painted before onCreate runs.

Pinning `windowSplashScreenBackground` to the brand colour already used for the
status bar makes the splash read as intentional in every combination. Only the
API 31+ splash attribute is set; `windowBackground` is deliberately left alone,
so the window stays opaque (clearing it measured ~17% worse at frame P90,
because a non-opaque window costs SurfaceFlinger the chance to skip the layers
beneath it) and pre-31 behaviour is unchanged.

Verified on an SM-T220 by recording a cold start and sampling frames: launcher
-> purple splash (63,12,181 ≈ #3700B3) -> dark UI, with no white frame.

Night-mode writes
-----------------
`AmethystTheme` set `UiModeManager.nightMode` to force the device night mode for
a pinned DARK/LIGHT theme. Changing it requires MODIFY_DAY_NIGHT_MODE, which the
manifest does not declare, so the call silently no-ops for a normal app — while
running a device-state write from inside composition on every recomposition of
the theme. The pinned choice already takes effect through the colour scheme
selected immediately below, which is what was actually doing the work.
2026-09-01 17:41:30 -04:00
Vitor PamplonaandGitHub f22c8908b6 Merge pull request #4036 from vitorpamplona/claude/linuxx64-localcache-alternative-kj1gzp
Replace copy-on-write LargeCache with striped-lock hash table
2026-09-01 17:04:04 -04:00
Claude 9e2859f719 fix(quartz): stripe from the bucket, not from unrelated hash bits
Audit finding, and a real defect in the striped table two commits back.

A striped hash table is only sound when the stripe is a function of the bucket.
lockFor picked bits 16-19 of the hash while the bucket index used the low bits,
so the 16 locks did not partition the table: two keys could share a bucket while
holding different locks, and two writers would then read the same chain head and
both publish over it. One insert silently disappears while entryCount counts
both. The same window loses an overwrite, and loses entries through remove's
chain rebuild. That is precisely the class of bug this work set out to remove
from the copy-on-write version it replaced.

Stripe now comes from `hash and (STRIPES - 1)`. Because STRIPES and every
capacity are powers of two with STRIPES <= capacity, those are exactly the low
bits of the bucket index, so same bucket implies same stripe at every size. It
stays derived from the hash rather than the capacity, so a key keeps its stripe
across a resize, which is what lets growTable exclude writers by taking all of
them. INITIAL_CAPACITY is now defined as STRIPES so raising one cannot silently
break the invariant.

That definition also fixes a memory regression the audit caught: the table
allocated 1024 slots eagerly, about 8 KB, per instance. LargeCache is not only
the one big LocalCache — EphemeralRoom, RelaySession, PoolRequests and others
build one per room, per connection and per subscription set, so a client holds
hundreds that stay nearly empty. An empty instance goes from ~8 KB to ~970
bytes. Growth is geometric, so a table that does fill to 100k pays the same ~2n
node rebuilds either way; re-measuring the shipped code confirms it (fill 16ms,
overwrite 4ms, reads 1ms, 20 scans 16ms, mixed 86ms, 1 GC — unchanged within
noise). The KDoc table is updated to those numbers.

Adds LargeCacheStripingTest, which builds keys that share a bucket while
differing in bits 16-19 and drives four workers at them behind a start barrier,
with few enough buckets that chains grow long and each insert holds its lock for
a while. It is documented for what it is: a stress test of the concurrent
same-bucket path, not a deterministic reproducer — it did not fail against the
broken striping in the runs attempted, which makes that race rare rather than
absent. The fix rests on reading the stripe selection against the bucket index,
not on a red test.

Remaining known cost, noted in the KDoc rather than changed here: those ~970
bytes are nearly all the 16 PlatformLocks, two objects each. Folding them into
one AtomicIntArray would reach ~250 bytes, but hand-rolling the spin wants its
own review rather than a change on the way to merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 21:01:16 +00:00
Vitor PamplonaandGitHub f4e583e4d9 Merge pull request #4037 from davotoula/fix/4024-portrait-sidebar-tier
fix: don't dock the sidebar on portrait tablets (#4024)
2026-09-01 16:47:44 -04:00
davotoula d9c10f4abb Code reviews:
- refactor(layout): one multi-pane shell, and name the panel predicate honestly
- fix(layout): don't dock the sidebar on portrait tablets (#4024)
2026-09-01 22:10:34 +02:00
davotoula ebcdd9d3d5 feat(layout): pure tier and panel decisions keyed on window shape
Introduces decideNavigationStyle/decideNotificationPanel with the shape
rule from #4024, plus unit tests for the whole behaviour table. Not wired
up yet - rememberScreenLayoutSpec is unchanged, so behaviour is identical.
2026-09-01 22:10:34 +02:00
Claude be4556eef1 test(quartz): take min-of-3 windows in the outbox scale assertion
PoolEventOutboxScaleTest tripped its 5x ratio on the macos-latest
runner: a single 2k-publish timing window is one GC pause away from a
false positive on a shared 3-core VM - the same GC-dominance reasoning
cd344ac0 used when it retired this assertion on Apple targets. Each
side now takes the minimum of three consecutive windows, which filters
stop-the-world pauses while keeping the intent: a real per-entry cost
slows every window, a pause only one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 20:05:54 +00:00
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
Vitor PamplonaandGitHub cecc3287b2 Merge pull request #4035 from vitorpamplona/claude/nip50-search-role-mapping-rt4lin
fix(quartz): route the roles the search extractor was stranding in the body tier
2026-09-01 15:40:06 -04:00
Claude fa3287f737 fix(quartz): match java.net.URLEncoder on native; drop the urlencoder dep
Both native targets delegated UrlEncoder to
net.thauvin.erik.urlencoder.UrlEncoderUtil, which implements RFC 3986
percent-encoding. The JVM/Android actual is java.net.URLEncoder/URLDecoder,
which implements application/x-www-form-urlencoded. Different specifications,
and the difference was observable:

                      JVM/Android    UrlEncoderUtil
  encode(" ")         "+"            "%20"
  encode("*")         "*"            "%2A"
  decode("a+b")       "a b"          "a+b"

This is not cosmetic. encode() builds strings that leave the device —
TorrentEvent puts it in magnet links, Nip54InlineMetadata in inline metadata,
Nip47DeepLink in the callback/appname/value parameters of NWC deep links — so
Android and iOS emitted different bytes for the same title. The decode row is
worse: a link written by Android carries '+' for its spaces, and reading it on
iOS or desktop-native gave back literal plus signs, silently, with no error.

Replaced with one UrlEncoder.native.kt in nativeMain, shared by linuxX64 and
every Apple target, matching URLEncoder/URLDecoder exactly — unreserved set is
alphanumerics plus -_.* (note '*' survives and '~' does not, the opposite of
RFC 3986), space to '+', uppercase %XX of UTF-8 bytes otherwise, and '+' back
to space on the way in. Escape runs are encoded and decoded as runs so surrogate
pairs and multi-byte sequences survive, and both directions short-circuit on a
string with nothing to change, as the java.net pair does.

UriParser.linux now delegates to UrlEncoder.decode rather than carrying its own
copy of the decoder added in the previous commit.

The new UrlEncoderTest lives in commonTest, so it pins every target against the
JVM's answers — it is what found all three rows above, by passing on jvmTest and
failing three of ten on linuxX64.

net.thauvin.erik:urlencoder-lib had no other user and is removed from both
source sets and the version catalog.

One deliberate edge difference from the JVM, documented at the call site: an
unpaired UTF-16 surrogate encodes as %EF%BF%BD rather than %3F.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 19:38:00 +00:00
Claude ed9a42f6ed fix(test): import the commons auth types in main's new relay-auth test
RelayAuthPolicyEverywhereTest (from #4033) resolved UserAuthChoice,
RelayAuthPermissionLedger, RelayAuthSessionGrants and
InMemoryRelayAuthPermissionStore via same-package visibility; those
classes moved to commons relayClient.auth on this branch, so the merged
tree needs explicit imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 19:31:07 +00:00
Claude c52fbc428e fix(quartz): stop dropping a bird sighting's alt text, and stop allocating per role
Two findings from an audit of this PR's own diff.

BUG. The kind 2473 branch dropped the `alt` tag whenever commonName()
parsed one out of it, on the reasoning that the alt is only Birdstar's
boilerplate wrapper around the two species names. But commonName()
matches a PREFIX and then cuts at the last " (", so a publisher can
write anything after the parenthetical and it parses just the same:
"Bird detection: Purple Gallinule (Porphyrio martinica) at Lake
Merritt, 7am" yielded "Purple Gallinule" and the tail reached NO role,
while indexableContent() still carried it. That is exactly the drift
against the flat form this PR exists to remove, introduced by the PR
itself. The alt now always reaches the summary tier: the duplicate it
repeats there lands in the weakest role, whereas the drop cost recall
outright.

PERFORMANCE. Extraction runs once per stored event and per full
reindex, and the funnel allocated a throwaway list per role whether or
not the role had anything in it. The single-value tiers() overload
wrapped each of its three values in a list only for cleanAll() to build
another; cleanAll() allocated even when every value was null; the
hashtag role called hashtags(), which allocates unconditionally, on
every event including the great majority carrying no `t` tag; and
locationValues() allocated a list per event to hold, almost always,
nothing.

Both overloads now end in one build() -- so hashtags and locations are
still filled in a single place no branch can forget -- and each
collector allocates lazily. Measured with getThreadAllocatedBytes over
1M extractions, JIT-warm:

  kind 1, no tags          160 -> 40 B/event
  kind 1, six tags         528 -> 168 B/event
  kind 30023 title+summary 272 -> 88 B/event

The hash of every extracted value is unchanged across the A/B, and the
guard added before hashtags() is HashtagTag.parse's own acceptance
test, so it cannot skip a tag the accessor would have returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GznRZiv3zS7V9c2QQ9aMk9
2026-09-01 19:29:38 +00:00
Claude 9134204164 fix(merge): reconcile the Crowdin sync and relay-auth relabel with Wave 3
The merge of main resurrected ~23.4k locale entries for migrated keys
(Crowdin's full sync rewrote regions git couldn't see as conflicting)
and re-added 14 migrated keys to the app default. Reconciled: every
locale entry whose key lives in commons moved there, keeping Crowdin's
fresher text (23,113 replacements); duplicate default keys removed from
app res, except podcast_value_for_value which legitimately lives in
both trees (a toastManager.toast(Int) call site needs the Android id).
RelayAuthPromptHost keeps main's relabeled-button behavior with mixed
addressing - new keys via R.string, migrated ones via Res.string - and
its RelayAuthPrompt/UserAuthChoice imports now point at the commons
relayClient.auth home. Orphan gate green, both apps compile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 19:27:37 +00:00
Claude 8cd6a8d3e9 Merge remote-tracking branch 'origin/main' into claude/amethyst-commons-migration-hm8vgm
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt
#	amethyst/src/main/res/values-cs/strings.xml
#	amethyst/src/main/res/values-de-rDE/strings.xml
#	amethyst/src/main/res/values-hi-rIN/strings.xml
#	amethyst/src/main/res/values-hu-rHU/strings.xml
#	amethyst/src/main/res/values-nl-rNL/strings.xml
#	amethyst/src/main/res/values-pl-rPL/strings.xml
#	amethyst/src/main/res/values-pt-rBR/strings.xml
#	amethyst/src/main/res/values-sl-rSI/strings.xml
#	amethyst/src/main/res/values-sv-rSE/strings.xml
#	amethyst/src/main/res/values/strings.xml
2026-09-01 19:10:09 +00:00
Claude d759741f96 fix(quartz): make the whole linuxX64 test suite pass; widen the CI leg
The 78 failures on this target were not 78 unimplemented actuals. Two root
causes accounted for all of them.

TestResourceLoader.linux was a TODO(), so every vector-driven suite failed
before it reached any production code: the full MLS interop set, NIP-44, the
NIP-01 hint indexer, the SQLite store's large-DB tests and the Bolt12 payer
proofs — 69 tests. Implemented over platform.posix (linuxX64 has no Foundation
for the Apple actual's NSData path), resolving against the same
TEST_RESOURCES_ROOT that build.gradle.kts already exports onto every
KotlinNativeTest task. The read is one ftell-sized allocation filled by fread,
so a vector file costs exactly one ByteArray — less than the JVM actual's
bufferedReader().readText(), which grows a StringBuilder as it goes.

UriParser.linux never URL-decoded query values or fragments, though the JVM
actual runs both through URLDecoder.decode(.., "UTF-8"). Every NIP-47 failure
was one symptom of that: relay=wss%3A%2F%2Frelay.damus.io reached
RelayUrlNormalizer still percent-encoded and came back "Invalid relay Url" (6
tests), and the deep-link round trips compared an encoded string against a plain
one (3 tests). Added a decoder matching URLDecoder where the behaviour is
observable — '+' to space, a run of consecutive %XX decoded as one UTF-8
sequence, malformed escapes throwing IllegalArgumentException — with the same
short-circuit URLDecoder makes, returning the original instance when there is
nothing to decode.

Two other divergences fixed while there: getQueryParameter returned an empty
list where the JVM returns null for an absent parameter, and the query string
was re-split on every call rather than parsed once into a lazy map, so a URI
read for four parameters was parsed four times.

With those, linuxX64Test is 3495 tests, 0 failures, so the CI leg added
alongside the LargeCache work drops its cache-package filter and runs the whole
:quartz suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 19:06:53 +00:00
Claude edab00815d fix(quartz): route the roles the search extractor was stranding in the body tier
Audited all ~70 branches of SearchFieldExtractor.base() against the file's
own stated invariant -- "each explicit branch splits exactly the accessors
that kind's indexableContent() concatenates" -- for all 133 searchable
kinds. Nothing tested it, and it had drifted three ways. The full table is
in the PR description; this commit is what it turned up. 19 kinds gain a
branch.

1. A title in the wrong tier. 17 kinds fell through to the catch-all, which
   dumps the whole indexableContent() into the body role, so their titles
   could never reach the title band a weighted backend gives one. The
   marketplace family (30017/30018/30019/30020) and the Podcasting 2.0 pair
   (30054/30055) are the sharpest -- a stall name and an episode title are
   what people actually type. Kind 9002 is the tell-tale: it edits the very
   metadata kind 39000 publishes, and 39000 had a branch while 9002 did
   not. Also branched: 1010, 1065, 1068, 1163, 1985, 2473, 6969, 12473,
   38192, 38383. Every kind still falling through is now body-only -- its
   whole searchable text really is a body (a chat message, a zap comment, a
   git patch, a DVM prompt) -- so no title is left stranded.

2. A role the branch forgot. hashtags and locations are filled systemically
   by the tiers() funnel, but websites is per-branch, and four kinds with a
   public URL were not passing one: GitRepositoryEvent (clones(), the URL
   most people would search a repo by), MeetingSpaceEvent (endpoint(), the
   same `streaming` tag kind 30311 already carries), and both nSite kinds
   (source()). Image, icon and infrastructure URLs stay out on purpose.

3. Drift against indexableContent(). Six kinds concatenated their `t` tags
   INTO the flat blob while the funnel also carried them as hashtags, so
   the same words were indexed twice, in the weakest role -- exactly the
   shape most likely to skew a term-frequency ranker. Fixed by their new
   branches (1111, 1311, 9002, 30018, 30020, 30054), the same treatment
   InterestSetEvent and ContactCardEvent already had.

Two of those branches avoid creating the same duplication they remove:
kind 2473's `alt` is Birdstar's boilerplate wrapper around the two species
names, so it is indexed only when commonName() proves it is NOT that
shape; kind 12473 is a life LIST, so its unbounded species collection sits
in the secondary tier rather than claiming the title band once per bird.

Also writes down the PROFILE XOR TIERED contract in the IndexableFields
KDoc. The sealed type enforces it, and weighted backends already depend on
it: a ranker that scores the two role groups independently and sums them
stays correct only while no document can answer from a naming column in
each group. A shape filling Profile.name and Tiered.primary at once would
claim the top band twice -- measured downstream at ~260 000 against the
~130 000 a whole-field title match earns, i.e. one word per column
outranking a document that IS the query. Saying so makes a future
both-shapes kind a decision with a known cost rather than an accident.

This is derived data: consumers must re-run
IEventStore.reindexFullTextSearch() after upgrading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GznRZiv3zS7V9c2QQ9aMk9
2026-09-01 19:05:12 +00:00
Claude 5e662fef0b perf(quartz): back linuxX64's LargeCache with a striped hash table
The HAMT was the wrong structure for this workload. LocalCache fills on the
order of 100,000 entries in a few seconds, and a persistent map allocates a
fresh path of ~4-5 nodes for every write — including overwrites, which change
no structure at all — then discards it. Over a 100k fill plus scans that is 24
GC cycles.

Replace it with a chained hash table: lock-free reads, striped-lock writes.
This is ConcurrentHashMap's shape, which Kotlin/Native does not ship. Adding a
key prepends one node; overwriting one is a single volatile store into the node
already there, allocating nothing; a scan walks the buckets in place. Chain
nodes hold `next` immutably so a reader never sees it change, which is what lets
reads take no lock at all — structural edits publish a new bucket head, and a
resize rebuilds nodes rather than relinking them.

Measured on linuxX64 (-opt), 100,000 String keys of event-id length:

                 fill  overwrite  reads  20 scans  mixed   GCs  heap
  HAMT + CAS       70         78      6       117    676    24  67MB
  lock + HashMap   13          6      3        71   1197    36  51MB
  striped          15          3      3        13     64     1  43MB

"mixed" is a full fill with a whole-table scan every 1000 writes — the shape
LocalCache actually has. Copy-on-write, the original, is off the scale: 20k
entries alone took 18s to fill.

Every bulk operation now walks the table directly instead of a snapshot, so
scans allocate nothing beyond the result and caller lambdas run outside any
critical section — a LocalCache predicate that reaches back into the cache
cannot deadlock, and there is no ConcurrentModificationException window.

getOrCreate and createIfAbsent are now the JVM actual's bodies verbatim over the
same putIfAbsent contract.

Honest difference from the JVM actual: ConcurrentSkipListMap is fully
non-blocking, whereas writers here block writers hashing to the same one of 16
stripes, for a bucket walk of a few nodes. ConcurrentHashMap makes the same
trade. Readers block for nothing.

Adds LargeCacheCollisionTest, which forces every key into one bucket so the
chain paths — in particular removal, which clones the nodes ahead of the target
onto its tail — run deterministically rather than only on a chance collision.

ConcurrentHashCache.linux moves onto the same table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 18:36:33 +00:00
Vitor PamplonaandGitHub 79f1de3dd4 Merge pull request #4034 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-01 14:29:45 -04:00
vitorpamplonaandgithub-actions[bot] 3e9521204c chore: sync Crowdin translations and seed translator npub placeholders 2026-09-01 18:27:46 +00:00
Vitor PamplonaandGitHub 54fba7f972 Merge pull request #4033 from vitorpamplona/claude/relay-auth-always-allow-09cwpc
Add account-wide relay auth policy choices to NIP-42 prompt
2026-09-01 14:24:02 -04:00
Claude b948a0941a fix: name the scope on the account-wide confirmation buttons
Relabelling the prompt's buttons collided with the confirmation behind
them: with the remember switch on, the prompt says "Always log in" for one
relay while the confirmation for "Always, all relays" said "Always log in"
too, one tap apart and meaning every relay. Same for "Never" against "Never
log in". The confirmation now echoes the link that opened it — "Always, all
relays" / "Never, all relays" — so the scope is stated exactly where the
account-wide answer is committed. No new strings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NTYFnqWcwLVPNNSr5kusd
2026-09-01 18:22:20 +00:00
Claude 65ea34342e perf(quartz): make linuxX64's LargeCache lock-free, not lock-based
Follow-up to the previous commit, which fixed the O(n) write by putting a
PlatformLock around a mutable map. That traded one problem for another: the
JVM/Android actual is a ConcurrentSkipListMap, where readers never block and
writers publish with a CAS, and a global lock is a step down from that — worse,
the linux PlatformLock is a spin lock, so a reader could burn a core waiting on
a writer that had been descheduled.

Keep copy-on-write's shape instead — an immutable map behind an AtomicReference,
which is what made reads free in the first place — and fix the two things that
were actually wrong with it. Copying a LinkedHashMap is O(n); a HAMT's putting()
shares structure and copies only the path to the changed key, O(log32 n). And
the read-copy-write was not a CAS loop, so concurrent writers dropped each
other's entries; now they retry.

Reads (get/containsKey/size/keys/values) are a single atomic load plus a lookup.
Bulk operations iterate that same immutable map with no copy, so caller lambdas
run outside any critical section and a LocalCache predicate that reaches back
into the cache cannot deadlock. Writes are a CAS retry.

This is already the house pattern for shared mutable state in commonMain —
FilterIndex and nip86 BanStore hold state in one AtomicReference over persistent
collections and mutate it with the same loop — and kotlinx-collections-immutable
is already a quartz commonMain dependency.

Measured on linuxX64 (-opt, ms per loop), vs copy-on-write and vs the lock
variant this replaces:

  n=20,000       fill   reads  20 scans  mixed
  copy-on-write 17,949      2        13  25,278
  lock+HashMap       1      0        12      35
  HAMT+CAS          14      0        19      28

  n=200,000      fill   reads  20 scans  mixed
  lock+HashMap      44      9       177   6,736
  HAMT+CAS         197     12       237   2,486

Write-only, the lock wins ~4x. But LocalCache interleaves full-cache scans with
arriving events, and there the lock must rebuild an O(n) read snapshot per write
epoch: it loses by 2.7x at 200k. So the non-blocking design also wins the
workload that matters.

ConcurrentHashCache.linux gets the same treatment; iteration order becomes hash
order (as on Apple) rather than insertion order. Nothing depends on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 17:57:22 +00:00
Claude d3aa91f856 feat: relabel the prompt's buttons to the answer the remember switch gives
With the switch on, "Not now" wrote a permanent DENY and "Log in" wrote a
permanent ALLOW while both still read as one-off answers. The switch is the
scope of the answer, so the buttons now state the answer they actually
give: "Never" and "Always log in". The refusal takes the error colour with
it while the switch is on, which is the weight the removed red "Never
allow" button used to carry.

This closes the mis-tap the switch's new binding opened: flipping it for
"log in", then changing your mind and pressing what still said "Not now",
blocked the relay for good with nothing on screen saying so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NTYFnqWcwLVPNNSr5kusd
2026-09-01 17:50:49 +00:00
Claude 6c07dcb839 perf(quartz): drop copy-on-write from linuxX64's LargeCache
LocalCache's linuxX64 store kept a LinkedHashMap inside an AtomicReference
and replaced it wholesale on every write, so each put was O(n) in the size
of the cache and filling it was O(n^2). It was not thread-safe either: the
read-copy-write was not a CAS loop, so concurrent writers silently dropped
each other's entries.

Replace it with a mutable map guarded by PlatformLock plus a lazily rebuilt
read snapshot. Point operations (get/put/remove/containsKey/size) are O(1)
under the lock; bulk operations run against a point-in-time copy rebuilt at
most once per write epoch, which also keeps caller-supplied lambdas out of
the critical section — PlatformLock is not reentrant here and LocalCache
predicates call back into the cache.

Two behaviour fixes fall out of matching the JVM actual's putIfAbsent:
createIfAbsent now reports true only when this call inserted (it previously
returned get(key) != null, which also reported true when another thread had
just created the entry), and getOrCreate publishes atomically.

ConcurrentHashCache.linux gets the same treatment. Its only caller,
CachingEventDecoder, writes once per event arriving from a relay, so the
per-write map rebuild was the worst-placed copy of the three.

None of this was caught because no CI job compiled or ran linuxX64. Add
LargeCacheTest to commonTest as a cross-target contract for the ~40 methods
each actual reimplements by hand, a linuxTest suite covering the concurrency
this actual now has to get right on its own, and a CI leg that runs both on
Linux Native.

That leg is scoped to the cache and concurrency packages: the full
linuxX64Test suite is 3,490 tests with 78 pre-existing failures, nearly all
TODO() stubs in linux actuals that were never written (MLS crypto, the
SQLite driver, NIP-44, Bolt12). Filling those in is its own project; the
filter keeps the job meaningful and green, and widening it later is one line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 17:26:50 +00:00
Vitor PamplonaandGitHub 94db88943c Merge pull request #4032 from vitorpamplona/claude/pool-outbox-scale-test-fix-2dohcc
Move relay set test to commonTest, expand scale test docs
2026-09-01 12:54:00 -04:00
Claude e61d30dfb7 fix: don't let the answer window swallow an account-wide relay auth answer
Audit of the two commits before this one turned up two ways the new
"Always/Never, all relays" answers could be given and not take effect.

A prompt's answer window is 60s from the dialog appearing, and the
confirmation dialog spends it: a user who reads the warning, thinks, and
confirms past the minute hits a resolved deferred, where complete() is a
no-op. The AUTH was already lost at that point — fine, the socket cannot
wait — but the *setting* was lost with it, silently, which is not. The
policy write moves to AuthCoordinator.applyPolicyEverywhere, called by the
prompt the moment the user confirms; the answer path calls the same
function, so there is still one writer and it is idempotent. A confirmation
that lands late now still sets the policy, and the relay's next challenge
is answered by it.

The other one: prompts queued behind the dialog were decided before the
policy existed, so "all relays" was immediately followed by a question
about relay B. They are now answered with the same choice. That needs
markShown() as well as respond() — an unshown prompt is parked in the
five-minute queue-wait window and does not read an answer dropped into its
deferred until that window ends, which would have left a relay
unauthenticated for five minutes after the user answered for it.
RelayAuthPromptBusTest pins the timing; it fails at 300000ms without the
markShown.

Also retires the comments in the ledger, Account and the resolver that
still explained a DENY as the "never allow" button, which no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NTYFnqWcwLVPNNSr5kusd
2026-09-01 16:35:21 +00:00
Claude cd344ac07d fix(quartz): stop asserting the outbox cost curve where the GC dominates it
PoolEventOutboxScaleTest failed on iosSimulatorArm64. The outbox is not at
fault: on Apple targets LargeCache wraps charlietap's CacheMap, whose
LeftRight `mutate` applies each write to both of its two maps under a lock.
That is O(1) per put with no copying, so the quadratic this test exists to
catch cannot occur there.

What the test actually measures on Kotlin/Native is the GC. It keeps 60k
entries alive on purpose, so the late window runs against a heap ~30x larger
than the early one. A generational collector does not rescan that old
generation on a young collection and the growth stays invisible; Kotlin/
Native's non-generational tracing GC does rescan it, and the ratio reports
the collector instead of the outbox.

Measured on Kotlin/Native (linuxX64, -opt), publishing the same 60k events
into structures that are O(1) per put by construction:

  retains nothing                 ratio 0.51 - 0.80
  one HashMap, 60k live           ratio 1.93 - 3.39
  two HashMaps per put, 60k live  ratio 2.28 - 5.21

The last row is the Apple path's actual work, and it crosses the test's 5.0
threshold on a loaded machine — which is how a shared CI runner turns a
healthy implementation red. The first row is the control: same allocations,
nothing retained, curve flat.

So move the timing assertion to jvmAndroidTest, where LargeCache is a
ConcurrentHashMap and a wall-clock ratio is a valid instrument. The guard it
provides is unchanged: reintroducing a copy-on-write map or a per-publish
full scan in this class still fails it. The test body is untouched; only its
source set and its KDoc change.

The relay-set bookkeeping half was platform-independent logic, not a
measurement, so it stays in commonTest as PoolEventOutboxRelaySetTest and
keeps running on every target.

Worth a separate look: linuxX64's LargeCache actual is genuinely
copy-on-write (LinkedHashMap(mapRef.value) per mutation), so it really is
O(N) per put. No CI job runs linuxX64Test today, and the numbers above show
a wall-clock ratio cannot report that reliably anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4Z1E5JJkYXqZznqVsYex6
2026-09-01 16:29:11 +00:00
Claude 8d2c50ae5c fix: make the prompt's remember switch mean the same thing for both answers
"Remember for this relay" was read only by the Log in button. Pressing "Not
now" with the switch on wrote nothing at all — no exception, not even a
session-scoped no — so the same dialog came back on the relay's next
reconnect, while the switch sat there claiming otherwise. The one way to
say "stop asking about this relay" was the red "Never allow" button beside
it.

The switch is now the scope of whichever answer is given, so the two
buttons times the switch are the four per-relay UserAuthChoice values: log
in once or always, refuse once or for good. That makes "Never allow"
exactly "Not now" with the switch on, written twice, so it goes.

Its slot becomes the missing half of the account-wide pair: "Never, all
relays" sets RelayAuthPolicy.NEVER opposite "Always, all relays". Both
confirm first, sharing one confirmation that names the consequence of each
direction — the never side warns that relays will refuse to serve, which is
the part a link label cannot carry. It routes through
Account.changeDefaultRelayAuthPolicy, which drops this run's session grants
along with the flip; a grant left behind outranks the policy, so "never log
in" would have gone on authenticating the relays just answered "log in".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NTYFnqWcwLVPNNSr5kusd
2026-09-01 16:23:55 +00:00
Claude 19d3472ad8 Merge remote-tracking branch 'origin/main' into claude/amethyst-commons-migration-hm8vgm
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt
2026-09-01 16:05:08 +00:00
Claude 3f31c78242 test: pin Blossom notes across the consume window - LargeSoftCache evicts
Root cause of the recurring DesktopBlossomServerListTest CI failures,
finally caught by the diagnostic added last round (flow=[], getter=null,
no verification warning): DesktopLocalCache.addressableNotes holds notes
via SoftReference (LargeSoftCache). Under CI memory pressure a GC evicts
the consumed note between cache.consume() and the state's
getOrCreateAddressableNote(), which then mints a fresh EMPTY note - the
flow can never surface the servers. Production is immune because
BlossomServerListState pins blossomListNote as a field for its lifetime;
the tests just never held a strong reference across that window. All
three tests now pin the note before consuming, and the state test
asserts the event landed before construction so an eviction fails fast
at the source.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 15:58:21 +00:00
Vitor PamplonaandGitHub 6f52d1de90 Merge pull request #4028 from vitorpamplona/claude/m3u8-playlist-quality-kl0xrz
fix(video): use the HLS master playlist and its full quality ladder
2026-09-01 11:54:02 -04:00
Claude 58ca467ff0 feat: let the relay auth prompt turn on "Always log in" for every relay
The prompt's bottom row had one standing answer, "Never allow", and a link
out to the settings screen. The opposite standing answer — "just log in
everywhere, stop asking" — was only reachable by finding Settings ▸ Relay
login, so the fast way to stop a run of prompts was to block relays one at
a time.

"How Amethyst decides" is replaced by "Always, all relays", which switches
the asking account to RelayAuthPolicy.ALWAYS and answers the pending
challenge. It is the one action here that writes an account-wide setting,
so it confirms first: the label cannot carry the fact that it applies to
every relay that ever asks, and a mis-tap would reveal that npub to all of
them.

The write lands in AuthCoordinator, not the dialog, because the policy
belongs to the account the prompt named — one socket serves every
logged-in account, so the screen's account is not necessarily that one. No
per-relay exception is stored alongside it: the policy already answers this
relay, and an exception would outlive a later switch back to "decide per
relay". Blocked relays and existing "never" exceptions still outrank it,
which is what the confirmation promises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NTYFnqWcwLVPNNSr5kusd
2026-09-01 15:50:45 +00:00
Claude 54c6521f3f fix(video): five defects found auditing this PR's own diff
Four were confirmed with throwaway probe tests against the branch.

An HLS master labelled `audio/x-mpegurl` or `audio/mpegurl` was read as a
separate audio track and dropped. Those are two of the four playlist MIMEs
this repo already recognises in isHlsMimeType and MediaItemCache — legacy
aliases naming the manifest format, not a claim about the content. A master
labelled that way lost to a 360p rung; when every entry used it the candidate
list emptied and selection fell through to the poster JPEG, handed to the
video player. The HLS test now precedes the audio test.

withLadderMetadataFrom filled `dimension` from every imeta, poster included,
so a 16:9 thumbnail beside a vertical short produced a 16:9 master and
JustVideoDisplay laid the box out at 16:9. Only entries that could be the
video may describe its shape; the poster still supplies the still image.

isHlsPlaylist treated any declared MIME as authoritative, so `master.m3u8`
served as application/octet-stream — a server default, not a claim — was not
HLS and lost to a correctly labelled low rung.

The metered 480px cap had no fullscreen exemption, so tapping into fullscreen
on mobile data pinned 480p and put a ceiling the quality menu's "Auto" could
not exceed. The cap exists to hold back feeds that autoplay unasked; someone
who tapped fullscreen asked.

The PiP gate skipped the viewport push entirely until isInPictureInPictureMode
turned true, with no retry. Since demoteToCold clears track overrides but not
the viewport, a pooled player kept whatever its previous view pushed if PiP was
never entered (per-app PiP off, no FEATURE_PICTURE_IN_PICTURE). It now caps the
pre-shrink measurement instead of skipping it, so a viewport is always pushed
and can never be a stale full-screen one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQs7TP2WeXNR8SwUNLgmUC
2026-09-01 15:50:04 +00:00
Claude 5b3eb140a6 Merge remote-tracking branch 'origin/main' into claude/amethyst-commons-migration-hm8vgm
# Conflicts:
#	amethyst/src/main/res/values-de/strings.xml
#	amethyst/src/main/res/values-eo/strings.xml
#	amethyst/src/main/res/values-fa/strings.xml
#	amethyst/src/main/res/values-fr/strings.xml
#	amethyst/src/main/res/values-nl/strings.xml
#	amethyst/src/main/res/values-ta/strings.xml
#	amethyst/src/main/res/values-th/strings.xml
2026-09-01 15:27:06 +00:00
Claude 7ed368118c feat(video): cap the rendition viewport on metered connections; fix a PiP race
Follow-up to @davotoula's review on #4028. Sizing the ladder to the player
is right on wifi, but it removed the app's only bandwidth lever and put
nothing back: on mobile data a full-width card would pull most of the ladder,
with only the ConnectivityType autoplay gate — which decides whether to play,
not how much to pull — standing between a scroll and the data bill.

Clamp the pushed viewport to a 480px short side while
isMobileOrMeteredConnection is true, preserving aspect so the viewport still
describes the player's shape. It stays one lever at the single setViewportSize
call rather than a policy per call site, and it keeps the "quality
proportional to the player" behaviour on wifi. Connectivity changes do not
relayout, so a LaunchedEffect re-pushes with the last measured size when the
ceiling flips; before the first measurement the existing zero-size guard makes
that a no-op.

Also from the same review: processIntentForPiP calls enterPictureInPictureMode
from composition (PiPFromIntents), so the first layout pass can measure the
activity at full screen before the window shrinks, handing the selector a
full-screen viewport for the opening seconds of a PiP that is a few inches
wide. RenderPipVideo now withholds the push until isInPictureInPictureMode is
true; the shrink relayouts and pushes the real size. The pre-T makeBasic()
path still wants an on-device look.

clampViewportShortSide rounds up so a rounding artifact can never ask for 479
and drop a rung that sits exactly at the cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQs7TP2WeXNR8SwUNLgmUC
2026-09-01 14:58:26 +00:00
Claude d691317489 fix(video): don't let a dim-less rendition outrank a dimensioned master
Review catch from @davotoula on #4028. The selector checked "any HLS entry
without a dim" before comparing declared resolutions, so a dim-less entry
won outright. That reads the wrong shape: the sloppy-publisher case is not
"master without dim, renditions with dim" but the reverse — a master that
declares its top resolution beside a rendition that forgot one. There the
old order picked the single-rung media playlist, which is the exact bug this
selector exists to fix, silently.

Tag order now decides only when no HLS entry declares a dim at all;
otherwise the largest declared dim wins. That fails the other way instead:
worst case we take the top rendition and lose adaptation, never the bottom
one.

Also from the same review: presentation metadata was filled from the
playable candidates only, so a poster published as its own image/* imeta —
which canBeTheVideo() excludes — never reached the chosen entry. Fill from
every imeta, and take an image sibling's own url as the poster when no entry
carries one in `image`, which is where the notification big-picture path
looks.

Restore the rendition diagnostics that went with the old fixed-policy
selector: every viewport push and the rung adaptive selection actually
landed on, against the ladder on offer, under the VideoQuality tag. The
listener is registered only when the trace can be emitted (debug sets
Log.minLevel = DEBUG, benchmark/release ERROR), so the release path keeps
the no-listener-per-player property.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQs7TP2WeXNR8SwUNLgmUC
2026-09-01 14:39:50 +00:00
Vitor PamplonaandGitHub 725bbc557c Merge pull request #4029 from vitorpamplona/dependabot/github_actions/actions-9a9d8496c4
chore(actions): bump the actions group across 1 directory with 3 updates
2026-09-01 10:06:44 -04:00
David KasparandGitHub e9437aa371 Merge branch 'main' into claude/m3u8-playlist-quality-kl0xrz 2026-09-01 13:30:51 +02:00
dependabot[bot]andGitHub cdff305242 chore(actions): bump the actions group across 1 directory with 3 updates
Bumps the actions group with 3 updates in the / directory: [actions/setup-java](https://github.com/actions/setup-java), [mikepenz/action-junit-report](https://github.com/mikepenz/action-junit-report) and [softprops/action-gh-release](https://github.com/softprops/action-gh-release).


Updates `actions/setup-java` from 5.7.0 to 6.0.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5.7.0...v6.0.0)

Updates `mikepenz/action-junit-report` from 6.4.2 to 6.5.0
- [Release notes](https://github.com/mikepenz/action-junit-report/releases)
- [Commits](https://github.com/mikepenz/action-junit-report/compare/d9f48fc87bc235f7e214acf696ca5abc0a986f16...a9170d5795813c01ab4901ffb045b52bab4ab09d)

Updates `softprops/action-gh-release` from 3.0.2 to 3.0.3
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/3d0d9888cb7fd7b750713d6e236d1fcb99157228...efb35369e0ad2afab669f228072c1b0d510eae64)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: mikepenz/action-junit-report
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 11:01:36 +00:00
David KasparandGitHub 1590dc8570 Merge pull request #4030 from davotoula/fix-i18n-and-add-hook-to-prevent-future-failure
Fix i18n and add hook to prevent future failures
2026-09-01 12:57:40 +02:00
davotoula 42c96bf8fc feature: document the orphaned-translation trap and gate it pre-push
The [ExtraTranslation] failure that took main red in 1ce583ec92 was not
documented anywhere. amethyst/src/main/res/CLAUDE.md and the
find-missing-translations skill both mention the lint rule, but only inside
one narrow case (converting a <string> to <plurals>). Neither stated the
general rule: removing or renaming a key in the default values/strings.xml
orphans every locale entry that still declares it.

Nor would running lint have caught it in practice. The only pre-push gate is
pre-push-spotless.sh, which runs spotlessApply and nothing else, and
:amethyst:lintFdroidBenchmark takes ~19 minutes on a warm daemon, so it is
not a per-commit check.

Add both halves:

- amethyst/src/main/res/CLAUDE.md gains a "Renaming or removing a string key"
  section stating the same-commit rule and why Crowdin is not a cleanup step
  CI waits for. The trap is that Crowdin is *partly* reliable — it cleaned 32
  of 47 locales — so the tree looks correct in whichever files you open.
  Retitled the file (it is no longer plural-only) and marked the existing
  sections as the plural-specific ones they always were.
- orphan_strings_check.py scans every locale's resource names against its
  tree's default values/, across both Crowdin-managed resource systems: the
  Android res trees and the commons Compose-Multiplatform catalog. 0.17s
  against the full repo. pre-push-orphan-strings.sh wraps it as a PreToolUse
  gate on git push / create_pull_request, reusing the shell-tokenizing
  push-detection from pre-push-spotless.sh so "push" inside a commit message
  is not mistaken for the subcommand.
- find-missing-translations gains a Common Mistakes entry pointing at both.

Verified: clean on the current tree; exit 2 listing the orphans when
route_video is reintroduced in two locales and a retired key is seeded in the
Compose catalog; gate fires on git push and create_pull_request, stays quiet
on a commit whose message contains "push" and on non-Bash tools.
2026-09-01 11:12:43 +02:00
davotoula 467a1f0c06 fix(i18n): drop retired route_video/new_short keys from 15 locales
d6d5a72e49 renamed route_video -> route_media and new_short -> new_media
in the default locale, on the assumption that Crowdin would retire the
old keys on its next sync. The sync merged right after (7a4dc1b378)
cleaned 32 of the 47 locales but left both stale keys in 15 of them.

Android lint runs on the pushed tree, not on Crowdin's next round, so
those 2 keys x 15 locales became 30 [ExtraTranslation] errors and failed
:amethyst:lintFdroidBenchmark on main:

  values-th/strings.xml:515: Error: "route_video" is translated here but
  not found in default locale [ExtraTranslation]

Delete the orphaned entries. A diff of all 4540 default keys against
every locale confirms these were the only two orphans;
:amethyst:lintFdroidBenchmark now passes.
2026-09-01 11:12:37 +02:00
Claude d2950b03de test: pin the Blossom flow test's collector to Dispatchers.Unconfined
Third CI failure mode for this test, and the first that was real: with
CoroutineScope(SupervisorJob()) the stateIn(Eagerly) collector needs a
Dispatchers.Default worker (4 on CI), and another desktop test in the
shared JVM leaking a blocked Default thread starves it - the flow then
never surfaces the servers and the 30s timeout fires. Unconfined starts
the collector synchronously and resumes it on the flowOn(IO) producer
thread, so the test depends only on the 64+-thread IO pool. Timeout now
fails with the flow/getter state for diagnosis instead of a bare
TimeoutCancellationException.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 04:20:57 +00:00
Claude 1b5aa8eadb docs(plans): record the executed Wave-3 bulk string migration
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 04:04:07 +00:00
Claude 7c6a18ee52 feat(i18n): bulk-migrate 2,565 mechanically-safe string keys to commons
Every key whose usages are all composable stringRes/stringResource
calls, with no XML references, no bare %s/%d, only %N$s/%N$d args and
no inline markup, moves from the app's res/ to commons Compose
resources (~105,800 locale entries across 57 locales, translations
preserved byte-for-byte for Crowdin). 568 app files repointed to
Res.string via the stringRes bridge overloads.

The app keeps 1,866 keys that are genuinely Android-bound: ctx-based
call sites, Int-typed id storage (maps/whens), @string/ XML references,
and non-positional format args.

Tool fix folded in: Crowdin emits some entries with attributes before
name= (xmlns:ns0=... name="key") - the extraction regex now matches
any attribute order; the six entries the old pattern missed (zh,
nl-rBE, es x account_backup_tips{2,3}_md) are relocated, and 11 in-file
duplicates from keys that already existed in commons are removed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 03:31:48 +00:00
Claude 8128507c12 feat(i18n): build the Wave-3 strings bridge and migrate the first key
- commons/ui/StringRes.kt: the Compose-resources twin of the app's
  stringRes family (composable, formatted, plural, and suspend
  loadStringRes variants). Thin delegates - compose-resources caches
  parsed locale files process-wide, so the Android-style LruCache is
  unnecessary here.
- App StringResourceCache.kt gains stringRes(StringResource) overloads
  delegating to the bridge, so a file can mix migrated and unmigrated
  keys under its existing single import; migrating a key is just
  R.string.x -> Res.string.x.
- tools/strings-migrate: moves keys from app res to commons
  composeResources across all locales byte-for-byte (both trees are
  Crowdin-managed with the same android mapping); refuses keys using
  bare %s/%d since compose-resources only formats positional args.
- Exemplar: profile_banner - the single string blocking the ui/layouts
  cluster - migrated across 57 locales, all 7 call-site files repointed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 01:35:21 +00:00
Claude 13dc29f50f refactor: restore the napplet registry boundary; dedupe the Skia converter
Move NappletLaunchRegistry back to :amethyst - CLAUDE.md's napplet
security model relies on the broker-side registry being unimportable
from :nappletHost, and its only consumers live in :amethyst anyway.

Hoist PlatformImage.toSkiaBitmap() into a new skikoMain source set
(desktop JVM + iOS, both Skiko-backed) instead of duplicating it in the
two CoilImageBridge actuals; add skikoMain to the KMP purity gate.

Update the migration plan's handoff notes: audit findings 1/2/3/5/6
fixed, baseline-profile regeneration is the one open item.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 01:22:55 +00:00
Claude 6423b65951 fix(app): set HTTP env flags before AppModules builds the OkHttp factories
AppModules' constructor eagerly builds both OkHttp factories, whose
dispatchers read HttpClientEnvironment.isEmulator at construction time.
Setting the flag after AppModules left the emulator-safe limits dead.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 01:22:52 +00:00
Claude cb9bb9d4b4 fix(model): keep TopFilter's pre-move serial names so saved prefs survive
The move to commons changed every subclass's kotlinx default serial name
(the FQN), which is the polymorphic type discriminator JsonMapper writes
into the per-account DEFAULT_*_FOLLOW_LIST preferences. Decode failures
are swallowed by parseTopFilterOrDefault, so without this every user's
~30 saved tab selections silently reset on upgrade. @SerialName pins the
old names; TopFilterSerialNameTest pins them (and legacy-JSON decoding)
on JVM and iOS.

Also annotate the nativeMain Address actual @Serializable to match the
jvm/android actuals: @Contextual properties only get the plugin's
compile-time fallback on targets whose actual is serializable, so
encoding an address-carrying TopFilter threw on iOS.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 01:22:49 +00:00
Claude 36edbb146e test: run the Blossom flow test on real dispatchers, not runTest virtual time
BlossomServerListState.flow hops through real Dispatchers.IO (flowOn)
into a stateIn collector. Under runTest both the awaiting coroutine and
the stateIn scope sit on the virtual-time scheduler, and the IO handoff
can park while that scheduler is idle - runTest then aborts with
UncompletedCoroutinesError, which is exactly how the previous hardening
(await-the-flow-first) failed on the Linux DEB CI job. runBlocking with
a private cancellable scope keeps every dispatcher real, and withTimeout
bounds a genuine hang with a clear error instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 01:06:17 +00:00
Claude 421b25bb0e refactor(video): size the quality ladder to the player, not to a policy
The rendition policy this branch added was two named cases — LOWEST for
inline media, AUTO for the media-card feeds — with the choice threaded
through VideoView, ZoomableContentView and every call site. Both cases
were guessing at the same underlying quantity: how big the player
actually is.

ExoPlayer already filters renditions by a viewport; its default is the
physical display size (TrackSelectionParameters.Builder.init sets
isViewportSizeLimitedByPhysicalDisplaySize), which is why AUTO on a
thumbnail fetched a display-resolution rung and why LOWEST had to exist
as a counterweight. Handing it the measured size instead answers the
question directly: a full-width short gets the top of the ladder, a
small inline player gets the rung matching its pixels, PiP gets a small
one because its window is small, and all of them still adapt to the
connection under that ceiling.

setViewportSize is safe where setMaxVideoSize would not be:
DefaultTrackSelector derives its retain threshold from an actual
rendition and leaves the group untouched when nothing covers the
viewport, so a small player can never filter every track away. Manual
picks from the quality menu still win, since overrides are re-applied
after constraint-based selection.

The measurement rides the onSizeChanged RenderVideoPlayer already had
for double-tap seeking, so no new layout observation and no
recomposition; a guard keeps a settling layout pass from re-running
track selection over IPC. VideoQualityPolicy, ApplyInitialVideoQuality
and findLowestResolutionTrackIndex are gone, and VideoView and
ZoomableContentView are back to byte-identical with main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQs7TP2WeXNR8SwUNLgmUC
2026-09-01 00:56:49 +00:00
Claude 7965ab5697 docs(plans): record migration state, audit findings, and next steps for handoff
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 00:52:14 +00:00
Claude 5cf47f6fb5 fix(i18n): drop orphaned route_video/new_short keys from 15 translations
PR #4026 retired the route_video and new_short keys from the default
locale (the mixed media feed is no longer labelled "Shorts"), expecting
the next Crowdin sync to drop the retired keys from the translations.
The sync merged in #4027 didn't, so 15 locales still carry them and
:amethyst:lintFdroidBenchmark fails ExtraTranslation with 30 errors
(15 locales x 2 keys) on main and on every branch that merges it.

Same cleanup as e4d288a9 did for the orphaned AI-writing keys.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 23:34:52 +00:00
Claude bf75e43102 Merge remote-tracking branch 'origin/main' into claude/amethyst-commons-migration-hm8vgm 2026-08-31 23:34:16 +00:00
Claude 2cbb39f113 test: harden DesktopBlossomServerListTest against the IO-dispatcher race
The compose-ui-test job failed once on this test (green locally and on
every re-run): BlossomServerListState's flow is stateIn over
Dispatchers.IO, so asserting the synchronous getter before the flow had
settled raced the IO hop on fast runners. Await the flow first - it
settling proves the state finished wiring - then assert the getter.
This PR does not otherwise touch nipB7Blossom; the test predates it
(#3918).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 22:36:58 +00:00
Claude 9639f97c5e fix(video): use the HLS master playlist and its full quality ladder
NIP-71 video events (kinds 34235/34236) carry a whole HLS ladder in their
imeta tags: one entry for the master playlist, which enumerates every
rendition, plus one per rendition, each locked to a single resolution.
Two separate things kept playback at the bottom of that ladder.

Renderers took `imetaTags()[0]` blindly. That only works because our own
publisher emits the master first (HlsVideoEventBuilder); a client that
orders the tags differently pinned us to one rung, with no adaptive
bitrate and no quality menu either, since a media playlist exposes a
single video track and VideoQualityButton hides itself below two. The
feed filters already accepted an event when *any* imeta was playable,
so the tag the card rendered was not necessarily the one that made it
pass. A new `VideoEvent.selectVideoTrack()` in commons prefers HLS over
a progressive file, then the master among the HLS entries (largest
declared dim, earliest tag winning a tie; a dim-less manifest ahead of
dimensioned rungs), skipping the separate audio track NIP-71 PR #2255
allows and any poster image. Presentation metadata is ladder-wide, so
whatever the chosen entry is missing is filled in from its siblings and
the blurhash, poster and aspect ratio survive the switch.

Even with the master selected, VideoViewInner derived its rendition
policy from `isFullscreen` alone, so everything outside the fullscreen
dialog was pinned to LOWEST. That is right for a video attached to a
note, but the shorts, video and longs feeds render the video full-width
as the post itself — a portrait short fills most of the screen at 360p.
The policy is now an explicit parameter, defaulting to today's behaviour
everywhere, and the media-card feeds pass AUTO so ExoPlayer adapts.

Notification big pictures go through the same selector, so a ladder that
declares `image` on only some rungs gets a poster instead of falling back
to a playlist URL Coil cannot decode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQs7TP2WeXNR8SwUNLgmUC
2026-08-31 22:29:10 +00:00
Claude a9fc0184d4 refactor: execute Tier 3 - diagnostics, image fetchers, minter JSON
BootRelayDiagnostics -> commonMain. The concurrency review found it
already fully non-blocking: per-relay atomic counters on the hot
per-message path and get-or-create maps, no locks. Swaps: stdlib
kotlin.concurrent.atomics, quartz ConcurrentMap, TimeUtils.nowMillis,
and the daemon dump thread became a coroutine that completes after the
last scheduled census instead of parking a thread for process lifetime.

Blurhash/Thumbhash/Base64 fetchers -> commonMain over a new
CoilImageBridge expect (PlatformImage.toCoilImage +
base64DataUriToCoilImage; android actual = Bitmap.asImage, jvm/ios
actuals = Skia N32 premul from the ARGB pixel buffer, iOS base64 via
Image.makeFromEncoded). desktopApp deletes its three hand-rolled clone
fetchers and registers the shared ones - the first UI-adjacent
duplication the migration removes outright.

BuzzInviteMinter drops Jackson for kotlinx-serialization but stays
jvmAndroid: its OkHttp pin is load-bearing, since the NIP-98 u tag is
signed over OkHttp's canonical URL string and the transport must not
drift from the canonicalizer. PodcastRemoteContent is reclassified to
the OkHttp tier - the object IS a capped HTTP GET; injecting the fetch
would leave an empty shell.

Verified: verifyKmpPurity, JVM, iosArm64 compile + test-compile,
Android, desktop, and the quartz/commons/cli test suites.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 22:19:16 +00:00
Vitor PamplonaandGitHub 2c4cf9d160 Merge pull request #4027 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-31 18:08:09 -04:00
Claude 33c87b4935 refactor: promote 16 Tier-2 files to commonMain with a concurrency review
Instead of blindly porting synchronized blocks, each lock was judged:

Removed (guarded nothing, or a lock-free design exists):
- RelayAuthPromptBus: mutableMap+synchronized -> ConcurrentMap.getOrPut
  with identity-check ownership; lock-free, cold path
- NappletLaunchRegistry: its JVM-only access-ordered LinkedHashMap +
  @Synchronized trio became androidx.collection.LruCache (internally
  synchronized, access-ordered cap - identical touch-on-resolve/evict
  semantics); SecureRandom -> quartz RandomInstance

Kept as KmpLock (real multi-field invariants; uncontended lock beats
copy-per-write CAS on GC):
- ChatDeliveryTracker (hot OK path already lock-free via volatile
  immutable maps; the lock coordinates three structures + eviction)
- NWCPaymentFilterAssembler (debounce set + job swap atomicity)
- NappletNotificationStore (per-coordinate insertion-ordered buckets)
- DeferredDeleteFileSystem (pending-set membership decides deletion)

Mechanical replacements throughout: java.util.concurrent atomics ->
kotlin.concurrent.atomics; ConcurrentHashMap -> quartz ConcurrentMap
(extended with putIfAbsent/remove(key,value)/clear across expect, JVM,
and native CoW actuals); System.currentTimeMillis -> TimeUtils.nowMillis;
java.io.IOException -> okio; TimeUnit TTL -> plain ms. speedLogger's
kotlin.concurrent.timer became a coroutine tick RelaySpeedLogger cancels
in destroy() - the old daemon timer ran forever.

Also promoted: BlossomAuth (quartz-only imports, unblocks the token
provider). Reclassified to blocked: NappletIdentityWatch (needs
NappletProtocolJson, pinned by java.util.Base64) and NamecoinNameService
(quartz ElectrumXClient is jvmAndroid).

Verified: verifyKmpPurity, JVM, iosArm64 compile + test-compile,
Android, desktop, and the quartz/commons/cli test suites.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 22:04:07 +00:00
vitorpamplonaandgithub-actions[bot] 7a4dc1b378 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-31 21:56:42 +00:00
Vitor PamplonaandGitHub b335e2991a Merge pull request #4026 from vitorpamplona/claude/shorts-link-naming-0nd6mz
Distinguish media feed from shorts feed in UI labels
2026-08-31 17:53:39 -04:00
Claude 8ab56e7b37 refactor: promote the 9 unpinned jvmAndroid files to commonMain
Tier 1 of the jvmAndroid promotability audit: the seven relay-AUTH model
files, EncryptionKeyCache, and HttpClientEnvironment have no JVM-only
API usage - a pure source-set move. Verified against verifyKmpPurity,
JVM, iOS (compile + test-compile), Android, and desktop.

NWCPaymentWatcherSubAssembler turned out to reference
NWCPaymentQueryState, declared same-package in the OkHttp-pinned
assembler, so it is reclassified to Tier 2 in the audit table.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 21:41:29 +00:00
Claude 38f0061710 docs: audit the jvmAndroid-parked files for commonMain promotability
Tier-by-tier table of the 59 files Waves 0-1 placed in jvmAndroid or
androidMain: the exact JVM pin per file and which in-repo KMP
replacement (KmpLock, TimeUtils, stdlib atomics, quartz ConcurrentMap,
RandomInstance, okio, kotlinx-serialization, PlatformImage) unlocks it.
11 move with no code change, 17 with one-line swaps, 6 with small
refactors, 5 wait on a dependency, 20 are the OkHttp engine that stays
until quartz has a KMP transport.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 21:26:23 +00:00
Claude 11ed622abb fix: make the moved commons files compile for the iOS target
CI's test-quartz-ios job compiles commons for iosSimulatorArm64, which
my JVM-only local checks never exercised. Four fixes:

- BitcoinExplorerEndpoint and OtsSettings reference quartz's JVM-only
  OkHttpBitcoinExplorer -> relocated commonMain to jvmAndroid
- GeohashListDecryptionCache, GenericRelayListCache, OutboxLoaderState
  now import kotlinx.coroutines.IO, the commonMain-visible extension
  (plain Dispatchers.IO is internal on Native)
- TorCircuitHealthTracker uses TimeUtils.nowMillis() instead of
  System.currentTimeMillis()
- TopFilter's Address properties are @Contextual: Address is an expect
  class with no serializer, and nothing in the repo actually serializes
  TopFilter, so deferring to a contextual lookup is behavior-preserving

Verified locally: :commons:compileKotlinIosArm64 and
compileTestKotlinIosArm64 now pass (after repairing the sandbox's
corrupted Kotlin/Native gcc toolchain), along with JVM/Android/desktop
compiles, verifyKmpPurity, and spotless.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 21:15:50 +00:00
Claude f598b951c9 fix: make TorCircuitHealthTracker KMP-pure with KmpLock
The move to commons commonMain put two JVM-only synchronized() blocks
behind the verifyKmpPurity gate, which failed CI's lint job. Guard the
streak fields with the commons KmpLock instead, matching the pattern
EOSEAccountFast already documents.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 20:58:15 +00:00
Claude 337e865b4a fix: point main's new NIP-34 notification test at the moved commons filter
NotificationsPerKeyKinds2 lives in commons/relayClient/account since the
migration; the test arrived from main importing the old app-module path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 20:47:21 +00:00
Claude d6d5a72e49 fix(nav): stop labelling the mixed media feed "Shorts"
Two different destinations in the bottom-bar picker showed the same
"Shorts" label with different icons and different content:

- Main > NavBarItem.VIDEO (Route.Video, VideoFeedFilter) is the combined
  media feed: NIP-68 pictures, NIP-94 file headers and every NIP-71 video
  kind (normal, horizontal, vertical, short), scoped by the "stories"
  follow list. This is the one that also shows images.
- Feeds > NavBarItem.SHORTS (Route.Shorts, ShortsFeedFilter) is vertical
  video only (kinds 22 and 34236), scoped by the "shorts" follow list.

Relabel the first one "Media", which is accurate for its contents and
doesn't collide with the neighbouring Pictures / Videos / Shorts feed
entries. The FAB on that screen was described as "New Shorts: images or
videos" for the same reason, so it becomes "New Media: image or video".

Both are new string keys rather than edits in place: all 47 locales had
translated the old keys as "Shorts", and those translations would be
wrong for the new meaning. Crowdin drops the retired keys on its next
sync and the label falls back to English until retranslated.

The NavBarItem enum constants are serialized into user settings by name,
so VIDEO keeps its name — only the display label changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ya9C9GkvkRHLRqrPCAa4WA
2026-08-31 20:41:48 +00:00
Claude 1b2f95f00d Merge remote-tracking branch 'origin/main' into claude/amethyst-commons-migration-hm8vgm 2026-08-31 20:33:27 +00: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
Claude beb4b9f456 fix: repair test sources for the commons moves
Adds the imports the migration's same-package rewrites missed in test
source sets (moved topNavFeeds filters, okhttp classes, LargeSoftCache
address extensions, latestBuzzEdit), inlines the two multi-line
old-package FQNs in NewMessageTaggerKeyParseTest, and widens
NappletRelayCleartext.forDelivery with the rest of the object so its
test keeps calling it cross-module.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 07:24:56 +00: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
Claude 10d20a2d08 docs: record executed state of migration waves 0-1 in the sweep plan
Adds an execution-status header: what landed on this branch, the
corrections found while executing (import-graph analysis undercounts
same-package coupling; ui/theme+layouts are not mechanically movable),
and the refined LocalCache move recipe with its one open IAccount
design question.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 19:57:29 +00: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 ffbf17dabe refactor: reunite topNavFeeds in commons via ICacheProvider signature fix
The two-line poison edge the migration sweep identified:
IFeedTopNavFilter.toPerRelayFlow/startValue hard-coded the concrete
LocalCache, which kept all 23 implementors app-side even though they
only call relayHints and getOrCreateAddressableNote. The signatures now
take the commons ICacheProvider port (checkGetOrCreateAddressableNote
gains a default implementation there, mirroring LocalCache's).

With that edge cut, this moves to commons/model/topNavFeeds:
- IFeedTopNavFilter, IFeedFlowsType, OutboxRelayLoader/State,
  CommunityRelayLoader, UsingRelayUnwrapper, FeedDecryptionCaches
- the TopNavFilter/FeedFlow pairs for allFollows, allUserFollows,
  global, hashtag, mine, relay, aroundMe (geohash), noteBased
  (community/author/muted), favoriteAlgoFeeds filters, unknown
- TopFilter itself, extracted out of AccountSettings.kt where it never
  belonged (persisted by its code string, so the move is wire-safe)
- the nip51 geohash list card + decryption cache they depend on

Still app-side, each named by its real blocker: FeedTopNavFilterState
(Account/AccountSettings wiring), AllFollows/AllUserFollows feed flows
(serverList MergedFollowListsState), Kind3UserFollowsFeedFlow
(nip02FollowLists), AroundMeFeedFlow (LocationState), favoriteAlgoFeeds
flows (algoFeeds orchestrator).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 07:07:03 +00:00
Claude 8424838450 refactor: move 37 clean model/service singles from amethyst to commons
Batch 5 of the commons migration sweep. Moves, with no behavior change:

- model root: Dao, HomeFeedType, VideoPostKind, ConcordInviteResult,
  NoteEditOverlays, PrivateChatroomReadState, RelayGroupContentRouting,
  MutedPublicChats -> commons/model; LargeSoftCacheAddressExt ->
  commons/model/cache (jvmAndroid, next to LargeSoftCache)
- model/nip03Timestamp (OTS settings, explorer endpoints, verification,
  Tor-aware resolver builder) -> commons/model/nip03Timestamp
- model/nip51Lists/relayLists RelayListCard + GenericRelayListCache and
  model/edits PrivateStorageRelayListDecryptionCache -> commons twins
- ChessAction -> commons/nip64Chess; InMemoryMlsGroupStateStore ->
  commons/marmot; NwcInfoCache -> commons/model/nip47WalletConnect
- AdditiveComplexFeedFilter -> commons/ui/feeds
- napplet clean half (LaunchRegistry, NotificationStore, IdentityWatch,
  RelayCleartext) -> commons/napplet; NappletRelayCleartext widened from
  internal so the Android broker can keep calling it cross-module
- NamecoinNameService -> commons/service/namecoin (desktop already
  reimplements it verbatim); image fetchers (Base64/BlurHash/ThumbHash
  to androidMain, BlossomReadAuth/DeferredDelete to jvmAndroid) ->
  commons/service/image; PodcastRemoteContent -> commons/podcasts;
  BuzzInviteMinter -> commons/actions; WritingAssistant ->
  commons/service/ai; DevReportContact -> commons/service/crashreports;
  ConnectivityStatus -> commons/service/connectivity;
  ScheduledPostWorkGate -> commons/scheduledposts; MeltResult ->
  commons/cashu/melt

Files whose hidden same-package coupling to Account/LocalCache the
sweep's import analysis missed (AccountMarmotActions, EventBroadcaster,
ParticipantListBuilder, UnexpectedCrashSaver, Blossom/ProfilePicture
fetchers, BlossomServerResolver) stay in the app until those hubs
migrate.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 06:55:34 +00:00
Claude 4a1d2211af refactor: move OkHttp client stack to commons/service/http (jvmAndroid)
Batch 3 of the commons migration sweep: IHttpClientManager, the dual
direct/Tor client managers, both OkHttp factories, all interceptors
(Blossom read-auth, encrypted blob, local-cache redirect, onion
location/rewrite, content type, logging), both event listeners,
OnionLocationCache, EncryptionKeyCache, OkHttpDebugLogging, plus the
role-based client builders from model/privacyOptions.

Two small seams so the shared code stays Android-free:
- MediaCallEventListener.verboseLogging replaces the app isDebug read;
  the app sets it at startup.
- HttpClientEnvironment.isEmulator replaces the Build-fingerprint call
  in the factories; the app sets it at startup, desktop stays false.
- EncryptionKeyCache now uses androidx.collection.LruCache (KMP) with an
  explicit null-url guard where android.util.LruCache would have thrown.

Desktop's hand-rolled DesktopHttpClient can now adopt these factories
and gain the interceptors it currently lacks.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 06:21:12 +00:00
Claude 4fa2bc6fa0 refactor: move 38 relayClient files from amethyst to commons
Batch 1 of the commons migration sweep (see
commons/plans/2026-08-30-commons-migration-sweep.md):

- eoseManagers (PerUser, PerUserAndFollowList, PerUniqueId,
  AccountScopedSingleSubNoEoseCache) -> commons/relayClient/eoseManagers
- AccountScopedQuery -> commons/relayClient, generalized from the
  concrete Account to commons IAccount (covariant overrides keep all
  56 implementors source-compatible)
- EOSEByKey/EOSEAccountKey (service/relays/EOSE.kt) -> commons/relays
- account/channel/search/nwc pure filter functions ->
  commons/relayClient/{account,channel,search,nip47WalletConnect}
- relay AUTH permission model (9 files) -> commons/relayClient/auth
- notify request model -> commons/relayClient/notify
- chatDelivery, speedLogger, diagnostics -> commons/relayClient (jvmAndroid)
- TorCircuitHealthTracker -> commons/relays/health

Also inserts the same-package imports the earlier shim-removal commit
missed (its insertion regex never matched below license headers).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 06:14:57 +00:00
Claude da28297761 refactor: delete 12 typealias shim files, point call sites at commons
Removes the backwards-compat re-export shims (Note, User, HashtagIcon,
TorRelaySettings/Evaluation, FeedFilters, ChangesFlowFilter, FeedStates,
BundledUpdates, BookmarkListState, ChatroomFeedFilter, UserFinderShims)
and the typealias lines inside the five mixed shim files, rewriting all
1,165 imports to the canonical commons FQNs. Renames the two files whose
remaining single class no longer matched the filename.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 05:56:17 +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
Claude b658d3047b docs: full sweep of amethyst-module sources that can and should move to :commons
Audit of all 2,347 Kotlin files in amethyst/src/main via import
classification + transitive-closure analysis plus six per-area deep
audits. Key findings: 509 files are movable today with no refactoring;
the dominant blockers are Account/LocalCache/AccountViewModel and the
string-resource bridge, not Android APIs; LocalCache itself has only
three trivial Android-dirty deps and moving it deletes the 1,173-line
DesktopLocalCache; a two-line IFeedTopNavFilter signature fix unlocks
the app half of topNavFeeds. Includes MOVE-NOW batches, blocker-tagged
MOVE-AFTER tables, a desktop-duplication catalog, and a six-wave
migration sequence.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 05:35:52 +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
carmim777 7594d826a8 feat: add Android screen sharing to calls 2026-08-18 21:20:36 -03:00
1989 changed files with 144599 additions and 117073 deletions
+27 -1
View File
@@ -263,7 +263,33 @@ Commit the regenerated `material_symbols_outlined.ttf` alongside your
at runtime because the glyph is not in the bundled font.
Reusing a codepoint already present in `MaterialSymbols.kt` does NOT require
regenerating. See `tools/material-symbols-subset/README.md` for details and
regenerating.
### Amethyst's own icons are also a font
The icons in `commons/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are
**also** compiled into a font, `composeResources/font/amethyst_icons.ttf`, and drawn
as glyphs via `AmethystIconGlyph`. Drawing an `ImageVector` rasterises its paths into
a per-instance cached layer, so a feed re-rasterised the same glyph once per card;
a glyph is a blit from the shared text atlas. Measured: frame P90 **-10.7%**,
overrun P90 **-17.4%** on the feed scroll benchmark.
**MANDATORY:** whenever you add or change an icon under `commons/.../commons/icons/`,
regenerate the font *and* its codepoint table together:
```bash
python3 tools/icon-font/build_icon_font.py \
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \
commons/src/commonMain/composeResources/font/amethyst_icons.ttf \
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt
```
Both outputs must be committed together: codepoints are assigned in filename order,
so adding an icon renumbers the ones after it, and a stale `AmethystIcons.kt` then
points at the wrong glyph. Needs `fonttools` (`pip install fonttools`). The script
prints any icon it could not convert — an icon that is skipped must keep using its
`ImageVector`.
See `tools/material-symbols-subset/README.md` for details and
prerequisites (`pip install fonttools brotli`).
## Code Formatting
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Decide whether a PreToolUse payload on stdin is a push/PR boundary.
Shared by every pre-push hook in this directory (pre-push-spotless.sh,
pre-push-orphan-strings.sh) so the gate condition is defined once. Each hook is
a separate process with its own stdin, so this is exec'd per hook rather than
run once and shared.
Exit 0 = this call publishes code (gate it). Exit 1 = let it through.
"""
import json
import shlex
import sys
# Reaching the push subcommand means stepping over git's global options first.
GLOBAL_WITH_ARG = {"-c", "-C", "--namespace", "--git-dir", "--work-tree", "--exec-path"}
def is_boundary(data):
tool = data.get("tool_name", "")
if tool.endswith("create_pull_request"):
return True
if tool != "Bash":
return False
cmd = (data.get("tool_input") or {}).get("command", "")
# Tokenize like a shell so `push` inside a quoted commit message or heredoc
# stays one token and is NOT mistaken for the push subcommand.
try:
tokens = shlex.split(cmd, comments=True)
except ValueError:
tokens = cmd.split()
for i, token in enumerate(tokens):
if token != "git" and not token.endswith("/git"):
continue
j = i + 1
while j < len(tokens):
tok = tokens[j]
if tok in GLOBAL_WITH_ARG:
j += 2
elif tok.startswith("-"):
j += 1
else:
break
if j < len(tokens) and tokens[j] == "push":
return True
return False
def main():
try:
data = json.load(sys.stdin)
except Exception:
return 1
return 0 if is_boundary(data) else 1
if __name__ == "__main__":
sys.exit(main())
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Fail if any locale declares a string resource its default values/ no longer has.
A key removed or renamed in a default `values/strings.xml` orphans every
`values-<locale>/strings.xml` entry that still declares it. In an Android res
tree that is an `[ExtraTranslation]` lint ERROR, which aborts
`:amethyst:lint<Variant>` and with it the whole `test-and-build-android` CI job.
Run directly, or via the pre-push-orphan-strings.sh hook that wraps it.
Exits 0 when clean, 2 with a report when not.
"""
import glob
import os
import re
import sys
from collections import defaultdict
# values-night, values-v29, values-sw600dp, ... are configuration qualifiers,
# not locales; only locale-qualified dirs can hold a translation.
LOCALE = re.compile(r"^values-(?:b\+[A-Za-z0-9+]+|[a-z]{2,3}(?:-r[A-Z]{2,3})?)$")
NAMED = re.compile(r'<(?:string|plurals|string-array)\s+[^>]*name="([^"]+)"')
# Both Crowdin-managed resource systems (see the find-missing-translations
# skill, "Resource trees — scan BOTH"), each with what an orphan costs there.
# Android res is the tree lint policies; the Compose-Multiplatform catalog is
# not lint-checked, but an orphan there is the same authoring mistake and
# leaves a dead translation behind.
ROOTS = (
("*/src/*/res/values", "Android lint [ExtraTranslation] error — aborts the build"),
("*/src/*/composeResources/values", "dead translation — key no longer exists in the default catalog"),
)
def names(paths):
found = set()
for path in paths:
with open(path, encoding="utf-8") as handle:
found |= set(NAMED.findall(handle.read()))
return found
def find_orphans():
orphans = defaultdict(list) # (res_root, key, consequence) -> [locale, ...]
for pattern, consequence in ROOTS:
for default_dir in sorted(glob.glob(pattern)):
res_root = os.path.dirname(default_dir)
base = names(glob.glob(os.path.join(default_dir, "*.xml")))
for locale_dir in sorted(glob.glob(os.path.join(res_root, "values-*"))):
locale = os.path.basename(locale_dir)
if not LOCALE.match(locale):
continue
extra = names(glob.glob(os.path.join(locale_dir, "*.xml"))) - base
for key in extra:
orphans[(res_root, key, consequence)].append(locale[len("values-"):])
return orphans
def main():
orphans = find_orphans()
if not orphans:
return 0
total = sum(len(v) for v in orphans.values())
out = sys.stderr
print(
f"BLOCKED: {total} orphaned translation(s) across {len(orphans)} key(s) — "
"translated in a locale, absent from that tree's default values/.",
file=out,
)
print(file=out)
for (res_root, key, consequence), locales in sorted(orphans.items()):
print(f" {res_root}: {key!r} in {len(locales)} locale(s) — {consequence}", file=out)
print(f" {' '.join(sorted(locales))}", file=out)
print(file=out)
print(
"A key removed or renamed in a default values/strings.xml must be deleted\n"
"from every values-*/strings.xml in the SAME commit. Crowdin's next sync is\n"
"not a cleanup step CI waits for — lint runs on the tree you push.\n"
"See amethyst/src/main/res/CLAUDE.md, 'Renaming or removing a string key'.",
file=out,
)
return 2
if __name__ == "__main__":
sys.exit(main())
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# PreToolUse gate: no locale string may outlive its default-locale key.
#
# Fires on `git push` (Bash tool) and on the create_pull_request MCP tool.
# Delegates to orphan_strings_check.py, which compares every
# `values-<locale>/*.xml` resource name against the union of names declared in
# that tree's default `values/*.xml`. Anything present in a locale but absent
# from the default is an orphan: in an Android res tree Android lint reports it
# as an [ExtraTranslation] ERROR, which aborts `:amethyst:lint<Variant>` and
# therefore the whole `test-and-build-android` CI job.
#
# Why a dedicated hook instead of "just run lint": `:amethyst:lintFdroidBenchmark`
# takes ~19 minutes on a warm daemon, so nobody runs it per-commit. This check is
# a directory scan and finishes in well under a second.
#
# Run the scan by hand any time with: .claude/hooks/orphan_strings_check.py
set -uo pipefail
hook_dir="$(cd "$(dirname "$0")" && pwd)"
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
# --- Is this call a push/PR boundary? ---
payload="$(cat)"
# Cheap pure-bash pre-filter before paying for a python spawn. The gate below
# can only answer "yes" for a payload containing "push" (a git push command) or
# "pull_request" (the create_pull_request MCP tool), so anything else is a
# guaranteed no. This hook runs on EVERY Bash tool call, and the spawn it skips
# costs ~35ms each time.
case "$payload" in
*push*|*pull_request*) ;;
*) exit 0 ;;
esac
printf '%s' "$payload" | python3 "$hook_dir/lib/git_push_gate.py" || exit 0
exec python3 "$hook_dir/orphan_strings_check.py"
+13 -38
View File
@@ -9,48 +9,23 @@
# so a clean apply means a green check.
set -uo pipefail
hook_dir="$(cd "$(dirname "$0")" && pwd)"
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
# --- Parse the tool call off stdin; decide whether this call is a boundary. ---
# --- Is this call a push/PR boundary? ---
payload="$(cat)"
should_gate="$(
printf '%s' "$payload" | python3 -c '
import json, shlex, sys
try:
data = json.load(sys.stdin)
except Exception:
print("no"); sys.exit(0)
tool = data.get("tool_name", "")
if tool.endswith("create_pull_request"):
print("yes"); sys.exit(0)
if tool != "Bash":
print("no"); sys.exit(0)
cmd = (data.get("tool_input") or {}).get("command", "")
# Tokenize like a shell so `push` inside a quoted commit message or heredoc
# stays one token and is NOT mistaken for the push subcommand.
try:
tokens = shlex.split(cmd, comments=True)
except ValueError:
tokens = cmd.split()
GLOBAL_WITH_ARG = {"-c", "-C", "--namespace", "--git-dir", "--work-tree", "--exec-path"}
for i, t in enumerate(tokens):
if t != "git" and not t.endswith("/git"):
continue
j = i + 1
while j < len(tokens): # skip git global options to reach the subcommand
tok = tokens[j]
if tok in GLOBAL_WITH_ARG:
j += 2; continue
if tok.startswith("-"):
j += 1; continue
break
if j < len(tokens) and tokens[j] == "push":
print("yes"); sys.exit(0)
print("no")
' 2>/dev/null
)"
[ "$should_gate" = "yes" ] || exit 0
# Cheap pure-bash pre-filter before paying for a python spawn. The gate below
# can only answer "yes" for a payload containing "push" (a git push command) or
# "pull_request" (the create_pull_request MCP tool), so anything else is a
# guaranteed no. This hook runs on EVERY Bash tool call, and the spawn it skips
# costs ~35ms each time.
case "$payload" in
*push*|*pull_request*) ;;
*) exit 0 ;;
esac
printf '%s' "$payload" | python3 "$hook_dir/lib/git_push_gate.py" || exit 0
# Nothing to format if no Kotlin is tracked/changed at all — cheap early out.
if ! git ls-files --error-unmatch '*.kt' '*.kts' >/dev/null 2>&1; then
+5
View File
@@ -8,6 +8,11 @@
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-spotless.sh",
"timeout": 180
},
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-orphan-strings.sh",
"timeout": 30
}
]
}
@@ -540,6 +540,7 @@ When adding translated strings to locale files:
- **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`.
- **Renaming or removing a key in `values/strings.xml` without deleting it from every locale in the same commit** — the surviving locale entries become orphans, and `ExtraTranslation` is an error. "Crowdin drops retired keys on its next sync" is the same false belief as the `other`-only shortcut above: lint runs on the tree you push. Worse, it's *partly* true — the sync cleans some locales and silently leaves others, so the files you happen to open look fine. Scan with the sub-second `.claude/hooks/orphan_strings_check.py` instead of the ~19-minute lint; see `amethyst/src/main/res/CLAUDE.md`, "Renaming or removing a string key". (Happened 2026-08-31: `route_video`/`new_short` left in 15 of 47 locales, 30 errors, red `main`.)
- **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".
+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
```
@@ -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
+100 -6
View File
@@ -21,8 +21,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Orphaned translations (no locale string may outlive its default key)
run: .claude/hooks/orphan_strings_check.py
- name: Set up JDK 21
uses: actions/setup-java@v5.7.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -69,7 +72,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.7.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -94,6 +97,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@a9170d5795813c01ab4901ffb045b52bab4ab09d # v6.5.0
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 +162,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.7.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -158,6 +194,64 @@ jobs:
name: geode Test Reports
path: geode/build/reports
# Until this job existed nothing ran the linuxX64 target at all — it was compiled by
# no CI leg. That is how a copy-on-write LargeCache with O(n) writes and a non-atomic
# read-copy-write (concurrent writers silently dropped entries) sat in the tree
# unnoticed, and how TestResourceLoader stayed a TODO() that failed every vector-driven
# suite on the target.
#
# Runs the whole :quartz suite on a Linux Native frontend, which also catches a
# commonMain or commonTest source reaching for a JVM-only API on a target that, unlike
# Apple, has no Foundation to fall back on.
test-quartz-linux-native:
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# The Kotlin/Native toolchain (compiler distribution + LLVM + the sysroot) lands
# in ~/.konan, which setup-gradle does not cache. Without this the job re-downloads
# well over a gigabyte on every run. Keyed on the version catalog so a Kotlin bump
# re-populates it.
- name: Cache Kotlin/Native toolchain
uses: actions/cache@v4
with:
path: ~/.konan
key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }}
restore-keys: konan-${{ runner.os }}-
- name: Test Quartz on Linux Native
run: ./gradlew :quartz:linuxX64Test
- name: Linux Native Test Report
uses: mikepenz/action-junit-report@a9170d5795813c01ab4901ffb045b52bab4ab09d # v6.5.0
if: always()
with:
report_paths: 'quartz/build/test-results/linuxX64Test/TEST-*.xml'
annotate_only: true
detailed_summary: true
fail_on_failure: true
- name: Upload Linux Native Test Reports
uses: actions/upload-artifact@v7
if: failure()
with:
name: Quartz Linux Native Test Reports
path: quartz/build/reports
test-quartz-ios:
# Phase 1 of the iOS support plan
# (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS
@@ -173,7 +267,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.7.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -232,7 +326,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.7.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -289,7 +383,7 @@ jobs:
# GITHUB_TOKEN is read-only). fail_on_failure preserves the old step's
# behavior of marking the job red when a test fails.
- name: Android Test Report
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6.4.2
uses: mikepenz/action-junit-report@a9170d5795813c01ab4901ffb045b52bab4ab09d # v6.5.0
if: always()
with:
report_paths: '**/build/test-results/**/TEST-*.xml'
@@ -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`
#
+8 -8
View File
@@ -78,7 +78,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.7.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -344,7 +344,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -405,7 +405,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.7.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -604,7 +604,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -662,7 +662,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.7.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -864,7 +864,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -953,7 +953,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.7.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -1097,7 +1097,7 @@ jobs:
fi
- name: Upload Android assets to GH Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: dist/*
tag_name: ${{ github.ref_name }}
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.7.0
uses: actions/setup-java@v6.0.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.7.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
+23 -5
View File
@@ -533,7 +533,7 @@ reads an optional per-release changelog from
## Bootstrap runbook (one-time)
> **Status as of v1.14.0: 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
@@ -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
+31 -13
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.14.0** 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:
@@ -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
@@ -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.
@@ -0,0 +1,346 @@
# NIP-A3 Payment Targets in the zap picker — v1
**Status:** proposal
**Modules:** `quartz`, `commons`, `amethyst`
**Scope:** when a note's author publishes a NIP-A3 payment target, **an
installed app can handle it**, and the note carries no NIP-57 zap split — show
one amount-less chip per such target that hands off to that app.
> **Revised after the first implementation.** This document originally gated the
> chip on *symmetry* — both parties publishing the same protocol — and capped the
> row at two chips. Both are gone: the gate is capability alone (can anything on
> this phone open the URI), there is no cap, and the setting now defaults **on**.
> Sections below that argue for symmetry are kept for the reasoning, but §5 is
> the current rule.
Deliberately excluded from v1: amounts, in-app payment, receipts, fiat
conversion, desktop.
---
## 1. Why v1 has no amounts
Zap presets are **sats**. A `venmo` / `iban` / `upi` chip cannot send 1000
sats, and there is **no FX or bitcoin-price service anywhere in this repo**
(grepped `quartz`, `commons`, `amethyst`). So v1 does not pretend: the chip
carries no number, emits no RFC-8905 `amount=`, and the amount is named in the
external app. The UI has to *say* that rather than leave a suspicious blank —
see §4.
Corollary: **the note's zap counter will not move.** No kind:9735, nothing to
count. In code it is a rail; to the user it must read as *pay*, not *zap*.
---
## 2. The layout decision — and the refactor it deletes
> This is the one place v1 diverges from the sketch, and the reason is that it
> makes the change roughly half the size.
The sketch was "add the icons to the toggle." The toggle is the segmented
control **inside each amount pill** (`UnifiedZapAmountChip`,
`ReactionsRow.kt:2362`). Putting an amount-less rail there has two costs:
1. **It repeats.** With presets of 1000/5000/10000, the identical amount-less
Venmo segment renders three times and means the same thing each time.
2. **It forces `ZapRail` to become a sealed interface.** The enum
(`ReactionsRow.kt:2481`) is payload-free, so a segment can't know *which*
target it opens. Making it data-carrying drags in `present`, `preferred`,
`selectedRail`, `ZapRailIcon`, `previewPreferredRail`, `previewRailsFor`
and the settings preview row — and, because `PaymentTarget` has no
`equals`, breaks the `remember(preferred, present)` key so the user's
selection resets on recompose.
**Instead: render the chip as a sibling of the amount pills**, appended to the
existing `FlowRow` in `ZapAmountChoiceGrid` (`ReactionsRow.kt:2297`), next to
the `Tune` preset-editor button. It wraps for free, it renders **once**, and
`ZapRail`, `UnifiedZapAmountChip` and every preview stay **completely
untouched**. Same popup, same place the user is already looking.
If an FX service ever lands and the amount becomes expressible, the chip moves
into the toggle then — that is the natural migration, not a reason to pay for
it now.
---
## 3. Intent discovery — the constraint that decides it
`targetSdk = 37`. Under Android 11+ package visibility,
`queryIntentActivities` returns **empty** for any intent not covered by a
`<queries>` declaration — so *without a manifest change this feature silently
shows nothing on every modern device*. The existing `<queries>` block
(`AndroidManifest.xml:4`) covers only `nostrsigner`, TTS, Health Connect and
Tor.
### 3.1 Manifest
Add one `<intent>` per scheme we probe. The important economy: an arbitrary
user-typed type (`iban`, `upi`, `pix`, …) always falls back to
`payto://<type>/<authority>`, so **one `payto` entry covers every generic
type**. Only the ~12 special-cased crypto schemes in `paymentTargetStyleFor`
(`DisplayPaymentTargets.kt:190`) need their own entries.
```xml
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="payto" />
</intent>
<!-- + one each: bitcoin, lightning, liquidnetwork, ethereum, monero, dash,
zcash, bitcoincash, litecoin, dogecoin, solana, tron -->
```
Use **`<queries>`, never `QUERY_ALL_PACKAGES`** — the latter is a
policy-restricted permission on Play and would need a declaration; specific
`<intent>` filters need nothing. On minSdk 2629 `<queries>` is ignored and
everything resolves, which is a strict superset of the gated behaviour.
### 3.2 https targets are exempt
`cashapp` / `venmo` / `paypal` map to `https://…`, which a browser always
resolves — discovery would be a tautology. **Skip discovery for https
targets and always show them**: opening `venmo.com/<handle>` in a browser is a
legitimate way to pay, so nothing is broken. For these three types the chip is
therefore gated only on the author having published one.
**But §4.2 still needs the control probe here.** To tell a real app handler
from a browser, resolve a control `https://<nonexistent-host>/` and treat the
target as app-backed only if its resolver set contains a package outside that
control set. It never gates the chip — it decides whether the chip wears the
app's icon or the brand-colour glyph, and a Chrome icon on a Venmo chip is
worse than no icon at all.
### 3.3 The cache — keyed by scheme+host, warmed from the open picker
The naive cache is per-post and lazy. With symmetry gone the probe set is the
author's target list, so:
> **Probe the targets of the one author whose picker is open** — typically 15
> entries — and **merge** the answers into the cache. Merging matters: replacing
> would evict what was learned about every other author the moment a second
> picker opened. Feed rendering still never triggers a probe.
- **Key:** `"<scheme>://<host>"`, e.g. `payto://iban`, `bitcoin://`. Scheme
alone is too coarse — an app may declare `android:scheme="payto"
android:host="iban"`, so a scheme-only hit would wrongly claim `payto://upi`
is handled.
- **Warm:** a `LaunchedEffect` keyed on the author's observed kind:10133 probes
that handful of keys off the main thread when the picker opens.
- **Read:** synchronous map lookup — required, because
`RailCapabilityResolver.peek` is called from inside `remember {}`.
- **Recomposition:** the map must be a `MutableStateFlow<Map<String, Boolean>>`,
not a bare `ConcurrentHashMap`. A plain map write is invisible to Compose and
the chip would not appear until something else recomposed.
- **Invalidation:** clear on app foreground (`ProcessLifecycleOwner`
`ON_START`) and re-warm — this is exactly the "user left, installed Venmo,
came back" flow. A `PACKAGE_ADDED`/`REMOVED` receiver is more precise but is
more moving parts than v1 needs.
Home: `amethyst/…/service/payments/PayToAppAvailability.kt` (Android-only;
`PackageManager` has no KMP equivalent). The scheme mapping it needs moves out
of the UI file into `commons` (§6.0).
---
## 4. The chip's face
### 4.1 Saying "the app decides the amount"
An amount-less chip beside pills that all show numbers reads as a bug unless
it is visibly a *different kind of thing*. Three cues, no extra layout:
1. **No number.** Icon + protocol label only (`VENMO`).
2. **A different terminal glyph.** `MaterialSymbols.OpenInNew` instead of the
`ArrowForward` every amount segment uses — "this leaves the app."
3. **A string that says it outright**, e.g. *"Amount set in %1$s"*, shown as
the chip's `contentDescription` and as a toast on long-press.
**Both glyphs are already in `MaterialSymbols.kt`** (`OpenInNew:280`,
`AccountBalanceWallet:27`) — **no `tools/material-symbols-subset/subset.sh`
run is needed**, and §4.2 adds no new glyphs either.
Long-press must **not** inherit `onChangeAmount` (the sat-preset editor is
meaningless here); it copies the authority, matching `PaymentTargetChip`'s
long-press on the profile.
### 4.2 Which icon it wears — the installed app's, not a bundled logo
**This already works in this codebase.** `ExternalSignerButton.kt:118` renders
installed NIP-55 signers with `it.loadLabel(pm)` / `it.loadIcon(pm)`
`toBitmap()` → Coil's `rememberAsyncImagePainter`, off the back of
`getExternalSignersInstalled` (`quartz/…/IsExternalSignerInstalled.kt`), which
is `queryIntentActivities(ACTION_VIEW, "nostrsigner:")` — **the same call
§3 already makes for discovery.** The `ResolveInfo` we keep to answer "can
anything open this?" also carries the icon and the app's own name. The icon is
therefore very close to free; what it costs is care.
**Do not bundle brand logos.** Three reasons, in order of weight:
1. **Trademark, not licence.** `CLAUDE.md`'s dependency gate covers *code*
licences; a Venmo or PayPal mark shipped inside an MIT APK is a separate
trademark question. Referential use is usually permitted, redistribution of
the mark often is not. That is a maintainer's call, not a silent one.
2. **The type space is unbounded.** `PaymentTargetsViewModel.addTarget` accepts
any `type.trim().lowercase()`, so a bundled set can never be complete —
`pix`, `upi`, `swish`, `interac` and the next one all miss.
3. **The codebase already decided this.** `paymentTargetStyleFor` pairs brand
*colours* (`VENMO_BLUE #008CFF`, `PAYPAL_DEEP_BLUE #003087`,
`CASHAPP_LIME #00E64D`) with the generic `AccountBalanceWallet` glyph.
Brand colour + generic glyph is the established pattern; keep it as the
fallback. Brand marks are also absent from Material Symbols, so each would
be a hand-authored `ImageVector` like `CustomHashTagIcons.Cashu`.
So: **the installed app's icon *is* the brand icon**, sourced from the device
instead of shipped. It is self-limiting in the right direction — the "popular
options" are exactly the ones with an app installed.
**Four things the precedent gets away with and we would not:**
- **Load once, in the warm step.** `ExternalSignerButton` calls `loadIcon()` +
`toBitmap()` inside a `LazyColumn` item, so it re-runs on recomposition —
tolerable in a one-shot dialog, not in the zap popup. `loadIcon` reads the
target APK's resources, so it is I/O: do it in §3.3's off-main warm and
cache the **`ImageBitmap`**, never the `Drawable`.
- **Size and mask it.** minSdk is 26, so any icon may be an
`AdaptiveIconDrawable`: a 108×108 canvas whose outer margin the launcher
masks away. A bare `toBitmap()` drawn at 18dp shows a small logo floating in
padding. Use `toBitmap(px, px)` at the target size plus
`Modifier.clip(CircleShape)` — what a launcher does. The precedent renders
at 48dp and gets away with it.
- **Pick one app, or none.** `payto://` can resolve to several. Ask
`resolveActivity(intent, MATCH_DEFAULT_ONLY)` for the user's default; when
Android hands back its `ResolverActivity` (no default set) there is no app
to name — fall back to the glyph rather than showing the chooser's icon.
- **Accept that it cannot be tinted.** Every other rail is a monochrome glyph
tinted `BitcoinOrange` / `onSurface`. A full-colour raster can't join that
scheme — which is arguably the point: it is the visual signal that this
segment leaves the app. It needs the circular clip and a slightly smaller
optical size to sit beside 18dp glyphs.
**This promotes the https control-probe from a nicety to v1 work.** §3.2 exempts
`venmo` / `paypal` / `cashapp` from discovery because a browser always resolves
`https://`. That is fine for *gating*, but not for *icons*: with only a browser
installed, `resolveActivity` returns **Chrome**, and a Chrome icon on a Venmo
chip is worse than no icon. So an https target needs the control probe
(resolve `https://<nonexistent-host>/`, treat the target as app-backed only if
its resolver set contains a package outside that control set) to decide
**icon vs brand-colour glyph**, even though it never gates the chip.
---
## 5. Gates (all must hold)
1. Setting `showPayToZapChip`**default on**. The chip only ever shows a
target its author chose to publish, to a device that can already open it,
so the discovery gate is doing the real narrowing (`UiSettings.kt:67`
`UiSettingsFlow.kt:59``UISharedPreferences.kt:192`).
2. Note has **no** zap split: `zapSplitSetup().isNullOrEmpty()`. payto can't
fan out and returns no receipt. `RailCapabilityResolver.peek` **already
computes `splits`** — one-line reuse.
3. Recipient (note author) publishes ≥1 handoff-class target.
4. §3 says an app can handle it (or it's https). **This is the substantive
gate**; everything else is a precondition.
No cap: every openable target is offered. Discovery is what bounds the row —
a target with nothing to open it never reaches the picker.
**Handoff-class** excludes the wallet-covered types — `lightning`/`ln`/`lnurl`
and `bitcoin`/`btc`/`onchain` *are* the existing LIGHTNING and ONCHAIN rails.
Without this exclusion the picker grows a second Bolt icon beside the first.
---
## 6. Implementation
### 6.0 Prep — no behaviour change
- `quartz`: `PaymentTarget``data class` (it has no `equals` today; needed
for list keys and dedupe, and it fixes the hand-rolled field-by-field
compare in `PaymentTargetsViewModel.addTarget`).
- `commons/…/model/payments/PaymentTargetTypes.kt` (package exists, holds
`PaymentSourceResolver`): `canonical(raw)`, `isWalletCovered(canonical)`,
`schemeFor(canonical)`. Move `LIGHTNING_TARGET_TYPES` /
`BITCOIN_TARGET_TYPES` (`DisplayPaymentTargets.kt:67,70`) and the scheme half
of `paymentTargetStyleFor` here — today they are duplicated twice inside one
Android UI file, and discovery needs them too.
- `commons/…/model/User.kt`: `paymentTargetsNote` + `paymentTargets()`,
mirroring `nutzapInfoNote` (`User.kt:79`).
**No new relay subscription:** kind 10133 already rides in
`UserMetadataForKeyKinds` beside kind:0 and kind:10019
(`FilterUserMetadataForKey.kt:50`), so the recipient's targets are in cache by
the time the note renders — same as the nutzap rail.
### 6.1 Matcher — pure, headless
`commons/…/model/payments/PayToRailMatcher.kt`: canonicalize both sides, drop
wallet-covered types, intersect on type, dedupe by type. No Android, no
Compose.
### 6.2 Discovery
`amethyst/…/service/payments/PayToAppAvailability.kt` per §3.3 + the manifest
`<queries>` entries per §3.1. Each cache entry holds what §4.2 needs as well as
the yes/no: `{ resolves: Boolean, label: String?, icon: ImageBitmap? }`
decoded once in the warm step at the 18dp target size, never per composition.
Icon and label are null for the no-default (`ResolverActivity`) and
browser-only cases, and the chip falls back to the brand-colour glyph.
### 6.3 Capability
- `RailCapability` += `payToTargets: List<PaymentTarget> = emptyList()`
defaulted, so `RailCapabilityCashuStatusTest` and every existing call site
compile untouched.
- `peek(..., senderTargets = emptyList(), payToEnabled = false, available = emptyMap())`
**defaulted, because `zapClick` also calls `peek`**
(`ReactionsRow.kt:1464`) for the one-tap fast path, which must stay
Lightning-only. Returns empty when splits exist.
- `observeZapRailCapability` (`ReactionsRow.kt:2098`) adds four inputs, each
both a subscription trigger and a `remember` key — the contract spelled out
in the "do NOT delete these as unused" comment at `ReactionsRow.kt:2105`:
`paymentTargetsState.flow`, the author's `paymentTargetsNote`,
`uiSettingsFlow.showPayToZapRail`, and the availability `StateFlow`.
### 6.4 UI
One new `PayToHandoffChip` composable appended to `ZapAmountChoiceGrid`'s
`FlowRow`. Action: `uriHandler.openUri(...)`; keep the existing try/catch →
`no_payment_app_found_for_type` toast (string exists) as a belt-and-braces
fallback for the race where the app is uninstalled between warm and tap. It
must not touch `zappingProgress`, `zapStartingTime` or `accountViewModel.zap`.
### 6.5 Settings + strings
`showPayToZapRail` through the `showOnchainWallet` chain + `SettingsCatalogBuilder`;
new strings; changelog.
---
## 7. Tests
| Level | Test | Asserts |
|---|---|---|
| `commons/commonTest` | `PaymentTargetTypesTest` | alias collapse, case/whitespace, wallet-covered set, scheme mapping |
| `commons/commonTest` | `PayToRailMatcherTest` | empty sender → empty; no overlap → empty; `ln` vs `lightning` → empty (wallet-covered); `Venmo` vs `venmo` → match; dedupe by type |
| `amethyst/test` | sibling of `RailCapabilityCashuStatusTest` | split present → empty; setting off → empty; no author → empty; unavailable scheme → empty; https target → shown without probe; **existing rails unaffected** |
| `amethyst/test` | `PayToAppAvailabilityTest` | key is scheme+host, not scheme; probe count == sender's target count, independent of post count; `ResolverActivity` default → null icon; browser-only https → null icon (control probe) |
| Manual | | chip appears once (not per pill); tap opens the app; **counter does not move**; split note shows no chip; install app → background → foreground → chip appears; adaptive icon is masked round, not floating in padding; https target with no app shows the glyph, not Chrome |
---
## 8. Open decisions
1. **Chip placement** — sibling vs inside the toggle (§2). Recommend sibling:
renders once and deletes the whole `ZapRail` refactor. Flagged because it
diverges from the original sketch.
2. **Default for `showPayToZapRail`** — recommend **off**, matching how
`ReactionRowAction.Pay` already ships disabled.
3. **Private rumors** — on-chain is suppressed there (it would e-tag the
rumor). A payto handoff publishes nothing, so it is arguably safe.
Recommend **allow**, noting the divergence from the on-chain precedent.
4. **`ReactionRowAction.Pay` overlap** — recommend keeping both, `Pay`
disabled by default: `Pay` browses *all* of a recipient's targets, this
chip is the *matched, installed, splitless* shortcut.
5. **Colour icon beside monochrome glyphs** (§4.2). The app icon can't be
tinted, so the chip will be the one full-colour thing in the popup.
Recommend **accepting** it as the "this leaves the app" signal — but it is a
visible break from the rail iconography and worth an explicit yes.
6. ~~**Symmetry heuristic**~~*removed; see the note at the top.* It was
right for closed loops (Venmo, Cash App, UPI),
arguably too strict for open ones (Monero: a sender needs a wallet, not a
published address). Ship strict; relaxing later is additive. Note that
intent discovery already covers much of what symmetry was proxying for, so
dropping symmetry for scheme-based types is a live option.
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.okhttp.EncryptedBlobInterceptor
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.commons.service.http.EncryptedBlobInterceptor
import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import okhttp3.OkHttpClient
@@ -25,10 +25,10 @@ import android.graphics.Color
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.service.uploads.ImageDownloader
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -21,14 +21,14 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
@@ -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
}
}
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
@@ -20,9 +20,16 @@
*/
package com.vitorpamplona.amethyst.service.ai
import com.vitorpamplona.amethyst.commons.service.ai.WritingAssistant
import com.vitorpamplona.amethyst.commons.service.ai.WritingAssistantStatus
import com.vitorpamplona.amethyst.commons.service.ai.WritingResult
import com.vitorpamplona.amethyst.commons.service.ai.WritingTone
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,
@@ -21,8 +21,12 @@
package com.vitorpamplona.amethyst.service.ai
import android.content.Context
import com.vitorpamplona.amethyst.commons.service.ai.WritingAssistant
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()
}
@@ -54,9 +54,19 @@ import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_dialog_button_ok
import com.vitorpamplona.amethyst.commons.resources.push_server_explainer
import com.vitorpamplona.amethyst.commons.resources.push_server_install_app
import com.vitorpamplona.amethyst.commons.resources.push_server_install_app_description
import com.vitorpamplona.amethyst.commons.resources.push_server_none
import com.vitorpamplona.amethyst.commons.resources.push_server_none_explainer
import com.vitorpamplona.amethyst.commons.resources.push_server_title
import com.vitorpamplona.amethyst.commons.resources.push_server_uses_app_explainer
import com.vitorpamplona.amethyst.commons.resources.quick_action_dont_show_again_button
import com.vitorpamplona.amethyst.commons.resources.select_push_server
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
@@ -100,7 +110,7 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
if (readableListWithExplainer.size > 1) {
SpinnerSelectionDialog(
title = stringRes(id = R.string.select_push_server),
title = stringRes(id = Res.string.select_push_server),
options = readableListWithExplainer,
onSelect = { index ->
if (list[index] == "None") {
@@ -122,9 +132,9 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
} else {
AlertDialog(
onDismissRequest = { distributorPresent = true },
title = { Text(stringRes(R.string.push_server_install_app)) },
title = { Text(stringRes(Res.string.push_server_install_app)) },
text = {
val content = stringRes(R.string.push_server_install_app_description)
val content = stringRes(Res.string.push_server_install_app_description)
val astNode =
remember {
@@ -149,7 +159,7 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
sharedPrefs.dontShowPushNotificationSelector()
},
) {
Text(stringRes(R.string.quick_action_dont_show_again_button))
Text(stringRes(Res.string.quick_action_dont_show_again_button))
}
Button(
onClick = { distributorPresent = true },
@@ -163,7 +173,7 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(stringRes(R.string.error_dialog_button_ok))
Text(stringRes(Res.string.error_dialog_button_ok))
}
}
}
@@ -191,12 +201,12 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
.mapIndexed { index, name ->
TitleExplainer(
name,
stringRes(id = R.string.push_server_uses_app_explainer, list[index]),
stringRes(id = Res.string.push_server_uses_app_explainer, list[index]),
)
}.plus(
TitleExplainer(
stringRes(id = R.string.push_server_none),
stringRes(id = R.string.push_server_none_explainer),
stringRes(id = Res.string.push_server_none),
stringRes(id = Res.string.push_server_none_explainer),
),
).toImmutableList()
@@ -217,8 +227,8 @@ fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {
val selectedIndex = list.indexOf(currentDistributor).coerceAtLeast(0)
SettingsBlockTile(
icon = MaterialSymbols.CloudSync,
title = stringRes(R.string.push_server_title),
description = stringRes(R.string.push_server_explainer),
title = stringRes(Res.string.push_server_title),
description = stringRes(Res.string.push_server_explainer),
) {
TextSpinner(
label = null,
+76 -1
View File
@@ -16,6 +16,69 @@
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<!-- NIP-A3 payment targets. Android 11+ package visibility means
queryIntentActivities returns NOTHING for a scheme not declared here,
so without these the zap picker's pay-to chip is invisible on every
modern device. Specific <intent> filters rather than
QUERY_ALL_PACKAGES, which is policy-restricted on Play.
Unknown target types all fall back to payto://<type>/<authority>,
so the single payto entry covers the open-ended tail.
No <category>: a category here narrows visibility the same way it
narrows an intent match, and would hide any app whose filter declares
only DEFAULT - which is what our ACTION_VIEW hand-off actually uses. -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="payto" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="bitcoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="lightning" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="liquidnetwork" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="ethereum" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="monero" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="dash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="zcash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="bitcoincash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="litecoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="dogecoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="solana" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="tron" />
</intent>
</queries>
@@ -52,6 +115,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" />
@@ -453,7 +517,7 @@
<service
android:name=".service.call.CallForegroundService"
android:foregroundServiceType="microphone|camera|phoneCall"
android:foregroundServiceType="microphone|camera|phoneCall|mediaProjection"
android:stopWithTask="false"
android:exported="false" />
@@ -579,6 +643,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"
@@ -23,12 +23,15 @@ package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ComponentCallbacks2
import android.os.Build
import com.vitorpamplona.amethyst.commons.service.http.HttpClientEnvironment
import com.vitorpamplona.amethyst.commons.service.http.MediaCallEventListener
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
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.okhttp.isEmulator
import com.vitorpamplona.amethyst.service.priority.WorkerThreadPriorityGovernor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import com.vitorpamplona.quartz.utils.Log
@@ -57,6 +60,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" }
}
@@ -82,9 +88,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
@@ -118,6 +129,12 @@ class Amethyst : Application() {
Log.i("AmethystApp") { "Amethyst ${BuildConfig.VERSION_NAME} starting in main process (log level ${Log.minLevel})" }
// Both flags MUST be set before AppModules: its constructor eagerly builds the
// OkHttp factories, whose dispatchers read isEmulator at construction time —
// set afterwards, the emulator-safe limits are never applied.
MediaCallEventListener.verboseLogging = isDebug
HttpClientEnvironment.isEmulator = isEmulator()
instance = AppModules(this)
// Keeps the ~650 relay/ingest worker threads a cold start spawns from starving the UI
@@ -28,11 +28,26 @@ import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.relayClient.diagnostics.BootRelayDiagnostics
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
import com.vitorpamplona.amethyst.commons.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.commons.relays.health.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostWorkGate
import com.vitorpamplona.amethyst.commons.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthInterceptor
import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache
import com.vitorpamplona.amethyst.commons.service.http.OnionLocationCache
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
@@ -43,18 +58,18 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
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
@@ -63,7 +78,6 @@ import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
@@ -75,13 +89,7 @@ import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServ
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthInterceptor
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
@@ -91,15 +99,10 @@ import com.vitorpamplona.amethyst.service.pow.PowJobStore
import com.vitorpamplona.amethyst.service.pow.PowMiningForegroundService
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.diagnostics.BootRelayDiagnostics
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.resourceusage.BatteryDrainSampler
import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker
@@ -117,7 +120,6 @@ import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncForegroundService
@@ -130,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
@@ -208,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.commons.service.image.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()
@@ -269,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
@@ -292,6 +306,11 @@ class AppModules(
// 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()
@@ -401,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) }
}
@@ -448,9 +471,8 @@ class AppModules(
// tracks the logged-in account.
blossomReadAuth =
BlossomReadAuthInterceptor(
BlossomReadAuthTokenProvider(
signerProvider = { sessionManager.loggedInAccount()?.signer },
)::authHeader,
cachedHeaderProvider = blossomReadAuthTokens::cachedHeader,
onAuthRequired = blossomReadAuthTokens::warm,
),
)
@@ -635,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()
},
)
@@ -704,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() }
@@ -1083,6 +1105,7 @@ class AppModules(
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
thumbnailCache = thumbnailDiskCache,
backgroundScope = applicationIOScope,
readAuth = blossomReadAuthTokens,
)
}
@@ -25,16 +25,16 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.HomeFeedType
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
@@ -65,6 +65,19 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_block
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_button
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_how_handle
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_subtitle
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_full_trust
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_full_trust_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_paranoid
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_paranoid_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_reasonable
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_reasonable_desc
import com.vitorpamplona.amethyst.commons.resources.nip46_connect_requests_title
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
class SignerConnectActivity : ComponentActivity() {
@@ -164,7 +177,7 @@ private fun SignerConnectScreen(
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.napplet_connect_subtitle),
stringRes(Res.string.napplet_connect_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -193,7 +206,7 @@ private fun SignerConnectScreen(
) {
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
stringResource(R.string.nip46_connect_requests_title),
stringRes(Res.string.nip46_connect_requests_title),
style = MaterialTheme.typography.labelLarge,
)
info.requestedPermissions.forEach { perm ->
@@ -216,7 +229,7 @@ private fun SignerConnectScreen(
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.napplet_connect_how_handle),
stringRes(Res.string.napplet_connect_how_handle),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
)
@@ -230,22 +243,22 @@ private fun SignerConnectScreen(
PolicyOption(
selected = selected == AppSignerPolicy.FULL_TRUST,
symbol = MaterialSymbols.LockOpen,
label = stringResource(R.string.napplet_policy_full_trust),
description = stringResource(R.string.napplet_policy_full_trust_desc),
label = stringRes(Res.string.napplet_policy_full_trust),
description = stringRes(Res.string.napplet_policy_full_trust_desc),
onClick = { selected = AppSignerPolicy.FULL_TRUST },
)
PolicyOption(
selected = selected == AppSignerPolicy.REASONABLE,
symbol = MaterialSymbols.Shield,
label = stringResource(R.string.napplet_policy_reasonable),
description = stringResource(R.string.napplet_policy_reasonable_desc),
label = stringRes(Res.string.napplet_policy_reasonable),
description = stringRes(Res.string.napplet_policy_reasonable_desc),
onClick = { selected = AppSignerPolicy.REASONABLE },
)
PolicyOption(
selected = selected == AppSignerPolicy.PARANOID,
symbol = MaterialSymbols.Lock,
label = stringResource(R.string.napplet_policy_paranoid),
description = stringResource(R.string.napplet_policy_paranoid_desc),
label = stringRes(Res.string.napplet_policy_paranoid),
description = stringRes(Res.string.napplet_policy_paranoid_desc),
onClick = { selected = AppSignerPolicy.PARANOID },
)
}
@@ -262,7 +275,7 @@ private fun SignerConnectScreen(
Text(stringResource(R.string.cancel))
}
Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.napplet_connect_button))
Text(stringRes(Res.string.napplet_connect_button))
}
}
@@ -272,7 +285,7 @@ private fun SignerConnectScreen(
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(
stringResource(R.string.napplet_connect_block, info.domain),
stringRes(Res.string.napplet_connect_block, info.domain),
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -75,11 +75,31 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_allow_always
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_fewer_options
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_hide_event
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_more_options
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_show_event
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_wants_to
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_allow_24h
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_allow_30d
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_allow_all
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_allow_once
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_allow_session
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_deny_once
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_deny_op
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_allow
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_deny
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_remember
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_signing_as
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
@@ -184,7 +204,7 @@ private fun SignerConsentDialog(
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.napplet_consent_wants_to, info.operationSummary),
stringRes(Res.string.napplet_consent_wants_to, info.operationSummary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -197,7 +217,7 @@ private fun SignerConsentDialog(
// that person as an avatar + name, never as nothing.
if (info.counterpartyName != null) {
Text(
stringResource(R.string.nip46_signer_messages_with),
stringRes(Res.string.nip46_signer_messages_with),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -230,14 +250,14 @@ private fun SignerConsentDialog(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
Text(stringRes(Res.string.napplet_consent_allow_always))
}
} else {
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
Text(stringRes(Res.string.napplet_consent_allow_always))
}
}
@@ -246,7 +266,7 @@ private fun SignerConsentDialog(
onClick = { onGrant(SignerOpGrant.AllowOnce) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_once))
Text(stringRes(Res.string.napplet_signer_allow_once))
}
// "More options" toggle: session and time-bound grants
@@ -261,9 +281,9 @@ private fun SignerConsentDialog(
) {
Text(
if (showMoreOptions) {
stringResource(R.string.napplet_consent_fewer_options)
stringRes(Res.string.napplet_consent_fewer_options)
} else {
stringResource(R.string.napplet_consent_more_options)
stringRes(Res.string.napplet_consent_more_options)
},
style = MaterialTheme.typography.bodyMedium,
)
@@ -280,25 +300,25 @@ private fun SignerConsentDialog(
onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_session))
Text(stringRes(Res.string.napplet_signer_allow_session))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_24h))
Text(stringRes(Res.string.napplet_signer_allow_24h))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_30d))
Text(stringRes(Res.string.napplet_signer_allow_30d))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowAll) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_all))
Text(stringRes(Res.string.napplet_signer_allow_all))
}
}
@@ -311,14 +331,14 @@ private fun SignerConsentDialog(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.napplet_signer_deny_once))
Text(stringRes(Res.string.napplet_signer_deny_once))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary))
Text(stringRes(Res.string.napplet_signer_deny_op, info.operationSummary))
}
}
}
@@ -390,9 +410,9 @@ private fun SignerConsentPreview(info: SignerConsentInfo) {
) {
Text(
if (showRawData) {
stringResource(R.string.napplet_consent_hide_event)
stringRes(Res.string.napplet_consent_hide_event)
} else {
stringResource(R.string.napplet_consent_show_event)
stringRes(Res.string.napplet_consent_show_event)
},
style = MaterialTheme.typography.labelSmall,
)
@@ -468,7 +488,7 @@ private fun BatchedConsentDialog(
)
account.accountName?.let { name ->
Text(
stringResource(R.string.nip46_signer_batch_signing_as, name),
stringRes(Res.string.nip46_signer_batch_signing_as, name),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -553,7 +573,7 @@ private fun BatchedConsentDialog(
) {
Switch(checked = rememberChoice, onCheckedChange = { rememberChoice = it })
Text(
stringResource(R.string.nip46_signer_batch_remember),
stringRes(Res.string.nip46_signer_batch_remember),
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -575,7 +595,7 @@ private fun BatchedConsentDialog(
enabled = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.nip46_signer_batch_allow, selected.size))
Text(stringRes(Res.string.nip46_signer_batch_allow, selected.size))
}
OutlinedButton(
onClick = { onResolve(pending.filter { it.token in selected }.map { it.token }, SignerOpGrant.DenyOnce) },
@@ -583,7 +603,7 @@ private fun BatchedConsentDialog(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.nip46_signer_batch_deny, selected.size))
Text(stringRes(Res.string.nip46_signer_batch_deny, selected.size))
}
}
}
@@ -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
@@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.favorites
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -40,7 +40,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
* which is why opening that feed and coming back made a favorite suddenly launchable.
*
* This subscribes each favorited coordinate to the shared
* [EventFinder][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler]
* [EventFinder][com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssembler]
* — the same lifecycle-aware loader [observeNote][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote]
* uses — so the manifests fetch (via the author's outbox relays) as soon as the launcher opens and are
* already in [LocalCache] by the time the user taps. The loader drops each coordinate from its filter
@@ -31,16 +31,21 @@ 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.AddressableNote
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.VideoPostKind
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.cache.filter
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
import com.vitorpamplona.amethyst.commons.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState
@@ -54,7 +59,12 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListD
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcInfoCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
@@ -63,7 +73,21 @@ import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDe
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.FeedDecryptionCaches
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
import com.vitorpamplona.amethyst.commons.relayClient.auth.InMemoryRelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthPermissionCache
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthSessionGrants
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthVenues
import com.vitorpamplona.amethyst.commons.relayClient.chatDelivery.ChatDeliveryTracker
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.notify.NotifyRequestsCache
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
@@ -76,10 +100,12 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.AccountMarmotActions
import com.vitorpamplona.amethyst.model.AccountRelayGroupActions
import com.vitorpamplona.amethyst.model.EventBroadcaster
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
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
@@ -100,12 +126,8 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcInfoCache
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
@@ -113,7 +135,6 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
@@ -138,25 +159,16 @@ 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.nipBCOnchainZaps.OnchainWalletState
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
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithSearchRelayListsState
import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
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
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
@@ -384,12 +396,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
@@ -775,6 +791,18 @@ class Account(
// ([CashuWalletHistoryEoseManager]) binds its orchestrator to these.
val cashuHistory = RelayLoadingCursors()
/**
* NIP-BC on-chain wallet balance for this account's Taproot address. Cached
* (one Esplora round trip per minute at most) so the zap picker can ask
* synchronously whether an amount is payable on-chain before offering it.
*/
val onchainWalletState =
OnchainWalletState(
pubKey = signer.pubKey,
scope = scope,
backend = { cache.onchainBackend },
)
val cashuWalletState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
pubKey = signer.pubKey,
@@ -809,6 +837,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)
@@ -840,6 +871,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)
@@ -3501,6 +3556,15 @@ class Account(
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
/**
* Blocks a single relay, leaving the rest of the kind-10006 list alone.
*
* Once published, [com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient]
* strips the relay from every REQ, COUNT and publish, so the pool drops the socket as soon as
* the subscriptions that wanted it are recomputed.
*/
suspend fun blockRelay(relay: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(blockedRelayList.addRelay(relay))
/**
* Returns all known signed replaceable events that configure this account
* (profile, contact list, relay lists, mute list, bookmarks, etc.). Events
@@ -3624,9 +3688,13 @@ 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
// strongest signal available here — the weaker per-relay "never" answer 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
@@ -24,6 +24,9 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
@@ -1381,7 +1384,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 +1524,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 +1602,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)" }
}
}
@@ -22,17 +22,21 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.HomeFeedType
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.mergeMutedPublicChats
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
@@ -98,96 +102,6 @@ val DefaultSignerPermissions =
Permission(CommandType.DECRYPT_ZAP_EVENT),
)
@Serializable
sealed class TopFilter(
val code: String,
) {
interface AddressableTopFilter {
val address: Address
}
@Serializable
object Global : TopFilter(" Global ")
/**
* Notifications-only curated mode: like [Global] it admits authors the
* user doesn't follow, but it also applies per-kind relevance heuristics
* to remove less interesting notes (reactions/reposts that don't target
* the user's own notes, unrelated thread replies, etc.). In Notifications,
* [Global] shows every event that p-tags the user instead.
*/
@Serializable
object Selected : TopFilter(" Selected ")
@Serializable
object AllFollows : TopFilter(" All Follows ")
@Serializable
object AllUserFollows : TopFilter(" All User Follows ")
@Serializable
object DefaultFollows : TopFilter(" Main User Follows ")
@Serializable
object AroundMe : TopFilter(" Around Me ")
/**
* Not a real selection: a sentinel for the "Teleport" chip in the top-nav filter.
* The spinner intercepts it to open the map picker and then applies the chosen
* [Geohash] instead — it is never persisted or dispatched to a feed flow.
*/
@Serializable
object TeleportPicker : TopFilter(" Teleport ")
@Serializable
object Mine : TopFilter(" Mine ")
@Serializable
class PeopleList(
override val address: Address,
) : TopFilter(address.toValue()),
AddressableTopFilter
@Serializable
class MuteList(
override val address: Address,
) : TopFilter(address.toValue()),
AddressableTopFilter
@Serializable
class Community(
override val address: Address,
) : TopFilter("Community/${address.toValue()}"),
AddressableTopFilter
@Serializable
class Hashtag(
val tag: String,
) : TopFilter("Hashtag/$tag")
@Serializable
class Geohash(
val tag: String,
) : TopFilter("Geohash/$tag")
@Serializable
class Relay(
val url: String,
) : TopFilter("Relay/$url")
@Serializable
class FavoriteAlgoFeed(
val address: Address,
) : TopFilter("FavoriteAlgoFeed/${address.toValue()}")
@Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ")
@Serializable
class InterestSet(
val address: Address,
) : TopFilter("InterestSet/${address.toValue()}")
}
@Stable
class AccountSettings(
val keyPair: KeyPair,
@@ -788,6 +702,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)
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
@@ -99,18 +101,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
}
@@ -127,12 +131,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))
}
@@ -283,16 +295,18 @@ class AccountZapActions(
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.send(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientPubKey = recipientPubKey,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
return OnchainZapSender
.send(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientPubKey = recipientPubKey,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
.alsoRefreshBalanceIfSpent()
}
/**
@@ -308,14 +322,15 @@ class AccountZapActions(
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendToAddress(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientAddress = recipientAddress,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
)
return OnchainZapSender
.sendToAddress(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientAddress = recipientAddress,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
).alsoRefreshBalanceIfSpent()
}
/**
@@ -332,14 +347,30 @@ class AccountZapActions(
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendSplit(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
return OnchainZapSender
.sendSplit(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
.alsoRefreshBalanceIfSpent()
}
/**
* Once a transaction is on the chain the cached balance is wrong — and it is
* what the zap picker gates the on-chain rail on, so leaving it would keep
* offering amounts the wallet just spent. Covers the failure case too: a
* receipt that fails to publish still broadcast the payment.
*/
private fun OnchainZapSendResult.alsoRefreshBalanceIfSpent(): OnchainZapSendResult {
val broadcast =
this is OnchainZapSendResult.Success ||
(this is OnchainZapSendResult.Failure && broadcastTxid != null)
if (broadcast) account.onchainWalletState.invalidate()
return this
}
}
@@ -20,7 +20,10 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -25,8 +25,14 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Dao
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.RelayGroupTargetCandidate
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.UserContext
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
@@ -35,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
@@ -49,11 +56,12 @@ import com.vitorpamplona.amethyst.commons.model.observables.NewEventMatchingFilt
import com.vitorpamplona.amethyst.commons.model.observables.NoteListMatchingFilter
import com.vitorpamplona.amethyst.commons.model.observables.Observable
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.commons.model.redirectStrayRelayGroupContent
import com.vitorpamplona.amethyst.commons.service.BundledInsert
import com.vitorpamplona.amethyst.commons.service.nwc.NwcPaymentTracker
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent
@@ -825,6 +833,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,
@@ -952,7 +966,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
return Hex.isHex64(key)
}
fun checkGetOrCreateAddressableNote(key: String): AddressableNote? =
override fun checkGetOrCreateAddressableNote(key: String): AddressableNote? =
try {
val addr = Address.parse(key)
if (addr != null) {
@@ -1272,6 +1286,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) }
}
@@ -3102,6 +3132,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 -> {
@@ -3121,9 +3161,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
}
@@ -3137,7 +3180,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)) {
@@ -1,26 +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.model
// Re-export from commons for backwards compatibility
typealias Note = com.vitorpamplona.amethyst.commons.model.Note
typealias AddressableNote = com.vitorpamplona.amethyst.commons.model.AddressableNote
typealias NoteState = com.vitorpamplona.amethyst.commons.model.NoteState
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.quartz.nip01Core.core.HexKey
class ParticipantListBuilder {
@@ -60,6 +60,11 @@ data class UiSettings(
// on-chain rail in the Send Payment screen. Defaults to true (shown) so the
// behavior is unchanged for everyone who doesn't turn it off.
val showOnchainWallet: Boolean = true,
// Whether the zap picker offers a NIP-A3 pay-to hand-off chip when the sender
// and recipient share a payment protocol. Defaults to false: those targets can
// be bank or Venmo handles carrying legal names, and this puts them one tap
// from every note in the feed.
val showPayToZapChip: Boolean = true,
)
enum class ThemeType(
@@ -56,6 +56,7 @@ class UiSettingsFlow(
val fontSize: MutableStateFlow<FontSizeType> = MutableStateFlow(FontSizeType.NORMAL),
val composeSignature: MutableStateFlow<String> = MutableStateFlow(""),
val showOnchainWallet: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showPayToZapChip: MutableStateFlow<Boolean> = MutableStateFlow(true),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -88,6 +89,7 @@ class UiSettingsFlow(
fontSize,
composeSignature,
showOnchainWallet,
showPayToZapChip,
)
// emits at every change in any of the propertyes.
@@ -124,6 +126,7 @@ class UiSettingsFlow(
flows[26] as FontSizeType,
flows[27] as String,
flows[28] as Boolean,
flows[29] as Boolean,
)
}
@@ -158,6 +161,7 @@ class UiSettingsFlow(
fontSize.value,
composeSignature.value,
showOnchainWallet.value,
showPayToZapChip.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -279,6 +283,10 @@ class UiSettingsFlow(
showOnchainWallet.tryEmit(torSettings.showOnchainWallet)
any = true
}
if (showPayToZapChip.value != torSettings.showPayToZapChip) {
showPayToZapChip.tryEmit(torSettings.showPayToZapChip)
any = true
}
return any
}
@@ -333,6 +341,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.fontSize),
MutableStateFlow(uiSettings.composeSignature),
MutableStateFlow(uiSettings.showOnchainWallet),
MutableStateFlow(uiSettings.showPayToZapChip),
)
}
}
@@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46Clien
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -33,10 +35,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -90,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()
@@ -111,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)
}
}
}
@@ -144,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}" }
}
}
@@ -160,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}" }
}
}
}
@@ -282,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,
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.model.bolt12Offers
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Log
@@ -20,11 +20,11 @@
*/
package com.vitorpamplona.amethyst.model.buzz
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification
import com.vitorpamplona.amethyst.commons.model.buzz.MembershipNotice
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
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
@@ -20,10 +20,11 @@
*/
package com.vitorpamplona.amethyst.model.edits
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.marmot
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.model.nip02FollowLists
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.model.nip02FollowLists
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.UsingRelayUnwrapper
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.UsingRelayUnwrapper
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.nip02FollowLists
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.nip02FollowLists
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.nip02FollowLists
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
@@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.model.nip02FollowLists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -20,8 +20,9 @@
*/
package com.vitorpamplona.amethyst.model.nip03Timestamp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.cacheVerifyOts
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
import kotlinx.coroutines.CoroutineScope
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.model.nip03Timestamp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.nip17Dms
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
@@ -20,16 +20,16 @@
*/
package com.vitorpamplona.amethyst.model.nip30CustomEmojis
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.update
@@ -21,11 +21,12 @@
package com.vitorpamplona.amethyst.model.nip47WalletConnect
import com.vitorpamplona.amethyst.commons.model.INwcSignerState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcInfoCache
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentQueryState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentQueryState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -37,11 +38,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 +65,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 +141,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 +250,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 +290,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 +304,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 +342,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 +352,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
}
}
@@ -21,9 +21,9 @@
package com.vitorpamplona.amethyst.model.nip51Lists
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -73,6 +73,20 @@ class BlockedRelayListState(
emptySet(),
)
/**
* Adds [relay] to the kind-10006 list, keeping whatever is already there.
*
* Callers that only want to block one relay (the NOTIFY prompt's "Block Relay" button, for
* instance) must not rebuild the list from a snapshot they captured earlier: the list is
* shared across clients and may have grown since. Reading the current note here keeps the
* add additive.
*/
suspend fun addRelay(relay: NormalizedRelayUrl): BlockedRelayListEvent {
val current = normalizeBlockedRelayListWithBackup(blockedListNote).toMutableList()
if (relay !in current) current.add(relay)
return saveRelayList(current)
}
suspend fun saveRelayList(blockedRelays: List<NormalizedRelayUrl>): BlockedRelayListEvent {
if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException()
val relayListForBlocked = getBlockedRelayList()
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
@@ -20,12 +20,12 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
@@ -20,10 +20,11 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.geohashLists
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.utils.Log
@@ -20,11 +20,11 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.utils.Log
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
@@ -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
@@ -20,16 +20,16 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.interestSets
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestSets.InterestSet
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
@@ -20,16 +20,16 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update
@@ -20,11 +20,11 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.muteList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
@@ -20,16 +20,16 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.peopleList
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update
@@ -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(
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.model.nip51Lists.peopleList
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.commons.model.User
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@@ -20,17 +20,17 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.peopleList
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update
@@ -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(
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.searchRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.model.nip51Lists.searchRelays
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
@@ -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.
*/
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent

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