Commit Graph
18154 Commits
Author SHA1 Message Date
Vitor PamplonaandGitHub 7e9abd86b3 Merge pull request #3833 from vitorpamplona/claude/crypto-security-review-f13uy2
Fix SharedKeyCache hash collision vulnerability & add constant-time MAC comparison
2026-07-31 18:13:57 -04:00
Claude a59bc6d82d refactor(nip44): use a content-addressed key object for the shared-key cache
Replaces the hex-string cache key with a small CacheKey holding the raw
(privateKey, pubKey) references — no byte copy and no ~400-byte hex String per
lookup. The precomputed Int hash is only a bucket selector; equals() does the
authoritative full-content comparison, so collisions share a bucket and are
disambiguated rather than returning the wrong peer's secret. Same value-type
immutability contract already relied on by X25519KeyPair as a map key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC
2026-07-31 20:13:59 +00:00
Claude e94b8eaf3b fix(nip44): content-address shared-key cache and use constant-time MAC checks
Two hardening fixes in the NIP-44/NIP-04 encryption primitives:

1. SharedKeyCache keyed conversation/shared secrets by a 32-bit polynomial
   hashCode of (privateKey || pubKey). Distinct peers whose bytes hash to the
   same Int collided on one LRU slot, so get() could return one peer's key for
   a message meant for another — a silent wrong-key encrypt/decrypt. The hash
   collides independently of the private key, so a pubkey aliasing a victim's
   contact is grindable. Now keyed on the full (priv || pub) byte content.
   Added SharedKeyCacheTest, which fails on the old hashCode key and passes now.

2. NIP-44 MAC verification (Hkdf.fastExpand and Nip44v2.checkHMacAad) used
   ByteArray.contentEquals, which short-circuits on the first mismatching byte
   and leaks a timing side channel on the HMAC tag. Switched to a shared
   equalsConstantTime helper (utils/ConstantTime.kt), matching the constant-time
   tag checks already used in ChaCha20Poly1305/XChaCha20Poly1305/MLS.

Also corrects the X25519 KDoc, which claimed the JVM/Android actual delegates to
java.security XDH; it is in fact the same pure-Kotlin Montgomery ladder as the
other targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC
2026-07-31 20:00:07 +00:00
davotoulaandClaude Opus 5 d9e01c8504 i18n: translate missing amethyst strings for cs, de, sv and pt-BR
Fills the on-disk gaps in the four target locales: channel archive/unarchive,
the new-forum title, the External Content screen title, the archived channel
section header, the three share targets (picture / short / video) and the URL
preview's open-in-browser action.

Wording follows each locale's existing siblings rather than being translated
fresh -- url_preview_open_in_browser reuses the string already in
git_repo_open_in_browser, share_target_as_picture follows new_picture, and
share_target_as_short_video follows home_content_type_shorts.
relay_group_section_archived heads a channel list, so cs/sv/pt take the plural
adjective.

Keys whose translation would be byte-identical to the English source are left
out on purpose: Crowdin strips source-identical entries on export and Android
already falls back to values/strings.xml at runtime. That covers the "workflow"
loanword in cs/de, and Highlights / Podcasts / Reposts / Torrents / Videos /
Name / Backlog in de, plus Podcasts / Torrents / Backlog in pt-BR.

The commons composeResources tree was already complete for all four locales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
2026-07-31 21:42:30 +02:00
Vitor PamplonaandGitHub 5231f5b051 Merge pull request #3826 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-31 14:05:43 -04:00
vitorpamplonaandgithub-actions[bot] 0f4580ab91 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-31 14:58:45 +00:00
Vitor PamplonaandGitHub cdef4e9658 Merge pull request #3830 from vitorpamplona/claude/servicetype-tag-bounds-check-533b4r
Fix ServiceType parsing to handle edge cases safely
2026-07-31 10:56:02 -04:00
Claude 51e388d2a1 fix: bounds-check ServiceType parsing of NIP-85 service tags
ServiceType.parse destructured the result of split(":", limit = 2) into
two components, so any value without a colon (e.g. the "client" of a
["client", "nostria"] tag) threw IndexOutOfBoundsException instead of
returning null. It also accepted "30382:" as an empty service type.

ServiceType.isOfKind had the same class of bug: it read
serviceType[kind.length] after startsWith, which is out of bounds when
the value equals the kind exactly ("30382" vs "30382").

Parse the separator by index and check the length before indexing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112ysMEzSaezH9mXFteNt13
2026-07-31 14:54:13 +00:00
Vitor PamplonaandGitHub 9a5d47004c Merge pull request #3827 from davotoula/fix/location-foreground-gate
Listen only while foregrounded, and fix the meter that measures it
2026-07-31 10:21:07 -04:00
Vitor PamplonaandGitHub 731f4369cb Merge pull request #3828 from vitorpamplona/claude/content-parse-error-a1pf9h
Fix metadata JSON parsing to use lenient parser consistently
2026-07-31 10:14:56 -04:00
davotoula d93f4db451 docs(location): record the clean-install smoke run, closing R1 2026-07-31 15:57:04 +02:00
Claude 50e61901f7 fix: read profile json with the same lenient parser that renders it
contactMetaData() decodes kind-0 content with the lenient JsonMapper, but
contactMetadataJson() ran the strict default parser. Profiles that sit in
that gap (bare keys, unquoted values) rendered fine yet read back as null,
so updateFromPast() started from an empty map and silently dropped every
field Amethyst does not edit itself the next time the owner touched their
profile — the exact thing its "tries to not delete any existing attribute
that we do not work with" contract promises not to do.

Both accessors now use JsonMapper.jsonInstance. The lenient reader tags
unquoted tokens as strings, so re-encoding still emits valid JSON.

Also pins the behaviour of two malformed kind-0s seen in the wild — a
JavaScript object literal and a value truncated by stray quotes. Neither
is recoverable by any parser; the test records that they are dropped with
a warning and never throw.
2026-07-31 13:38:04 +00:00
davotoula 2360829e83 Code review:
- refactor(location): dedupe test fixtures and simplify the gate internals
- test(location): assert the release, not just its ledger side-effect
- test(location): cover the LocationState -> LocationFlow -> RefCountedSession seam
2026-07-31 15:19:44 +02:00
davotoula f09eef1470 feat(amethyst): wire the location foreground gate and refcounted meter 2026-07-31 10:26:28 +02:00
davotoula 78f0583b16 test(amethyst): document LocationStateTest deviations, close profile gap
feat(amethyst): gate location listening on foreground
2026-07-31 10:26:28 +02:00
davotoula 4234c4f45b docs(location): cite the proven mechanism for LocationFlow's try/finally
fix(test): close the seed-after-registration ordering gap in LocationFlow

fix(test): replace non-discriminating LocationFlow cleanup test
2026-07-31 10:26:28 +02:00
davotoula a742cec495 feat(amethyst): register one location provider with a paired listening hook
feat(amethyst): add location provider ladder
feat(amethyst): add RefCountedSession for overlapping ledger holders
2026-07-31 10:24:39 +02:00
davotoula 3fe936bedb docs 2026-07-31 10:24:16 +02:00
Vitor PamplonaandGitHub 7cece7eafb Merge pull request #3825 from vitorpamplona/claude/intent-receivers-feed-sharing-rkwiv1
Add media share targets for Pictures, Shorts, and Video feeds
2026-07-30 12:22:44 -04:00
Claude dcf2807e75 fix: address audit findings on the media share targets
- Correct the KDoc on the Shorts and Longs composers. They claimed
  everything posted from them lands in that feed; VideoPostKind only
  governs videos, and the gallery picker takes images with no mime
  filter, so a picked JPEG still posts as a kind-20 picture that
  neither feed reads. Say so instead of asserting a false invariant.

- Forward the shared text as the composer's caption. The media targets
  dropped EXTRA_TEXT entirely, so sharing a photo with a caption lost
  it — while the DM target kept it. The routes now carry the message
  and NewMediaModel.load() seeds the caption field from it.

