Clearing a connected client on logout had two gaps:
- The user-facing "Forget this app" button only revoked the permission ledger;
it never cleared the NIP-46 client store, so a forgotten app's metadata and
relays lingered and were re-recovered on the next restart. Route NIP-46
coordinates through the host's new forgetClient() so the store is cleared too.
- Neither logout path stopped the RUNNING session from listening on the app's
relays — only the next restart picked up the change. extraRelays is now a live
projection of the client store (recomputed on connect, on start, and on
disconnect via a new onDisconnected hook), so a forgotten app's relays are
dropped immediately.
onLogout and the UI Forget now share one authorizer.forget() path (revoke grant
+ clear store + clear throttle entry + signal the host), so client-initiated and
user-initiated disconnects behave identically. Adds tests for both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The per-app signing-authorization plumbing was named/located under `napplet/`
for historical reasons, but it is not napplet-specific — it already gates
napplets, the sandboxed browser, and (now) NIP-46 remote clients through one
shared ledger. The package name mislabelled what the code is, so:
- commons: `napplet/signers/` (generic) → `connectedApps/signers/`
(AppSignerPolicy, NostrOpDecision, NostrSignerOp, NostrSignerConsentPrompt,
NostrSignerPermissionLedger/Store). The NIP-46-specific bridge moves to
`connectedApps/nip46/` (Nip46PermissionAuthorizer, Nip46ClientStore), so the
feature is no longer split across unrelated packages.
- The `NappletRequest.toSignerOp()` extension — napplet protocol leaking into
the generic layer — moves back to `napplet/protocol/`.
- amethyst: `napplet/DataStoreNostrSignerPermissionStore` → `connectedApps/`,
`napplet/DataStoreNip46ClientStore` → `connectedApps/nip46/`.
Pure move + repackage: all 27 import sites updated, no behaviour change.
Napplet-specific code (broker, capabilities, consent, :nappletHost) and the
Connected Apps UI folder are untouched — those really are napplet/UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Follow-ups to the audit:
- Abuse protection: the signer service now bounds its event queue
(DROP_LATEST) and rate-limits per author BEFORE decrypting — decryption can
be an external-signer (NIP-55) IPC round-trip, so a looping or hostile client
can no longer force one per event or grow the queue without limit. Fixed
window (default 40 requests / 10s per author, oldest authors evicted). The
limiter is touched only by the single consumer coroutine, so it needs no
locking. Covered by a headless test.
- logout now clears the client's persisted metadata/relays too (not just the
ledger grant), so a disconnected app stops being listened for after restart.
Not changed: get_public_key/ping stay ungated — gating them behind a prior
connect risks breaking clients that discover the pubkey at connect time, and
the pubkey is already public, so the enumeration leak is negligible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Findings from an audit of the signer, all verified against the code:
- Data race: NostrConnectSignerService deduped request ids inside onEvent,
which the relay pool invokes CONCURRENTLY from each relay's socket thread
(PoolRequests dispatches listeners outside its lock). Two relays delivering
the same subscription could mutate the LinkedHashSet at once → race / CME.
Move dedup into the single consumer coroutine; onEvent now only does the
thread-safe channel send.
- Swallowed cancellation: broad `catch (Exception)` around suspend calls in the
processor, the service's decrypt + publish, and connectViaNostrConnect caught
CancellationException too, breaking structured cancellation when the service
restarts. Rethrow it first (matching the AccountCacheState convention).
- Write amplification: the ledger wrote last-used to that client's DataStore
file on EVERY authorized request (unthrottled, unlike the relay-auth store).
Coalesce to at most one write per client per 60s in the authorizer.
- Redundant resubscribe: the enable/relays collector lacked distinctUntilChanged,
so a duplicate inbox-relay emission tore the subscription down and re-opened
it on every relay for nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The signer's relay subscription lives in the account scope and needs the shared
NostrClient connected and the process alive to keep working while Amethyst is
backgrounded or closed. Hook it into the existing always-on foreground service
(and its five restart layers) instead of building a new one:
- AlwaysOnNotificationServiceManager now starts/stops the layers when EITHER the
notification service or nip46SignerEnabled is on (combined flow).
- NotificationRelayService.isEnabled (the auto-restart guard) honors the signer
flag too, so START_STICKY / watchdog / boot restart keep the signer up.
- The signer screen notes that a background connection (ongoing notification)
is what lets it answer requests while closed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Add a Nip46ClientStore (commons interface + InMemory + a single-file Android
DataStore) keyed by the same signer-namespaced coordinate as the permission
ledger, holding each connected client's self-declared name/url/image and the
relays it reaches us on.
- The host persists metadata on connect (bunker + nostrconnect) and, for the
nostrconnect flow, the app's own relays. On startup it re-adds those relays
to the listen set, so a nostrconnect-paired app stays reachable across app
restarts instead of silently going dark until it re-pairs.
- Connected Apps now shows the app's real name (falling back to the generic
label + npub) for remote-signer clients.
- Wired the store through AppModules → AccountCacheState → Account → host.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The Connected Apps signer store is app-global, so a remote client keyed only by
its own pubkey would share one trust level across every local account. Namespace
the coordinate as `nip46:<signerPubKey>:<clientPubKey>` so the same client paired
with two accounts on one device gets independent grants.
- Nip46PermissionAuthorizer takes the user's signerPubKey; coordinateFor/belongsTo
encode + match the namespace; clientPubKeyOf reads the trailing segment.
- onLogout now revokes the client's grant (wired through the new quartz hook).
- Connected Apps lists only the active account's remote clients (napplet/browser
grants stay app-global); the signer screen counts the same way.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
- NostrConnectSignerService now bounds its request-id dedup set (LinkedHashSet
with an evicting cap) so a long-lived bunker can't leak memory on the ids it
has seen.
- Add NIP-46 `logout`: the processor recognises the method, acks it, and calls
a new Nip46RequestAuthorizer.onLogout hook (default no-op) so a host can
revoke the app's grant when it disconnects.
- New NostrConnectSignerServiceTest drives full request→reply round trips
(connect/sign/logout + drop-if-not-addressed) through the service over a fake
relay client with passthrough signers — headless proof of the subscribe →
decrypt → dispatch → publish wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Rework the signer settings screen from a plain toggle list into a purpose-
built pairing surface:
- A disabled-state hero (key medallion, headline, one big "Turn on signer"
call to action) that reads as a feature intro rather than a setting.
- A live status card with an animated pulsing dot summarising "signing for N
apps · listening on M relays".
- A QR hero: the bunker:// address rendered as a large scannable QR
(QrCodeDrawer) on a white surface — pairing is scan-first, with copy and
new-secret as tonal actions and the raw string kept as a caption.
- Scan-to-connect: a primary "Scan a code" button opening the QR scanner for
nostrconnect:// offers, with paste-a-link as a revealable fallback.
- A connected-apps row showing the live count and linking into Connected Apps.
Reuses the existing QrCodeDrawer / SimpleQrCodeScanner composables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Adds the "Nostr Signer" feature: Amethyst can now be a remote signer (a
"bunker") for other apps, listening on the user's inbox relays and signing
through whatever signer the account uses — a local key or a NIP-55 external
app — gated by the shared Connected Apps trust ledger.
- Nip46SignerState: account-scoped host that runs the quartz
NostrConnectSignerService on the inbox relays whenever the feature is on,
restarting on relay/toggle changes. Builds the bunker:// advertisement,
handles nostrconnect:// paste pairing, and manages the pairing secret.
Requests are authorized through Nip46PermissionAuthorizer (the Connected
Apps ledger), so a remote client is a connected app under nip46:<pubkey>.
- AccountSettings: persisted nip46SignerEnabled toggle + nip46BunkerSecret,
wired through LocalPreferences.
- Account/AccountCacheState/AppModules: build the signer ledger from the
app-global Connected Apps store and construct the host per account.
- UI: a Nostr Signer settings screen (enable toggle, listening status,
copyable bunker address with secret regeneration, nostrconnect:// connect
box, link to Connected Apps) + settings-catalog entry + route. Connected
Apps renders nip46: clients as remote-signer cards with their trust chip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Nip46PermissionAuthorizer implements the quartz Nip46RequestAuthorizer by
routing every remote-signer request through the shared Connected Apps
permission ledger (NostrSignerPermissionLedger). A NIP-46 client becomes a
connected app under the coordinate `nip46:<clientPubKey>`, so it reuses the
same per-app trust levels and per-op overrides as napplets and web origins:
- sign/encrypt/decrypt requests map to NostrSignerOp and are allowed only when
the ledger's standing decision is ALLOW (ASK/DENY are refused — a background
signer cannot prompt, so access is granted ahead of time in the UI).
- connect validates the pairing secret, then registers the app at a default
REASONABLE policy (never downgrading a level the user already set) and echoes
the secret back.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Replace BunkerCommand's hand-rolled subscribe/decrypt/dispatch/publish loop
with NostrConnectSignerService + BunkerRequestProcessor, and route bunker://
/ nostrconnect:// URI parsing+building through NostrConnectURI. Behaviour is
unchanged (the CLI bunker still hosts the operator's own key and auto-approves
every request via a small CliAuthorizer that only checks the pairing secret);
this removes the duplicated protocol logic now that it lives in quartz.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Adds the bunker/signer half of NIP-46 as reusable, signer-agnostic quartz
components so Amethyst can act as a remote signer for other apps:
- BunkerRequestProcessor: turns a decrypted BunkerRequest into the
BunkerResponse the client expects, performing the work through whatever
NostrSigner the account uses (local keypair or NIP-55 external app).
Signing/encryption/decryption are gated through a Nip46RequestAuthorizer;
public reads (get_public_key/ping/get_relays) are not.
- Nip46RequestAuthorizer: the permission boundary the host app plugs its
own trust model into (connect validation + per-op authorization).
- NostrConnectSignerService: subscribes to kind-24133 requests on a relay
set, decrypts, dispatches to the processor, and publishes the reply.
- NostrConnectURI: KMP-safe parse/build for bunker:// and nostrconnect://
pairing URIs (percent-encoded), shared by CLI/desktop/Android.
Unit tests cover the dispatch/authorization matrix with a fake signer (no
crypto) and the URI round-trips.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Replace the Material 3 DockedSearchBar with a persistent, pill-shaped
search field that filters the settings list in place instead of opening
a results dropdown. The docked component is aimed at a search surface
with its own results view (and the tablet/desktop form factor); an
in-page filter — as in the Android system Settings app — is a better fit
here.
- Rounded, tonal search field pinned above the list (Material 3 look).
- Typing narrows the categorized list directly; a blank query shows all.
- Clear (X) resets the query; "no results" state when nothing matches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQYnKXff1esD1bdPGNzyHp
With onPrimary contrast-picked, the default filled Button/FAB/Switch already
reads correctly on both themes, so the dedicated deep-fill path is redundant.
Remove it everywhere for one uniform look:
- Revert the Save/Post/Send/Create top-bar button and standalone SaveButton to
plain Button (no colors override).
- Revert all 27 AmethystSwitch call sites back to Switch and delete the
AmethystSwitch wrapper + amethystSwitchColors.
- Drop the filledAccent / onFilledAccent ColorScheme getters.
- Update the color-pairs preview: primary/onPrimary now covers the whole
filled-control surface.
The entire filled-control fix is now the single onPrimary = onAccent(primary)
line in the scheme builders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
Drop the old-vs-new normalization comparison now that the color direction is
settled; keep ThemeColorPairsPreview (fg/bg pairs by ColorScheme role, dark |
light) and bump it to heightDp=980 so all rows render. Revert darkColors/
lightColors back to private (the removed preview was the only external caller).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
A live relayop.xyz kind-33301 invite bundle (vsk=6) decrypted correctly but
failed to open: its JSON diverged from quartz's CommunityInvite model on two
CORD-05 wire details, so decodeOrNull returned null and the invite reported
Unreadable ("This invite link can't be opened...") instead of joining.
- InviteChannel.key was required; a public channel (e.g. an unencrypted
`general`) carries no delivered grant key and some reference clients omit
the field. Default it to "" so a keyless channel no longer rejects the
whole bundle.
- icon was modeled strictly as an ImagePointer object; relayop emits a bare
public URL string for an unencrypted icon. Add LenientImagePointerSerializer
(a JsonTransformingSerializer) that lifts a string into ImagePointer(url=...)
on read while still serializing the canonical object form on write.
Adds a regression test driving the real live bundle + its fragment token,
asserting it now classifies as Live. This is a distinct interop case from the
existing vsk=8 mis-posted-registry test: here the sub-kind, token, and crypto
are all correct — only the JSON schema was too strict.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iLfo68yVtG2ALkKB8tpAa
Revert onPrimary from a hardcoded white back to onAccent(primary) in both
schemes. On the dark theme primary is a light pastel, so white content on it
was only ~2.6:1 — every filled Button and FAB (which default to
primary/onPrimary) rendered washed out. onAccent picks by contrast: black on
the dark theme's light accents (~7.9:1), white on the light theme's deep
primary (unchanged). One theme-level change fixes all ~90 filled-control sites
without touching a single element. Save button + switches keep their dedicated
filledAccent fill, left as an on-screen A/B against the lighter default look.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
Switch the Settings search from a custom expand-into-the-top-bar icon to
the Material 3 DockedSearchBar component, matching the modern in-page
search convention (e.g. Android Settings).
- A persistent search pill sits below the "Settings" title bar.
- Tapping it expands the docked results dropdown, listing the filtered
settings (a blank query lists everything, narrowing as the user types).
- The back arrow, the system back gesture, and picking a result all
collapse the bar and clear the query.
- The full categorized settings list shows below the pill while collapsed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQYnKXff1esD1bdPGNzyHp
A live HLS stream shared as a plain kind:1 note (a FAST/IPTV channel `.m3u8`)
played its first ~60 s window and then broke or looped. Root cause: the
isLiveStream flag is derived from the Nostr event kind — only kind:30311 live
activities set it — so a live `.m3u8` in a note arrived flagged non-live and was
routed to the caching data source. Caching a live playlist makes ExoPlayer reload
a stale, non-advancing manifest and throw PlaylistStuckException, after which
playback loops replaying the frozen window.
Caching: learn liveness from ExoPlayer instead of the URL or event kind.
HlsLivenessRecorder records the playlist's live/on-demand verdict into
HlsLivenessCache once the media playlist resolves; CustomMediaSourceFactory routes
the next play by it (shouldBypassCache, pure/tested). A live stream is never cached
(the unclassified first play bypasses too), while immutable multi-rendition NIP-71
VOD is cached from its second view. The verdict is asymmetric on purpose — a wrong
"live" only forgoes caching, a wrong "on-demand" breaks playback — so live is
recorded eagerly while on-demand is recorded only from a resolved static window at
STATE_READY. That keeps a live stream's early/placeholder timeline, and a
geo-blocked stream that serves a VOD-shaped placeholder and then 403/404s before it
plays, from being mislearned as cacheable.
Error recovery: recover only ERROR_CODE_BEHIND_LIVE_WINDOW (seek to live edge +
re-prepare) for genuine live-edge drift. An earlier broad "recover any live I/O
error" thrashed on a stream whose segments fail to parse — it re-prepared, briefly
reached READY, hit the same bad segment, and reset its cap on READY, so it looped
forever. I/O and decode errors are now terminal (RenderPlaybackError's overlay),
and the recovery budget refills only after real forward progress past the error.
Restore ThemeColorPairsPreview (every fg/bg pair listed by ColorScheme role
name, current theme, dark | light) next to the by-component old-vs-new table.
The role view answers "what is primaryContainer right now"; the comparison
answers "how did this surface change". Both read the live scheme.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
The Settings screen previously showed a permanent search text field below
the top bar. Replace it with a search icon in the top bar's actions slot
that expands into an inline text field when pressed.
- Collapsed: shows the "Settings" title plus a search icon action.
- Expanded: the title becomes an auto-focused inline search field, the
back arrow (and system back) collapses it and clears the query, and a
clear (X) action appears while there is text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQYnKXff1esD1bdPGNzyHp
Replace the single-theme pair preview with a side-by-side comparison: each app
surface (Save button, checked/unchecked switch, settings tile, card, links,
error) rendered Old·Dark | New·Dark | Old·Light | New·Light with live contrast
and hexes. "Old" reconstructs the pre-normalization scheme (purple primary +
teal secondary, Material's violet-tinted surfaces/containers, deep default
onPrimary); "new" uses the real production schemes, so editing Theme.kt
re-renders both sides. Makes darkColors/lightColors internal so the preview
builds the actual current scheme rather than a copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
The modernized UI Preferences screen (merged from main) added a raw Switch,
which would show the old pale checked colors. Point it at AmethystSwitch like
every other toggle so it picks up the deep filledAccent fill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
The 1dp outlineVariant outline around every SettingsSection card read boxy.
Replace it with a surface-tone step: fill the card with surfaceContainer
instead of surfaceContainerLow. The neutral surface ramp puts the page at
background #FDFDFD, where …Low (#F7F7F7) was nearly invisible; …Container
(#F2F2F2) reads as a soft card in light and #252525 stands clear of black in
dark — the Material way to separate a card, without an outline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
Caught by the first successful build after the Gradle distribution became
seedable in the web sandbox:
- BooleanSwitchTile's toggle inferred as (Boolean) -> Boolean because
MutableStateFlow.tryEmit returns Boolean; annotate it (Boolean) -> Unit so
it fits Switch.onCheckedChange and SettingsControlRow.onClick.
- Font tile referenced MaterialSymbols.Article, which lives under the nested
AutoMirrored object; use the top-level MaterialSymbols.Description glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
A living reference composable that renders each foreground/background pair
the mobile theme uses (filledAccent, primary, containers, surfaces, the
unchecked-switch pair, …) with the real AmethystTheme colors — no
approximation — so editing Theme.kt re-renders it. Left column dark, right
column light, via the existing ThemeComparisonRow. Each row shows the live
WCAG contrast, the two hexes, and the concrete in-app surfaces that use it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
Upgrade the preview to render the real screen — top bar plus the redesigned
content — side by side in dark and light (ThemeComparisonRow), so both
themes and the card-hairline contrast can be checked at a glance instead of
the content-only, chrome-less preview.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
Applies the audit findings on the reworked screen:
- Cards get a 1dp outlineVariant hairline so they separate from the page in
light mode, where surfaceContainerLow (#F7F7F7) barely differs from the
background (#FDFDFD). Applies to every SettingsSection, keeping settings
screens consistent.
- Segmented buttons drop the default selected check icon (the fill already
signals selection) and use compact labels in the tight 3-4-up rows
(Wi-Fi, Full/Simple/Fast, System/Sans/Serif/Mono) so labels stop
ellipsizing to "Unmet…", "Simpl…", "System D…".
- Language becomes a disclosure row (icon + title + current language +
chevron, whole row opens the existing picker dialog) instead of a lone
text-field dropdown, matching the app's other "opens a picker" rows.
- Font-size preview uses a gentler 13sp base so the row no longer lurches
taller for "Huge"; still previews small-to-huge.
- Accent swatches use FlowRow so all six wrap into view instead of scrolling
off-edge with no affordance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
The font family and font size segmented rows now render each option's label
in the very typeface / at the very scale it selects — Sans Serif in sans,
Monospace in mono, Small small and Huge huge — so the control demonstrates
the choices instead of only naming them.
Adds an optional per-option text-style hook to SegmentedChoiceTile, reusing
the existing FontFamilyType.toFontFamily() mapping and FontSizeType.scale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
Replace the flat list of dropdown (TextSpinner) rows on the UI Preferences
screen with the card-based settings design already used by the Compose and
security settings: contextual SettingsSection cards holding in-screen
SingleChoiceSegmentedButtonRow controls and Switch tiles, so every option
is visible and one tap away instead of hidden behind a spinner.
Group the settings by context:
- Appearance: theme, accent color, font, font size
- Media & Data: image preview, video playback, autoplay, URL preview,
profile pictures
- General: language, UI mode, immersive scrolling
The two boolean (Always/Never) settings — autoplay videos and immersive
scrolling — become proper switches. Language keeps a dropdown (too many
locales for segmented buttons) but restyled into the new card. The
externally-consumed SettingsRow overloads and language helpers are kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
With the Gradle distribution now pre-seeded in the web sandbox
(session-start.sh) and dependencies resolving through the proxy, spotlessApply
no longer fails for infra reasons. Remove the network-error skip branch so any
spotlessApply failure blocks the push: a failure now means a real
formatting/compile error, and the narrow regex can no longer mask one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7bocRTSPyXwz6zVMzDbKR
Claude Code Web routes github.com through a git-only proxy, so the Gradle
wrapper's distributionUrl (services.gradle.org, which 307-redirects to a
github.com release asset) can't bootstrap — it 403s even at Full network
access, and `./gradlew` fails before running any task.
Seed the pinned distribution in the existing web-only SessionStart hook,
reusing the same idempotent curl-download pattern already used there for the
Android SDK and Kotlin/Native deps: skip if already installed, else fetch
Gradle's OFFICIAL sha256 (served from services.gradle.org, reachable here),
download the zip from a mirror, and verify before extracting so a tampered or
wrong mirror file is rejected and never executed. The wrapper cache dir is
derived as base36(md5(distributionUrl)) so it survives version bumps.
Also pin distributionSha256Sum in gradle-wrapper.properties as defense in
depth: Gradle then verifies any distribution it installs (mirror-seeded, CI,
or local) against the known-good hash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7bocRTSPyXwz6zVMzDbKR
Correctness:
- GeoRelayDirectory.relays is now @Volatile; on the process-wide `shared`
directory the CSV refresh was written by one thread and read by others with
no memory barrier, so readers could keep using the FALLBACK list forever and
never route to the correct rendezvous relays.
- sendPostSync bails before cancel() when a geohash cell has no resolvable
relays, so the composer text + draft are preserved instead of the message
being silently dropped with its draft deleted.
- Teleport detection compares on the common geohash prefix; a cell finer than
the fixed 8-char device fix could never be a startsWith prefix, so the user
was wrongly marked teleported even when physically present.
Performance:
- GeoRelayDirectory.closest precomputes each relay's great-circle distance
once instead of recomputing the trig inside the sort comparator (was
O(n log n) haversine calls over the ~370-relay directory).
- GeohashChatChannel.relays() memoizes the derived set, invalidated by a new
directory version token, instead of re-sorting the whole directory (and
allocating a fresh Set) on every call.
- filterFollowingGeohashChats groups cells by relay into one filter each
(g = [cells]) rather than one REQ per (cell, relay).
Leak/thread-safety:
- FollowingGeohashChatSubAssembler.userJobMap is a ConcurrentHashMap and
endSub now removes the entry (it previously cancelled the jobs but left the
stale entry behind).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
The color standardization set the dark theme's `primary` to Purple200
(#BB86FC), a light pastel tuned for accent text/icons on black. Filled
controls (the top-bar Save/Post/Send/Create button and checked switches)
inherit `primary` + `onPrimary`, so they rendered as a washed-out pastel
fill under white content — low contrast on the black background.
`primary` can't simply be deepened: it's read by ~470 accent text/link/
icon sites that need a light tint on black, and `onPrimary` drives ~40 FAB
glyphs and chip labels. So instead of bending a shared role, add a
dedicated fill role:
- `ColorScheme.filledAccent` / `onFilledAccent`: on dark, deepen `primary`
halfway toward the accent's deep variant (carried in the scheme as
`inversePrimary`) for a rich, saturated fill; content picks black/white
by WCAG contrast. Light themes already use the deep variant, unchanged.
- Consumed only by the top-bar action button, the standalone SaveButton,
and a new `AmethystSwitch` wrapper (`amethystSwitchColors()`) that every
Switch call site now routes through.
Derives per-accent, so Blue/Green/Orange/Red/Pink get the same treatment.
`primary`/`onPrimary` are untouched, so links, NIP-05, chat bubbles,
unread dots and FAB glyphs are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
The location-chat nickname was in-memory only (ChannelNewMessageViewModel
.geohashNickname), so it reset to empty on process death. It can't be
recovered from relays either: the anonymous per-cell key publishes no kind-0
profile, and kind-20000 messages are ephemeral, so the nickname (a per-message
`n` tag) has no durable home on the network.
Persist it on-device instead, as a single global handle:
- GeohashChatIdentityState gains nickname()/setNickname(), stored in the
account's encrypted storage next to the per-cell device seed (survives
restarts, switches with the account).
- The chat screen restores it into the composer on room open, and the
nickname dialog writes it back on Save.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV