Compare commits

...
Author SHA1 Message Date
Claude 00379719c8 Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 15:00:55 +00:00
Vitor PamplonaandGitHub 0c59d0ef2e Merge pull request #4113 from vitorpamplona/claude/app-relay-connection-count-jo13xd
fix(relay): honest connected-relay count by finishing the WebSocket close handshake
2026-09-13 10:59:09 -04:00
Claude 3e9b629b8d refactor(relay): one adapter is one session; drop the lock and the socket identity checks
The adapters compared the socket OkHttp named in each callback against
the one they held, and took a monitor to make the compare-and-null
atomic and to close a window in connect() where a callback could arrive
before the field was assigned. Neither is needed: the relay client builds
a fresh adapter per dial and OkHttp binds exactly one socket to the
listener created in connect(), so anything that reaches that listener is
from this session by construction. The only question a callback has to
ask is whether the session already ended, which is one AtomicBoolean
claimed by whichever of onClosed, onFailure or disconnect() gets there
first. A compare-and-set keeps the "exactly one terminal report"
guarantee without a monitor, and there is no assignment window left to
guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 13:02:47 +00:00
Vitor PamplonaandGitHub 032546f70b Merge pull request #4114 from vitorpamplona/claude/image-blurhash-load-delay-y0o5f3
fix(media): stop a no-imeta GIF rendering as an invisible note while it loads
2026-09-13 08:49:07 -04:00
Claude e2bbe41991 Merge remote-tracking branch 'origin/claude/image-blurhash-load-delay-y0o5f3' into claude/image-blurhash-load-delay-y0o5f3 2026-09-13 12:17:39 +00:00
Claude 25e61542ba fix: three defects this branch introduced, found auditing its own diff
**Tor's control flow was being pinned alive for the process lifetime.** The
eviction wiring subscribed to `torManager.activePortOrNull` from a never-cancelled
coroutine on `applicationIOScope`, in both managers. That flow chains to
`TorManager.status`, whose upstream is `WhileSubscribed` and runs
`launch { service.start() }` when collected — so a permanent subscriber starts
Arti at process construction and never lets it unsubscribe on background.
AppModules documents this exact hazard for the battery ledger and deliberately
watches the raw `TorService.status` instead; I wired the poisoned well four lines
away from the warning.

Rewired from signals that are plain StateFlows and therefore free to observe:
`torPrefs.torType`, `torPrefs.externalSocksPort`, and `torService.status`'s socks
port. It moves to AppModules, which is where those live and where the precedent
is; commons had no business knowing Tor's subscription hazards anyway.

**`drop(1)` promised more than it delivered.** In the manager it skipped whatever
was present when the *coroutine started*, not when the call was made, so a route
change landing in that window was swallowed. In its new home the two coincide —
this runs during AppModules construction, before anything is pooled — so the
operator now means what the comment says.

**Animated media in a Crop cell flashed its raw URL.** The loading ladder keyed
its last branch on `ratio != null`, but `mediaSizingModifier` also bounds the
height for `ContentScale.Crop`. MyAsyncImage passes dimensions/blurhash/thumbhash
all null, so every gif in a card slot — DVM covers, long-form headers,
follow-set/calendar/music cards — hit the unbounded branch on first load and drew
URL text where it used to draw nothing. The predicate wanted "is the height
bounded", so it now says so: `contentScale == Crop || ratio != null`.

Drops ProxyRouteChange.kt and its tests with the rewire. The trigger is now three
stdlib flow operators; what needed judgement was which signals are safe to watch,
and that is recorded in the comment rather than in a test of combine().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 12:16:56 +00:00
Claude 624e38ce34 Merge remote-tracking branch 'origin/claude/beautiful-brahmagupta-17clq5' into claude/beautiful-brahmagupta-17clq5 2026-09-13 12:09:54 +00:00
Claude b243184026 refactor(relay): count from the connected flow itself; drop the members snapshot
The first commit on this branch added INostrClient.connectedRelays(), a
snapshot read from the pool members, because connectedRelaysFlow could not
be trusted: a relay the pool had dropped could sit in it for minutes. That
has since been fixed at the source -- the pool clears the flow itself when
it lets a relay go, and every transport reports its session end exactly
once -- so the flow's value and the members' isConnected() move on the
same transitions, and re-reading the members on every emission was a
redundant pool walk. The notification takes its count from the emitted
set, the breakdown and the Active Subscriptions screen go back to the
flow, and the snapshot API is removed. The pool test keeps pinning what
the app actually reads: the flow drops a relay on removal and on
disconnect even when the socket layer never confirms the close.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 12:07:46 +00:00
Vitor PamplonaandGitHub 41113ca259 Merge pull request #4118 from vitorpamplona/claude/confident-darwin-rbzz0f
Remove unused Guardian Project Maven repository
2026-09-13 08:04:21 -04:00
Claude 2b5ce62c6e build: drop the unused Guardian Project maven repository
Nothing in the version catalog or any module references an
info.guardianproject artifact anymore (the old tor-android/jtorctl
dependencies were replaced by kmp-tor from Maven Central). Resolving
every module's dependency graph with and without the repository yields
an identical result, so the repository is dead weight that only adds
a network lookup to every miss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmSZEn7Ub9URvxow4etZc
2026-09-13 12:01:59 +00:00
Claude 5dcaa0bf5d Revert "test(commons): make the macOS keychain probe path testable on every host"
This reverts commit 047eb197a4.
2026-09-13 11:59:46 +00:00
Claude 2afe9b9221 Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 11:59:00 +00:00
Vitor PamplonaandGitHub 1590fca9b7 Merge pull request #4117 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-13 07:52:50 -04:00
davotoulaandgithub-actions[bot] afeb70b15b chore: sync Crowdin translations and seed translator npub placeholders 2026-09-13 11:47:56 +00:00
davotoula 68a9f77337 fix(cli): fail fast when a buzz-agent wrapper can't be made executable
setExecutable's result was ignored (Sonar). The runner launches the wrapper
via sh -c <path>, so a missing exec bit only surfaced later as an opaque
permission-denied; check it at extraction time instead.
2026-09-13 13:41:25 +02:00
David KasparandGitHub 153fd89f8d Merge pull request #4116 from davotoula/fix/sonar-http-literals
fix(amethyst): use https in preview data and image-URL placeholders
2026-09-13 13:36:19 +02:00
davotoula f9b11ef68b fix(amethyst): use https in preview data and image-URL placeholders
Clears Sonar 'Using HTTP protocol is insecure' hits. None were real requests:
six are @Preview dummy strings, four are picture-URL field hints that
suggested http:// to users. The Namecoin RPC onion placeholder stays
http:// on purpose (Tor encrypts; Namecoin Core RPC has no TLS).
2026-09-13 12:19:49 +02:00
David KasparandGitHub c8262d440b Merge pull request #4107 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-13 10:58:43 +02:00
davotoulaandgithub-actions[bot] 686975e322 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-13 08:22:05 +00:00
David KasparandGitHub 5943dfe3d7 Merge branch 'main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 10:20:46 +02:00
David KasparandGitHub 8e6cb1f7ac Merge branch 'main' into claude/image-blurhash-load-delay-y0o5f3 2026-09-13 10:19:56 +02:00
David KasparandGitHub 0a4a0e60ff Merge pull request #4115 from davotoula/fix/vault-test-non-mac-strict-contract
test(commons): make the vault strict-lookup test pass off macOS
2026-09-13 10:19:20 +02:00
davotoula 1725425658 test(commons): make the vault strict-lookup test pass off macOS
`strict lookup keeps the strict contract for aliases outside the vault`
drove the miss through the macSecurityLookup seam, which getPrivateKeyOrThrow
only consults when isMacOs(). On Linux and Windows the strict path is
javakeyring, which cannot tell a miss from a denial and deliberately throws,
so the test's assertNull failed there — red on the Android job and the
Linux/Windows desktop builds of main.

Branch on the host like SecureKeyStorageOrThrowTest does: off macOS assert
the miss throws SecureStorageException with the PasswordAccessException in
its cause chain (coroutine stack-trace recovery may wrap it); on macOS keep
the null-on-miss / throw-on-ambiguous assertions.

Verified with os.name forced to Linux and natively on macOS (29/29
SecureKeyStorage* tests).
2026-09-13 09:12:48 +02:00
Claude 047eb197a4 test(commons): make the macOS keychain probe path testable on every host
SecureKeyStorageVaultTest's "strict lookup keeps the strict contract for
aliases outside the vault" fails on Linux (red on main's own CI run for
38bbfa8c and reproduced in a clean worktree of origin/main): the test wires a
fake mac `security` probe through macSecurityLookup, but the strict lookup
gates that path on the real os.name, so on a Linux runner it takes the
javakeyring branch, where an unknown alias throws instead of answering null.

Make the OS check injectable like the other test hooks (keyringFactory,
macSecurityLookup) and have wireMacProbe force it on, so the suite exercises
the probe path it was written for on any host.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-13 02:54:11 +00:00
Claude 795394c454 Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 02:43:34 +00:00
Claude a2b0fd9405 fix(http): drop pooled connections once per real Tor route change
Removing the per-rebuild eviction left one sliver: when the user switches Tor ON,
the direct client's idle sockets to real hosts stayed pooled for the 5-minute
keepalive. Nothing could route a request through them — OkHttp keys the pool by
`Address`, which includes the proxy — but they are real connections to real hosts
outliving the moment the user asked for everything to go through Tor.

[evictOnProxyRouteChange] closes that by watching the one signal that means the
route actually moved: `torManager.activePortOrNull`. Tor coming up (null -> 9050),
going away (9050 -> null), or moving (9050 -> 9150) each evict exactly once.
`drop(1)` keeps subscribing from counting as a change.

Deliberately not driven by the two things that misled the old code:

  - Client rebuilds. One factory mints both the proxied and the direct client and
    they share a pool, so a per-rebuild check fired on every isMobileDataProvider
    emission and every resubscribe — constantly, and never specifically on a Tor
    toggle.
  - The per-feature Tor switches (imagesViaTor, videosViaTor, …). Those change
    which of the two existing clients a request picks, not the route either one
    uses, so no pooled connection goes stale.

Wired in both managers' init, so the media and relay pools behave identically.
Six tests cover the trigger, including that re-emitting the same port never
evicts — the regression the old design had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 02:42:06 +00:00
Vitor PamplonaandClaude Opus 5 38bbfa8c74 Merge PR: fix(desktop): consolidate keychain items so cold-boot prompts once
Merges nostr proposal 1c931194 (v4) into main:
- fix(commons): consolidate desktop keychain items into a single vault-v1 item
- fix(desktop): wire two-phase vault bootstrap into AccountManager
- fix(commons): make the keychain vault authoritative without blinding lookups
- fix(desktop): migrate the keychain vault before the first account-store read
- style(desktop): import CancellationException instead of inlining its name

Every nsec, bunker ephemeral and NWC URI now lives in one vault-v1 keychain
item behind a single ACL, so cold boot prompts once. One approval releases
every secret; this single-ACL model is an accepted maintainer decision.

v2-v4 fixed two account-store wipes (strict lookup ignoring the vault;
migration running after refreshAccountListOnStartup's first read), a
missing legacy fallback, and orphaned nsecs on logout. Verified with 17
mutation-checked tests and three consecutive launches against a real
macOS Keychain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHCtgMDNkAHvRDXnthuXQc
2026-09-12 22:01:52 -04:00
Vitor PamplonaandClaude Opus 5 94a229e983 style(desktop): import CancellationException instead of inlining its name
The vault bootstrap added two more inline
kotlin.coroutines.cancellation.CancellationException references next to
two existing ones; CLAUDE.md forbids fully-qualified names in function
bodies. One import, no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHCtgMDNkAHvRDXnthuXQc
2026-09-12 21:52:31 -04:00
Vitor PamplonaandClaude Opus 5 81b9ba853a fix(desktop): migrate the keychain vault before the first account-store read
Found by running the migration against a real macOS Keychain: the first
launch migrated and loaded fine, the second launch wiped the account store.

bootstrapConsolidatedVault() was called from loadSavedAccount(), and its
comment claimed it ran "before any other keychain read on the hot startup
path". It did not. Main.kt's startup DisposableEffect calls
refreshAccountListOnStartup() first, which reads accounts.json.enc and so
needs the metadata AES key.

On the first launch that read still found the legacy per-alias item and
cached the key, so the migration that followed looked harmless. On the
second launch the item was gone -- migrated into vault-v1 and deleted --
and the vault had not been activated yet, so the strict lookup answered
"definitively absent", getOrCreateKey minted a fresh AES key, wrote it back
as a legacy item, and the decrypt that followed failed with a GCM tag
mismatch. Observed exactly that: accounts.json.enc renamed to
.corrupt.<ts>, account-metadata-key resurrected as a per-alias item, and
the original key still sitting unused inside vault-v1.

Phase 1 is now a run-once, mutex-guarded ensureVaultMetadataKeyMigrated()
that every account-store entry point calls, including refreshAccountList().
Phase 2 still runs from loadSavedAccount() once the npub list is known.

Verified on macOS against the real Keychain and the real account store:
three consecutive launches across the migration boundary all load the
account, no corruption events, accounts.json.enc byte-identical throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 21:50:36 -04:00
Vitor PamplonaandClaude Opus 5 9193502748 fix(commons): make the keychain vault authoritative without blinding lookups
Three defects found reviewing the vault consolidation against current main.

1. The vault wiped every account on the first cold boot after upgrade.

   getPrivateKeyOrThrow was the only accessor without a vaultActive branch
   (savePrivateKey, getPrivateKey and deletePrivateKey all had one). The
   migration deletes the legacy per-alias items after writing vault-v1, so
   the strict path probed the OS for an item that no longer existed, read
   macOS exit 44 / NotFound as "definitively absent", and let
   DesktopAccountStorage.getOrCreateKey mint a fresh AES key over the one
   that decrypts accounts.json.enc -- the exact silent wipe proposal
   5d31b68e added that method to prevent.

   It was also unrecoverable: phase 1 preserves the original key inside the
   vault, but getOrCreateKey then persists the new key over the same alias,
   destroying the only key that could decrypt the .corrupt backup. And
   because phase 2 calls loadAccounts(), the wipe happened inside the
   bootstrap itself, so no nsec was ever folded in.

   The strict path now consults the vault first. A vault miss still falls
   through to the strict per-alias probe, so uncovered aliases keep the
   strict contract.

2. The documented legacy fallback did not exist. getPrivateKey was
   `vaultActive -> vaultGet(npub)` with no fallback, so once the vault was
   active any alias it did not cover read as absent. Phase 1 activates it
   with only the metadata key, and a phase 2 that throws is swallowed, so
   the real worst case was every nsec reading null rather than the
   advertised "old two-prompt behaviour". A vault miss now falls back to
   the legacy per-alias item.

3. deletePrivateKey left orphaned secrets. vaultDelete returned false for
   an alias outside the vault and never touched the legacy item, so logging
   out of an account whose nsec had not been folded in left the nsec in the
   OS keychain indefinitely -- still readable via the fallback in 2. It now
   unlinks the legacy item as well.

Also logs the swallowed vault-bootstrap failure; silently discarding it
made a half-migrated keychain impossible to diagnose from a user report.

All three are pinned by new tests in SecureKeyStorageVaultTest and
mutation-checked: reverting any one fix fails exactly its own test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 21:50:36 -04:00
mstrofnoneandVitor Pamplona e524d38dc5 fix(desktop): wire two-phase vault bootstrap into AccountManager
Runs SecureKeyStorage.enableConsolidatedVault at the top of
loadSavedAccount, before any other keychain read on the cold-boot
hot path, so migrated setups pay exactly one Keychain Access prompt
regardless of how many accounts, per-account bunker-ephemerals, and
NWC URIs the user has.

Phase 1 migrates only account-metadata-key (the AES key that
decrypts accounts.json.enc). Nothing else can be enumerated before
that file is readable, so this phase runs against a single-alias
candidate list. It is a no-op on fresh installs (no legacy item)
and on already-migrated setups (vault-v1 exists).

Phase 2 runs after accounts.json.enc has been decrypted and the
full npub list is known. For each npub we add the nsec alias
itself, the per-account bunker-ephemeral alias, and the NWC alias.
The legacy shared bunker-ephemeral alias is included for the
pre-per-account migration compatibility branch in
loadBunkerAccount. Phase 2 is idempotent so it is safe to run on
every startup, including when the vault already covers every alias.

DesktopAccountStorage.METADATA_KEY_ALIAS is promoted from private
to internal so AccountManager.bootstrapConsolidatedVault can name
it without duplicating the alias string. The alias itself, its
storage, and its read/write path all still live in
DesktopAccountStorage.

Bootstrap failures are swallowed: SecureKeyStorage falls back to
legacy per-alias reads for anything the vault does not cover, so
the worst case is the old two-prompt behaviour. Nothing on the
account-load path breaks.

No API change on AccountManager (bootstrapConsolidatedVault is
private) and no visible behaviour change on the fallback
(no-keyring) storage path, which already uses a single encrypted
file and does not have the per-item ACL problem the vault exists
to solve.
2026-09-12 21:50:36 -04:00
mstrofnoneandVitor Pamplona bfdeaa1ace fix(commons): consolidate desktop keychain items into a single vault-v1 item
Follow-up to ae3218249a. Caching the Keyring handle collapsed
Keyring.create() calls but did not fix the double prompt on macOS,
because macOS Keychain gates access per item, not per session.
Amethyst's cold-boot path reads at minimum two distinct items,
account-metadata-key (the DesktopAccountStorage AES key) and the
active account's nsec, so the OS still surfaces two Keychain Access
prompts unless the user explicitly picked "Always Allow" on every
single item, which many users don't.

Fix: consolidate all Amethyst-managed keychain items into a single
vault-v1 item, so the OS sees exactly one item to gate.

SecureKeyStorage.enableConsolidatedVault(candidateAliases):

  1. If vault-v1 already exists, load it and mark the vault active.
     Legacy per-alias items in candidateAliases are still folded in
     on this pass so a crash-during-migration leaves nothing stranded.
     Aliases not in candidateAliases are ignored, so pre-existing
     items from unrelated accounts do not get pulled in and re-prompt.
  2. If vault-v1 is absent, batch-read each candidate alias in the
     legacy per-item layout (paying the migration prompt once), pack
     the recovered entries into vault-v1, and delete the originals.
     The vault item is written first, legacy items are deleted only
     after that write succeeds, so a crash mid-migration leaves the
     legacy items in place and the next run retries cleanly.
  3. Fresh installs write an empty vault so future savePrivateKey
     calls stay inside it.

Migration is idempotent and cheap when the vault already exists
(one keychain read plus a JSON parse). Safe to call on every cold
boot, and safe to call twice per process for the two-phase
bootstrap pattern (see the AccountManager change in the next
commit).

Once the vault is active, savePrivateKey / getPrivateKey /
deletePrivateKey / hasPrivateKey read and write the in-memory
LinkedHashMap and persist the whole map back to the vault item on
mutation. Fresh SecureKeyStorage instances that have not opted into
the vault continue to use the legacy per-alias layout, so callers
that never call enableConsolidatedVault keep working unchanged.

The vault contents are stored as a JSON envelope of the form
{"schemaVersion":1,"entries":{alias: base64(secret), ...}}.
schemaVersion reserves room for future migrations. Values are
base64-encoded so alias / secret contents that contain quotes,
backslashes, control chars, or non-ASCII round-trip cleanly through
the hand-rolled JSON codec (kept hand-rolled so the keystorage
module does not need Jackson; Jackson lives in desktopApp and
quartz). Amethyst does not layer additional crypto over the OS
keyring for legacy per-item storage; the OS keychain is the trust
boundary. The vault follows the same policy.

Tests (SecureKeyStorageVaultTest, hermetic CountingKeyring
KeyringHandle fake, no real OS keychain):

- fresh install writes an empty vault item
- legacy items are migrated into one vault-v1 item and originals
  deleted
- an existing vault is loaded without re-probing aliases it already
  contains
- legacy leftovers from an interrupted earlier migration get
  absorbed on the next boot when still named in the candidate list
- savePrivateKey after vault enabled persists to the vault
- getPrivateKey after vault enabled reads from in-memory contents
  without further keychain traffic
- enableConsolidatedVault is idempotent across repeated calls
- two-phase migration folds in aliases discovered after phase 1
- partial legacy migration survives a relaunch (two SecureKeyStorage
  instances against one backing store)
- delete removes from the vault and unlinks the whole item when the
  last alias goes
- vault round-trips keys with quotes, newlines, backslashes, and
  Unicode

All existing SecureKeyStorageKeyringCacheTest cases still pass;
the changes are additive and back-compatible when
enableConsolidatedVault is never called.
2026-09-12 21:50:36 -04:00
Claude 9e380cf9dc perf(http): drop the proxy-change pool eviction in both OkHttp factories
Both factories emptied the whole connection pool whenever the proxy differed
from the last one they were handed. It was defensive, and it was not free.

It is not needed. OkHttp keys the pool by `Address`, and `Address.equalsNonHost`
compares `proxy` — so a call is only ever given a connection opened through the
very same route. A connection left over from an old proxy is already unreachable
by anything using the new one; it just ages out of the pool on its own. There was
never a stale-route connection to protect against.

The cost was real, though. `evictAll()` empties the ENTIRE shared pool, and each
factory mints BOTH clients: `DualHttpClientManager` builds defaultHttpClient
(always SOCKS, since buildLocalSocksProxy falls back to 9050 rather than
returning null) and defaultHttpClientWithoutProxy (always null) from one
instance, and they share one `rootClient.connectionPool`. A single "last proxy"
field therefore alternated forever, and every rebuild read as a route change and
dropped every warm connection the other client was relying on. Both are
stateIn(WhileSubscribed(1000)) flows collected from a composable, so it fired on
each isMobileDataProvider change and each foreground round trip — and the next
image then paid a fresh DNS + TCP + TLS. Plausibly the "first image after a pause
takes forever" stall the pingInterval above it was added for.

DualHttpClientManagerForRelays has the identical shape, so the relay factory is
fixed the same way. Less damaging there — evictAll spares connections with active
calls, so live relay sockets survived — but it was still discarding idle pooled
connections on every network flap.

This replaces the ProxyRouteTracker approach from earlier on this branch, which
kept the eviction and merely made its bookkeeping correct. Deleting the mechanism
is the better answer, and it takes the class and its tests with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 01:43:43 +00:00
Claude 0067db3608 refactor(notifications): trigger the count refresh on the connected flow alone
availableRelaysFlow was merged in when connectedRelaysFlow could not be
trusted to move on a removal: a relay the pool had dropped could sit in it
for minutes, so the one flow that did move on membership changes was used
as an extra wake-up and the count re-read from the pool members. The pool
now clears the connected flow itself whenever it lets a relay go, and every
transport reports its session end exactly once, so that flow emits on every
change the count can reflect and the extra trigger only added wake-ups on
membership churn. The count still comes from client.connectedRelays(),
the members' live socket state, which is what it is meant to show.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 01:43:11 +00:00
Claude 08529c3a74 refactor(relay): make the socket adapters own the session, and take the guard out of BasicRelayClient
The previous commit taught BasicRelayClient to remember which listener it
had wired and to ignore callbacks from any other. That put the knowledge
"this report is about a socket I already threw away" in the wrong layer:
the transport adapter is the one that owns the socket, and OkHttp names
the socket in every callback, so the adapter can tell for free.

BasicRelayClient goes back to exactly its previous code. The contract it
relies on is now written on the WebSocket interface and kept by every
transport: a session ends with exactly one terminal callback, and
disconnect() reports onClosed synchronously and forwards nothing from that
socket afterwards -- what InProcessWebSocket has always done. Both OkHttp
adapters (quartz's BasicOkHttpWebSocket and the Android app's
OkHttpWebSocket) now check ownership on every callback, claim the terminal
report under a lock so a session cannot be reported twice, and answer
disconnect() themselves instead of waiting for OkHttp, which raises nothing
for a cancel when no reader is left to fail and otherwise raises it later
on its own thread. The reconnect race this closes is the same one as
before: with disconnect()+connect() back to back, the old socket's late
failure used to land on the new connection and cancel it.

The client-level stale-socket test is replaced by adapter-level tests on
both modules: disconnect() reports once and synchronously, OkHttp's own
reaction to the cancel never surfaces, and a relay-initiated close followed
by disconnect() is still one report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 01:36:28 +00:00
Claude 5f53568204 Merge remote-tracking branch 'origin/main' into claude/image-blurhash-load-delay-y0o5f3
One conflict, in BlossomReadAuthTokenProvider.signOnce(). Main already carries
the same fix via PR #4108 (davotoula), landed while this branch was open. The two
are functionally identical — same third cache look, in the same place, for the
same reason — so the conflict is resolved by taking main's wholesale and dropping
mine. The file is now byte-identical to main; my add7bfe3 contributes nothing
beyond it.

What this branch still adds over main:
  - GifVideoView loading fallback (the invisible no-imeta note)
  - ProxyRouteTracker + its tests (the shared connection pool being evicted
    whenever the proxied and direct clients alternate)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 01:20:04 +00:00
Claude c33256a08e Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 01:16:42 +00:00
Claude 64e0ce4b3e revert(http): restore the dispatcher limits, and record why they stay
I lowered these to 32/8 on reasoning that does not survive reading the source.

`maxRequests` is the thread ceiling — Dispatcher's executor really is
corePoolSize=0 / maxPoolSize=MAX_VALUE over a SynchronousQueue, and its own kdoc
notes a pool sized exactly to maxRequests is not even sufficient. That much was
right. The conclusions drawn from it were not:

- "128 concurrent TLS handshakes" is wrong. These hosts are HTTP/2, so concurrent
  calls to one host multiplex over a single connection; handshake count is bounded
  by distinct hosts and the pool, not by maxRequests.
- "A tighter total lets the visible images finish first" is backwards.
  promoteAndExecute walks readyAsyncCalls as a strict FIFO with no priority, and
  PrefetchFeedMedia enqueues notes before the user reaches them — so a lower cap
  makes the on-screen image queue behind those prefetches rather than start
  immediately. That is the very symptom under investigation.
- Halving maxRequestsPerHost also halves HTTP/2 stream concurrency against the
  single Blossom host a feed pulls from, which is what these were tuned for.

What is left is thread memory, and blocked threads commit little. No measurement
justified the change, so the values go back as they were. The comment now carries
the analysis so the next reader does not re-derive the same wrong intuition.

The ProxyRouteTracker fix from the same commit is unaffected and stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 00:46:21 +00:00
Vitor PamplonaandGitHub 71f72a0910 Merge pull request #4112 from vitorpamplona/claude/intelligent-newton-bw5dmf
BOLT12 offers in the profile payment rail and zap picker, with BOLT11 fallback on refused offers
2026-09-12 20:40:50 -04:00
Claude 6030d22aa9 fix(commons): declare okio so the Apple targets compile after the Compose split
test-quartz-ios failed on :commons:compileKotlinIosSimulatorArm64 with
"Unresolved reference 'okio'" in service/image/DeferredDeleteFileSystem.kt.
The file always used okio, but commons only ever got it transitively:
through Coil on every target, and through OkHttp on JVM. Moving Coil to
:commonsUI removed the only Apple-side provider, and the JVM builds kept
passing, so nothing caught it locally.

Declare okio (3.18.1, the version already resolved everywhere; Apache-2.0
per its POM) in commons commonMain. Reproduced and re-verified on Linux with
:commons:compileCommonMainKotlinMetadata, which resolves commonMain against
the shared-dependency set exactly like the iOS compile. That task, for all
three KMP library modules, is now part of the lint job so this class of gap
fails fast on Linux.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-13 00:36:31 +00:00
Claude d108ba0cc8 fix(relay): answer CLOSE in the Android socket too, and stop trusting socket callbacks for pool bookkeeping
Follow-ups from an audit of the two previous commits on this branch.

The Android app does not use quartz's BasicOkHttpWebSocket: AppModules wires
its own OkHttpWebSocket, a near-twin that decides needsReconnect() from the
OkHttpClient in use. The onClosing answer therefore only reached desktop,
CLI and geode. Mirror it here, with the same loopback RFC 6455 test. The
unit-test android.util.Log stub gets a primitive-signature isLoggable (the
boxed one it had was a different method to the JVM, which is why OkHttp
could never be built in this module's tests) plus println, so the socket can
be exercised for real.

RelayPool now clears its connected flow itself on every path where it lets
a relay go -- removeRelay, removeAllRelays and disconnect -- instead of
waiting for a callback the socket layer may never deliver. The previous
commit added a members-read snapshot and migrated three callers; the other
consumers of connectedRelaysFlow (drawer status, connection-time accounting,
"wait until connected" loops) were still reading the stale set.

BasicRelayClient retires its listener before tearing a socket down and
ignores anything a retired socket reports afterwards. OkHttp delivers
onClosed from its writer thread and a cancelled socket's failure later
still; with the pool rebuilding sessions via disconnect()+connect(), a late
callback from the old socket could null the new one, orphaning a live
connection and dialing a third. disconnect() also reports onDisconnected
itself now, so a client-initiated teardown never depends on the socket
layer confirming it.

KDoc and comments that described the unanswered-close behaviour in the
present tense, or claimed a still-desired relay reads isConnected()=false
on a silent close, are corrected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-12 23:28:47 +00:00
Claude 5142c88629 build: restore upstream's Gradle 10 source-set cleanup in quartz after the merge
The merge of origin/main resolved quartz/build.gradle.kts with the branch
side wholesale, which kept the shared purity-gate apply but dropped the
`by getting` -> `getByName(...)` cleanup from 6e3af61e. Re-applied on top.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 23:20:12 +00:00
Claude 732baf8a8a Merge remote-tracking branch 'origin/main' into claude/intelligent-newton-bw5dmf
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt
2026-09-12 23:18:43 +00:00
Claude ae0b70728e Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5
# Conflicts:
#	commons/build.gradle.kts
#	quartz/build.gradle.kts
2026-09-12 23:11:15 +00:00
Vitor Pamplona ea28ea2f76 Merge remote-tracking branch 'upstream/main' into main 2026-09-12 19:06:40 -04:00
Vitor PamplonaandGitHub 8c6a38a7bf Merge pull request #4110 from vitorpamplona/claude/stoic-faraday-2qv9f5
fix: clear every compiler, Gradle and mechanical lint warning, and two things the sweep turned up
2026-09-12 19:06:31 -04:00
Vitor PamplonaandGitHub 49fb3a7a00 Merge pull request #4109 from vitorpamplona/claude/determined-volta-u4l2rf
test: run relay-backed tests against geode; fix the relay bugs that surfaced
2026-09-12 19:06:20 -04:00
Vitor PamplonaandClaude Opus 5 78fef44e2f Merge PR: fix(desktop): stop silent account wipe on keychain-access errors
Merges nostr proposal 5d31b68e into main:
- fix(desktop): stop silent account wipe on keychain errors and upgrade races
- fix(desktop): allow first-launch key bootstrap and stop caching unwritten state

Desktop lost every logged-in account whenever the OS keychain answered
ambiguously. getOrCreateKey() treated any failed lookup as "key absent" and
minted a fresh AES key, orphaning accounts.json.enc; the read path then
renamed the unreadable file to .corrupt.<ts>; and nothing serialised access,
so a Homebrew upgrade race could interleave two instances.

Now: getPrivateKeyOrThrow() distinguishes confirmed-absent from refused or
ambiguous (macOS via /usr/bin/security exit codes) and only the former
rotates; genuine corruption (AEAD tag, bad padding, malformed JSON) is
separated from transient failures, which preserve the file and surface as
StorageCorruption.TransientError; and a cross-process advisory lock plus
in-process mutexes guard the file.

Review follow-ups in the second commit: non-macOS keyring backends throw for
a genuinely absent credential, so a fresh Linux/Windows install could never
mint the key -- creation is now allowed when accounts.json.enc does not yet
exist, where there is no ciphertext to orphan. And the metadata cache is
populated only after the disk write succeeds, so a failed save no longer
leaves the session serving accounts that were never persisted.

Verified on macOS against the real Keychain and the real account store:
security lookup exit 0, accounts load and survive a restart, ciphertext
byte-identical, zero corruption events.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 19:03:09 -04:00
Claude add7bfe3ca fix(http): close the read-auth single-flight window left open before putIfAbsent
signOnce() looked at the cache a second time to catch a leader that had already
finished, but it looked too early — before claiming the in-flight slot. The gap
between that look and putIfAbsent still spans signerProvider() and an allocation,
and a leader caches its token and retires its entry inside it. A straggler in
that gap therefore put into a map the leader had just emptied, won the slot, and
signed a duplicate.

Winning the slot is not proof that nobody signed; only a look from inside it is.
Once we hold the entry no one else can be leader, and our successful put observed
the map after that leader's removal, which its cache write is ordered before — so
a token visible at that point is the last word. Hand it over and stand down.

This is pre-existing, not fallout from the dispatcher change: the same test fails
on an unmodified origin/main worktree, and aFastSignerStillSharesOneSignature
already says the window is "microseconds wide, so one round hits it only now and
then" and runs 200 rounds to catch it. It cost a duplicate signature — with a
NIP-55 external signer that is a second IPC round trip, and potentially a second
prompt, for a burst of images from one gated host.

Verified with 8 consecutive runs of the suite (1600 signing rounds), all green,
against a baseline that failed inside the first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-12 22:52:59 +00:00
Claude b8135e76c4 fix(relay): answer a relay's WebSocket CLOSE frame so OkHttp can finish the handshake
OkHttp fires onClosed only once both peers have sent a CLOSE frame, and
sending ours is the application's job (WebSocketListener KDoc; RealWebSocket
emits onClosed solely from the writer once our Close is dequeued with the
peer's code already set; the bundled WebSocketEcho recipe answers onClosing
with close(1000, null)). BasicOkHttpWebSocket never implemented onClosing,
so a relay-initiated close left the socket half-closed: no onClosed, no
onFailure, send() still accepted and silently discarded, and a later
cancel() silent as well because no reader was left to fail. The relay
client kept believing it was connected, with its REQs live, until the 120s
ping path finally failed up to two intervals later.

Answer onClosing with close(1000, null). Verified against OkHttp 5.5.0:
onClosed then fires at once whether the relay still holds the TCP session
or has already dropped it, and the existing onClosed path in
BasicRelayClient marks the connection closed and lets the pool reconnect
under its normal backoff. Always 1000 rather than echoing the relay's code,
since close() validates the code it writes and relays may send reserved
ones.

The test drives the wrapper against a minimal RFC 6455 server on a loopback
ServerSocket (no new dependency): the relay sends CLOSE, the client must
answer with its own CLOSE frame and report onClosed with the relay's code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-12 22:52:37 +00:00
Claude 18c576a068 fix(relay-server): acknowledge a superseded replaceable with OK true duplicate:
Second finding from the Marmot headless harness on geode. wn's `keys
publish` mints a KeyPackage (kind 30443, same `d` tag) in the same second
as the one its bootstrap already published; NIP-01's lowest-id-wins tie
keeps the stored one, and the insert of the loser trips the addressable
unique index. The store classified that as a rejection carrying SQLite's
text — "UNIQUE constraint failed: event_headers.kind, event_headers.pubkey,
event_headers.d_tag" — so the relay answered OK false with a reason no
client can classify, and MDK filed it as "publish acknowledgement
unknown" and retried forever.

nostr-rs-relay, which this harness was validated against, does not even
attempt the insert when a newer version exists and acknowledges the event
as `OK true "duplicate: ..."` (its Duplicate status maps to true). Match
that: the replaceable and addressable unique-index failures now classify
as RejectionReason.SUPERSEDED, a `duplicate:`-prefixed reason the session
already answers with OK true. The stored version is untouched, nothing is
fanned out, and the STORE-W01/W02 contract in the event-store-semantics
skill is updated to say so.

Tests: NostrServerTest covers an older kind-0 re-insert and the
same-second kind-30443 tie, asserting the OK true duplicate: reply and
that the winner remains the only stored version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
2026-09-12 22:18:04 +00:00
Claude 5b846d64d6 fix(relay-server): answer a duplicate EVENT with OK true, per NIP-01
Running the Marmot headless harness against the embedded geode relay
failed 10 of 29 scenarios, every one on the same reply: the relay
answered a resent EVENT with

  ["OK", <id>, false, "Error code: 2067, message: UNIQUE constraint
   failed: event_headers.id"]

NIP-01 says a relay that already holds the event answers
["OK", <id>, true, "duplicate: already have this event"], and every
client here depends on that: amethyst's outbox writes an event as soon
as the socket is ready and resends it when the connection finishes
syncing, so one of the two copies is always a duplicate; MDK's wn
counts a `duplicate:` prefix as idempotent success but files an
unclassified OK false as "publish acknowledgement unknown" and keeps
retrying. Both amy's group commits and wn's KeyPackage publish were
failing on it, while nostr-rs-relay had answered the resend correctly.

SQLiteEventStore now recognises the unique-index violation on
event_headers.id and reports RejectionReason.DUPLICATE, the constant
that already carried NIP-01's exact wording but was never produced;
RelaySession sends `OK true` for a `duplicate:` reason and keeps
`OK false` for every other rejection. The store outcome stays Rejected,
so a duplicate is still not fanned out to live subscriptions or counted
as a new write by the mirror worker and importer. The filesystem store
already treated a duplicate insert as a no-op.

Two tests pinned the old OK false behaviour (NostrServerTest,
KtorRelayTest) and now assert the NIP-01 reply.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
2026-09-12 22:11:23 +00:00
Claude 4fa619d2a2 fix: audit of commons and commonsUI — 34 verified bug and performance fixes
Three read-only sweeps over commons (feeds, relayClient, model, viewmodels,
services) and commonsUI produced 44 candidates; each was re-read in the
source, 38 held up, 34 are fixed here, 4 are deferred with rationale in
commons/plans/2026-09-12-audit-findings.md.

Correctness highlights:
- SecureKeyStorage (jvm): AES-GCM was initialised with IvParameterSpec, which
  every JDK rejects, so the no-keyring fallback never worked. GCMParameterSpec.
- OnionLocationInterceptor cached any Onion-Location header and re-pointed all
  Tor traffic at it for 24h over plain http; only .onion targets now.
- BasicBundledInsert wedged forever after one exception (no finally).
- CachedRichTextParser keyed parses on a 32-bit hash without checking inputs.
- noProtocolUrlValidator backtracked exponentially per composer keystroke.
- Base83 indexed a 255-entry table with any char code from an imeta tag.
- MetadataRateLimiter never flushed a batch smaller than 20 pubkeys.
- FeedMetadataCoordinator mutated six HashSets from two threads and marked
  pubkeys 101+ as requested without asking for them.
- Note: two discarded boolean/relay expressions, an NPE window in flow(),
  removeReport leaving empty buckets, unlocked read-modify-write on
  replies/boosts/edits/reactions/reports/labels.
- EventCollectionState restarted its flush timer on every insert.
- Chatroom prune outside the lock; top-zappers publish outside the mutex;
  hashCode used as dedup/feed keys; OnlyLatestVersionSet.addAll always true.
- UI: ClickableTexts remembered a stale onClick, ZonedSwipeModifier a stale
  openDrawer, two robohash light-theme predicates thrashed one cache, a chess
  remember key summed two counters.

Performance: user-cache search off the Compose dispatcher, regexes hoisted
in SearchResultSorter, frame-rate animation reads moved out of composition
in Shimmer/LoadingAnimation/BunkerHeartbeat, GlowingCard allocations cached,
emoji inlineContent remembered, itemsIndexed in the search pickers.

Ten regression tests added (rate limiter flush/dedup/rate, GCM round trip,
regex timing, Base83 bounds, CosineCache key). Verified: commons and
commonsUI jvmTest, compiles of cli, desktopApp, nappletHost, amethyst.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 22:03:11 +00:00
Claude fe8580ca56 chore: share the KMP purity gate, fix stale doc paths and dead imports after the split
Follow-ups from a review of the commons/commonsUI split:

- `verifyKmpPurity` was copy-pasted into quartz, commons and commonsUI and the
  three copies had already drifted (checked dirs, hint text). It now lives
  once in gradle/kmp-purity.gradle.kts and each module applies it; the
  checked-dir list is the union, filtered by existence. Verified the shared
  task still fails on a deliberate java.util.UUID reference in commons.
- amethyst/src/main/res/CLAUDE.md and the compose-expert catalog reference
  still pointed at the pre-split composeResources / ui paths.
- Same-package imports left behind in the files moved to commons.feeds and
  commons.model are removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 21:43:20 +00:00
Vitor PamplonaandClaude Opus 5 1ea820e699 fix(desktop): allow first-launch key bootstrap and stop caching unwritten state
Two defects found reviewing the strict-keychain fix.

1. Fresh Linux/Windows installs could never persist an account.

   getPrivateKeyOrThrow turns any PasswordAccessException into a
   SecureStorageException on non-macOS backends, but every backend
   java-keyring ships throws that exact exception for a *genuinely absent*
   credential:

     - WinCredentialStoreBackend: CredReadA false (ERROR_NOT_FOUND) -> throw
     - FreedesktopKeyringBackend: empty object paths ->
       throwNoExistingCredentialException
     - KWalletBackend: hasEntry false -> "Password is not in wallet"

   So the strict lookup structurally cannot report "definitively absent"
   there, the create branch in getOrCreateKey was unreachable, and nothing
   between it and AccountManager.addAccountToStorage catches the throw.

   getOrCreateKey now bootstraps a fresh key when the strict lookup fails
   *and* accounts.json.enc does not exist. With no ciphertext on disk there
   is nothing a new key can orphan, so the invariant the strict contract
   protects is untouched: once the file exists the exception propagates
   exactly as before.

2. writeCachedMetadata updated the in-memory cache before the disk write,
   so a failed write (keychain refusal, I/O error, disk full) left the
   session serving accounts that were never persisted -- a save that
   reported success and vanished on the next launch. Persist first, cache
   second.

Both are pinned by new tests, and both were mutation-checked: reverting
either fix fails exactly one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 17:43:12 -04:00
Claude 9e7ffe6854 Merge remote-tracking branch 'origin/main' into claude/determined-volta-u4l2rf
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt
2026-09-12 21:39:53 +00:00
Claude 501c9a5b5a Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt
2026-09-12 21:38:48 +00:00
mstrofnoneandVitor Pamplona 285a51e98f fix(desktop): stop silent account wipe on keychain errors and upgrade races
Three independent bugs in DesktopAccountStorage / SecureKeyStorage could
turn one ambiguous macOS Keychain reply, one transient read error, or
one Homebrew upgrade race into permanent account-metadata loss on
~/.amethyst/accounts.json.enc.

1. Silent AES metadata-key rotation on ambiguous keychain miss.
   getOrCreateKey() treated null from getPrivateKey("account-metadata-
   key") as "no key exists" and generated a fresh AES key. On macOS
   javakeyring collapses errSecItemNotFound (-25300), errSecAuthFailed
   (-25293), errSecUserCanceled (-128), and errSecInteractionNotAllowed
   (-25308) into the same PasswordAccessException; getFromKeyring turns
   them all into null. A single Deny click on the OS Keychain dialog
   silently rotated the AES key and destroyed the ability to decrypt
   the existing accounts.json.enc.

   Fix: add a new strict SecureKeyStorage.getPrivateKeyOrThrow(npub)
   on the common expect. On JVM/macOS it wraps /usr/bin/security
   find-generic-password whose exit codes (0 = found, 44 = not found,
   others = ambiguous) are documented and unambiguous. On JVM
   Windows/Linux it uses javakeyring but throws on any
   PasswordAccessException from the strict path. On Android it uses
   EncryptedSharedPreferences.contains(). On iOS it mirrors the
   existing "pending (iOS Phase 4)" stub. getOrCreateKey now calls
   getPrivateKeyOrThrow and propagates SecureStorageException without
   ever rotating the key. The permissive getPrivateKey(npub) is
   unchanged; its callers (per-account nsec, ephemeral bunker keys)
   still tolerate null on any error.

2. Any read failure resets the file. readMetadataFromDisk() used to
   rename to accounts.json.enc.corrupt.<ts> and return empty
   AccountMetadata() on any exception, including transient IO and
   the newly-throwing keychain path from bug 1.

   Fix: distinguish exception types.
   - AEADBadTagException / BadPaddingException: back up to
     .corrupt.<ts>, reset, fire StorageCorruption.FileCorrupted
     (unchanged).
   - JacksonException: back up but to .jsonerror.<ts> so it is
     distinguishable from ciphertext corruption; fire
     StorageCorruption.JsonMalformed.
   - Anything else (IO error, OOM, thrown keychain path): do NOT
     rename; rethrow to caller and fire a new
     StorageCorruption.TransientError(cause) subtype. The file stays
     untouched. AccountManager.loadSavedAccount already wraps in
     try/catch and turns the throw into Result.failure.
   - Truncated file (size < GCM IV size): still backup + reset,
     genuinely unusable.

3. No cross-process advisory lock. Homebrew replacing the .app while
   the old process is mid-save, or an accidental double-launch of
   Compose Desktop (no built-in single-instance guard), could produce
   a truncated file that trips bug 2.

   Fix: withAccountsFileLock helper (mirrors SecureKeyStorage.
   withFileLock) wraps read + write in a
   RandomAccessFile(lockFile, "rw").channel.lock() on
   ~/.amethyst/accounts.json.enc.lock (0600). Because FileChannel.lock
   is per-JVM, an in-process Mutex is held before acquiring the
   channel lock. A separate stateMutex guards the read-modify-write
   cycle in saveAccount / deleteAccount / setCurrentAccount so two
   concurrent writers cannot each read the same base metadata and
   each rewrite it.

Backward compatibility: existing keychain items are read unchanged;
no schema migration for accounts.json.enc; the file lock adds a
.lock sidecar older builds ignore.

Tests: new SecureKeyStorageOrThrowTest (pure exit-code parser,
mac lookup Found/NotFound/Ambiguous, non-mac keyring hit and
throw-on-PasswordAccessException). DesktopAccountStorageTest gains
five cases: getOrCreateKey ambiguous-error preserves file and does
not rotate; getOrCreateKey definitive-not-found happy path;
readMetadataFromDisk transient-IO preserves file with no backup
sibling; GCM tag mismatch keeps .corrupt.<ts> backup; JSON malformed
uses new .jsonerror.<ts> suffix; eight concurrent saveAccount calls
serialize under the file lock with no lost updates. All existing
AccountManager* MockK setups extended to also stub
getPrivateKeyOrThrow.

Local verify: :desktopApp:test + :commons:jvmTest, 2490 tests, all
pass. Spotless clean.
2026-09-12 17:28:09 -04:00
Vitor PamplonaandGitHub 8fa4a72a9a Merge pull request #4108 from davotoula/fix/blossom-read-auth-single-flight-race
fix(blossom): re-check the token cache after winning the in-flight slot
2026-09-12 17:18:43 -04:00
Claude 97b13b7c47 refactor: move reply-context logic to commons.model; keep the Compose compiler in commons by measurement
Closes the last two items of documented debt from the commons/commonsUI split.

ParentNote (replyingDirectlyTo, isCommunityDefinition) and ReplyContext are
pure thread logic used by ViewModels, so they move from the misleading
`ui.note` package to `commons.model`, next to ThreadAssembler, together with
their tests and the StubCache fixture that shared the package. No `ui.*`
package is left in commons. The `ui.note` composables in commonsUI gain
explicit imports; consumer imports rewritten.

Whether commons still needs the Compose compiler plugin was an open question;
it is now measured. With compiler reports on the three GUI modules and full,
non-incremental recompiles in both configurations, removing the plugin flips
composable parameters typed with unannotated commons classes (TopFilter,
TorSettings, ProfileBroadcastStatus, ScheduledPost, EmojiPackState, ...) from
runtime-stable to unstable: 20→28 in commonsUI, 33→65 in desktopApp,
90→149 in amethyst. The plugin stays; the numbers are recorded in the build
file, ARCHITECTURE.md and the split plan so the question is not reopened.

Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug
compiles for nappletHost and amethyst; commons/commonsUI/cli/desktopApp JVM
test suites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 20:58:28 +00:00
Claude 42698ed59c fix(eventsync): drain the outbox before closing and count each send once
Audit of the sync path the new geode-backed EventSyncTest exercises found
two bugs in EventSync itself, plus review nits on the harness changes.

- runSync closed its client (`use {}`) the moment the last page arrived,
  while `publish` is fire-and-forget through the client's outbox. Events
  forwarded from the final page of the last relay were still waiting for
  a socket or an OK when the outbox was destroyed, so the sync reported
  Done and silently never delivered them. Wait, bounded by the existing
  per-relay timeout, until no forwarded event has a relay left pending.
- The "events sent" counters incremented on every onSent, including the
  failed write to a destination still connecting and the outbox's
  at-least-once resend of an unacknowledged event after the connection
  syncs. Every cold destination therefore reported at least one extra
  event sent. Count only successful writes, once per (event, relay).
  The test now asserts the sent total equals the routed total.
- Harness: the 127.0.0.2 rationale claimed it survives Quartz's
  isLocalHost() strip; that filter now covers all of 127.0.0.0/8, so
  say so and note what it means for the strict-inbox DM cases. The
  interactive Marmot harness gets an overridable RELAY_HOST/RELAY_BIND
  and documents the loopback/RFC1918 stripping limit it inherits, and
  its new --port guards a missing value instead of dying on set -u.
- EventSyncTest builds both scenarios through one helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
2026-09-12 20:57:11 +00:00
Claude 943e137d4e perf(http): stop wiping the media connection pool, and size the dispatcher for a phone
Two problems in the shared non-relay client, both of which make every HTTP
request cost more than it should.

The connection pool was being emptied constantly. buildHttpClient() evicted the
whole pool whenever the proxy differed from the last one it was handed, tracked
in a single field. But one factory mints BOTH long-lived variants:
DualHttpClientManager builds defaultHttpClient (always SOCKS, since
buildLocalSocksProxy falls back to 9050 rather than returning null) and
defaultHttpClientWithoutProxy (always null) from the same instance, and the two
share one rootClient.connectionPool. So the field alternated between the proxy
and null forever, and every rebuild read as a route change and wiped the pool
they share. Both are stateIn(WhileSubscribed(1000)) flows collected from a
composable, so that happened on every isMobileDataProvider change and every
foreground round trip — and the next image then paid a fresh DNS + TCP + TLS.
This is a plausible source of the "first image after a pause takes forever"
stall the pingInterval above it was added for.

ProxyRouteTracker narrows it to what the eviction was actually for: a direct
build never evicts (null is that variant's permanent route), and a proxied build
evicts only when the Tor port really moved. Nothing is lost by being this narrow
— OkHttp's Address, the pool key, already includes the proxy, so proxied and
direct connections to the same host are distinct entries that can never be
handed to each other's calls.

Second, maxRequests was 128. Dispatcher's executor is an unbounded cached pool,
so that is the thread ceiling: up to 128 threads running 128 concurrent TLS
handshakes on a handset. Nothing upstream bounds the arrival rate either —
Coil's enqueue is unbounded and PrefetchFeedMedia warms ±3 notes on both sides
of the viewport on every visible-range change — so a fast scroll really does
reach it. Past saturation, more concurrency slices the same bandwidth thinner
and pushes every image's completion out together, the on-screen one included.
32 total / 8 per host keeps the per-host lift that feeds need while letting
visible images finish and paint.

The pool fix is covered by tests. The dispatcher numbers are a reasoned choice,
not a measured one — worth a run against benchmark/ before release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-12 20:55:41 +00:00
Claude e076b1eaee fix(media): stop a no-imeta GIF rendering as an invisible note while it loads
GifVideoView's Loading branch emitted DisplayBlurHash unconditionally, and
DisplayBlurHash renders nothing at all when both hashes are absent —
placeholderModel(null, null) returns null and the composable early-returns.

That is exactly the state a post with no imeta lands in. With no `dim` tag and
nothing in MediaAspectRatioCache, `ratio` is null, so mediaSizingModifier falls
to a bare fillMaxWidth() with no height constraint. The container then wraps an
empty loading state and the whole note collapses to zero height: no picture, no
URL, no spinner, just a gap in the feed for however long the fetch takes, and
then the image appearing from nowhere. Seen on a kind-1111 comment from Sidecar
whose content is a single blossom .gif URL.

UrlImageView already has the ladder this needs, so mirror it: blurhash/thumbhash
when there is one, a spinner in the reserved box when only a ratio is known, and
otherwise the URL plus a loading symbol via WaitAndDisplay. Every branch now
emits something with a height.

This is the missing feedback, not the latency — a slow fetch still takes as long
as it takes, it just stops being invisible while it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-12 20:55:22 +00:00
Claude 2ab934b2d2 perf: stop debugState walking the whole cache when its output is dropped
MainActivity.onPause() calls debugState() unconditionally, and every line it
emits is Log.d built through the *eager* overload — so the arguments are
evaluated before the level check. Those arguments are the expensive part:
nine materialising LargeCache.filter scans over notes/addressables/users and
the three channel maps, nested sums over every channel's and chatroom's
notes, two sorted passes, and three passes calling Event.countMemory(), which
itself walks every tag and tag element of every cached event.

Release builds sit at WARN, so all of that ran on every backgrounding and the
result was discarded. Gated on `Log.minLevel > LogLevel.DEBUG` rather than
`isDebug`, because that is exactly the condition under which the lines are
dropped: benchmark builds are `isDebug` but sit at INFO, so an isDebug gate
would have left the one variant whose numbers are meant to be trustworthy
still paying the cost. The function only reads and logs — no mutation — so
the early return cannot skip a side effect.

The same shape is already gated elsewhere: AppModules builds relayReqStats
and bootDiagnostics as `if (isDebug) ... else null`, and BootRelayDiagnostics
does comparable work. debugState was the one that was not.

fix: rethrow CancellationException in six catch blocks

All six sat in suspend functions whose try body contains a suspension point,
so a cancelled scope surfaced as CancellationException and was swallowed,
against the `if (e is CancellationException) throw e` convention this
codebase follows in 192 files. What cancellation used to mean:

- VanishRequestsState: published ComplianceStatus.ERROR, showing a relay as
  having answered badly when it was never asked.
- NappletResourceFetcher: reported ERROR_NETWORK to the sandboxed page, so a
  cancelled fetch was indistinguishable from an upstream failure.
- MarmotAgentStreamWatcher (two sites): the per-candidate handler does
  `continue`, so a cancelled watcher kept dialling the remaining broker
  candidates.
- CallSession: logged as a PeerConnection creation failure.
- NamecoinSharedPreferences: returned emptyList(), i.e. "no pinned certs".

Uses kotlin.coroutines.cancellation.CancellationException, the import that
also works from commons/commonMain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
2026-09-12 20:49:24 +00:00
Claude d4ac5149f7 fix(zaps): audit follow-ups on the BOLT12 fallback and profile chips
- Only retry a refused BOLT12 offer over BOLT11 when the wallet rejected it
  before attempting a payment (EXPIRED, NOT_FOUND, BAD_REQUEST,
  NOT_IMPLEMENTED, UNSUPPORTED_PAYMENT_INSTRUCTION, UNSUPPORTED_NETWORK).
  NIP-47 defines PAYMENT_FAILED as possibly "due to a timeout", so an HTLC
  can still settle after that reply; retrying on it, or on INTERNAL / OTHER
  / a missing code, could pay the recipient twice. The decision is now an
  allowlist and the test locks the non-retry set.
- NwcInfoCache exposes an `updates` counter bumped on every stored entry.
  The zap picker keys its rail recompute on it, so a BOLT12-only recipient's
  bolt appears when the wallet's kind:13194 info lands after the popup
  opened, instead of only after closing and reopening it.
- A BOLT12 refusal with neither message nor code no longer toasts the raw
  "%1$s" placeholder; the code name (or OTHER) fills the detail.
- One abbreviateBolt12Offer() replaces three copies of the lno1 truncation
  in the profile chip, the offers dialog and the settings screen.
- Reword the "these four" recompute-key comment so it covers the BOLT12
  reads added alongside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
2026-09-12 20:47:05 +00:00
davotoula ae2e971dd9 fix(blossom): re-check the token cache after winning the in-flight slot
The pre-insert cache look added in 6dc631e85d narrowed the single-flight
gap but didn't close it: a fast leader can insert, sign, cache and retire
its entry entirely between a straggler's cache read and its putIfAbsent,
so the straggler wins an empty map and signs a second time. That is the
intermittent `expected:<1> but was:<2>` in aFastSignerStillSharesOneSignature
failing main CI.

Check the cache again once this caller owns the slot. A leader always caches
before retiring its entry, so any token minted before the insert is visible
there; take it and give the slot back instead of re-signing.

Reproduced locally at round 2431 of 5000; two 5000-round runs pass with the fix.
2026-09-12 22:29:04 +02:00
Claude 88ae3d0f5a fix: close the fast-signer single-flight race in BlossomReadAuthTokenProvider
`aFastSignerStillSharesOneSignature` failed on a loaded machine (rounds 11,
18 and 31 across three runs, on the branch head and on the already-pushed
commit alike): a straggler could miss the in-flight map, miss the cache,
get descheduled, and then win `putIfAbsent` only because the fast leader
had already cached *and* retired its entry — and sign a second time.

Take one more look at the cache after winning the in-flight slot. A prior
leader's cache write happens before its `remove`, and winning the slot
after that `remove` goes through the same ConcurrentHashMap bin, so the
just-minted token is visible there; hand it out and retire the slot instead
of launching a duplicate signature. The test class now passes four runs in
a row where it previously failed three in a row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 20:20:30 +00:00
Claude 1093b3ce8a fix(notifications): count connected relays from the pool's live socket state
The always-on notification read its "Connected to N relays" from
RelayPool.connectedRelays, a set that only moves on the onConnected /
onDisconnected callbacks. That set over-reports: a relay that sent a
WebSocket CLOSE frame never produces a callback (the app does not answer
onClosing, so OkHttp fires neither onClosed nor onFailure, and the later
cancel() is silent too), so the URL lingers until the 120s ping path
finally fails. After the feeds tore down in the background this left
hundreds of already-dropped relays in the count for minutes, with no
subscription in the "show details" breakdown to justify any of them.

Expose the pool's ground truth instead: RelayPool.connectedRelayUrls()
reads each member's isConnected(), surfaced as INostrClient.connectedRelays()
(defaulting to the flow's value for pool-less clients). The notification
keeps the flows only as a trigger, merging in availableRelaysFlow because
that is the flow that moves when the pool drops such a relay, and re-reads
the live count on each sample. The "show details" breakdown and the
Active Subscriptions screen read the same source so all three agree.

A pool test pins the drift: with a socket layer that never confirms the
close, removing a relay leaves it in the flow but out of the snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-12 20:17:29 +00:00
Claude 3887b033e0 feat(zaps): fall back to a BOLT11 zap when the wallet refuses a BOLT12 offer
The wallet resolves the offer itself, so a stale or unreachable kind:10058
offer surfaces as a NIP-47 error reply, which by the spec means nothing was
paid. When that recipient also publishes a lightning address, re-send the
same share as a regular zap through the BOLT11 lane instead of toasting the
BOLT12 error; the toast stays for a recipient with no other route.

Bolt12LightningFallback keeps the decision pure and tested: every refusal
retries except the ones about our own wallet (insufficient balance, quota,
rate limit, restricted, unauthorized, unsupported encryption), which would
fail the same way over BOLT11. A paid-but-no-receipt outcome is never
retried, and neither is a wallet that never answers: sendBolt12Zap now
passes a timeout handler, so a silent wallet reports a timeout and steps
the progress instead of leaving the zap hanging.

The BOLT11 lane moves into zapOverLightning so the main zap and the
fallback share one path, and Bolt12Recipient carries the lnAddress and
relay hint the retry needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
2026-09-12 20:17:09 +00:00
Claude 713b7d41f6 refactor: move feed DAL to commons.feeds, chess UI to nip64Chess.ui, tighten amy budget
Follow-ups to the commons/commonsUI split.

Feed data-access layer: FeedFilter, AdditiveFeedFilter, AdditiveComplexFeedFilter,
ChangesFlowFilter, FeedContentState, FeedState, InvalidatableContent,
DefaultFeedOrder, RepostRenderability and friends move from the misleading
`ui.feeds` package to the root of `commons.feeds`, next to `feeds/custom`.
The `ui.feeds` composables (NewPostsChip, RelayReachMarker, DM history cards)
stay in commonsUI and gain explicit imports. Consumer imports rewritten.

Chess: the eleven composable files in commonsUI move from the flat
`nip64Chess` package to `nip64Chess.ui`; the logic stays in
`commons/…/nip64Chess`. Consumer imports rewritten.

CLI size budget: measured after the split (1.15.2, Linux x64) the JVM
tarball is 55 MB and the jlink image tarball 80 MB, so the release gate
drops from 200 MB to 120 MB per asset. BUILDING.md, the architecture docs,
the feed-patterns skill and the split plan record the new state.

Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug
compiles for nappletHost and amethyst; commons/commonsUI/cli/desktopApp JVM
test suites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 20:13:18 +00:00
Claude 6a4ddfa974 fix(blossom): re-check the cache after winning the read-auth in-flight slot
aFastSignerStillSharesOneSignature still failed about two runs in five: a
straggler that read the in-flight map and the cache as empty could then win
putIfAbsent because the leader had already signed, cached and retired its
entry in between, and would sign a second token.

The leader's cache put happens-before its removal of the same key, so once
a caller has claimed the slot a cached token, if any, is visible. Look once
more there: hand the cached token to this caller and to any follower that
already picked up the fresh deferred, retire the entry, and skip the
signature. Ten reruns of the class pass where two in five failed before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
2026-09-12 19:21:27 +00:00
Claude 1f4d2bf9b5 fix: clear the mechanical Android Lint warnings
Lint reports 874 warnings / 0 errors per amethyst variant. 512 of those are
in Crowdin-managed values-*/strings.xml (mostly MissingQuantity — a
translator's plural missing a quantity) and are not ours to hand-edit; this
takes the ones that are mechanical and behaviour-preserving:

- EmptySuperCall (24 in amethyst, plus 2 in commons the amethyst run cannot
  see): ViewModel.onCleared is documented empty, so drop the super calls.
- UseKtx (2): Canvas.withTranslation for the LaTeX drawable, and
  Bitmap.toDrawable for the map pin — the KTX form CLAUDE.md asks for, and
  both compile to the same calls.
- ConstantLocale (1): CalendarEventListCard held its "MMM" formatter in a
  file-level val, which captures Locale.getDefault() once — month
  abbreviations stayed in whatever language was active at class init. The
  formatter is now cached per locale, which keeps the property the original
  comment was protecting (a formatter per recompose was 500 allocations while
  scrolling), and the locale comes from LocalLocale.current.platformLocale so
  the read is observable: Locale.getDefault() inside a composable is not, and
  Compose's own NonObservableLocale check rates that an error.
- UnusedResources (6): the Android Studio new-project wizard's leftover
  colors (purple_200, teal_200, teal_700, black, white, transparent), each
  verified unreferenced from Kotlin and XML. purple_500/700 are in use and
  stay.
- UseTomlInstead (3): the debug-only Compose/Perfetto tracing dependencies
  move into the version catalog. Same coordinates and versions; the catalog
  already carries BOM-managed versionless entries.

playDebug goes from 874 warnings to 838, still 0 errors.

Deliberately left, because each is a decision rather than a cleanup:
AppLinkWarning (autoVerify only works if the domains serve a matching
assetlinks.json), the 126 unused source strings and 10 PluralsCandidate
(both churn the translation surface), GradleDependency /
NewerVersionAvailable (dependency bumps need the license check), VectorRaster
/ VectorPath / IconDensities / IconXmlAndPng (redrawing assets), BatteryLife
(the battery-optimization helper working as designed), and InlinedApi /
ClickableViewAccessibility / DiscouragedApi / InsecureBaseConfiguration (each
needs its surrounding intent read first).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
2026-09-12 19:17:39 +00:00
Claude 5e8fdedb83 fix(zaps): offer the Lightning rail to BOLT12-only recipients
The zap picker gated its Lightning bolt on the recipient's lud16/lud06, but
the send path has routed a recipient with a kind:10058 offer over BOLT12 for
a while (when our default NWC wallet advertises `pay`). A recipient who
published only an offer therefore had no bolt in the popup and no one-tap
zap, even though ZapPaymentHandler could pay them.

Teach RailCapabilityResolver about the BOLT12 route: hasLightning is now true
for a recipient with an offer when our wallet can pay offers. The rail keeps
its single bolt — which flavour is used stays a send-time decision. The
popup observes the recipient's offer list and the default wallet URI so the
bolt appears as those load, and the one-tap fast path uses the same check.
The sender-side test moves into AccountZapActions.canZapViaBolt12 so the
handler, the picker and the profile dialog share it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
2026-09-12 18:49:03 +00:00
davotoula 54252b0fb9 docs(skill): teach find-missing-translations to seed source-identical values
The Crowdin workflow now passes import_eq_suggestions: true, so a value
equal to the English source is no longer skipped on upload — the repo's
locale files are the seed for Crowdin's database. The skill said the
opposite ("Don't add source-identical fallbacks"), which now leaves keys
untranslated in the UI forever.

- Background rewritten for the flag, with the confirming evidence: run
  34706537802 -> PR #4107 round-tripped 330 identical values with zero
  net key changes.
- Items 1-3: missing keys are actionable in the repo; copy English
  verbatim, except <plurals> (trips MissingQuantity) and words a locale
  would genuinely translate.
- Item 4 reconciled with item 3: seeding a key Crowdin holds nothing for
  sticks; overwriting a value it holds differently still loses.
- Records the diff-reading trap: compare key sets per file, never -/+
  lines separately, or a reorder reads as a mass strip.
2026-09-12 20:43:27 +02:00
davotoula 3fefb62f1a update cs,pt,de,sv 2026-09-12 20:24:11 +02:00
Claude 6e3af61e6a fix: clear the Gradle 10 deprecation warnings in the build scripts
Every build printed "Deprecated Gradle features were used in this build,
making it incompatible with Gradle 10". With --warning-mode all that was
five distinct Kotlin DSL delegated-property deprecations, all in our own
scripts:

- `val x by extra(...)` / `val x: T by extra` in the root script, for the
  opt-in Sonar gate that buildscript {} publishes and the body reads. Now
  extra.set("x", v) and extra["x"] as T.
- `val x by getting { }` for eight of quartz's KMP source sets. Now
  getByName("x") { }, which is what commons already used. None of those
  vals were referenced, so the local binding goes away with them.
- `val x by tasks.registering { }` and the typed
  `by tasks.registering(T::class) { }`, thirteen tasks across quartz,
  commons, cli, geode, nestsClient and desktopApp. Now
  tasks.register("x") { } and tasks.register<T>("x") { }, which return the
  same TaskProvider, so the dependsOn / finalizedBy references to them are
  unchanged.

`./gradlew --warning-mode all help` is now silent, and all nineteen
converted tasks still register and run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
2026-09-12 17:50:14 +00:00
Claude cfa6f72760 fix(blossom): take a third cache look once the in-flight slot is held
BlossomReadAuthTokenProviderTest.aFastSignerStillSharesOneSignature still
fails about one run in three (round N: expected 1 signature, was 2), which
the pre-push hook turns into a hard block on every push.

The second cache look added in 6dc631e8 closes the leader-finished-early
window for callers that reach it after the leader retired its entry, but
not for a caller parked between that look and its own putIfAbsent: a
leader that starts after the caller's miss can sign, cache and retire in
that gap (a local key does it in microseconds), so the parked caller's
putIfAbsent then succeeds against an empty map and mints a second token.

Once the caller holds the in-flight slot, any earlier leader has already
cached, because a leader caches before it retires. So a cache hit taken
at that point is definitive: hand the cached token to ourselves and to
every follower already parked on our deferred, and retire the slot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
2026-09-12 16:43:59 +00:00
Claude 6d34d3d9f9 feat(profile): show BOLT12 offers as payment pills, drop the header wallet buttons
BOLT12 offers saved in Settings were only reachable through a small bolt
button in the profile header action row, next to a second NIP-A3 wallet
button, while every other way to pay a profile (Lightning, CLINK, on-chain,
Cashu, NIP-A3 targets) rendered as a chip in the payment rail below the bio.

Render one chip per NIP-B1 offer in that rail: tap opens the existing
copy / pay-with-wallet / pay-via-intent dialog for that offer, long-press
copies the raw lno1 string. Remove both header buttons, since the rail
already lists every NIP-A3 target with the same tap-to-pay and long-press
copy behaviour the dialog rows have. The two dialogs stay (the reaction row
still opens the NIP-A3 one), so their files are renamed after what is left.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
2026-09-12 16:33:56 +00:00
Claude a87222c51f refactor: split Compose UI out of commons into a new commonsUI module
`:commons` is on the CLI classpath, yet it declared Compose UI, Coil, Compose
resources, markdown and desktop Compose as dependencies, dragging ~40 MB of
UI/Skiko jars into every `amy` distribution. This moves every
Compose-dependent file into a new KMP module, `:commonsUI`, that
`api`-depends on `:commons`; `:commons` keeps only the Compose runtime
(stability annotations + snapshot state) and lifecycle-viewmodel.

Files keep their `com.vitorpamplona.amethyst.commons.*` packages, so the split
is a build-graph boundary and no consumer import changed. 236 files were
`git mv`'d (composables, icons, robohash, theme, Coil fetchers, the
`@Composable` relay-client entry points, `composeResources`, and the tests
that exercise them). Two headless files needed surgery instead of a move:
`GalleryParser` lost a vestigial foundation `@OptIn`, and the
`LocalPrivacyLockState`/`lockStateFor` CompositionLocal accessor moved out of
`PrivacyLockState` into its own commonsUI file. The feed DAL under `ui/feeds`
and `ui/note/ParentNote`+`ReplyContext` stay in `commons` because ViewModels
depend on them.

`amethyst`, `desktopApp`, `nappletHost` (NappletWebContract serves the shell
from composeResources) and `benchmark` now depend on `:commonsUI`; `cli`,
`geode` and `marmotBench` do not. commons' androidMain gains an explicit
androidx.core KTX dep it previously got transitively through Compose UI.

CI, crowdin, the icon-font tools and the escaping hook point at the new
composeResources location; CLAUDE.md, commons/ARCHITECTURE.md, a new
commonsUI/ARCHITECTURE.md, CONTRIBUTING, BUILDING and the affected skills
document the boundary. A plan doc under commons/plans records the
classification method and follow-ups.

Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug
compiles for nappletHost and amethyst; commons/commonsUI/cli JVM test suites;
both verifyKmpPurity gates; the cli runtime classpath no longer resolves
Compose UI, material3, Skiko or Coil.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 16:26:59 +00:00
Claude 32f0c462bb test: run relay-backed tests against geode instead of external relays
Every test that used to open a socket to something outside the repo now
talks to geode, the relay this project ships, either in-process or as the
embedded `amy serve`.

- amethyst: the Android instrumented EventSyncTest dialed vitor.nostr1.com,
  pyramid.fiatjaf.com and the nos.lol / nostr.mom defaults and asserted
  nothing. Replaced by a JVM unit test on geode's InProcessRelays that
  preloads a source relay and asserts what lands on the outbox, inbox and
  DM relays, plus a NIP-42 variant with the source behind FullAuthPolicy
  to cover the RelayAuthenticator wiring. Wires :geode, its testFixtures
  and the JVM SQLite driver into amethyst's unit-test classpath, the same
  way quartz's jvmAndroidTest already does.
- cli/tests: the cache, dm and marmot headless harnesses cloned and
  cargo-built nostr-rs-relay on first run. They now boot `amy serve`
  (geode) from the amy binary they already build, via a shared
  start_local_relay / stop_local_relay in headless/helpers.sh. Rust is no
  longer needed for the cache and dm suites at all.
- cli/tests/marmot/marmot-interop.sh: the interactive harness defaulted
  to relay.damus.io / nos.lol / primal / bitcoiner.social / nostr.mom,
  with `--local-relays` pointing at MDK's docker stack. It now boots the
  embedded relay on 0.0.0.0 by default (the phone reaches it over the
  LAN) and keeps the public set behind an explicit `--public-relays`.
- docs: cli/tests/README.md, CONTRIBUTING.md, cli/DEVELOPMENT.md and
  cli/ROADMAP.md no longer describe a loopback nostr-rs-relay.

The quartz prodbench probes (NegentropyStallRepro, CursorTerminationProbe,
NegentropyMultiRelayLiveTest, ProductionReceiverBenchmark, ...) are left
as they are: they are opt-in diagnostics of production relay behaviour,
gated behind PROD_RELAY_BENCH / NEG_MULTI / NEG_STALL_REPRO, and would
measure nothing against a local relay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
2026-09-12 16:21:51 +00:00
Claude d63e14bb36 fix: clear the remaining compiler warnings in amethyst and desktopApp
Second half of the warning sweep, covering :amethyst (both flavors, all
build types, unit + instrumented tests), :desktopApp and :benchmark:

- GitReplyEvent (NIP-34 kind 1622) is deprecated in favour of NIP-22
  comments, but events already on relays still arrive and still have to be
  routed, rendered and surfaced. @Suppress("DEPRECATION") with that reason
  at the five production sites and the coverage test that pins them.
- The four instrumented Compose tests move to
  androidx.compose.ui.test.junit4.v2.createComposeRule. The v2 factory
  returns the same ComposeContentTestRule, so mainClock, setContent and the
  node assertions are unchanged; only the effect dispatcher differs.
- RelayAuthPromptBusTest / RelayAuthSessionGrantsTest: @OptIn for the
  ExperimentalCoroutinesApi members (testScheduler.currentTime, runCurrent)
  they already use, matching the annotation the file's other tests carry.
- MarmotFileUploader: drop a nullable alias of a non-null cipher, left
  behind when the v2 reference stopped being conditional.
- LocalCacheSearchParityTest: hoist the Json format out of the loader.
- LivesSection: FlowRowOverflow and FlowRow's overflow parameter are
  deprecated; the non-deprecated overload already clips beyond maxLines.
- HexBenchmark: drop a bare `null` expression statement from the measured
  lambda.

Also fixes :commons:compileCommonMainKotlinMetadata, which did not compile
at all: shared code called BigDecimal.toLong(), which resolves in every
platform compilation (every actual is a Number) but not in the common
metadata one, where only the expect class's own members are visible.
`expect class BigDecimal : Number` cannot work — java.math.BigDecimal leaves
toByte()/toShort() abstract, so the JVM typealias fails the expect/actual
modality check — so the conversion is a top-level expect/actual extension
instead, with actuals next to each BigDecimal actual.

Verified warning- and error-free across the jvm, android (play/fdroid ×
debug/release/benchmark), linuxX64, and the common/jvmAndroid/native/apple/
ios metadata compilations. The apple actuals are checked by
compileAppleMainKotlinMetadata, which runs the frontend against the Apple
klibs without needing a macOS host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
2026-09-12 16:17:00 +00:00
davotoula f6a49761f5 Update import_eq_suggestions to true to avoid tedious updating in UI of identical strings.
update cs,pt,de,sv
2026-09-12 17:57:45 +02:00
Claude a4b83b86e7 fix: clear compiler warnings in quartz, commons, marmotBench and quic-interop
Fixes the Kotlin warnings the compiler reports for these modules across the
jvm, android, linuxX64 and metadata compilations:

- PartialTokensTest / marmotBench: drop redundant casts. kotlin.test's
  assertTrue carries a `returns() implies` contract, so the `is` check
  already smart-casts; the benchmark values were never nullable-typed.
- MarmotPublish*Test, LastResortKeyPackageReuseTest: name overridden
  parameters as the supertype does (`retainedSecrets`, `snapshot`), so
  named-argument calls through the interface stay correct.
- AuthOutcomeTest: PersistentMap.put is deprecated in favour of putting(),
  which is what the rest of the codebase already uses.
- IndexableContentGoldenTest: drop an unnecessary !! on a non-null String.
- Nip46Test: the generic encode/decode round trip cannot be checked at
  runtime, so suppress UNCHECKED_CAST with a note on why it is safe.
- InternTradeoffBenchmark: hoist the liveness anchor from a local to a
  field. As a local its assignments were visible to data flow, which folded
  the trailing `check(sink != null)` into a constant.
- Http3GetClient: an empty `else -> {}` branch instead of a bare `Unit`
  expression statement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
2026-09-12 15:31:21 +00:00
Vitor PamplonaandGitHub 08a3bab605 Merge pull request #4101 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 11:15:46 -04:00
Vitor PamplonaandGitHub 4a5278ee55 Merge pull request #4105 from vitorpamplona/chore/bump-amethyst-cask-v1.15.2
chore: sync amethyst-nostr cask to v1.15.2
2026-09-12 11:15:32 -04:00
Vitor PamplonaandGitHub 7aa345ba28 Merge pull request #4104 from vitorpamplona/chore/bump-winget-manifest-v1.15.2
chore: sync winget manifests to v1.15.2
2026-09-12 11:15:22 -04:00
Vitor PamplonaandGitHub 284671aa0f Merge pull request #4103 from vitorpamplona/chore/bump-amy-formula-v1.15.2
chore: sync amy Homebrew formula to v1.15.2
2026-09-12 11:15:15 -04:00
Vitor PamplonaandGitHub a894315335 Merge pull request #4102 from vitorpamplona/chore/bump-geode-formula-v1.15.2
chore: sync geode Homebrew formula to v1.15.2
2026-09-12 11:15:08 -04:00
vitorpamplonaandgithub-actions[bot] 7995f042ab chore: sync amethyst-nostr cask to v1.15.2 2026-09-12 15:14:59 +00:00
vitorpamplonaandgithub-actions[bot] d22e2c1fc7 chore: sync winget manifests to v1.15.2 2026-09-12 15:14:54 +00:00
vitorpamplonaandgithub-actions[bot] 3b69fae6f4 chore: sync geode Homebrew formula to v1.15.2 2026-09-12 15:14:39 +00:00
vitorpamplonaandgithub-actions[bot] cb53a863ce chore: sync amy Homebrew formula to v1.15.2 2026-09-12 15:14:38 +00:00
vitorpamplonaandgithub-actions[bot] 0118c74163 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-12 15:12:54 +00:00
Vitor PamplonaandGitHub a5da7e49ba Merge pull request #4100 from vitorpamplona/claude/intelligent-edison-gcwtbr
Fix single-flight guarantee for fast signers in BlossomReadAuthTokenProvider
2026-09-12 11:09:57 -04:00
Claude 6dc631e85d fix(blossom): close the read-auth single-flight gap a fast signer slips through
BlossomReadAuthTokenProvider.header() reads the token cache, then signOnce()
reads the in-flight map — two separate reads. A leader caches its token before
retiring its in-flight entry, so a caller sitting between those two reads sees
an empty cache (its read came first) and an empty in-flight map (the leader
already finished), and signs a second token for the same host.

A 300ms test signer never opens that window, which is why the provider's own
concurrency test missed it. A local in-process key signs in microseconds, so
BlossomReadAuthFetcherTest.aBurstOf401sSharesOneSignature — 16 fetchers that
all 401 and all retry — hit it and intermittently saw two distinct tokens.

signOnce() now takes a second look at the cache once it finds no in-flight
entry: an absent entry proves the leader's cache write is already visible, so
the straggler reuses that token instead of starting another signature.

Covered by a new aFastSignerStillSharesOneSignature, which runs the 16-caller
burst against an instant signer over many rounds — the existing test's slow
signer cannot reach the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011APd48WJ5pZVK4Ltpj3YpL
2026-09-12 14:50:15 +00:00
Vitor PamplonaandGitHub 0629b23b1a Merge pull request #4099 from vitorpamplona/chore/release-1.15.2
chore(release): bump to 1.15.2
2026-09-12 10:34:37 -04:00
Vitor PamplonaandGitHub c0a38bc0b7 Merge pull request #4098 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 10:31:54 -04:00
vitorpamplonaandgithub-actions[bot] aa2e598e28 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-12 14:29:36 +00:00
Vitor PamplonaandClaude Opus 5 cb49caae69 chore(release): bump to 1.15.2
app 1.15.1 -> 1.15.2, appCode 459 -> 460. That single edit drives Android's
versionName/versionCode, Desktop and CLI packageVersion, quartz's Maven version
and geode's RelayInfo.VERSION.

Three substantive PRs since v1.15.1, plus Crowdin translations and the packaging
syncs the bump workflows opened after the last tag:

- #4092 media previews: an extension match now requires a real dot, so a player
  page whose path merely ends in the letters `_mp3` stops going to the video
  player, and `og:audio`/`og:video` are read and played with the page's
  `og:image` as poster. A declaration whose type is `text/html` -- YouTube's --
  is refused.
- #4095 nested NIP-22 replies: engagement subscriptions asked only for the
  lowercase `e`/`a` tags, so a comment two or more levels deep was invisible
  until ThreadScreen opened its own subscription. Each relay gets a second,
  root-scoped filter on `E`/`A`. Kind 1619 moves there too -- NIP-34 gives PR
  updates only an uppercase `E`, so it had been in a filter it could never match.
- #4096 Health Connect: a rationale screen Play requires, reachable from the
  composer, from Health Connect's permission screen and standalone without an
  account; reads moved off the UI thread; source names memoized; and the workout
  form is replaced rather than merged when a second suggestion is picked.

Verified on a Pixel 9 emulator before cutting, since two of the three are only
observable on device: the og:audio track plays in a thread with real transport
controls (00:30 / 05:00) where it used to buffer forever, and the Health Connect
rationale opens from all three routes -- including the one that matters for
review, where Health Connect's own permission screen launches our
ViewPermissionUsageActivity through the START_VIEW_PERMISSION_USAGE-guarded
filter.

RELEASE_NOTES_ID deliberately stays on the v1.15.0 note: RELEASE_OPS has it
repointed on x.y.0 only.

Left alone deliberately: everything under */packaging/ and translators.json's
tag, which the bump workflows and the Crowdin job write after the tag exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 10:28:52 -04:00
Vitor PamplonaandGitHub c03e7cfa5e Merge pull request #4097 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 10:26:57 -04:00
Vitor PamplonaandGitHub 1a812c3df5 Merge pull request #4095 from vitorpamplona/claude/note-replies-loading-mwidat
Split NIP-22 root-scope replies into separate engagement filters
2026-09-12 10:26:44 -04:00
Vitor PamplonaandGitHub 8c87745b21 Merge pull request #4096 from vitorpamplona/claude/focused-gates-w9dtcw
Health Connect: add rationale screen and improve source name caching
2026-09-12 10:24:20 -04:00
vitorpamplonaandgithub-actions[bot] 7d01d3b155 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-12 14:15:50 +00:00
Vitor PamplonaandGitHub 17f0ee7fbe Merge pull request #4092 from vitorpamplona/fix/og-media-playback
fix(media): require a dot before a file extension, and play a player page's og:audio/og:video
2026-09-12 10:13:01 -04:00
Claude d504f41870 fix: replace the whole form when a second workout suggestion is picked
Three defects found auditing the Health Connect workout path.

applyPrefill only assigned a field when the incoming route carried a value,
so picking a second suggestion merged into the first instead of replacing it.
Tapping a run (5 km, 380 kcal) and then a gym session left the run's distance
and calories in the form — the user publishes numbers from a workout that
never happened. Every metric is now assigned on both branches. Notes are
deliberately left alone: they are typed by the user, never carried by a route.

The `source` tag published the writing app's display label ("Samsung Health"),
or its raw package name when that app is not installed. SourceTag defines a
vocabulary — gps / manual / health_connect — and the feed badge uppercases
whatever is in the tag, so an imported workout rendered as "SAMSUNG HEALTH"
or "COM.HUAWEI.HEALTH" beside other clients' "GPS". It now publishes
SourceTag.HEALTH_CONNECT, which existed for this and had no callers.
DetectedWorkout.source keeps the friendly name for the UI.

readNewWorkouts ran entirely on Dispatchers.Main: the callers launch into a
composable's rememberCoroutineScope, and nothing in the feature switched
dispatcher. Health Connect's own calls suspend, but resolveSourceName's
PackageManager lookup is a blocking binder call made once per session, so a
week of sessions blocked the UI thread once each. The read now runs on
Dispatchers.IO and the label lookups are memoized per writer package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f
2026-09-12 13:52:19 +00:00
David KasparandGitHub 212008b037 Merge pull request #4094 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 11:09:46 +02:00
davotoulaandgithub-actions[bot] 17189aba56 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-12 08:56:54 +00:00
David KasparandGitHub 3ae53d5d19 Merge pull request #4091 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 10:54:15 +02:00
Claude 92f3fd49b0 fix: kind 1619 was in the lowercase-e engagement filter, where it matched nothing
NIP-34's PR Update example carries only `["E", <pull-request-event-id>]` —
there is no lowercase `e` tag on a 1619 at all, and
GitPullRequestUpdateEvent.build() writes only RootEventTag to match. Listing
1619 in RepliesAndReactionsKinds2 (the `#e` filter's kind list) therefore
never pulled a single PR revision, and the comment claiming it was "rooted at
the target patch/PR/issue via a `root`-marked `e` tag" was wrong for that kind
(it is correct for the 1630-1633 statuses beside it).

The `#E` filter added in the previous commit is what actually makes a PR's
revision chain reachable from an on-screen PR row, so 1619 moves there and
comes out of the `e` list.

Nip34NotificationCoverageTest asserted the false half, which is how this
survived: it now checks each kind against the filter that can actually match
it, and pins 1619 out of the `e` list so it cannot drift back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARAmQQWbT2gGVo9tysdAgM
2026-09-12 01:44:32 +00:00
Claude 84c6dba81b feat: justify and document the Health Connect permissions
Google rejected the Health Connect declaration for "Insufficient Information
to Determine App Functionality": neither the store listing, the privacy
policy, nor the in-app experience explained what Amethyst does with the seven
read permissions it asks for.

All seven are used by HealthConnectManager, so none can be dropped. What was
missing was the explanation, on every surface a reviewer looks at:

- The rationale intents the manifest declares
  (ACTION_SHOW_PERMISSIONS_RATIONALE, ACTION_VIEW_PERMISSION_USAGE +
  CATEGORY_HEALTH_PERMISSIONS) pointed at MainActivity, which handles neither
  — tapping "privacy policy" in Health Connect just opened the feed. They now
  land on HealthConnectRationaleActivity, a static, account-free screen that
  lists each data type, what it fills in, and what the app never does. It is
  also reachable from a "What Amethyst reads" link on the Connect card, so
  the rationale is available before the permission request, not only after.
- PRIVACY.md gains a "Health and fitness data (Health Connect)" section: a
  per-type purpose table plus the limits (read-only, foreground-only, 7-day
  window, no route/background/history permissions, no secondary use).
- The Play listing description now covers the app's features and the
  Workouts flow, so the listing reflects what the permissions are for.
- docs/health-connect-play-declaration.md holds the paste-ready Play Console
  text: app functionality, a reviewer walkthrough, and a per-permission
  justification — including that CyclingPedalingCadence and StepsCadence come
  bundled with READ_EXERCISE and READ_STEPS and are never read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f
2026-09-12 01:11:46 +00:00
Claude 2616bbfea8 fix: load nested NIP-22 replies in the feed, not just in ThreadScreen
The per-note engagement subscription only asked relays for the lowercase
`e`/`a` tags. NIP-22 puts the conversation root in the **uppercase** `E`/`A`
tags and only the direct parent in lowercase, so a kind-1111 comment two or
more levels deep never carries `e`=<rootId> (nor `a`=<address> when the root
is an article). Those comments therefore never matched the feed's REQs and
only showed up once ThreadScreen opened its own `E`/`A` subscription — which
is exactly the "half the replies appear later" behaviour on note cards.

Kind-1 threads were unaffected: NIP-10 repeats the root `e` tag on every
descendant, so the existing filter already caught them.

LocalCache was never the problem: CommentEvent.tagsWithoutCitations()
already returns root + reply ids, so a nested comment is wired into the root
note's replies as soon as it arrives — it simply never arrived.

Adds an `E` filter (kinds 1111 + 1619, which also anchors with `E`) to
filterRepliesAndReactionsToNotes and an `A` filter (kind 1111) to
filterRepliesAndReactionsToAddresses. They are separate filters because tag
names inside one filter are ANDed — folding `E` into the `e` filter would
only match comments carrying both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARAmQQWbT2gGVo9tysdAgM
2026-09-12 01:01:59 +00:00
vitorpamplonaandgithub-actions[bot] f0aa12982a chore: sync Crowdin translations and seed translator npub placeholders 2026-09-11 23:51:32 +00:00
Vitor PamplonaandGitHub 3c4ab62729 Merge pull request #4089 from vitorpamplona/chore/bump-winget-manifest-v1.15.1
chore: sync winget manifests to v1.15.1
2026-09-11 19:48:48 -04:00
Vitor PamplonaandGitHub 892aca2b12 Merge pull request #4090 from vitorpamplona/chore/bump-amethyst-cask-v1.15.1
chore: sync amethyst-nostr cask to v1.15.1
2026-09-11 19:48:39 -04:00
Vitor PamplonaandGitHub 1924da60f9 Merge pull request #4088 from vitorpamplona/chore/bump-amy-formula-v1.15.1
chore: sync amy Homebrew formula to v1.15.1
2026-09-11 19:48:27 -04:00
Vitor PamplonaandGitHub abc1bc8a82 Merge pull request #4087 from vitorpamplona/chore/bump-geode-formula-v1.15.1
chore: sync geode Homebrew formula to v1.15.1
2026-09-11 19:48:20 -04:00
vitorpamplonaandgithub-actions[bot] f337e2da50 chore: sync amethyst-nostr cask to v1.15.1 2026-09-11 23:44:37 +00:00
vitorpamplonaandgithub-actions[bot] 303b84ec30 chore: sync winget manifests to v1.15.1 2026-09-11 23:44:29 +00:00
vitorpamplonaandgithub-actions[bot] 10cdbffcfd chore: sync amy Homebrew formula to v1.15.1 2026-09-11 23:44:18 +00:00
vitorpamplonaandgithub-actions[bot] ec71cd7084 chore: sync geode Homebrew formula to v1.15.1 2026-09-11 23:44:13 +00:00
789 changed files with 8127 additions and 2051 deletions
+43 -25
View File
@@ -3,7 +3,7 @@
## Project Overview
Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching
over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `amethyst`,
over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `commonsUI`, `amethyst`,
`desktopApp`, `cli`, plus the audio-rooms transport stack `quic` + `nestsClient`. Quartz should
contain implementations of Nostr specifications and utilities to help implement them. Commons stores
shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop
@@ -48,11 +48,18 @@ amethyst/
│ ├── androidMain/ # Android-specific (crypto, storage)
│ ├── jvmMain/ # Desktop JVM-specific
│ └── iosMain/ # iOS-specific
├── commons/ # Shared UI components (convert to KMP)
├── commons/ # Shared HEADLESS layer (models, state, ViewModels, relay client) — CLI-safe
│ └── src/
│ ├── commonMain/ # Shared composables, icons, state
│ ├── androidMain/ # Android-specific UI utilities
── jvmMain/ # Desktop-specific UI utilities
│ ├── commonMain/ # Domain models, state holders, ViewModels, services
│ ├── jvmAndroid/ # JVM-bound services shared by Android + Desktop
── androidMain/ # Android-specific actuals (Keystore, DataStore)
│ └── jvmMain/ # Desktop-specific actuals (keyring, upload pipeline)
├── commonsUI/ # Shared Compose UI on top of commons (composables, icons, theme, Coil, resources)
│ └── src/
│ ├── commonMain/ # Shared composables, icons, theme, composeResources (strings/fonts)
│ ├── jvmAndroid/ # Markdown renderer, Coil OkHttp fetchers
│ ├── androidMain/ # Android Coil bridge
│ └── jvmMain/ # Desktop Coil bridge (+ skikoMain shared with iOS)
├── quic/ # Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport (audio-rooms transport)
│ └── src/
│ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine
@@ -70,12 +77,20 @@ amethyst/
**Sharing Philosophy:**
- `quartz/` = Nostr business logic, protocol, data (no UI)
- `commons/` = Shared code for every front end (Android, Desktop, iOS, and the
headless `cli`): domain models, state holders, ViewModels, the relay client,
shared services, **and** the Compose UI that ≥1 GUI front end renders. The
package taxonomy, the CLI-safe / UI boundary, and a "where does my code go?"
guide are documented in **`commons/ARCHITECTURE.md`** — read it before adding
a new package or dropping code into `commons`.
- `commons/` = Shared **headless** code for every front end (Android, Desktop,
iOS, and the headless `cli`): domain models, state holders, ViewModels, the
relay client, shared services. It may use the Compose *runtime*
(`@Stable`/`@Immutable`, snapshot state) but never Compose UI, Coil or
Compose resources — the build enforces this: `commons` has no such deps.
- `commonsUI/` = Shared **Compose UI** that ≥1 GUI front end renders
(composables, `ui/theme`, icons, robohash, Coil fetchers, markdown, the
`composeResources` strings/fonts and the generated `Res` class). Depends on
`commons` (as `api`); `cli` never depends on it. Files keep their
`com.vitorpamplona.amethyst.commons.*` packages — the split is a module
boundary, not a package rename. The package taxonomy, the CLI-safe / UI
boundary, and a "where does my code go?" guide are documented in
**`commons/ARCHITECTURE.md`** (+ `commonsUI/ARCHITECTURE.md`) — read them
before adding a new package or dropping code into either module.
- `quic/` = Transport library (QUIC + HTTP/3 + WebTransport); reusable for any
KMP project that needs MoQ. Has no Android-framework dependencies.
- `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport,
@@ -88,7 +103,7 @@ amethyst/
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic
allowed). May also depend on `:geode` (for `amy serve`, which embeds the
standalone relay); never on `:amethyst` or `:desktopApp`.
standalone relay); never on `:commonsUI`, `:amethyst` or `:desktopApp`.
**Plans per module:** design docs for new subsystems live in the owning
module's `plans/YYYY-MM-DD-<slug>.md` (e.g. `cli/plans/`, `commons/plans/`).
@@ -180,16 +195,19 @@ etc. instead of re-implementing them.
**Share vs keep platform-native:**
- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and
`commons/commonMain/` (major UI components, **ViewModels** under
`viewmodels/`, icons). ViewModels are platform-agnostic state + logic
(StateFlow/SharedFlow), so they belong in `commons`.
- **Share** → `quartz/commonMain/` (business logic, data models, protocol),
`commons/commonMain/` (**ViewModels** under `viewmodels/`, state holders,
relay client, services — headless) and `commonsUI/commonMain/` (major UI
components, icons, theme). ViewModels are platform-agnostic state + logic
(StateFlow/SharedFlow), so they belong in `commons`; anything that imports
`androidx.compose.ui`/`foundation`/`material3`, Coil, or `Res` belongs in
`commonsUI`.
- **Keep native** → screen composables/scaffolding (Desktop `Window` vs Android
`Activity`), navigation (sidebar vs bottom nav), platform interactions
(gestures, keyboard shortcuts), system integrations (notifications, file
pickers).
When extracting a composable: move it to `commons/commonMain/` (see
When extracting a composable: move it to `commonsUI/commonMain/` (see
`/compose-expert`), add expect/actual for any platform behavior (see
`/kotlin-multiplatform`), then point both Android and Desktop at the shared
version. `quartz/` is protocol-only — no composables.
@@ -216,7 +234,7 @@ version. `quartz/` is protocol-only — no composables.
## Dependency Licensing
**MANDATORY whenever you introduce a new third-party dependency** — in *any*
module (`quartz`, `commons`, `amethyst`, `desktopApp`, `cli`, `quic`,
module (`quartz`, `commons`, `commonsUI`, `amethyst`, `desktopApp`, `cli`, `quic`,
`nestsClient`, …), whether you add it to `gradle/libs.versions.toml` or to a
module's `build.gradle.kts`: determine its license **before** wiring it in.
Amethyst ships under the **MIT** license, so a copyleft dependency linked into a
@@ -253,9 +271,9 @@ JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns.
## Icons
The Material Symbols font bundled at
`commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf`
`commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf`
is a **subset** that only contains the glyphs referenced from
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`.
`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`.
**MANDATORY:** Whenever you add a new icon — i.e. introduce a
`MaterialSymbol("\uXXXX")` codepoint that wasn't already referenced anywhere in
@@ -274,21 +292,21 @@ regenerating.
### Amethyst's own icons are also a font
The icons in `commons/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are
The icons in `commonsUI/.../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/`,
**MANDATORY:** whenever you add or change an icon under `commonsUI/.../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
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \
commonsUI/src/commonMain/composeResources/font/amethyst_icons.ttf \
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt
```
Both outputs must be committed together: codepoints are assigned in filename order,
+2 -2
View File
@@ -21,7 +21,7 @@ that has to catch it.
Repair with:
python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\
commons/src/commonMain/composeResources
commonsUI/src/commonMain/composeResources
`--no-unwrap-quotes` is mandatory on already-migrated files: escape conversion is
idempotent, quote-unwrapping is not, and a second unwrap strips the real display
@@ -77,7 +77,7 @@ def main() -> int:
print(
"\nRepair:\n"
" python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\\n"
" commons/src/commonMain/composeResources\n"
" commonsUI/src/commonMain/composeResources\n"
"(--no-unwrap-quotes is mandatory on already-migrated files.)",
file=out,
)
@@ -47,7 +47,7 @@ Walk the imports. The usual offenders:
| `android.util.Log` | Replace with `quartz` `PlatformLog` (already multiplatform). |
| `android.graphics.Bitmap` | Almost never needed by Amy. Keep in Android and split the function. |
| `android.net.Uri` | Replace with `kotlinx.io` path types or a plain `String`. |
| `androidx.compose.*` | Must stay out of `commons/commonMain` unless you're in a Compose-Multiplatform module. Amy doesn't depend on Compose. |
| `androidx.compose.*` | Compose UI (`ui`/`foundation`/`material3`), Coil and `Res` must stay out of `commons` entirely — they belong in `:commonsUI`, which Amy never depends on. Only the Compose *runtime* (`@Stable`, snapshot state) is allowed in `commons`. |
### Step 3 — Pick a migration strategy per dependency
@@ -66,7 +66,7 @@ Walk the imports. The usual offenders:
# Target location depends on what it is:
# - Protocol → quartz/src/commonMain/kotlin/…
# - Business logic → commons/src/commonMain/kotlin/…
# - UI → commons/src/commonMain/… (needs Compose Multiplatform)
# - UI → commonsUI/src/commonMain/… (needs Compose Multiplatform; never used by amy)
git mv amethyst/src/main/java/com/.../FollowListManager.kt \
commons/src/commonMain/kotlin/com/.../FollowListManager.kt
```
+7 -7
View File
@@ -24,7 +24,7 @@ Visual UI patterns for sharing composables across Android and Desktop.
## Philosophy: Share by Default
**Default to `commons/commonMain`** unless platform experts indicate otherwise.
**Default to `commonsUI/commonMain`** (shared composables live in `:commonsUI`, the Compose half of the shared layer; headless state/ViewModels stay in `:commons`) unless platform experts indicate otherwise.
### Always Share
@@ -416,7 +416,7 @@ fun DataScreen(uiState: UiState) {
}
```
**Components** (all in `commons/commonMain`):
**Components** (all in `commonsUI/commonMain`):
- `LoadingState` - Progress indicator + message
- `EmptyState` - Empty message + optional refresh button
- `ErrorState` - Error message + optional retry button
@@ -527,12 +527,12 @@ fun FeedList(items: List<Item>) {
| Task | Pattern | Location |
|------|---------|----------|
| Reusable UI | State hoisting | commons/commonMain |
| Reusable UI | State hoisting | commonsUI/commonMain |
| Simple state | remember { mutableStateOf() } | Composable scope |
| Derived state | derivedStateOf { } | remember block |
| Async → state | produceState { } | Composable function |
| Custom icons | roboBuilder + PathData | commons/icons |
| Loading/Error | LoadingState, ErrorState | commons/ui/components |
| Custom icons | roboBuilder + PathData | commonsUI/icons |
| Loading/Error | LoadingState, ErrorState | commonsUI/ui/components |
| Theme colors | MaterialTheme.colorScheme | Any @Composable |
| Navigation | Delegate to platform expert | amethyst/, desktopApp/ |
@@ -540,7 +540,7 @@ fun FeedList(items: List<Item>) {
### Creating a Shared Component
1. Start in `commons/src/commonMain/kotlin/.../ui/components/`
1. Start in `commonsUI/src/commonMain/kotlin/.../ui/components/`
2. Use Material3 primitives only
3. Hoist state (parameters for data, callbacks for events)
4. Add modifier parameter
@@ -551,7 +551,7 @@ fun FeedList(items: List<Item>) {
1. Read current implementation in `amethyst/` or `desktopApp/`
2. Identify pure visual logic (no platform APIs)
3. Create in `commons/commonMain` with hoisted state
3. Create in `commonsUI/commonMain` with hoisted state
4. Replace platform implementations with shared component
5. Keep platform-specific wrappers if needed
@@ -1,11 +1,11 @@
# Shared Composables Catalog
This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`.
This catalog documents shared UI components in `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/` (the Compose half of the shared layer; headless state stays in `commons`).
## Directory Structure
```
commons/src/commonMain/kotlin/.../commons/ui/
commonsUI/src/commonMain/kotlin/.../commons/ui/
├── components/ # Reusable UI components
├── screens/ # Screen-level composables
├── theme/ # Theming and styling
@@ -232,7 +232,7 @@ private val pathData1 = PathData {
```
**roboBuilder** - Custom ImageVector.Builder DSL
- Located in: `commons/robohash/`
- Located in: `commonsUI/.../commons/robohash/`
- Pattern: Builder-based, composable paths
- Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each)
- Colors: Dynamic (fgColor parameter) + Black constants
@@ -143,8 +143,11 @@ messages quoted below (they surface as the NIP-01 `OK false` reason).
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract:
exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked.
row in place and fails the unique index → rejected with `RejectionReason.SUPERSEDED`
(`duplicate: a newer version of this replaceable event is already stored`), which the relay
session answers with `OK true` exactly like an id duplicate (NIP-01 `duplicate:` prefix; same
reply nostr-rs-relay gives). Net contract: exactly one version stored; newest wins; ties broken
by lowest id; older re-inserts blocked but acknowledged as already covered.
**STORE-W02 — addressable supersession.** Same as W01 with unique index
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
+4 -4
View File
@@ -1,6 +1,6 @@
---
name: feed-patterns
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
---
# Feed Patterns
@@ -25,7 +25,7 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
│ ◄── MarmotGroupFeedViewModel │
│ │
│ │
│ commons/.../ui/feeds/ (shared, KMP) │
│ commons/.../feeds/ (shared, KMP) │
│ IFeedFilter / FeedFilter<T> (abstract base) │
│ IAdditiveFeedFilter / AdditiveFeedFilter<T> │
│ ChangesFlowFilter │
@@ -68,7 +68,7 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
### Shared filter bases (commons)
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`:
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/`:
- **`FeedFilter.kt`** — `abstract class FeedFilter<T> : IFeedFilter<T>`. Has `feed(): List<T>` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`.
- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter<T> : FeedFilter<T>(), IAdditiveFeedFilter<T>`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything.
@@ -100,7 +100,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
## Filter Sharing (Android vs Desktop)
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/`**shared**. ViewModels are in `commons/.../viewmodels/`**shared**.
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../feeds/`**shared**. ViewModels are in `commons/.../viewmodels/`**shared**.
- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases.
- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules.
@@ -7,7 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans
## Overview
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs the missing keys, then offers the two things that close them: **translate** the ones needing translation, and **copy the English value verbatim** for the ones a locale deliberately keeps in English (since 2026-09-12 that copy is what seeds Crowdin — see Background).
The repo now has **two independent Crowdin-managed resource trees** — you must scan **both** (see "Resource trees" below).
@@ -24,9 +24,9 @@ There are two separate `strings.xml` trees, each with its own default `values/`
| Tree | Default file | Per-locale file |
|------|--------------|-----------------|
| **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` |
| **commons** (KMP Compose resources, shared by Android + Desktop) | `commons/src/commonMain/composeResources/values/strings.xml` | `commons/src/commonMain/composeResources/values-<locale>/strings.xml` |
| **commonsUI** (KMP Compose resources, shared by Android + Desktop) | `commonsUI/src/commonMain/composeResources/values/strings.xml` | `commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml` |
The `commons` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
The `commonsUI` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` — now `commonsUI/` since the UI split (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
**Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match.
@@ -37,7 +37,7 @@ The `commons` tree appeared when shared event-renderer composables were extracte
Detect name-overlap **and flag value mismatches** in one pass:
```bash
cdef=commons/src/commonMain/composeResources/values/strings.xml
cdef=commonsUI/src/commonMain/composeResources/values/strings.xml
adef=amethyst/src/main/res/values/strings.xml
comm -12 \
<(grep '<string name=' "$cdef" | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
@@ -54,7 +54,7 @@ Only `SAFE-COPY` keys may be copied verbatim. For `VALUE-DIFFERS`, translate the
**Whitespace-quote convention differs between trees.** Android string resources use surrounding double-quotes to preserve leading/trailing whitespace (`"replying to "`). The **commons Compose-resources tree does NOT use this convention** — it authors trailing/leading spaces raw and unquoted (`replying to `). So when copying/translating a commons string with edge whitespace, **match the commons source: raw spaces, no wrapping quotes.** (Mistake we made: we copied amethyst's quoted `"replying to "` into commons, where the quotes would render literally.) A quick check for stray quote-wrapping you introduced:
```bash
grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values-*/strings.xml
grep -nE '<string name="[^"]*">"' commonsUI/src/commonMain/composeResources/values-*/strings.xml
# The commons English tree has zero quote-wrapped values — any hit in a locale file is almost certainly a bad copy from amethyst.
```
@@ -67,15 +67,23 @@ grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values
**Do not** treat the value-overlap as something to deduplicate during a translation pass. Migrating amethyst's own screens onto the shared `action_*` strings is a *separate, optional* refactor and a maintainer call — out of scope for this skill. Just translate each tree correctly and independently.
## Background: Crowdin strip-identical behavior
## Background: source-identical translations and the `import_eq_suggestions` flag
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin does not *store* a translation that exactly equals the source unless it is told to, so historically a key a translator deliberately kept as English (brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, version prefixes like `"v%1$s"`) never appeared in the locale's `strings.xml`, even though the Crowdin UI showed it as 100% translated.
**That changed on 2026-09-12.** `.github/workflows/crowdin.yml` now passes `import_eq_suggestions: true` to `crowdin/github-action`, so `upload_translations` no longer skips values equal to the source — whatever sits in the repo's locale files is seeded into Crowdin's database, identical values included. `auto_approve_imported` stays at its default `false`, so they arrive as **pending** translations for a translator to approve.
Confirmed end-to-end the same day: the first sync after the flag landed (workflow run `34706537802` → PR #4107) rewrote all five touched locale files in Crowdin's own key order with **zero net key changes** — 323 additions and 323 removals that pair up exactly. All 330 identical values pushed that morning came back down intact, unapproved included. Since Crowdin's download *replaces* file content with its export, a value it did not hold would have vanished; none did.
**Reading such a sync diff: compare key *sets* per file, never `-`/`+` lines separately.** A reorder looks identical to a mass strip under `grep '^-'`, and it will convince you the mechanism failed when nothing changed at all.
What this means for this skill:
1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is either genuinely untranslated *or* a source-identical entry Crowdin stripped. Both are reported; the human decides which to skip. The Crowdin web UI ("N untranslated") is the ground truth for what genuinely needs work.
2. **Source-identical entries are a small, recognizable minority.** Brand terms (`Nowhere X`), single-word loanwords (`Apps` / `Feed` / `Issues`), and bare version/format strings (`v%1$s`) are the usual cases. Skip these by inspection rather than translating them to something identical.
3. **Don't add source-identical fallbacks.** Android falls back to `values/strings.xml` at runtime, so a key intentionally kept as English already renders correctly, and Crowdin's next sync would strip a local duplicate anyway.
1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is genuinely untranslated, *or* a source-identical entry stripped before 2026-09-12 that no sync has re-seeded yet. Both are reported, and both are now actionable in the repo — translate the first, copy English into the second. The Crowdin web UI ("N untranslated") remains the ground truth for what needs human work.
2. **Source-identical entries are still recognizable, but no longer skipped.** Brand terms (`Nowhere X`), loanwords (`Apps` / `Feed` / `Issues`), symbol- or format-only values (`v%1$s`, `+%1$d`, `%1$d/%2$d`, `∞`, 👀) and example placeholders (`iPhone 13`, `https://example.com`) are the usual cases. Copy the English value into the locale file verbatim so the upload can seed it.
3. **DO add source-identical values — that is now the mechanism, not churn.** A key absent from a locale file is invisible to `upload_translations`; writing the English value in is what gets it into Crowdin, so a translator approves it once in bulk instead of typing it into the UI ~70 times per locale. (Runtime behaviour is unchanged either way: Android still falls back to `values/strings.xml`.) Two exclusions:
- **Never for `<plurals>`.** Copying English `one`/`other` into cs/pl trips `MissingQuantity`, which is a CI error (cs needs `one`/`few`/`many`/`other`). Plurals stay a Crowdin-UI job.
- **Not for words a locale would genuinely translate.** German `buzz_dm_workspace` ("Arbeitsbereich"), `workout` ("Training"), `relay_group_threads_title` ("Themen"), `calendar_rsvp_section` ("Zusagen") are *gaps*, not deliberate English keeps. Copying English there seeds a wrong pending suggestion — list those for the human to translate rather than approve.
4. **A repo-side edit to a translated value only sticks where Crowdin's database
doesn't contradict it.** Download replaces file content with Crowdin's current
@@ -96,6 +104,13 @@ What this means for this skill:
from `values/strings.xml` removes it project-wide, and attributes declared
there propagate into every export.
**This does not contradict item 3 — the two cases differ.** Seeding a key
Crowdin holds *nothing* for (the identical-value copy) sticks, because there is
no stored value to contradict it; that is exactly why the copy pass works.
*Overwriting* a value Crowdin already holds differently — including an empty
one — still loses on the next sync. Add missing entries in the repo; change
existing translations in the UI.
> **Historical note:** an earlier version of this skill tried to auto-filter the
> candidate list with a git "sync-timestamp" heuristic (skip any key added before
> the last `New Crowdin translations` commit). It was **dropped** because it
@@ -132,14 +147,14 @@ Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml
# commons tree
Default: commons/src/commonMain/composeResources/values/strings.xml
Target: commons/src/commonMain/composeResources/values-<locale>/strings.xml
Default: commonsUI/src/commonMain/composeResources/values/strings.xml
Target: commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml
```
A convenient way to run the whole technique twice is to loop over the two base dirs:
```bash
for base in amethyst/src/main/res commons/src/commonMain/composeResources; do
for base in amethyst/src/main/res commonsUI/src/commonMain/composeResources; do
echo "########## tree: $base ##########"
# ... run the diff/count/value-extraction commands with $base/values[...] ...
done
@@ -172,7 +187,7 @@ comm -23 \
This gives two lists of missing key names — keep them separate; `<plurals>` translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care").
Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
Locale files are asymmetric — legacy pre-2026-09-12 strips and uneven translator progress both leave different keys missing in different locales — so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
```bash
for locale in cs de-rDE sv-rSE pt-rBR; do
@@ -190,7 +205,7 @@ for locale in cs de-rDE sv-rSE pt-rBR; do
done
```
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set (minus any source-identical entries you skip by inspection — see Background).
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set: translate what needs translating, and copy the English value verbatim for the entries a locale keeps in English (see Background).
### 3. Get English values for missing keys
@@ -258,8 +273,8 @@ Flag and offer to fix:
# hardcode "1" (or other literal digits) instead of using a placeholder.
# Looks at default + all values-* locales, in BOTH resource trees.
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
commonsUI/src/commonMain/composeResources/values/strings.xml \
commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
in_plurals && /quantity="one"/ {
@@ -279,8 +294,8 @@ Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-
```bash
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
commonsUI/src/commonMain/composeResources/values/strings.xml \
commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
# Skip Arabic, Latvian and Welsh — they natively use the zero category.
# (Latvian's zero covers 0, 10, 11-19, 20, 30, … — stripping it breaks most counts.)
case "$f" in
@@ -313,7 +328,7 @@ itre = re.compile(r'<item quantity="([^"]+)"[^>]*>(.*?)</item>', re.S)
# (?<!\\) is REQUIRED: \%2$d is an escaped literal, not a placeholder.
phre = re.compile(r'(?<!\\)%(?:(\d+)\$)?([sdf])')
sig = lambda t: sorted(m.group(0) for m in phre.finditer(t))
for base in ['amethyst/src/main/res', 'commons/src/commonMain/composeResources']:
for base in ['amethyst/src/main/res', 'commonsUI/src/commonMain/composeResources']:
d = io.open(f'{base}/values/strings.xml', encoding='utf-8').read()
dstr = {m.group(1): sig(m.group(2)) for m in keyre.finditer(d)}
dpl = {}
@@ -339,7 +354,7 @@ PY
# Empty plural items render as nothing at runtime — always a bug.
grep -rn '<item quantity="[a-z]*"></item>' \
amethyst/src/main/res/values*/strings.xml \
commons/src/commonMain/composeResources/values*/strings.xml
commonsUI/src/commonMain/composeResources/values*/strings.xml
```
Three things this scan taught us, all of which it now encodes:
@@ -458,7 +473,7 @@ When adding translated strings to locale files:
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because locale files are asymmetric (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
```bash
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
@@ -528,15 +543,15 @@ When adding translated strings to locale files:
## Common Mistakes
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commons/src/commonMain/composeResources`). A key extracted into `commons/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commonsUI/src/commonMain/composeResources`). A key extracted into `commonsUI/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
- **Copying an overlapping `commons` translation by key name alone** — a shared key name does NOT mean shared English. `napplet_card_permissions` is "What it can access" in commons but "Permissions:" in amethyst; copying by name produced the wrong string. Diff the English *values* first; copy verbatim only when they're byte-identical, else translate fresh (see "Overlap" in Resource trees).
- **Applying amethyst's `"…"` whitespace-quote convention to a commons string** — the commons Compose-resources tree authors edge whitespace raw and unquoted; wrapping quotes copied from amethyst render literally there. Match the commons source format.
- **Trying to "dedupe" the amethyst↔commons value-overlap** — it's required architecture (commons can't depend on amethyst, so shared composables need their own `Res.string` catalog), not an error. Don't fold consolidation into a translation pass.
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Diffing only `<string name=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Skipping source-identical entries instead of copying them in** — correct before 2026-09-12, wrong now. With `import_eq_suggestions: true` the repo file is the *seed* for Crowdin's database, so a key you leave out stays untranslated in the UI forever and reappears in every future scan. Copy the English value verbatim, except for `<plurals>` (trips `MissingQuantity`) and words the locale would really translate. (Confirmed by PR #4107: 330 identical values survived the next sync with zero net changes.)
- **Skipping per-locale diffs when only diffing cs** — different keys are missing in different locales (legacy strips plus uneven translator progress), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
- **Declaring the pass done without running `:amethyst:lintPlayBenchmark`** — the duplicate-key + XML + `convertXmlValueResourcesForCommonMain` gate is necessary but nowhere near sufficient. `MissingQuantity` and `ImpliedQuantity` are errors, there is no lint baseline, and `abortOnError` is on, so a change that compiles and passes every check in Step 6's first half can still take CI red. Compiling is not evidence. (Happened 2026-08-13: 3 lint errors after a clean duplicate/XML gate and a green `compileFdroidDebugKotlin`.)
- **Converting a `<string>` to `<plurals>` with `other` only** — "Crowdin fills the rest" is false; `MissingQuantity` errors immediately and CI fails before any sync. Supply every category the locale uses at conversion time, and re-check the declension rather than reusing the old text for `one`.
+1 -1
View File
@@ -383,7 +383,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
| State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns |
| **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** |
| UI formatters (pure) | commons/commonMain | Reusable, no dependencies |
| UI components (simple) | commons/commonMain | Cards, buttons, dialogs |
| UI components (simple) | commonsUI/commonMain | Cards, buttons, dialogs (Compose UI never goes in `commons`) |
| **Screen layouts** | **Platform-specific** | **Window vs Activity, sidebar vs bottom nav** |
| Navigation | Platform-specific only | Activity vs Window too different |
| Permissions | Platform-specific only | APIs incompatible |
+3 -3
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.15.1` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.15.2` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.15.1"
quartz = "1.15.2"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.15.1")
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.15.1
com.vitorpamplona.quartz:quartz:1.15.2
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.15.1"
quartz = "1.15.2"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.15.1")
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.15.1")
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
+1 -1
View File
@@ -17,7 +17,7 @@ The layer between `LocalCache`/`Account` and the raw relay connection. Ensures c
## Layout
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/`:
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/` (the `@Composable` entry points — `observeUser*`, `*FilterAssemblerSubscription`, `KeyDataSourceSubscription` — sit in the same package but in `commonsUI/src/commonMain/…`, the Compose half of the shared layer):
```
relayClient/
+18 -3
View File
@@ -45,7 +45,16 @@ jobs:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Linter (gradle)
run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity
# The three metadata compiles resolve commonMain against only the
# deps every target shares, which is the Apple classpath — a
# dependency that reaches JVM transitively (okio via OkHttp) but is
# missing for iOS fails here, on Linux, instead of in test-quartz-ios.
run: |
./gradlew spotlessCheck \
:quartz:verifyKmpPurity :commons:verifyKmpPurity :commonsUI:verifyKmpPurity \
:quartz:compileCommonMainKotlinMetadata \
:commons:compileCommonMainKotlinMetadata \
:commonsUI:compileCommonMainKotlinMetadata
build-desktop:
needs: lint
@@ -93,7 +102,7 @@ jobs:
- name: Test + Build Desktop (gradle)
run: |
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :commonsUI:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
if [ "${{ runner.os }}" = "Linux" ]; then
xvfb-run --auto-servernum $CMD
else
@@ -129,6 +138,7 @@ jobs:
path: |
quartz/build/reports/tests
commons/build/reports/tests
commonsUI/build/reports/tests
nestsClient/build/reports/tests
cli/build/reports/tests
desktopApp/build/reports/tests
@@ -307,11 +317,15 @@ jobs:
# :commons:jvmTest stays green — this is the job that catches it.
# - compileTestKotlinIosArm64 catches device-only compile drift
# (iosArm64 = aarch64-apple-ios) without needing a physical device.
# :commonsUI (the Compose half split out of :commons) gets the same
# treatment so the shared composables keep compiling on Apple targets.
- name: Test Commons on iOS
run: |
./gradlew \
:commons:iosSimulatorArm64Test \
:commons:compileTestKotlinIosArm64
:commons:compileTestKotlinIosArm64 \
:commonsUI:iosSimulatorArm64Test \
:commonsUI:compileTestKotlinIosArm64
- name: Upload iOS Test Reports
uses: actions/upload-artifact@v7
@@ -363,6 +377,7 @@ jobs:
:amethyst:lintPlayBenchmark \
:quartz:jvmTest \
:commons:jvmTest \
:commonsUI:jvmTest \
:nestsClient:jvmTest \
:amethyst:testFdroidDebugUnitTest \
:amethyst:testPlayDebugUnitTest \
+9 -9
View File
@@ -568,22 +568,22 @@ jobs:
( cd "$SRC" && tar czf "$OLDPWD/dist/amy-${VER}-jvm.tar.gz" bin lib )
echo "Collected: dist/amy-${VER}-jvm.tar.gz"
- name: Enforce CLI size budget (200 MB per asset)
- name: Enforce CLI size budget (120 MB per asset)
run: |
set -euo pipefail
# The plan at cli/plans/2026-04-21-cli-distribution.md §size-budget
# targets < 80 MB, but :commons currently leaks Compose + Skiko as
# transitive deps (~40 MB of unused UI jars). Budget is set to
# 200 MB until commons is split into core + ui modules — track that
# as a follow-up. Until then, this gate just catches pathological
# regressions (e.g. accidental :amethyst dep pulling Android libs).
# Measured after the :commons / :commonsUI split (1.15.2, Linux x64):
# amy-*-jvm.tar.gz 55 MB, amy-*.tar.gz (jlink image) 80 MB, lib/ 60 MB
# on disk. The budget sits 50% above the largest asset so a Compose /
# Skiko / Android leak (+25-40 MB compressed) trips it, while the
# per-OS JRE variance of the jlink image does not. The "Assert no
# Compose UI" step above is the precise check; this is the coarse one.
fail=0
for f in dist/*; do
if [[ -f "$f" ]]; then
size=$(wc -c < "$f")
mb=$(( size / 1048576 ))
if (( size > 209715200 )); then
echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB amy budget"
if (( size > 125829120 )); then
echo "::error file=$f::asset is ${mb} MB — exceeds 120 MB amy budget"
fail=1
else
echo "OK: $f — ${mb} MB"
+12 -4
View File
@@ -31,6 +31,14 @@ jobs:
with:
upload_sources: true
upload_translations: true
# Upload translations that are identical to the English source (brand
# terms, loanwords like "Feed"/"Apps", bare formats like "v%1$s").
# Without this they are SKIPPED on upload, so a locale that deliberately
# keeps English never reaches Crowdin's DB and the key keeps coming back
# as untranslated. They arrive as normal UNAPPROVED translations --
# auto_approve_imported stays at its default false, so a translator still
# approves them in the Crowdin UI (bulk-select in the Editor).
import_eq_suggestions: true
download_translations: true
# Let the downloaded translations stay in the working tree; the single
# create-pull-request step below opens the combined PR.
@@ -53,7 +61,7 @@ jobs:
# Both files in crowdin.yml are declared `type: android`, so Crowdin's Android
# serializer escapes apostrophes on the way down: `l'URL` comes back as `l\'URL`.
# That is correct for amethyst/src/main/res/, which aapt un-escapes at build time,
# and WRONG for commons/.../composeResources/, where Compose resolves only \uXXXX,
# and WRONG for commonsUI/.../composeResources/, where Compose resolves only \uXXXX,
# \n and \t and leaves \' \" \? \@ alone -- so the backslash reaches the screen.
#
# Without this step every sync reopens the same regression and CI's
@@ -68,14 +76,14 @@ jobs:
- name: Convert Android escaping to Compose escaping in the shared catalog
run: |
python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \
commons/src/commonMain/composeResources
commonsUI/src/commonMain/composeResources
# Assert the conversion actually satisfied the check that guards main, so a case
# the converter cannot repair fails the sync loudly here instead of opening a red
# PR. Known gap if this ever trips: fix_escapes.py only rewrites text inside
# <string>/<item> elements, while the check scans the whole file -- an escape in an
# XML comment (comments do propagate into the locale files) has to be fixed at the
# source string in commons/.../composeResources/values/strings.xml by hand.
# source string in commonsUI/.../composeResources/values/strings.xml by hand.
- name: Verify the shared catalog is free of Android-only escaping
run: .claude/hooks/compose_escaping_check.py
@@ -99,7 +107,7 @@ jobs:
branch: l10n_crowdin_translations
add-paths: |
amethyst/src/main/res/**/strings.xml
commons/src/commonMain/composeResources/**/strings.xml
commonsUI/src/commonMain/composeResources/**/strings.xml
docs/changelog/translators.json
commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders'
title: 'New Crowdin Translations'
+11 -9
View File
@@ -96,7 +96,7 @@ and each has its own guide:
| Artifact | Committed at | Regenerate when | Guide |
|---|---|---|---|
| **Material Symbols subset font** | `commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` |
| **Material Symbols subset font** | `commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` |
| **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) |
> **Material Symbols is mandatory after icon changes.** The bundled font is a
@@ -466,8 +466,8 @@ Homebrew removes the quarantine attribute on its own downloads.
> with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and
> now prints the per-file notary log on a non-`Accepted` verdict. If it comes
> back `Invalid`, the fix is to codesign the dylibs *inside* those jars before
> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image the
> `:commons` core/ui split the size budget already flags). The **desktop** app
> zipping (the unused `skiko`/Compose jars left the CLI image with the
> `:commons` / `:commonsUI` split). The **desktop** app
> bundles the same jars through Compose/jpackage notarization, so run a desktop
> dry-run too; its in-jar handling differs and is likewise unverified.
@@ -533,7 +533,7 @@ reads an optional per-release changelog from
## Bootstrap runbook (one-time)
> **Status as of v1.15.1:** both Homebrew packages are now live upstream — the
> **Status as of v1.15.2:** both Homebrew packages are now live upstream — the
> `amethyst-nostr` cask (`Homebrew/homebrew-cask`, at 1.14.0) and the `amy`
> formula (`Homebrew/homebrew-core`) both answer 200 on `formulae.brew.sh`, so
> `bump-homebrew.yml` finally has something to bump. **Winget is still not
@@ -587,7 +587,7 @@ The token then lives only in that maintainer's shell:
```bash
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.15.1
scripts/bump-homebrew-cask.sh v1.15.2
```
Create one at
@@ -603,7 +603,7 @@ Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives
runs fine from macOS or Linux:
```bash
scripts/bump-winget.sh v1.15.1
scripts/bump-winget.sh v1.15.2
```
CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the
@@ -686,9 +686,11 @@ Caveats that the maintainer must weigh before submitting:
- **Pre-built-jar scrutiny.** homebrew-core prefers source builds; downloading
a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to
justify it (sandboxed Gradle can't fetch Maven deps).
- **Bundle size.** The bundle is ~70 MB today because `:commons` leaks
Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons`
core/ui split) would shrink it and smooth review — tracked as a follow-up.
- **Bundle size.** The bundle used to be ~70 MB because `:commons` leaked
Compose/Skiko jars onto the CLI classpath. Compose UI now lives in
`:commonsUI`, which `:cli` does not depend on: the JVM tarball is ~55 MB
and the jlink image tarball ~80 MB (1.15.2, Linux x64). The release
workflow caps every amy asset at 120 MB.
After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot
auto-open version-bump PRs on each stable release — no token or workflow on our
+7 -3
View File
@@ -175,9 +175,13 @@ device. PRs that introduce any of them will be sent back.
### KMP source-set discipline
- **Android-only imports don't belong in `commons/commonMain` or
`quartz/commonMain`.** Use `expect`/`actual` for platform-specific
bits, or move the Android-specific code to `androidMain`.
- **Android-only imports don't belong in `commons/commonMain`,
`commonsUI/commonMain` or `quartz/commonMain`.** Use `expect`/`actual`
for platform-specific bits, or move the Android-specific code to
`androidMain`.
- **Compose UI (`ui`/`foundation`/`material3`), Coil and `Res` don't belong
in `commons` at all** — that module is on the CLI classpath. Put the file
in `commonsUI` (same package) instead.
### Logging
+13 -8
View File
@@ -3,7 +3,7 @@
Thanks for your interest in improving Amethyst. This document captures the
expectations, conventions, and review rules for code, documentation, and
translation contributions across all modules in this repository (`amethyst/`,
`desktopApp/`, `quartz/`, `commons/`, `cli/`, `quic/`, `nestsClient/`).
`desktopApp/`, `quartz/`, `commons/`, `commonsUI/`, `cli/`, `quic/`, `nestsClient/`).
By contributing, you agree to license your work under the MIT license. Any
work contributed where you are not the original author must contain its
@@ -157,7 +157,8 @@ Common Gradle entry points:
Modules:
- `quartz/` — Nostr KMP library (protocol, crypto, models). **No UI.**
- `commons/` — Shared Compose Multiplatform UI, icons, ViewModels, flows.
- `commons/` — Shared headless layer: models, ViewModels, flows, relay client. **No Compose UI** (the CLI depends on it).
- `commonsUI/` — Shared Compose Multiplatform UI, icons, theme, Compose resources, on top of `commons`.
- `quic/` — Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport.
- `nestsClient/` — Audio-rooms client (NIP-53) built on `:quic` and
`:quartz`.
@@ -175,7 +176,8 @@ of PR churn. Place new code by purpose:
| What you're adding | Goes in |
|---|---|
| Nostr event types, NIPs, tags, signing, crypto, Bech32 | `quartz/commonMain/` |
| Shared Composables, icons, ViewModels, StateFlows | `commons/commonMain/viewmodels/` or `commons/commonMain/` |
| Shared ViewModels, StateFlows, relay subscriptions | `commons/commonMain/viewmodels/` or `commons/commonMain/` |
| Shared Composables, icons, theme | `commonsUI/commonMain/` (same packages as `commons`) |
| Android-only screen, navigation, system integration | `amethyst/` |
| Desktop-only window, sidebar, menu bar, shortcut | `desktopApp/` |
| `amy <verb>` subcommand (thin assembly only) | `cli/src/main/kotlin/.../cli/` |
@@ -188,8 +190,9 @@ Hard rules:
- `cli/` has **no Nostr protocol or business logic** — it's a thin assembly
layer over `quartz` + `commons`. If your CLI command needs new behavior,
extract it into `commons/` first.
- ViewModels belong in `commons/commonMain/`. Only screens (the Composable
that wires layout + navigation) stay in the platform module.
- ViewModels belong in `commons/commonMain/`; shared composables in
`commonsUI/commonMain/`. Only screens (the Composable that wires layout +
navigation) stay in the platform module.
- For platform-specific behavior in a shared file, use `expect`/`actual`.
## Workflow
@@ -265,9 +268,11 @@ front:
sequentially: `for peer in aioquic picoquic quic-go quinn; do
quic/interop/run-matrix.sh -s $peer; done`. Plan at
`quic/interop/plans/2026-05-06-interop-runner.md`.
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): headless
variants need only `cargo` + a loopback `nostr-rs-relay`; the interactive
Marmot variant prompts a human to drive the Android UI.
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): every
relay-backed suite boots the embedded `amy serve` relay (geode) — no
external relay binary; only the Marmot suites additionally need `cargo`
for MDK's `wn`/`wnd`. The interactive Marmot variant prompts a human to
drive the Android UI.
If a change is documentation-only, UI-only, build-script-only, or otherwise
cannot affect wire bytes / decoded audio / MLS state / DM envelopes, skip
+32 -1
View File
@@ -3,7 +3,7 @@
**App:** Amethyst (Android Nostr client)<br>
**Publisher:** Vitor Pamplona<br>
**Contact:** amethyst@vitorpamplona.com<br>
**Last updated:** 2026-05-24
**Last updated:** 2026-09-12
Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device.
@@ -20,6 +20,7 @@ Using the app causes the following data to leave your phone:
- **Nostr events** you publish, sent to the relays you have configured.
- **Subscriptions** (filters describing what you want to read), sent to those relays.
- **Media uploads** (images, audio, video), sent to the media server you select.
- **Workout summaries**, when you choose to publish one — see [Health and fitness data](#health-and-fitness-data-health-connect) below.
- *(Google Play build, push notifications enabled)* a per-device push token, your public key, and a preferred relay, registered with Google Firebase Cloud Messaging so a notification proxy can wake the app.
- *(F-Droid build, push notifications enabled)* a per-device token registered with whichever UnifiedPush distributor you install (e.g. ntfy).
@@ -29,6 +30,36 @@ The developer does not run any server that aggregates or stores this data.
Configuration, cached events, keys, drafts, and other operational data live in the app's local storage. Other apps cannot read it on a standard, non-rooted Android device. You can wipe it by clearing the app's storage or uninstalling.
### Health and fitness data (Health Connect)
Amethyst's **Workouts** section lets you publish a summary of a finished workout to the Nostr relays you choose (a NIP-101e kind 1301 event), so the people who follow you can see it. To save you typing the numbers in by hand, Amethyst can read the workout your watch or fitness app already saved to **Android Health Connect** and pre-fill the post.
The feature is optional and off until you grant the permissions. Amethyst asks for them only when you open the New Workout composer — never on first launch.
**What Amethyst reads, and what each type is for:**
| Health Connect data type | Permission | What it is used for |
| --- | --- | --- |
| ExerciseSession | `READ_EXERCISE` | The workout itself: activity type, start time and duration — the title, date and duration of the post. |
| Distance | `READ_DISTANCE` | The distance of the run, ride, walk or swim. |
| ActiveCaloriesBurned | `READ_ACTIVE_CALORIES_BURNED` | The energy the workout burned. |
| TotalCaloriesBurned | `READ_TOTAL_CALORIES_BURNED` | Fallback energy figure for sources that only record total energy. |
| HeartRate | `READ_HEART_RATE` | Average and maximum heart rate over the workout — how hard the effort was. |
| Steps | `READ_STEPS` | The step count of a run, walk or hike. |
| ElevationGained | `READ_ELEVATION_GAINED` | How much you climbed. |
Health Connect groups a few data types under one permission: `READ_EXERCISE` also covers CyclingPedalingCadence and `READ_STEPS` also covers StepsCadence. Amethyst does not read, store, or publish cadence — those types come attached to the permissions above and are never requested separately.
**Limits on this access:**
- **Read-only.** Amethyst never writes to Health Connect.
- **Foreground only.** Reads happen only while the New Workout composer is on screen. Amethyst does not request `READ_HEALTH_DATA_IN_BACKGROUND` and has no background health worker.
- **Last 7 days only.** Only sessions that finished in the previous 7 days are offered. Amethyst does not request `READ_HEALTH_DATA_HISTORY`.
- **No location.** Amethyst does not request `READ_EXERCISE_ROUTE`, so it never receives the GPS track of a workout.
- **Nothing is uploaded automatically.** Health data stays on your device until you pick a suggestion, review the pre-filled post, and publish it yourself. The developer runs no server; a published post goes to the Nostr relays you configured, and those numbers then become public like any other post you make.
- **No other use.** Health data is never used for advertising, analytics, profiling, or sale, and is never shared with third parties. It is not used to determine your eligibility for insurance, credit, or employment, and is not transferred to any such party.
- **Revocable.** Turn the feature off under Settings → Compose → "Suggest workouts to share", or revoke the permissions in Health Connect at any time. Amethyst keeps the workout suggestions it has already shown only in memory; revoking access stops all reads immediately.
### What relays can see
A relay you connect to sees:
+5 -5
View File
@@ -328,16 +328,16 @@ repositories {
Add the following line to your `commonMain` dependencies:
```gradle
implementation('com.vitorpamplona.quartz:quartz:1.15.1')
implementation('com.vitorpamplona.quartz:quartz:1.15.2')
```
Variations to each platform are also available:
```gradle
implementation('com.vitorpamplona.quartz:quartz-android:1.15.1')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.15.1')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.15.1')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.15.1')
implementation('com.vitorpamplona.quartz:quartz-android:1.15.2')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.15.2')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.15.2')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.15.2')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
+2 -2
View File
@@ -240,9 +240,9 @@ readable by anyone with push access here), so a maintainer runs the last step:
```bash
# after merging the sync PRs
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.15.1
scripts/bump-homebrew-cask.sh v1.15.2
scripts/bump-winget.sh v1.15.1 # no token — uses your `gh` auth
scripts/bump-winget.sh v1.15.2 # no token — uses your `gh` auth
```
Both scripts re-verify the published artifact's sha256 before submitting, and
+13 -3
View File
@@ -399,12 +399,13 @@ dependencies {
// Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source:
// adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \
// -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver
debugImplementation("androidx.compose.runtime:runtime-tracing")
debugImplementation("androidx.tracing:tracing-perfetto:1.0.1")
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.1")
debugImplementation(libs.androidx.compose.runtime.tracing)
debugImplementation(libs.androidx.tracing.perfetto)
debugImplementation(libs.androidx.tracing.perfetto.binary)
implementation(project(":quartz"))
implementation(project(":commons"))
implementation(project(":commonsUI"))
implementation(project(":nestsClient"))
// Agent text stream previews: the raw-QUIC binding plus the QUIC
// stack under it (for the certificate validator it requires).
@@ -598,6 +599,15 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
// In-process Nostr relay (geode) so unit tests that drive a real
// NostrClient talk to an embedded relay instead of a public one. Same
// wiring quartz uses for its jvmAndroidTest source set: the engine, its
// testFixtures (RelayClientTest base, preload/publish helpers) and the
// JVM SQLite driver the in-memory EventStore needs on a host JVM.
testImplementation(project(":geode"))
testImplementation(testFixtures(project(":geode")))
testImplementation(libs.androidx.sqlite.bundled.jvm)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit.ktx)
@@ -31,7 +31,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
@@ -62,7 +62,8 @@ import org.junit.runner.RunWith
*/
@RunWith(AndroidJUnit4::class)
class PlaybackErrorOverlayFitTest {
@get:Rule val rule = createComposeRule()
@get:Rule
val rule = createComposeRule()
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
@@ -27,7 +27,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.playback.composable.audioSquare
@@ -50,7 +50,8 @@ import org.junit.runner.RunWith
*/
@RunWith(AndroidJUnit4::class)
class AudioPlayerBoxOverflowTest {
@get:Rule val rule = createComposeRule()
@get:Rule
val rule = createComposeRule()
private class Bounds {
var top = 0f
@@ -31,7 +31,7 @@ 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.compose.ui.test.junit4.v2.createComposeRule
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsAnimationCompat
@@ -70,7 +70,8 @@ import org.junit.Test
* fallback would silently start reading a dead value too.
*/
class ComposeImeInsetWedgeTest {
@get:Rule val rule = createComposeRule()
@get:Rule
val rule = createComposeRule()
private val keyboardHeight = 957
@@ -27,7 +27,7 @@ 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.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
@@ -1,112 +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.ui.screen.loggedIn.relays.eventsync
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.defaults.Constants
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
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EventSyncTest {
companion object {
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
@Test
fun testSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = {
listOf(Constants.mom, Constants.nos)
},
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
NostrClient(socketBuilder, appScope)
},
scope = appScope,
)
sync.runSync()
}
@Test
fun testFiatjafSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = { listOf(fiatjaf) },
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
val newClient = NostrClient(socketBuilder, appScope)
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
val signer = NostrSignerInternal(KeyPair())
// Authenticates with relays.
val auth =
RelayAuthenticator(
newClient,
appScope,
signWithAllLoggedInUsers = { _, authTemplate, _ ->
listOf(signer.sign(authTemplate))
},
)
newClient
},
scope = appScope,
)
sync.runSync()
}
}
+19 -11
View File
@@ -188,11 +188,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Health Connect privacy-policy rationale (required by Google when reading health data) -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -446,15 +441,28 @@
</intent-filter>
</activity-alias>
<!-- Health Connect privacy-policy rationale on Android 14+. Without this activity-alias
the permission request fails silently (no dialog appears). The system launches it,
guarded by START_VIEW_PERMISSION_USAGE, to show our privacy policy; it routes into
MainActivity. Android 13 and lower use the ACTION_SHOW_PERMISSIONS_RATIONALE
intent-filter declared on MainActivity above. -->
<!-- Health Connect permissions rationale. Both entry points land on the same screen,
which explains what Amethyst reads from Health Connect and why; it needs no account,
so it works even when launched cold from Health Connect itself.
Android 13 and lower open it with ACTION_SHOW_PERMISSIONS_RATIONALE; without a
target for it the permission request fails silently (no dialog appears). -->
<activity
android:name=".ui.screen.loggedIn.workouts.health.HealthConnectRationaleActivity"
android:exported="true"
android:label="@string/health_connect_rationale_activity_label"
android:excludeFromRecents="true"
android:theme="@style/Theme.Amethyst">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<!-- Android 14+ route to the same rationale screen. The system launches it, guarded by
START_VIEW_PERMISSION_USAGE, from the Health Connect data-management screens. -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".ui.MainActivity"
android:targetActivity=".ui.screen.loggedIn.workouts.health.HealthConnectRationaleActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
@@ -184,6 +184,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
@@ -647,6 +648,41 @@ class AppModules(
onionCache = onionLocationCache,
)
// Drops pooled connections once per real Tor route change. When the user switches
// Tor on, the direct clients' idle sockets to real hosts would otherwise sit in the
// pool for its 5-minute keepalive after the user has asked for everything to go
// through Tor. No request could use them either way -- OkHttp keys the pool by
// `Address`, which includes the proxy, so a connection on a dead route is already
// unreachable -- which is why this is hygiene and not correctness, and why it is
// fine for it to be a little late.
//
// Every source here is a plain StateFlow, so subscribing costs nothing. Deliberately
// NOT torManager.activePortOrNull: that chains to TorManager.status, whose upstream
// is WhileSubscribed and calls service.start() when collected, so a process-lifetime
// subscription there would hold Arti's control flow open forever -- the same hazard
// the battery ledger above documents and sidesteps the same way.
//
// Also deliberately not the per-feature Tor switches (imagesViaTor, videosViaTor, ...):
// those change which of the two existing clients a request picks, not the route either
// one uses, so no pooled connection goes stale.
init {
applicationIOScope.launch {
combine(
torPrefs.torType,
torPrefs.externalSocksPort,
torService.status.map { it.socksPort },
) { torType, externalPort, artiPort -> Triple(torType, externalPort, artiPort) }
.distinctUntilChanged()
// Only later moves count; the route in force at process construction is the
// status quo, and nothing is pooled yet to evict.
.drop(1)
.collect {
okHttpClients.factory.evictPooledConnections()
okHttpClientForRelays.factory.evictPooledConnections()
}
}
}
// Connects the INostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder(
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlin.time.DurationUnit
@@ -92,6 +93,16 @@ fun collectMemorySnapshot(context: Context): MemorySnapshot {
private const val STATE_DUMP_TAG = "STATE DUMP"
fun debugState(context: Context) {
// Everything below is logged at DEBUG, and every argument is built eagerly (the
// eager Log.d overload, not the lambda one). Gate on the level that would drop
// those lines, because the arguments are the expensive part: nine materialising
// LargeCache.filter scans over notes/addressables/users/channels, plus three
// passes calling Event.countMemory() — which walks every tag of every cached
// event. MainActivity.onPause() calls this unconditionally, so without the gate
// a release build (minLevel WARN) did all of that on every backgrounding and
// threw the result away. Benchmark builds sit at INFO and paid it too.
if (Log.minLevel > LogLevel.DEBUG) return
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024)
@@ -37,7 +37,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
@@ -162,6 +165,16 @@ class AccountZapActions(
?.supportsMethod(NwcMethod.PAY) == true
}
/**
* True when this account can settle a BOLT12 zap at all: an NWC wallet is
* configured and the default one advertises `pay`. The sender-side half of the
* BOLT12 route; the recipient-side half is a published kind:10058 offer.
*/
fun canZapViaBolt12(): Boolean =
account.settings.nwcWallets.value
.isNotEmpty() &&
defaultWalletSupportsBolt12Pay()
/**
* Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet.
*
@@ -173,6 +186,14 @@ class AccountZapActions(
* still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for
* a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no
* external-wallet or LNURL fallback because only NWC returns the proof.
*
* Outcomes are split by what they say about the money:
* - [onNotPaid]: the wallet answered with an error. The wallet does the offer
* invoice exchange itself, so a stale or dead offer lands here too. Whether a
* retry is safe depends on the code `PAYMENT_FAILED` may be a timeout with the
* HTLC still in flight see `Bolt12LightningFallback`.
* - [onError]: paid but no valid receipt, or nothing conclusive. Never retry.
* - [onTimeout]: the wallet never answered. Unknown state never retry.
*/
suspend fun sendBolt12Zap(
zappedEvent: Event?,
@@ -183,15 +204,21 @@ class AccountZapActions(
zapType: LnZapEvent.ZapType,
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
onError: (Int, String?) -> Unit,
// (code, detail) — the wallet refused or failed the payment; no funds moved.
onNotPaid: suspend (NwcErrorCode?, String?) -> Unit,
onTimeout: () -> Unit,
onProcessed: () -> Unit,
) {
// NONZAP means "pay, but publish no receipt" — settle the offer without binding
// a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP.
if (zapType == LnZapEvent.ZapType.NONZAP) {
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats), onTimeout) { response ->
account.scope.launch {
if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage())
onProcessed()
try {
if (response is IErrorResponseLike) onNotPaid(response.nwcErrorCode(), response.errorMessage())
} finally {
onProcessed()
}
}
}
return
@@ -211,7 +238,7 @@ class AccountZapActions(
val payerNote = Bolt12ZapBuilder.payerNote(intent)
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response ->
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote), onTimeout) { response ->
account.scope.launch {
// try/finally so a failure while assembling/publishing the receipt (e.g. a
// remote signer error) still steps progress and surfaces an error, instead
@@ -234,7 +261,7 @@ class AccountZapActions(
}
}
is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage())
is IErrorResponseLike -> onNotPaid(response.nwcErrorCode(), response.errorMessage())
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
}
@@ -374,3 +401,11 @@ class AccountZapActions(
return this
}
}
/** The NIP-47 error code on a failed reply, whichever error shape the wallet used. */
private fun Response.nwcErrorCode(): NwcErrorCode? =
when (this) {
is NwcErrorResponse -> error?.code
is PayInvoiceErrorResponse -> error?.code
else -> null
}
@@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
@Stable
data class VanishEventItem(
@@ -124,7 +125,10 @@ class VanishRequestsState(
}
)
}
} catch (_: Exception) {
} catch (e: Exception) {
// A cancelled check has no result. Reporting ERROR would show the relay as
// having answered badly when it was never asked.
if (e is CancellationException) throw e
item.complianceResults.update {
it + (relay to ComplianceStatus.ERROR)
}
@@ -166,7 +166,8 @@ class NamecoinSharedPreferences(
} else {
emptyList()
}
} catch (_: Exception) {
} catch (e: Exception) {
if (e is CancellationException) throw e
emptyList()
}
@@ -46,6 +46,12 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
* through that rail matching the existing best-effort behaviour of the
* actual send paths (Lightning skips pubkeys with no `lnAddress`; on-chain
* separately warns about lnAddress-only splits that can't be paid on-chain).
*
* [hasLightning] is the whole Lightning rail, not just BOLT11: a recipient with
* no `lnAddress` but a published kind:10058 BOLT12 offer counts when our own
* NWC wallet can pay offers, because the zap send path routes them over BOLT12
* (see `ZapPaymentHandler`). The chip stays one bolt either way which flavour
* gets used is decided at send time, not in the picker.
*/
@Immutable
data class RailCapability(
@@ -143,6 +149,12 @@ object RailCapabilityResolver {
baseNote: Note,
cashuState: CashuWalletState,
payToEnabled: Boolean = false,
/**
* Whether our default NWC wallet can pay BOLT12 offers
* (`AccountZapActions.canZapViaBolt12`). When true, a recipient's published
* offer makes them payable on the Lightning rail even without an lnAddress.
*/
bolt12Payable: Boolean = false,
): RailCapability {
val author = baseNote.author?.pubkeyHex
val splits = baseNote.event?.zapSplitSetup().orEmpty()
@@ -170,7 +182,9 @@ object RailCapabilityResolver {
val hasLightning =
lnAddressOnlySplits.isNotEmpty() ||
pubKeyRecipients.any { pk ->
LocalCache.getUserIfExists(pk)?.lnAddress() != null
val user = LocalCache.getUserIfExists(pk)
user?.lnAddress() != null ||
(bolt12Payable && user?.bolt12Offers()?.isNotEmpty() == true)
}
// On-chain pays the pubkey directly; an event with only lnAddress
@@ -60,6 +60,7 @@ import java.net.URLDecoder
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.util.concurrent.TimeUnit
import kotlin.coroutines.cancellation.CancellationException
/**
* Fetches a resource URL on an applet's behalf the applet has no direct network
@@ -162,7 +163,10 @@ class NappletResourceFetcher(
return failure(ERROR_BLOCKED, e.message)
} catch (_: InterruptedIOException) {
return failure(ERROR_TIMEOUT)
} catch (_: Exception) {
} catch (e: Exception) {
// Cancellation is not an upstream failure — do not report it to the
// napplet as one, and do not keep the request alive past it.
if (e is CancellationException) throw e
return failure(ERROR_NETWORK)
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
/**
* Decides whether a BOLT12 zap the wallet refused should be re-sent as a BOLT11 zap.
*
* Only ever consulted for a NIP-47 *error* reply. An allowlist, because not every
* error means no money moved: NIP-47 defines `PAYMENT_FAILED` as "may be due to a
* timeout, exhausting all routes, insufficient capacity or similar", and a wallet
* that gave up on a payment whose HTLC is still in flight can see it settle later.
* Retrying on that, or on the catch-all `INTERNAL` / `OTHER` / no-code replies,
* could pay the recipient twice. Only refusals the wallet raises *before* it
* attempts a payment qualify the offer could not be resolved or has expired, the
* request was rejected as malformed, or our wallet does not handle `lno` at all.
* Those are the "recipient's configuration is stale" cases the fallback exists for.
* Refusals about our own wallet (balance, quota, permissions) are out too: BOLT11
* through the same wallet would fail identically and only add a second error.
*/
object Bolt12LightningFallback {
/** Refusals raised before any payment attempt, about the offer or the instruction. */
private val offerSideCodes =
setOf(
NwcErrorCode.EXPIRED,
NwcErrorCode.NOT_FOUND,
NwcErrorCode.BAD_REQUEST,
NwcErrorCode.NOT_IMPLEMENTED,
NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION,
NwcErrorCode.UNSUPPORTED_NETWORK,
)
/** True when a refusal with [code] (null when the wallet sent none) should be retried over BOLT11. */
fun shouldRetry(code: NwcErrorCode?): Boolean = code in offerSideCodes
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -43,6 +44,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -84,11 +86,17 @@ class ZapPaymentHandler(
val user: User? = null,
)
/** A recipient routed over BOLT12 (NIP-B1): they publish a kind:10058 [offer] and we hold an NWC wallet. */
/**
* A recipient routed over BOLT12 (NIP-B1): they publish a kind:10058 [offer] and we
* hold an NWC wallet. [lnAddress] is their BOLT11 route, kept so a refused offer can
* fall back to a regular zap (see [payViaBolt12]); null when they publish none.
*/
data class Bolt12Recipient(
val user: User,
val offer: String,
val weight: Double = 1.0,
val lnAddress: String? = null,
val relay: NormalizedRelayUrl? = null,
)
suspend fun zap(
@@ -167,16 +175,13 @@ class ZapPaymentHandler(
// BOLT12 when our default NWC wallet advertises the nwc#2 `pay` method (needed for
// the payer proof). Otherwise — no wallet, or a wallet without `pay` — the recipient
// stays on lightning, so an unsupported wallet degrades gracefully instead of erroring.
val canBolt12 =
account.settings.nwcWallets.value
.isNotEmpty() &&
account.zaps.defaultWalletSupportsBolt12Pay()
val canBolt12 = account.zaps.canZapViaBolt12()
val bolt12Recipients =
unverifiedZapsToSend.mapNotNull {
val user = it.user
if (canBolt12 && it.bolt12Offer != null && user != null) {
Bolt12Recipient(user, it.bolt12Offer, it.weight)
Bolt12Recipient(user, it.bolt12Offer, it.weight, it.lnAddress, it.relay)
} else {
null
}
@@ -233,48 +238,20 @@ class ZapPaymentHandler(
// --- Lightning lane -----------------------------------------------------------
if (zapsToSend.isNotEmpty()) {
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats, totalWeight)
if (splitZapRequests.isNotEmpty()) {
onProgress(0.05f)
val payables =
assembleAllInvoices(
requests = splitZapRequests,
totalAmountMilliSats = amountMilliSats,
message = message,
okHttpClient = okHttpClient,
onError = onError,
onProgress = { onProgress(it * 0.7f + 0.05f) },
context = context,
totalWeight = totalWeight,
)
if (payables.isNotEmpty()) {
onProgress(0.75f)
// Route through the user's selected default payment source. A CLINK debit takes
// precedence over NWC when it is the chosen default; NWC-only users are unaffected
// (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app.
when (val source = account.settings.defaultPaymentSource()) {
is PaymentSource.ClinkDebit -> {
payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f)
}, context)
}
is PaymentSource.Nwc -> {
payViaNWC(payables, note, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context)
}
null -> {
onPayViaIntent(payables.toImmutableList())
}
}
}
}
zapOverLightning(
zapsToSend = zapsToSend,
note = note,
pollOption = pollOption,
message = message,
zapType = zapType,
totalAmountMilliSats = amountMilliSats,
totalWeight = totalWeight,
okHttpClient = okHttpClient,
onError = onError,
onProgress = onProgress,
onPayViaIntent = onPayViaIntent,
context = context,
)
}
// --- BOLT12 lane --------------------------------------------------------------
@@ -282,12 +259,15 @@ class ZapPaymentHandler(
payViaBolt12(
recipients = bolt12Recipients,
note = note,
pollOption = pollOption,
totalAmountMilliSats = amountMilliSats,
totalWeight = totalWeight,
message = message,
zapType = zapType,
okHttpClient = okHttpClient,
onError = onError,
onProgress = { onProgress(it * 0.25f + 0.75f) },
onPayViaIntent = onPayViaIntent,
context = context,
)
}
@@ -295,6 +275,68 @@ class ZapPaymentHandler(
onProgress(1f)
}
/**
* The BOLT11 lane: signs one kind 9734 per recipient, fetches each invoice from
* the recipient's LNURL, then settles through the default payment source. Used
* for every lnAddress recipient of a zap, and again by [payViaBolt12] for a
* recipient whose offer the wallet refused. [onProgress] spans 0.05..1.0.
*/
private suspend fun zapOverLightning(
zapsToSend: List<MyZapSplitSetup>,
note: Note,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
totalAmountMilliSats: Long,
totalWeight: Double,
okHttpClient: (String) -> OkHttpClient,
onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
context: Context,
) {
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, totalAmountMilliSats, totalWeight)
if (splitZapRequests.isEmpty()) return
onProgress(0.05f)
val payables =
assembleAllInvoices(
requests = splitZapRequests,
totalAmountMilliSats = totalAmountMilliSats,
message = message,
okHttpClient = okHttpClient,
onError = onError,
onProgress = { onProgress(it * 0.7f + 0.05f) },
context = context,
totalWeight = totalWeight,
)
if (payables.isEmpty()) return
onProgress(0.75f)
// Route through the user's selected default payment source. A CLINK debit takes
// precedence over NWC when it is the chosen default; NWC-only users are unaffected
// (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app.
when (val source = account.settings.defaultPaymentSource()) {
is PaymentSource.ClinkDebit -> {
payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f)
}, context)
}
is PaymentSource.Nwc -> {
payViaNWC(payables, note, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context)
}
null -> {
onPayViaIntent(payables.toImmutableList())
}
}
}
private fun calculateZapValue(
amountMilliSats: Long,
weight: Double,
@@ -463,21 +505,41 @@ class ZapPaymentHandler(
* and (if the returned proof validates) publishes a 9736 zap see
* [Account.sendBolt12Zap]. Fire-and-forget like [payViaNWC]: dispatch is optimistic
* and settlement/errors surface later through the async NWC response.
*
* When the wallet refuses the offer before attempting a payment it resolves the
* offer itself, so a stale, expired or unsupported offer fails there and the
* recipient also publishes a lightning address, the same share is re-sent as a
* regular BOLT11 zap through [zapOverLightning], silently. The BOLT12 error is
* shown when there is no BOLT11 route or when the refusal does not qualify
* ([Bolt12LightningFallback]: a failed payment attempt may still settle, and a
* refusal about our own wallet would repeat on BOLT11). A paid-but-no-receipt
* outcome and a wallet that never answers are never retried either.
*/
suspend fun payViaBolt12(
recipients: List<Bolt12Recipient>,
note: Note,
pollOption: Int?,
totalAmountMilliSats: Long,
totalWeight: Double,
message: String,
zapType: LnZapEvent.ZapType,
okHttpClient: (String) -> OkHttpClient,
onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
context: Context,
) {
val progress = PaymentProgress(recipients.size, onProgress)
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
fun reportBolt12Error(
msgRes: Int,
detail: String?,
) {
val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes)
onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user)
}
account.zaps.sendBolt12Zap(
zappedEvent = note.event,
recipientPubKey = recipient.user.pubkeyHex,
@@ -485,9 +547,46 @@ class ZapPaymentHandler(
amountMillisats = calculateZapValue(totalAmountMilliSats, recipient.weight, totalWeight),
message = message,
zapType = zapType,
onError = { msgRes, detail ->
val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes)
onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user)
onError = ::reportBolt12Error,
onNotPaid = { code, detail ->
val lnAddress = recipient.lnAddress
if (lnAddress != null && Bolt12LightningFallback.shouldRetry(code)) {
Log.i("ZapPaymentHandler") { "BOLT12 offer refused ($code: $detail); re-sending over BOLT11 to $lnAddress" }
try {
zapOverLightning(
zapsToSend = listOf(MyZapSplitSetup(lnAddress, recipient.weight, recipient.relay, recipient.user)),
note = note,
pollOption = pollOption,
message = message,
zapType = zapType,
totalAmountMilliSats = totalAmountMilliSats,
totalWeight = totalWeight,
okHttpClient = okHttpClient,
onError = onError,
// The zap's own progress finished when the BOLT12 request was
// dispatched; the retry settles in the background like NWC does.
onProgress = {},
onPayViaIntent = onPayViaIntent,
context = context,
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Nothing was paid on either rail. Report it as the lightning failure it
// is, rather than letting [sendBolt12Zap]'s catch call it "paid, no receipt".
Log.w("ZapPaymentHandler", "BOLT11 fallback failed after a refused BOLT12 offer", e)
onError(stringRes(context, R.string.error_dialog_zap_error), e.message ?: e.toString(), recipient.user)
}
} else {
// bolt12_payment_failed always formats a detail; a wallet may send neither
// message nor a recognised code.
reportBolt12Error(R.string.bolt12_payment_failed, detail ?: (code ?: NwcErrorCode.OTHER).name)
}
},
onTimeout = {
// No response callback will fire, so account for the settlement step here.
reportBolt12Error(R.string.bolt12_payment_failed, nwcTimeoutMessage(context))
progress.step()
},
onProcessed = { progress.step() },
)
@@ -201,6 +201,9 @@ class EventNotificationConsumer(
.onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } }
.getOrNull()
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
// events already on relays still arrive and still have to be routed.
@Suppress("DEPRECATION")
private suspend fun dispatchForAccount(
event: Event,
account: Account,
@@ -112,6 +112,9 @@ class NotificationDispatcher(
// recipient account.
// `internal` (was `private`) so the notification-kinds contract test
// can pin the push-side kind set against the in-app feed's kind set.
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
// events already on relays still arrive and still have to be routed.
@Suppress("DEPRECATION")
internal val NOTIFICATION_KINDS: Set<Int> =
setOf(
// Direct-arrival
@@ -320,6 +320,14 @@ class NotificationRelayService : Service() {
}
launch {
// This flow used to over-report: it is fed by socket callbacks, and until
// the OkHttp adapters answered a relay's CLOSE frame a relay-initiated close
// produced none (no onClosed, no onFailure, and a silent cancel()
// afterwards), so after the feeds tore down in the background it carried
// hundreds of already-dropped relays for minutes. The pool now clears it
// itself whenever it lets a relay go, and every transport reports its
// session end exactly once (see WebSocket), so what it emits is the count.
//
// sample() caps how often we touch the notification. During feed
// load/teardown connectedRelaysFlow churns dozens of times per second;
// posting on every delta blows past Android's notification rate limit
@@ -81,6 +81,9 @@ object CodeNotification {
event: GitPullRequestUpdateEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
// events already on relays still arrive and still have to be rendered.
@Suppress("DEPRECATION")
suspend fun notify(
context: Context,
account: Account,
@@ -34,6 +34,7 @@ import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.util.concurrent.atomic.AtomicBoolean
class OkHttpWebSocket(
val url: NormalizedRelayUrl,
@@ -41,7 +42,17 @@ class OkHttpWebSocket(
val out: WebSocketListener,
) : WebSocket {
private var usingOkHttp: OkHttpClient? = null
private var socket: okhttp3.WebSocket? = null
@Volatile private var socket: okhttp3.WebSocket? = null
/**
* Set once, by whichever of `onClosed`, `onFailure` or [disconnect] ends the session first.
* One adapter is one session (the relay client builds a fresh one per dial, and OkHttp binds
* exactly one socket to the listener), so a callback only has to ask whether the session
* already ended. See quartz's `BasicOkHttpWebSocket` for the full reasoning; the two adapters
* differ only in how [needsReconnect] is decided.
*/
private val ended = AtomicBoolean(false)
fun buildRequest() = Request.Builder().url(url.url).build()
@@ -68,8 +79,10 @@ class OkHttpWebSocket(
}
override fun connect() {
usingOkHttp = httpClient(url)
socket = usingOkHttp?.newWebSocket(buildRequest(), OkHttpWebsocketListener(out))
if (socket != null || ended.get()) return
val client = httpClient(url)
usingOkHttp = client
socket = client.newWebSocket(buildRequest(), OkHttpWebsocketListener(out))
}
inner class OkHttpWebsocketListener(
@@ -91,35 +104,60 @@ class OkHttpWebSocket(
}
}
/** Claims the session's single terminal report. False if it already ended. */
private fun endSession(): Boolean {
if (!ended.compareAndSet(false, true)) return false
socket = null
incomingMessages.close()
job.cancel()
scope.cancel()
return true
}
override fun onOpen(
webSocket: okhttp3.WebSocket,
response: Response,
) = out.onOpen(
(response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(),
response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false,
)
) {
if (ended.get()) return
out.onOpen(
(response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(),
response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false,
)
}
override fun onMessage(
webSocket: okhttp3.WebSocket,
text: String,
) {
// Asynchronously send the received message to the channel.
// `trySendBlocking` is used here for simplicity within the callback,
// but it's important to understand potential thread blocking if the buffer is full.
if (ended.get()) return
// Never blocks (unlimited channel): the OkHttp reader thread must
// stay free to keep draining the socket.
incomingMessages.trySendBlocking(text)
}
override fun onClosing(
webSocket: okhttp3.WebSocket,
code: Int,
reason: String,
) {
// The relay sent a CLOSE frame. OkHttp fires onClosed only once BOTH peers have sent
// one, and sending ours is the application's job (WebSocketListener KDoc; its own
// WebSocketEcho recipe does exactly this). Unanswered, the socket sat half-closed:
// no onClosed, no onFailure, send() still accepted and discarded, a later cancel()
// silent too -- so the relay client believed it was connected until the 120s ping
// path failed up to two intervals later.
//
// Always 1000 rather than echoing `code`: close() validates the code it writes and
// throws on the reserved ones (1005, 1006, 1015), and a relay may send anything.
webSocket.close(1000, null)
}
override fun onClosed(
webSocket: okhttp3.WebSocket,
code: Int,
reason: String,
) {
// Close the channel on failure, and propagate the error.
incomingMessages.close()
job.cancel()
scope.cancel()
socket = null
if (!endSession()) return
out.onClosed(code, reason)
}
@@ -128,12 +166,7 @@ class OkHttpWebSocket(
t: Throwable,
response: Response?,
) {
// Close the channel on failure, and propagate the error.
incomingMessages.close()
job.cancel()
scope.cancel()
socket = null
if (!endSession()) return
out.onFailure(t, response?.code, response?.message)
}
}
@@ -153,9 +186,15 @@ class OkHttpWebSocket(
}
override fun disconnect() {
// uses cancel to kill the SEND stack that might be waiting
socket?.cancel()
// Claim the session ourselves and cancel (which also kills a SEND stack that might be
// waiting): OkHttp's cancel() raises no callback when no reader is left to fail, and when
// it does the failure arrives later on its own thread. The relay client needs the answer
// now, and must not hear from this socket again.
val closing = socket ?: return
if (!ended.compareAndSet(false, true)) return
socket = null
closing.cancel()
out.onClosed(1000, "client disconnect")
}
override fun send(msg: String): Boolean = socket?.send(msg) ?: false
@@ -87,7 +87,7 @@ fun RenderTopButtonsPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
RenderTopButtons(
mediaData = MediaItemData("http://test.mp4"),
mediaData = MediaItemData("https://test.mp4"),
hasMultipleQualities = false,
qualityButton = {},
controllerVisible = remember { mutableStateOf(true) },
@@ -36,6 +36,8 @@ import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.Duration
import java.time.Instant
import kotlin.math.roundToInt
@@ -54,6 +56,9 @@ class HealthConnectManager(
) {
private val client: HealthConnectClient by lazy { HealthConnectClient.getOrCreate(context) }
/** Writer package -> display label. See [resolveSourceName]. */
private val sourceNames = mutableMapOf<String, String>()
companion object {
private const val TAG = "HealthConnectManager"
@@ -132,25 +137,31 @@ class HealthConnectManager(
return emptyList()
}
return try {
val response =
client.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(since, now),
),
)
Log.i(TAG) { "readNewWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" }
val mapped = response.records.mapNotNull { mapSession(it) }
// Fold split-up sessions of the same activity (a long run broken around
// breaks) into one suggestion so the composer offers the whole effort.
val merged = WorkoutMerger.mergeCloseWorkouts(mapped)
Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} -> ${merged.size} workout(s) after type/duration filtering and merging" }
merged
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w(TAG, "Failed to read workouts from Health Connect", e)
emptyList()
// The callers are composables launching into rememberCoroutineScope(), i.e.
// Dispatchers.Main. Health Connect's own calls suspend, but the PackageManager
// lookup in resolveSourceName is a blocking binder call, so the whole read
// moves off the UI thread rather than relying on each step to behave.
return withContext(Dispatchers.IO) {
try {
val response =
client.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(since, now),
),
)
Log.i(TAG) { "readNewWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" }
val mapped = response.records.mapNotNull { mapSession(it) }
// Fold split-up sessions of the same activity (a long run broken around
// breaks) into one suggestion so the composer offers the whole effort.
val merged = WorkoutMerger.mergeCloseWorkouts(mapped)
Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} -> ${merged.size} workout(s) after type/duration filtering and merging" }
merged
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w(TAG, "Failed to read workouts from Health Connect", e)
emptyList()
}
}
}
@@ -208,11 +219,19 @@ class HealthConnectManager(
*/
private fun resolveSourceName(packageName: String): String {
if (packageName.isBlank()) return DEFAULT_SOURCE
runCatching {
val pm = context.packageManager
return pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
}
return KNOWN_SOURCES[packageName] ?: packageName
// Memoized: getApplicationInfo is a blocking binder call, and a week of sessions
// almost always comes from the same one or two writer apps, so an uncached lookup
// pays for the same round trip once per session.
sourceNames[packageName]?.let { return it }
val resolved =
runCatching {
val pm = context.packageManager
pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
}.getOrNull() ?: KNOWN_SOURCES[packageName] ?: packageName
sourceNames[packageName] = resolved
return resolved
}
/** Aggregates the optional metrics over the session window. Null if aggregation fails. */
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.abbreviateBolt12Offer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -191,7 +192,7 @@ fun Bolt12OfferEntry(
horizontalArrangement = Arrangement.SpaceAround,
) {
Text(
text = "${offer.take(14)}${offer.takeLast(6)}",
text = abbreviateBolt12Offer(offer),
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
maxLines = 1,
@@ -65,6 +65,7 @@ import org.webrtc.RtpSender
import org.webrtc.VideoTrack
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException
private const val TAG = "CallSession"
private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000
@@ -429,6 +430,7 @@ class CallSession(
try {
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
return
}
@@ -47,10 +47,13 @@ import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.gif
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font10SP
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size6dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -72,6 +75,15 @@ fun GifVideoView(
// remember() to avoid the recompute would cost more (slot read + N equality checks)
// than the work it saves; that's why this stays as a plain expression.
val ratio = dimensions?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(videoUri)
// Mirrors [mediaSizingModifier]: Crop gets fillMaxSize() and a known ratio gets
// aspectRatio(), both of which bound the height. Everything else is a bare
// fillMaxWidth() that wraps its content, and only THAT case needs a loading state
// with intrinsic height to keep the note from collapsing to nothing. Keying the
// fallback on `ratio` alone would put raw URL text inside every Crop card cell --
// MyAsyncImage passes dimensions/blurhash/thumbhash all null, so a gif in a card
// slot hits this on first load, before MediaAspectRatioCache knows its size.
val heightIsBounded = contentScale == ContentScale.Crop || ratio != null
val autoPlay = accountViewModel.settings.autoPlayVideos()
val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier
val context = LocalContext.current
@@ -108,13 +120,29 @@ fun GifVideoView(
when (state) {
is AsyncImagePainter.State.Loading -> {
DisplayBlurHash(
blurhash,
contentDescription,
contentScale,
Modifier.fillMaxSize(),
thumbhash = thumbhash,
)
// When the height is unbounded (see [heightIsBounded]) this branch MUST
// emit something with an intrinsic height, or the box wraps nothing and the
// whole note collapses to zero -- no picture, no URL, no spinner, just a gap
// in the feed until the load finishes. DisplayBlurHash renders NOTHING when
// both hashes are absent (placeholderModel returns null), which is exactly
// what a no-imeta post hits. Mirrors UrlImageView's ladder.
if (blurhash != null || thumbhash != null) {
DisplayBlurHash(
blurhash,
contentDescription,
contentScale,
Modifier.fillMaxSize(),
thumbhash = thumbhash,
)
} else if (heightIsBounded) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
LoadingAnimation(Size40dp, Size6dp)
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(videoUri)
}
}
}
is AsyncImagePainter.State.Success -> {
@@ -40,6 +40,7 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.sp
import androidx.core.graphics.withTranslation
import com.vitorpamplona.amethyst.commons.richtext.MathParser
import ru.noties.jlatexmath.JLatexMathDrawable
@@ -130,12 +131,11 @@ fun LatexEquation(
Canvas(modifier = equationModifier) {
drawIntoCanvas { canvas ->
val native = canvas.nativeCanvas
val checkpoint = native.save()
// Position the icon's baseline on the text baseline within the padded box.
native.translate(0f, drawTopPx)
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
drawable.draw(native)
native.restoreToCount(checkpoint)
native.withTranslation(y = drawTopPx) {
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
drawable.draw(this)
}
}
}
if (trailing.isNotEmpty()) {
@@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.ui.feeds
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.commons.feeds.AdditiveComplexFeedFilter
import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.service.BundledInsert
import com.vitorpamplona.amethyst.commons.service.BundledUpdate
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveComplexFeedFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.utils.Log
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.ui.feeds
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
import kotlinx.coroutines.flow.MutableStateFlow
@Stable
@@ -28,8 +28,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -31,7 +31,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -34,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
@@ -42,7 +43,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -36,7 +36,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.feeds
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent
@Composable
fun WatchLifecycleAndUpdateModel(model: InvalidatableContent) {
@@ -29,8 +29,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -61,8 +61,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.isCommunityDefinition
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.replyingDirectlyTo
import com.vitorpamplona.amethyst.commons.model.textNoteModifications
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.approve
@@ -78,8 +80,6 @@ import com.vitorpamplona.amethyst.commons.ui.note.QuietMark
import com.vitorpamplona.amethyst.commons.ui.note.RenderCashuMint
import com.vitorpamplona.amethyst.commons.ui.note.RenderFedimint
import com.vitorpamplona.amethyst.commons.ui.note.RenderMintRecommendation
import com.vitorpamplona.amethyst.commons.ui.note.isCommunityDefinition
import com.vitorpamplona.amethyst.commons.ui.note.replyingDirectlyTo
import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
@@ -216,6 +216,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
@@ -225,6 +226,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -1480,10 +1482,15 @@ fun zapClick(
choices.size == 1 -> {
// One-tap fast path is Lightning-only. If the recipient can't
// receive Lightning (no lud16/lud06), firing a zap here would just
// fail — open the picker instead so the rail-aware chip can route to
// cashu / on-chain / reload.
val caps = RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState)
// receive Lightning (no lud16/lud06, and no BOLT12 offer our wallet
// can pay), firing a zap here would just fail — open the picker
// instead so the rail-aware chip can route to cashu / on-chain / reload.
val caps =
RailCapabilityResolver.peek(
baseNote,
accountViewModel.account.cashuWalletState,
bolt12Payable = accountViewModel.account.zaps.canZapViaBolt12(),
)
if (caps.hasLightning) {
onZapStarts()
accountViewModel.zap(
@@ -2124,16 +2131,26 @@ fun observeZapRailCapability(
): RailCapability {
val cashuState = accountViewModel.account.cashuWalletState
val author = baseNote.author
// These four are deliberately read only to drive the recompute below — do NOT
// delete them as "unused". Each observe* call ALSO subscribes the relay fetch
// (so a not-yet-seen kind:0 / kind:10019 gets pulled in while the popup is
// open), and each value is a remember() key so railCapability recomputes when
// it arrives. RailCapabilityResolver.peek re-reads everything itself; these
// just say *when* to re-run it.
// Every value below up to `showOnchainWallet` is deliberately read only to drive
// the recompute — do NOT delete them as "unused". Each observe* call ALSO
// subscribes the relay fetch (so a not-yet-seen kind:0 / kind:10019 / kind:10058
// gets pulled in while the popup is open), and each value is a remember() key so
// railCapability recomputes when it arrives. RailCapabilityResolver.peek re-reads
// everything itself; these just say *when* to re-run it.
val cashuMints by cashuState.mints.collectAsStateWithLifecycle()
val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle()
val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value }
val nutzapInfo = author?.let { observeNoteEvent<NutzapInfoEvent>(it.nutzapInfoNote, accountViewModel).value }
// BOLT12 route inputs, same contract: the recipient's kind:10058 offer list
// (rides in UserMetadataForKeyKinds beside kind:0) and our default NWC wallet,
// whose `pay` support decides whether that offer makes them Lightning-payable.
val bolt12OfferList = author?.let { observeNoteEvent<Bolt12OfferListEvent>(it.bolt12OfferListNote, accountViewModel).value }
val nip47State = accountViewModel.account.nip47SignerState
val defaultWalletUri by nip47State.defaultWalletUri.collectAsStateWithLifecycle()
// The wallet's kind:13194 info (its `pay` support) is a plain cache read inside
// canZapViaBolt12(); this counter is what recomputes when it lands after opening.
val walletInfoUpdates by remember(nip47State) { nip47State.infoCache?.updates ?: MutableStateFlow(0) }
.collectAsStateWithLifecycle()
// Honors the user's "show on-chain wallet" preference: off hides the on-chain
// rail from the zap chips too, matching the wallet screen, profile chips, and
// Send Payment screen.
@@ -2180,11 +2197,20 @@ fun observeZapRailCapability(
cashuEntries,
recipientInfo,
nutzapInfo,
bolt12OfferList,
defaultWalletUri,
walletInfoUpdates,
showPayToChip,
recipientPayTo,
payToApps,
) {
val rc = RailCapabilityResolver.peek(baseNote, cashuState, showPayToChip)
val rc =
RailCapabilityResolver.peek(
baseNote,
cashuState,
showPayToChip,
bolt12Payable = accountViewModel.account.zaps.canZapViaBolt12(),
)
if (onchainEnabled) {
rc.copy(onchainMaxSpendableSats = onchainFunds?.maxSpendableSats)
} else {
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.location
import android.graphics.ColorFilter
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.drawable.BitmapDrawable
import android.view.MotionEvent
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
@@ -36,6 +35,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.toDrawable
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -159,7 +159,7 @@ fun LocationPreviewMap(
remember(pinColor, pinEmoji) {
if (pinColor != null && pinEmoji != null) {
val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density)
BitmapDrawable(context.resources, bitmap)
bitmap.toDrawable(context.resources)
} else {
null
}
@@ -46,7 +46,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessGameViewer
import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessGameViewer
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.chess_accept
import com.vitorpamplona.amethyst.commons.resources.chess_decline
@@ -35,10 +35,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.replyingDirectlyTo
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel
import com.vitorpamplona.amethyst.commons.ui.note.replyingDirectlyTo
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -34,8 +34,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.replyingDirectlyTo
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.ui.note.replyingDirectlyTo
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
import com.vitorpamplona.amethyst.model.LocalCache
@@ -28,8 +28,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
@@ -25,10 +25,10 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.service.BundledUpdate
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
@@ -118,6 +118,5 @@ open class UserFeedViewModel(
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
bundler.cancel()
super.onCleared()
}
}
@@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.ComponentCallbacks2
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Dao
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
@@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuild
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCache
import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCacheAsync
@@ -1196,7 +1196,7 @@ class AccountViewModel(
.isNotEmpty()
/** True when a BOLT12 offer can be paid in-app: an NWC wallet is set and advertises `pay` (nwc#2). */
fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.zaps.defaultWalletSupportsBolt12Pay()
fun canPayBolt12ViaNwc(): Boolean = account.zaps.canZapViaBolt12()
/**
* Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2
@@ -2647,7 +2647,6 @@ class AccountViewModel(
com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
.clear()
feedStates.destroy()
super.onCleared()
}
fun loadMentions(
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedQueryState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import kotlinx.coroutines.flow.StateFlow
/**
@@ -30,7 +30,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal
import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
@@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal
import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.model.Account
class BookmarkPrivateFeedFilter(
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.model.Account
class BookmarkPublicFeedFilter(
@@ -214,7 +214,7 @@ private fun Picture(
onValueChange = { bookmarkGroupInfoViewModel.picture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
text = "https://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.model.Account
class OldBookmarkPrivateFeedFilter(
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.model.Account
class OldBookmarkPublicFeedFilter(
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal
import com.vitorpamplona.amethyst.commons.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.model.Account
/**
@@ -310,7 +310,6 @@ class AgentConsoleViewModel : ViewModel() {
override fun onCleared() {
stopObserving()
stopWatching()
super.onCleared()
}
/** One decrypted observer telemetry frame rendered on the Observer tab. */
@@ -218,7 +218,6 @@ class AgentWorkBoardViewModel : ViewModel() {
override fun onCleared() {
stopWatching()
super.onCleared()
}
companion object {
@@ -360,6 +360,5 @@ class BuzzDmListViewModel : ViewModel() {
override fun onCleared() {
liveJob?.cancel()
liveJob = null
super.onCleared()
}
}
@@ -137,7 +137,6 @@ class JobBoardViewModel : ViewModel() {
override fun onCleared() {
stopWatching()
super.onCleared()
}
companion object {
@@ -258,7 +258,6 @@ class WorkflowRunBoardViewModel : ViewModel() {
override fun onCleared() {
stopWatching()
super.onCleared()
}
companion object {
@@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
@@ -47,6 +47,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
@@ -56,8 +58,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_collection_count
import com.vitorpamplona.amethyst.commons.resources.calendar_empty_collections_subtitle
import com.vitorpamplona.amethyst.commons.resources.calendar_empty_collections_title
import com.vitorpamplona.amethyst.commons.resources.calendar_export_event
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
@@ -51,6 +51,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarLocalDayKeyRange
@@ -63,8 +65,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_day_subtitle
import com.vitorpamplona.amethyst.commons.resources.calendar_empty_day_title
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_day
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_day
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -58,11 +59,15 @@ import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
// Thread-safe and hoisted: previously each CalendarDateBadge recompose allocated a new
// SimpleDateFormat, which (a) is not thread-safe and (b) created 500 allocations while scrolling.
private val MonthShortFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("MMM", Locale.getDefault())
// Cached per locale rather than in a single val that captures the locale once: the month
// names have to follow a language the user changes while the app is running.
private val monthShortFormatters = ConcurrentHashMap<Locale, DateTimeFormatter>()
private fun monthShortFormatter(locale: Locale): DateTimeFormatter = monthShortFormatters.getOrPut(locale) { DateTimeFormatter.ofPattern("MMM", locale) }
@Composable
fun CalendarEventListCard(
@@ -214,7 +219,10 @@ private fun CalendarDateBadge(startSeconds: Long?) {
Instant.ofEpochSecond(startSeconds).atZone(ZoneId.systemDefault()).toLocalDate()
}
val day = localDate.dayOfMonth.toString()
val month = remember(localDate) { MonthShortFormatter.format(localDate).uppercase() }
// LocalLocale rather than Locale.getDefault(): the latter is not observable, so a
// locale change while the app runs would leave the month name in the old language.
val locale = LocalLocale.current.platformLocale
val month = remember(localDate, locale) { monthShortFormatter(locale).format(localDate).uppercase() }
Column(
modifier = Modifier.size(width = 52.dp, height = 60.dp),
@@ -38,6 +38,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarEndSeconds
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarStartSeconds
@@ -46,8 +48,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_feed_subtitle
import com.vitorpamplona.amethyst.commons.resources.calendar_empty_feed_title
import com.vitorpamplona.amethyst.commons.resources.calendar_section_past
import com.vitorpamplona.amethyst.commons.resources.calendar_section_upcoming
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
@@ -53,6 +53,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MONTH_GRID_MAX_LANES
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MonthGridBarSegment
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBars
@@ -62,8 +64,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_day_a11y_selected_s
import com.vitorpamplona.amethyst.commons.resources.calendar_day_a11y_today_suffix
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_month
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_month
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -51,6 +51,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -60,8 +62,6 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_week_subtitle
import com.vitorpamplona.amethyst.commons.resources.calendar_empty_week_title
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_week
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_week
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -31,7 +31,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
@@ -109,7 +109,6 @@ class NewCalendarCollectionViewModel : ViewModel() {
override fun onCleared() {
liveScanJob?.cancel()
super.onCleared()
}
fun toggle(address: Address) {
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal
import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.upcomingFirstCalendarOrder
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal
import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
@@ -36,9 +36,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.commons.ui.feeds.MarmotGroupFeedFilter
import com.vitorpamplona.amethyst.commons.feeds.MarmotGroupFeedFilter
import com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -119,7 +119,6 @@ class MarmotFileUploader(
// imprecisely.
val canonicalMediaType = MarmotMediaType.canonicalize(mimeType) ?: GENERIC_MEDIA_TYPE
val cipher = EncryptedMediaV2Cipher(exporterSecret, canonicalMediaType, filename)
val v2Cipher = cipher
item.orchestrator.uploadEncrypted(
uri = media.uri,
@@ -142,21 +141,19 @@ class MarmotFileUploader(
// compression and metadata stripping — because that is what the
// key was derived from.
val reference =
v2Cipher?.let {
EncryptedMediaReferenceV2(
locators =
listOf(
MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url),
),
ciphertextSha256 = it.ciphertextSha256,
plaintextSha256 = it.plaintextSha256,
nonce = it.nonce,
mediaType = it.mediaType,
filename = filename,
dim = serverResult.fileHeader.dim?.toString(),
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
)
}
EncryptedMediaReferenceV2(
locators =
listOf(
MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url),
),
ciphertextSha256 = cipher.ciphertextSha256,
plaintextSha256 = cipher.plaintextSha256,
nonce = cipher.nonce,
mediaType = cipher.mediaType,
filename = filename,
dim = serverResult.fileHeader.dim?.toString(),
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
)
results.add(
Mip04UploadResult(
url = serverResult.url,
@@ -38,13 +38,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip04
import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip17
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers

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