- Accept SEND_MULTIPLE on the three media targets. Sharing several
  files at once previously did not offer Amethyst at all, even though
  the picture composer publishes N images as one kind-20 event. Routes
  carry a URI list; other targets take the first.

- Replace, rather than stack, a feed entry when a second share arrives
  while the first is still open. Only entries that carry attachments
  are replaced, so a feed reached from the bottom bar keeps its
  tab-root marker underneath.

- Rename NewImageButton to NewVideoFeedButton: it is the Video feed's
  composer and handles pictures and video alike.

Adds ShareTargetManifestTest, which pins the activity-alias names in
AndroidManifest.xml to the constants ShareIntentRouting matches them
by — in both directions, plus the SEND_MULTIPLE filters. That link is
invisible to the compiler and fails silently at runtime by routing a
share to the wrong composer. Verified the guard bites by renaming an
alias in the manifest alone: three of its four tests go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
2026-07-30 16:07:52 +00:00
Claude ea2472468b feat: share images, shorts and videos straight into their feeds
Adds three share targets next to "New Post", "Send as DM" and
"New Highlight":

- New Picture (image/*) -> the picture feed's composer, publishing a
  NIP-68 kind 20 picture post
- New Short (video/*)   -> the Shorts feed's composer, publishing a
  NIP-71 kind 22 short
- New Video (video/*)   -> the Video feed's composer, publishing a
  NIP-71 video event

Until now every SEND intent landed in the kind-1 composer, so a shared
picture became a text note with a link instead of a post in the feed
the user was aiming for.

Each alias resolves to MainActivity like the existing ones, so
ShareIntentRouting now maps the launching component class to a
ShareTarget enum instead of a growing chain of isShareAsX() booleans.
The media targets navigate to the destination feed carrying the shared
content URI, and the feed's existing composer button opens on it.

Video kind is no longer guessed from orientation alone in feeds that
only read one of the two kinds: the Shorts composer always publishes
kind 22 and the Longs composer always publishes kind 21, so a post
lands in the feed it was composed from whatever the footage's shape.
The mixed Video feed and the picture feed keep the automatic choice.

Also fixes the New Post launch path passing the literal string "null"
as the attachment when a share carried no media.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
2026-07-30 15:11:56 +00:00
Vitor PamplonaandGitHub 1f4d9728be Merge pull request #3824 from vitorpamplona/claude/event-kind-0-empty-content-josbwa
Handle blank and malformed metadata gracefully
2026-07-29 22:17:41 -04:00
Claude 25c4516270 fix: parse kind 0 with empty or array-wrapped fields instead of erroring
Four kind-0 events collected from relays exposed two content-parsing
problems. Both `MetadataEvent.contactMetaData()` and `contactMetadataJson()`
are shared code, so Jackson (JVM/Android) and kotlinx (native) were equally
affected — verified against both mappers.

Empty content is a valid, empty profile — someone wiping their metadata —
not a parse failure. It logged "Content Parse Error" and returned null,
which made `LocalCache.consume` drop the event, so the stale profile stayed
in place forever even though a newer replaceable event had arrived. Blank
content now decodes to a blank `UserMetadata` (and an empty `JsonObject`).

A string field wrapped in a one-element array (`"nip05":["a@b.com"]`) has
only one possible reading, so `TolerantStringSerializer` now unwraps it
rather than silently dropping the user's NIP-05 verification. Empty,
multi-element, and non-primitive arrays stay ignored.

The other two events already behaved correctly and are pinned as
regressions: `"nip05":{}` with foreign client keys, and Ditto's ambiguous
`"birthday":"10-24"` — both drop just the offending field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGYcqfBKsP9SCvRdPzdkSx
2026-07-30 02:08:04 +00:00
Vitor PamplonaandGitHub d56d0cf725 Merge pull request #3819 from vitorpamplona/fix/log-defaults-and-concord-cross-epoch-floor
fix: quiet the default log, add a boot narrative, and stop pinning Concord entities across a Refounding
2026-07-29 21:56:52 -04:00
Vitor PamplonaandGitHub f7cf257365 Merge pull request #3821 from vitorpamplona/claude/notification-card-pattern-95omqu
Redesign channel invite cards as feed rows with metadata-only observers
2026-07-29 21:32:58 -04:00
Vitor PamplonaandClaude Opus 5 edd36b467c test(commons): deflake FeedMetadataCoordinatorTest
`loadMetadataBatched follows the same retry semantics` failed on the macOS
build-desktop runner (1431 tests in the module, 1 failed). The coordinator is
fine; the test was wall-clock coupled.

It fired call 1 with `timeoutMs = 200`, waited a flat `delay(350)`, then
asserted that call 2 re-subscribed. That leaves a 150ms budget for
`scope.launch` to be scheduled, subscribe, `awaitAll(200)`, unsubscribe and roll
the pubkeys out of `inFlightBatchedMetadata`. On a loaded runner the launch
itself can be queued past the margin, so the roll-back lands late, call 2
short-circuits by design, and the assertion fails on healthy code. Every test in
the file had the same shape.

"Has call 1 finished?" is a question about the job tree, not the clock, and the
test owns the scope — so it can just ask. `awaitCoordinatorIdle()` waits until
no child of the test scope is active; `waitUntil {}` polls the few preconditions
that aren't expressible that way (listener registered before EOSEs are fired).
Both carry a generous deadline and fail with a message rather than hanging. Safe
here because the coordinator launches nothing at construction and
`BatchEoseGate` closes and joins its consumer inside `awaitAll`, so the scope
does reach idle.

Also made the test fake thread-safe. `subscriptions` and `subscribeCalls` were a
plain map and list, written from the coordinator's `Dispatchers.Default`
coroutines (and `Dispatchers.IO` in the concurrent-EOSE test) and read from the
test thread: an unsynchronized `size` read can be stale, and `fireEose`
iterating `subscriptions.values` while a coordinator coroutine calls
`unsubscribe` can throw ConcurrentModificationException. Concurrency is the
thing under test, so the fake shouldn't be the weak link. Now
ConcurrentHashMap + synchronizedList + AtomicInteger, with a snapshot in
`fireEose`.

Verified deterministic rather than just green:
- 3/3 idle, 4/4 under 24 busy loops on 12 cores (the old shape needed the
  margin; the new one is load-independent).
- Still catches its regression: reintroducing the original mark-on-send bug
  (`eosedRelays > 0` -> `>= 0`) fails exactly this test. A deflaked test that no
  longer detects the fault would be worse than the flake.

This is a pre-existing flake (test dates from 5217035f94, 2026-07-07) unrelated
to the rest of this branch, which touches no commons code — fixed here because
it blocks the branch's CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:25:20 -04:00
Claude 5b357cd2fc perf: trim the channel-invite row's per-item cost
Three findings from auditing the previous commit.

observeChannel registered a ChannelFinder query per invite row that could
never produce a filter: every assembler under ChannelFinderFilterAssemblyGroup
is gated on `is PublicChatChannel` / `is LiveActivitiesChannel`, so a
RelayGroupChannel contributes nothing and the registration only churned the
app-wide key set (an allKeys() Set copy per bundled invalidation) on every
mount and unmount. Collect the channel's metadata stateFlow directly instead —
same live name updates when the group's kind-39000 lands, none of the churn.

TimeAgo used the default Dotted style inside a row that already spaces its
children, so the variant's own leading " • " doubled the gap. DottedTight is
what the note header uses in exactly this position.

Key each row by channel id. The list is sorted newest-first, so an arriving
invite shifts every row below it; without a key Compose matches children by
call-site position and each shifted row recomposed against a different invite,
re-resolving the actor and reloading their avatar. Also remember the row
modifier rather than rebuilding the chain on every recomposition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
2026-07-30 01:16:29 +00:00
Vitor PamplonaandGitHub c47afe6427 Merge pull request #3823 from vitorpamplona/claude/buzz-relay-tor-reachability-7lrj4m
Fix Tor→clearnet fallback banner logic with live socket state
2026-07-29 21:10:22 -04:00
Vitor PamplonaandGitHub a2e199bd57 Merge pull request #3822 from vitorpamplona/claude/buzz-notification-relay-ebj12q
Extract RelayNameChip to shared component and add it to relay group message headers
2026-07-29 21:09:43 -04:00
Vitor PamplonaandClaude Opus 5 2d7ef2fd93 feat(logs): give the default log a boot narrative
The INFO default landed a readable log but an incomplete one: only the boot
census came from our own code, so the default read as "warnings + census" with
nothing between process start and the 20s mark. The first four seconds — the
expensive part — were silent.

Adds the missing spine, ten INFO lines a boot:

- `AmethystApp`: version, process and effective log level at start. The
  napplet-sandbox skip is promoted too — it is the one line that explains why
  `instance` is unset and `LocalCache` empty in that process, which otherwise
  reads as a broken app rather than a deliberately secret-free sandbox.
- `LocalPreferences`: how many accounts are saved (nearly every per-account
  subsystem multiplies by this), then each account's load with its elapsed ms.
  Decrypting one account's settings is among the most expensive things a cold
  start does and "which account was slow" is the first question when a boot
  drags. The six intermediate steps stay at DEBUG.
- `TorService`: every status transition, with time since bootstrap started. All
  status writes now go through one `setStatus` helper instead of nine scattered
  assignments, so a transition is logged exactly once; self-transitions are
  skipped because the reset paths reassign `Off` defensively. A stuck
  `Connecting`, an `Active` that took 40s and a silent drop to `Off` are three
  different bugs that used to look identical.
- `BootRelayDiagnostics`: adds a census at 5s. The pool is assembled and
  dialling well before 20s, and the early datapoint is what separates "slow to
  connect" from "connected fine, slow to serve" — on device it reads pool=21
  at 5s against pool=359 at 20s. Paid for by folding the `=====` banners down
  to DEBUG, so a census is 3 INFO lines instead of 5.

Reads on device as:

    I AmethystApp: Amethyst 1.13.1-DEBUG starting in main process (log level INFO)
    I TorService: Off -> Connecting
    I LocalPreferences: Found 4 saved account(s)
    I LocalPreferences: Loaded account npub1gcx… in 394ms
    I LocalPreferences: Loaded account npub142g… in 760ms
    I BootRelayDiag: census @5s pool=21 opened=17 served_events=14 …
    I TorService: Connecting -> Active after 9910ms
    I BootRelayDiag: census @20s pool=359 opened=158 served_events=78 …

Verified by tag on a cold start: +10 INFO lines from our code, no other change
to volume. (Raw totals moved more, but that run happened to autoplay video, so
15 extra INFO lines came from Android's media/codec stack — not from this.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:26:05 -04:00
Claude 21623cfd3f fix: debounce the Tor→clearnet offer on the socket and hide it when inert
Audit follow-ups on the previous commit's gating.

1. The `hasLoadedContent` gate over-suppressed. Cached channels in
   LocalCache are proof the socket answered *once*, not that it answers
   now — so a relay whose circuits died mid-session, leaving a full but
   frozen screen, would never get the offer. Replace it (and the
   one-shot grace timer) with a debounce on the connection itself: the
   countdown restarts on every drop and clears the moment the socket
   returns. That covers the cold-start case, the reconnect blip and the
   mid-session death with one signal, and no longer depends on what
   happens to be cached.

2. Don't offer what can't help. The action adds the relay to the
   kind-10089 Trusted list, which only moves it to clearnet while
   trusted relays are off Tor. Under the Small-Payloads and
   Full-Privacy presets (trustedRelaysViaTor = true) it changed no
   routing at all — the old `relay !in trustedRelays` condition merely
   hid the banner afterwards, so the dead button looked like it worked.
   Withhold the offer under those presets instead of silently flipping
   a global privacy setting from a per-relay banner.

Tests cover both gates plus the preset routing they read.
2026-07-29 22:24:41 +00:00
Claude 08b3cf266b fix: draw the "added you to a channel" prompt as a note row
The invite prompt was a floating Material card with a surfaceVariant fill,
sitting above the Notifications feed and Messages > New Requests as its own
visual language — nothing else on either surface looks like that.

Render it through NoteComposeLayout instead, the same layout every note and
notification card uses: the actor becomes the row's author (picture, name and
time in the usual header), "Added to <channel>" plus the host relay become the
row's content, and the three choices take the reactions slot. So the prompt now
reads like the reply and mention notifications it sits next to, and a divider
closes each row the way the feed does.

The channel name is now read through observeChannel, the metadata-only observer
the channel rows use, so a stranger's add resolves to a name instead of a raw
group id. It does not open the channel's message subscription — holding that
back until the viewer answers is the whole point of the prompt.

Also relabel "Show" to "Add to Messages". acceptChannelInvite is defined as
`= addRelayGroupToMessages(channel)`, literally the call behind the channel top
bar's "Add to Messages" item, so one action had two words for it. Reusing the
existing string drops channel_invite_accept and channel_invite_body (the actor
and the question both live in the row now).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
2026-07-29 22:23:13 +00:00
Claude f6e68f31d4 feat: name the host relay on Buzz notification cards
A Buzz/NIP-29 group id is only unique within its host relay, so a message
notification titled just "#general" doesn't say which #general it came from.
The Notifications card already draws a RelayGroupChannelHeader naming the
channel; add the relay next to it, mirroring what a Concord message gets on
the same cards (ConcordCommunityPill naming its parent community) and what the
Messages row already does for these groups.

Extracts the relay chip out of ChatroomHeaderCompose into a shared
RelayNameChip so the Messages row and the Notifications feed render the same
chip, the way ConcordCommunityPill is already shared between them. The chip
taps through to the relay's channel list; the name/avatar still open the room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DsoLSb42C3CdjMceJW2Pq
2026-07-29 22:22:11 +00:00
Vitor PamplonaandGitHub 27c863925a Merge pull request #3820 from dmnyc/fix/nip73-scope-prefetch
perf(nip73): prefetch external-content preview URLs
2026-07-29 18:13:30 -04:00
The Daniel c4c8649fcc perf(nip73): prefetch external-content preview URLs
Closes #3817.

collectWarmTargets() harvested link URLs from note content but not from
NIP-73 `I`-tag scopes, so the external-content preview cards popped in
mid-scroll while every other link preview was warmed ahead of view.

Gated on `is CommentEvent` so only kind 1111 walks the tags, keeping the
other kinds off that path. Reuses scope() -- the same accessor the
renderer uses -- so a URL is only warmed when it will actually be shown.
Inherits the existing showUrlPreview() data-saver gate already applied to
targets.links.
2026-07-29 18:03:36 -04:00
Vitor PamplonaandGitHub 112fbb8379 Merge pull request #3818 from vitorpamplona/claude/local-blossom-main-thread-anr-sfcfv9
Confine Blossom cache probe and resolver to IO dispatcher
2026-07-29 18:02:24 -04:00
Vitor PamplonaandClaude Opus 5 6fbcb880dd fix(concord): stop pinning entities whose chain crosses a Refounding
An entity edited after a CORD-06 Refounding was frozen at its pre-Refounding
version and its newer state silently discarded on every refold. Observed on
device: entity e83ee182 of a real community held at v1 across 21 consecutive
refolds while the current epoch offered v2 citing v1's own hash.

`EditionFold.foldEntity` anchored the walk by requiring the offered set to
*contain* the floor edition. But a Refounding re-wraps ONE edition per entity
(CORD-06 §3, "the last Control Plane state is simply rewrapped"), so an entity
edited afterwards has only its successor on the new epoch while the floor
edition stays behind on the old one. `prev == floor.hash` is a stronger proof
the chain connects than mere presence, and it was being ignored. Worse, the
gap made `admissible` — the pre-filter every derived fold shares — keep only
`version < floor.version`, so the newer edition was dropped every refold: a
role edit, channel rename or banlist entry reverting itself for that user.

Checked against Armada (semantics only, AGPLv3) and the canonical spec before
changing consensus behaviour. Armada selects an arm per entity, and Amethyst
was missing both halves:

1. Its chain-walk arm has three anchor branches to our one; the missing
   `versions[0] === floor + 1n -> bytesEq(lo.prevHash, floorHash)` is now
   implemented.
2. Once an entity has been re-wrapped into the epoch being folded it does not
   chain-walk at all — it anchors on version alone (`bootstrapHead`), because
   behind a compaction dangling `prev`s are normal and, since seal signatures
   survive re-wrap, any group-key holder can re-serve a genuine old edition
   under the current group. A re-wrap cannot raise the version inside the
   signed seal, so version is what bounds that. This is the half that fixes
   the observed pin; branch 1 alone would still refuse a floor-v1 entity whose
   only served edition is v3.

Implemented as an optional `snapshot` (the rumor ids of the epoch being
folded), defaulting to null = pure chain walk for every other caller.
`ConcordCommunityState.fold`/`authorizedHeads` capture it from their own
editions argument before `admissible` re-seats older-epoch heads — Amethyst
folds exactly one epoch per call, so the argument is the snapshot. `admissible`
and `candidates` now ask `foldEntity` whether it gapped rather than
re-deriving the test, so the three sites cannot drift apart again.

`LOG_GAP` also dedups on (entity, floor, offered): the same refusal was
re-reported on every refold, 22 byte-identical warnings a boot, which read as
22 attacks rather than one unchanged state. Deduping is what made the two
genuinely distinct refusals visible and led here. Fails open above 4096
distinct refusals — a flood is when the warnings matter most.

Anti-rollback is unchanged: all 12 pre-existing floor tests still pass, and
three new negative tests cover the fork, the below-floor re-serve, and an
entity absent from the snapshot. Device gaps for the pinned entity: 21 -> 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:01:14 -04:00
Vitor PamplonaandClaude Opus 5 60e3df72b2 fix(logs): make the default log a boot narrative, not a firehose
A cold start emitted 6,198 app lines in 80s (~77/sec) on a real device.
Six tags produced 83% of it, and the lines that are actually actionable —
relay protocol refusals like `auth-required`, `rate-limited`,
`unsupported: too many filters` — were buried among them.

`Log.minLevel` was `DEBUG` in debug and `ERROR` in release: two settings,
both wrong. Release dropped all 460 `Log.w` call sites, so the field never
saw a single relay refusal. Debug kept everything.

Now debug defaults to INFO and release to WARN, with `Amethyst.VERBOSE_LOGS`
to restore the firehose. 333 files already route through `quartz.utils.Log`
(only 13 use raw `android.util.Log`), so `minLevel` is a real global gate —
which is why three of the four loudest buckets needed no code change at all:
relay socket lifecycle (1,659 lines), `RelaySpeedLogger` (1,308) and Arti's
per-stream SOCKS errors (753) were already `Log.d`.

The rest:
- `RelayLogger.onCannotConnect` E -> D. Under the outbox model a boot dials
  ~400 relays and most fail identically; one line per dead socket is not
  actionable, and its multi-line TLS certificate dumps were 3-4 logcat lines
  each. This one mattered most: at `Log.e` it survived every level.
- `BootRelayDiagnostics` rollup -> INFO, per-relay WASTE/SERVE tables -> DEBUG.
  The census line carries the aggregate the 655 per-socket errors spelled out.
- `Duplicated/SPAM` -> DEBUG: a detection is the filter working, and it is
  already reported via `relayStats.newSpam()` and `flowSpam` to the UI.
- `MarmotDbg` "not a member of group" -> DEBUG: relays serve kind:445 for
  every group they carry, so this is the steady state. It was burying the
  real warning next to it ("Generation N already consumed").

Measured on emulator (4 accounts, Tor on): 6,198 -> 433 lines. Flipping
VERBOSE_LOGS gives 5,574 back, so nothing was deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:01:14 -04:00
Claude 14046fd0ce fix: gate the Buzz Tor→clearnet banner on real relay reachability
The "Can't reach this relay over Tor" card on a community's screen was
driven by a bare 6s timer: `connectTimedOut` flipped true after the delay
and never flipped back, and the condition never consulted the relay at
all. On a Buzz relay the banner is an unconditional item in the sectioned
list (the empty-list guard only covers the vanilla NIP-29 branch), so it
appeared six seconds after opening any community and stayed up while the
relay was connected and delivering messages.

The Tor half was wrong too: `torType != OFF` says Tor is enabled somewhere,
not that this relay is routed through it — onion, localhost and the
per-role presets (trusted / DM / new) each decide independently.

Gate the offer on live signals instead, extracted into a pure predicate:

- routed over Tor  — TorRelayEvaluation.useTor, the same predicate the
  relay pool dials with
- Tor is up        — TorManager.status is Active, so a bootstrapping Tor
  isn't misreported as the relay blocking exits
- not connected    — client.connectedRelaysFlow(); a relay that answers is
  reachable by definition
- nothing loaded   — no channels/DMs on screen, so a reconnect blip can't
  flash the banner over a live list
- grace elapsed    — unchanged, so a slow first connect isn't nagged

Adds unit tests for each gate, including the reported regression
(connected and delivering -> banner stays hidden).
2026-07-29 21:43:08 +00:00
Claude 91bd2b44ee fix: re-probe the local Blossom cache when the toggle flips
Flipping either local-cache setting called probe.invalidate(), which only
clears the TTL timestamp — it never re-runs the probe. Nothing else does
either: isAvailable() is reached only from the one-shot startup warm and
from inside findServers().

That leaves the feature dormant when enabled mid-session. The feed image
path gates on `available.value` (AccountViewModel.useLocalBlossomBridge
and shouldBridgeBlossomCache), so while the probe reads false no request
ever routes through the resolver, and the resolver is the only thing that
would have refreshed the probe. The settings "detected" chip reads the
same flow, so it also stays stale.

Re-probe right after invalidating so enabling the toggle activates the
feature — and reports its true state — in the same session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
2026-07-29 21:34:45 +00:00
Vitor PamplonaandGitHub 09902fe470 Merge pull request #3816 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-29 16:17:43 -04:00
Vitor PamplonaandGitHub 14b615b10d Merge pull request #3810 from dmnyc/feat/nip73-comment-previews
feat(nip73): show rich previews for external-content comments
2026-07-29 16:17:30 -04:00
vitorpamplonaandgithub-actions[bot] 8678c88db6 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-29 20:15:13 +00:00
Vitor Pamplona 6fac964d16 Improving the shape of the mention icon 2026-07-29 16:11:40 -04:00
Vitor PamplonaandGitHub 96a896f78e Merge pull request #3814 from vitorpamplona/claude/corcord-buzz-height-difference-8fp108
Remove facepile from channel rows, simplify layout
2026-07-29 15:41:17 -04:00
Vitor PamplonaandGitHub 1c24ba9cf6 Merge pull request #3815 from vitorpamplona/claude/push-notification-icons-branding-dy4egx
Add Nostr badge watermark to notification icons
2026-07-29 15:40:58 -04:00
Claude 9e4aedee05 fix: enlarge the media Amethyst peak another 10%
Gem height 9.68 -> 10.65.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:39:11 +00:00
Claude eae0ca814d fix: raise the media Amethyst peak 2%
cy 15.62 -> 15.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:36:43 +00:00
Claude ec60834046 fix: enlarge the media Amethyst peak 10%
Gem height 8.8 -> 9.68.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:31:48 +00:00
Claude 2684d2d4a8 fix: nudge the media Amethyst peak down 1% and right 2%
cx 15.28 -> 15.76, cy 15.38 -> 15.62.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:29:55 +00:00