POWR and RUNSTR both publish kind 1301 but with incompatible tag schemas.
A POWR event previously rendered with the raw "33401:...:back-squat-bb"
coordinate as its activity label, no duration, and none of the set data.
Parse the POWR / NIP-101e dialect in quartz and render it in Amethyst:
- type tag for the activity (strength/circuit/emom/amrap), preferred over
the RUNSTR exercise verb; coordinate-form exercise tags no longer leak as
a verb.
- start/end session timestamps -> derived duration; completed flag.
- structured per-set exercise tags (kg weights, reps, rpe, set_type),
grouped per exercise template with volume/top-weight aggregates.
- WorkoutDisplay now shows Exercises/Sets/Volume stats and a per-exercise
breakdown (e.g. "Back Squat Bb -> 3 x 8 x 84 kg") in the viewer's unit.
Rendering interop only; Amethyst still publishes the RUNSTR-canonical form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
When replying to a note that is a kind 1 TextNoteEvent, is the root of a
new thread (no e-tags), and was itself posted from Amethyst (NIP-89
client tag), build a NIP-22 kind 1111 CommentEvent instead of a kind 1
reply. Forks keep using kind 1.
Applies across all kind-1 reply paths: the Android composer
(ShortNotePostViewModel), the notification quick-reply
(NotificationReplyReceiver), and the desktop composer (ComposeNoteDialog).
Adds Event.isClient / TagArray.isClient helpers (NIP-89, case-insensitive)
with unit coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7RyevA6jL1NuY7uev2agS
The media OkHttp client keeps 32 connections warm in a 5-minute pool but,
unlike the relay client, set no pingInterval. Hosts like blossom.primal.net
silently drop idle HTTP/2 connections between feed-scroll bursts. OkHttp then
pulls a dead connection from the pool and the request stalls until the read
timeout (30s wifi / 90s mobile), which users see as "the first image after a
pause takes forever".
Device MediaHttp logs showed the signature repeatedly:
blossom.primal.net total=30000ms ttfb=-1ms conn=reused error=SocketTimeout
Add a 10s HTTP/2 keepalive ping so dead pooled connections are detected in
seconds and retryOnConnectionFailure re-issues on a fresh one, mirroring what
the relay client already does.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously the Crowdin workflow ran two independent jobs that each opened
their own pull request: the crowdin/github-action sync PR ("New Crowdin
Translations") and the peter-evans seed PR ("Seed translator npub
placeholders").
Collapse them into a single job: the Crowdin action now only downloads
translations into the working tree (push_translations/create_pull_request
disabled), the seed script edits translators.json in the same checkout, and
one create-pull-request step opens a single combined PR on
l10n_crowdin_translations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kxm4Pq3rm4doVLqJ2LaXtr
The version-chip observer filtered releases by author only, so an
author with many apps pulled every release they ever published into the
observer's working set. Narrow the filter on the release `i` tag (the
app id) so only this app's releases are loaded.
A blind limit is avoided on purpose: LocalCache.filter applies
take(limit) before sorting by created_at, so a limit could drop the
latest release the chip needs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w
Replace the global newEventBundles + full-cache rescan with an
index-driven LocalCache.observeNotes(kind 30063 / author) observer, the
established idiom (NestLobbyScreen, OpenPollsState, DvmContentDiscovery).
The FilterIndex only wakes the observer when a matching release is
inserted, instead of re-scanning the whole addressables map on every
event app-wide.
Extract the NIP-82 release parsing (dTag-prefix + kind-30063 collision
handling) into shared helpers reused by findLatestNip82Release and
findAllNip82Releases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w
The Apps feed version chip read the latest SoftwareReleaseEvent (kind
30063) once via a produceState keyed only on the app event id. NIP-82
releases point back to the app through an `i` tag rather than an `a`
tag, so they are never indexed as replies to the app note and never
ping its flows. As a result a newer release arriving while the card was
visible left the chip showing the old version.
Re-scan LocalCache on every new-event bundle (the same pattern used by
the calendar RSVP/calendar scans) so the chip updates without a manual
refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w
The URL detector stripped a single trailing punctuation char unconditionally,
which dropped the closing ")" from legitimate URLs such as
https://en.wikipedia.org/wiki/Bitcoin_(disambiguation).
Make the trailing strip balance-aware: a trailing ")", "}" or "]" is kept when
the URL contains its matching opener (balanced), and only stripped when it is
unbalanced wrapping/sentence punctuation (e.g. "(see example.com)" or
"http://test.com)"). Commas without surrounding spaces were already kept inside
paths; this also adds "]" to the begin/end punctuation sets so an unbalanced
bracket is handled symmetrically with parens and braces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzZzVcMcuzjSdhD3xqCE87
The Blossom auth-header encoding (`Nostr <base64-event>`), the `/upload`
endpoint path, and the `X-Reason` failure header were each re-derived in
both the commons JVM `BlossomClient`/`BlossomAuth` and the Android
`BlossomUploader`, using two different Base64 APIs. Move these
protocol-level facts into the quartz `nipB7Blossom` package, where the
rest of the Blossom protocol lives:
- `BlossomAuthorizationEvent.toAuthorizationHeader()` / `rawToken()` +
`AUTH_HEADER_SCHEME`, mirroring NIP-98's
`HTTPAuthorizationEvent.toAuthToken()` that Blossom auth reuses.
- new `BlossomServerUrl` with `upload()` / `blob()` endpoint builders and
the `REASON_HEADER` constant.
Both transports now call these helpers instead of hand-building strings.
No behavior change for upload (existing desktop BlossomClientTest still
green); the Android delete URL now omits the trailing dot when no file
extension is known, matching BUD-02's `DELETE /<sha256>`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJgwV4Y99brVa97v7p3jJb
The settle/reset animation drove a critically-damped spring with the
fling's leftover velocity. A critically-damped spring does not oscillate
from rest, but when handed an initial velocity in the target's direction
its response still crosses the target once before decaying back. On a fast
reveal fling the top bar's offset shot well past 0 (measured ~+190px in a
test) — rendering the bar sliding below its resting position and springing
back, the "goes beyond its final position and then comes back" wobble that
only appeared on fast flings.
Clamp the settle Animatable to the visible travel range [-limit, 0] via
updateBounds, so hitting an edge ends the animation crisply with no
rebound. Add a regression test that steps the settle under a manual frame
clock and asserts the offset never crosses the resting edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEGCrB5uRBAZES1PSp4Ctz
Extends the CLI to fetch and verify NIP-5D napplet kinds, mirroring `amy nsite`
but adding the napplet-specific runtime checks.
- NappletCommands: `amy napplet fetch AUTHOR [--d ID] | --snapshot EVENT-ID
[--path P] [--server …] [--relay …] [--out FILE] [--timeout SECS]`. Fetches a
root (15129), named (35129, via --d), or snapshot (5129, via --snapshot
<event-id>) manifest; recomputes the NIP-5A aggregate hash and refuses a
manifest whose `x` tag doesn't match its path tags (`aggregate_mismatch`)
before touching any blob; then resolves the path with per-blob sha256
verification. Output adds `requires` (NAP capabilities), `aggregate_sha256`,
and `aggregate_verified`.
- StaticSiteFetch: new shared helper holding the Blossom download + resolve +
emit logic, so `nsite` and `napplet` don't duplicate it. NsiteCommands is
slimmed down to use it (also now reports the manifest `kind`).
Smoke-tested offline: bad-args, help, and dead-relay runs resolving cleanly to
not_found with the correct kind for all three napplet variants (15129/35129/5129)
plus a no-regression check on `nsite fetch`. The aggregate/per-blob verification
logic itself is covered by the quartz unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
Adds the full NIP-5D napplet manifest layer, plus the NIP-5A aggregate-hash
infrastructure it depends on. Follows the nip88Polls package structure (event
class + tags/ + TagArrayExt + TagArrayBuilderExt).
NIP-5A shared infra (nip5aStaticWebsites):
- XTag — the aggregate-hash tag ["x", "<sha256>", "aggregate"].
- SiteAggregateHash — computes/verifies the NIP-5A aggregate hash: sort the
per-path lines "<hash> <path>\n" lexicographically, concat as UTF-8, SHA-256.
Pinned by a test against an independently computed sha256sum vector.
- siteAggregateHash() parse + builder extensions.
NIP-5D napplets (nip5dNapplets):
- NappletSnapshotEvent (5129, regular), RootNappletEvent (15129, replaceable),
NamedNappletEvent (35129, addressable, d-tag) — all built on the NIP-5A
path/server/title/description/source/x tag set.
- RequiresTag — ["requires", "<bare-nap-name>"] capability declarations.
- NappletManifest interface — uniform accessors (paths/servers/requires/title/
…) plus computeAggregateHash()/verifyAggregate() shared across the three kinds.
build() auto-stamps the x aggregate (required for snapshots, recommended for
root/named).
- Registered all three kinds in EventFactory.
This covers the NIP-5D runtime verification contract end-to-end in quartz:
signature (core Event.verify), per-blob sha256 (StaticSiteResolver.verify), and
the aggregate x-tag (NappletManifest.verifyAggregate). Note the napplet kinds
5129/15129/35129 are distinct from the NIP-5A nsite kinds 5128/15128/35128, so
there is no collision.
Tests: SiteAggregateHashTest (vector + order-independence + tamper) and
NappletEventTest (build/parse round-trip for all three kinds, aggregate
verification, tamper detection, EventFactory routing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
When saving media to the gallery, a non-2xx response triggered a bare
check(response.isSuccessful), which threw IllegalStateException("Check
failed.") with no context. The error was caught and logged, but the
message was useless for diagnosing failures and produced a generic toast.
Include the URL, HTTP status code, and status message in the check so the
log and downstream error handling explain why the download failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cwv4DpPLTyiJP2Thv3H3jR
An emoji pack can carry duplicate shortcodes: NIP-30 puts no uniqueness
constraint on emoji tags, so foreign packs may repeat them and our own
addEmoji appends without a duplicate guard. The grid keyed items on
"${code}-${priv|pub}", so two same-code entries in the same visibility
bucket produced identical keys (e.g. "kohakucho-pub") and crashed the
LazyVerticalGrid with IllegalArgumentException.
Collapse to one cell per (shortcode, visibility) with distinctBy when
building the list. Beyond fixing the crash this is the correct UX: two
cells with the same shortcode are indistinguishable and share one delete
path (removeEmoji deletes by shortcode, dropping both). Dedup on code +
visibility so a legit public/private pair of the same shortcode survives.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01346aiAXBbdg5hMTAGydTqp
Some OEM ROMs (e.g. LineageOS/peridot on Android 15) crash with
"cannot use a recycled source in createBitmap" inside
MediaMetadata.Builder.scaleBitmap() when the legacy MediaSession path
sets metadata artwork.
media3 size-limits artwork using Resources.getSystem()'s
config_mediaMetadataBitmapMaxSize, which is unresolvable on these ROMs
and falls back to the full screen width. The over-sized bitmap is then
re-scaled by android.media.session.MediaSession.setMetadata(), and those
ROMs recycle the source bitmap during scaling. media3's
CacheBitmapLoader caches the now-recycled bitmap and reuses it on the
next metadata update, hitting createBitmap() on a recycled source.
Cap decoded artwork via DataSourceBitmapLoader.setMaximumOutputDimension
to the same framework limit the platform compares against (resolved from
the app context, 320dp default), so build() never re-scales the bitmap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2raH4U7FCpK99YMQfGVQS
ActivityResultLauncher.launch() throws ActivityNotFoundException on
devices without an app that handles IMAGE_CAPTURE / video capture
intents, crashing the app from a background dispatcher. Wrap the launch
in launchOrToast(), which runs on the main thread, catches the
exception, shows a toast, and dismisses the capture flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhTdM5SuUo6WMTCsZz6XWG
An ignored external NIP-55 signer prompt surfaces as
SignerExceptions.TimedOutException. Relay auth (NIP-42) signs replies in a
fire-and-forget scope.launch whose host scope (e.g. viewModelScope) carries no
CoroutineExceptionHandler, so an uncaught timeout there reached the platform
default handler and crashed the app ("Could not sign: User didn't accept or
reject in time.").
Guard the launch in RelayAuthenticator so signing failures are swallowed and
logged (re-throwing only CancellationException). Apply the same guard to
NostrSignerRemote's incoming-bunker-response launch, which decrypts untrusted
relay data on a handler-less scope. Add RelayAuthenticatorTimeoutTest covering
the swallowed-timeout and happy-path-still-sends-AUTH cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RM8zAKJNE8aAQL5nUboso
LiveStreamsFeedFilter.sort and DiscoverLiveFeedFilter.sort computed the primary
sort key, convertStatusToOrder(it.event), lazily inside the comparator. That key
reads OnlineChecker.isCachedAndOffline(url), which depends on a moving
five-minute window and on the checkOnlineCache LruCache. A background online
check can mutate that cache while the sort is running, so the same note could
compare as LIVE (order 2) in one pairwise comparison and offline (order 0) in
another. The resulting unstable ordering makes TimSort throw
"Comparison method violates its general contract!".
Snapshot the status order once per item before sorting (matching how
participantCounts/allParticipants are already precomputed) so the comparator
reads stable values.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwZzCdNMQoRWbgtZrp4SKH
The ForegroundServiceDidNotStartInTimeException came from MainActivity.onResume
calling NotificationRelayService.start() on every resume. Each
startForegroundService() re-arms Android's "must call startForeground() within
the timeout" requirement, even when the service is already running and already
foregrounded. The old initializeForeground() early-returned via an
`if (foregroundStarted) return` guard once it had been started once, so later
startForegroundService() calls were never matched by a startForeground() — the
re-armed requirement went unsatisfied and the OS crashed the whole app.
ensureForeground() now runs on every onStartCommand (startForeground() is
idempotent — it just refreshes the existing notification) and rebuilds the
notification with the current relay count so repeated calls don't flicker back
to "connecting". It also stopSelf()s on every promotion-failure path (not only
ForegroundServiceStartNotAllowedException), which clears the OS fgRequired flag
and cancels the pending timeout when promotion genuinely can't happen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hCJDhJmCNkzqb7SaMWQgB
DiscoverLongFormFeedFilter.sort (and ~44 other feed filters/view models)
sorted notes with the live DefaultFeedOrder comparator, which reads
Note.createdAt() on every comparison. When another thread swaps a Note's
event mid-sort (e.g. a newer replaceable/addressable event arriving from
a relay), createdAt() changes between comparisons and TimSort throws
"Comparison method violates its general contract!"
(IllegalArgumentException).
Migrate every amethyst Set<Note>/Iterable<Note> sort from
sortedWith(DefaultFeedOrder) to the existing sortedByDefaultFeedOrder()
helper, which snapshots createdAt() once per note so the comparator stays
consistent. The pinned-chatroom comparator in ChatroomListKnownFeedFilter
is given the same snapshot treatment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Sh5XNLssw9GJNxjkxZcRS
Address is a data class over (kind, pubKeyHex, dTag), the same fields
toValue() encodes, so distinct() dedupes on exactly the grid key without
the redundant toValue() projection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fxWguG7dXgwwe2Et3JBjR
Some OEM builds (e.g. ITEL S665L) report the Health Connect provider as
SDK_AVAILABLE yet fail to bind to the underlying service, so
getGrantedPermissions() throws RemoteException("Binding to service failed").
hasAllPermissions() ran this call without any error handling, and since it is
launched from a LifecycleResumeEffect coroutine the exception propagated
uncaught and crashed the app.
Catch the failure (matching the existing pattern in readNewWorkouts/aggregate)
and treat it as "not granted" so the workout carousel quietly stays in its
prompt state instead of crashing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmuJDzzin15Nfsn1jUYkHh
A kind 10030 emoji selection event can carry the same `a` tag more than
once. MyEmojiListScreen keys its LazyVerticalGrid items by
address.toValue(), so a duplicate address crashed Compose with
"Key ... was already used". Deduplicate on the same value used as the
grid key before rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fxWguG7dXgwwe2Et3JBjR
ForegroundServiceDidNotStartInTimeException crashes the whole app when a
service started via startForegroundService() never successfully calls
startForeground() within Android's ~10s window.
NotificationRelayService.initializeForeground() only called stopSelf() on
ForegroundServiceStartNotAllowedException. Any other failure to promote to
the foreground (OEM-specific RemoteException/IllegalStateException, a
resource lookup failure while building the notification, etc.) was logged
but left the service in a "started but not foregrounded" zombie state,
guaranteeing the timeout crash.
Now stopSelf() runs on every failure path, which clears the OS's fgRequired
flag and cancels the pending timeout. onStartCommand also bails early
(START_NOT_STICKY) when foreground promotion failed, so we don't spin up
relay coroutines on a service that's tearing itself down.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hCJDhJmCNkzqb7SaMWQgB
Wires the quartz NIP-5A resolver end-to-end so it can be exercised against
real manifests (interop / agents), without building the security-sensitive
WebView shell yet.
- commons BlossomClient: add download(url) — a Blossom GET returning raw bytes
(null on non-2xx; connection failures propagate so callers try the next
server). Does not verify the hash; that is the resolver's job.
- cli NsiteCommands: `amy nsite fetch AUTHOR [--d ID] [--path P] [--server …]
[--relay …] [--out FILE] [--timeout SECS] [--max-inline-bytes N]`. Fetches
the manifest (kind 15128 root, or 35128 named with --d) from relays, then
resolves one path through StaticSiteResolver, downloading from the manifest's
Blossom servers (plus any --server fallbacks) and accepting only the first
blob whose sha256 matches the manifest pin. Emits the verified path's bytes
(inlined for small text, or written to --out) with hash/server/content-type,
or a structured not_found / path_not_found / unresolvable error.
Thin-assembly only: all resolution + verification stays in quartz, the byte
fetch in commons. Smoke-tested offline: bad-args, help, and a dead-relay run
that resolves cleanly to not_found in both text and --json modes.
Also converts the StaticSitePathLookup file-overview KDoc to a plain block
comment to satisfy ktlint no-consecutive-comments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
Adds a platform-agnostic resolver for NIP-5A static-website / napplet (NIP-5D)
manifests in quartz commonMain, under nip5aStaticWebsites/resolver/:
- StaticSitePathLookup: request-path normalization (query/fragment stripping,
leading-slash insensitivity, root/dir -> index.html), slash-insensitive
path lookup over a manifest's path tags, and a web-asset Content-Type guess.
- StaticSiteResolver: hash verification, Blossom candidate-URL assembly, and a
suspend resolve() that downloads each listed server in order and accepts the
first blob whose recomputed sha256 matches the manifest pin. HTTP is injected
via a BlobFetcher typealias so quartz keeps no HTTP dependency.
The trust model is the point: the signed manifest is the authority, the Blossom
server is untrusted. A server that substitutes/corrupts a blob fails
verification and is skipped -- it can withhold content but never forge it.
Tests cover normalization, lookup, MIME guessing, and the security cases
(tampered server skipped -> falls through to honest server; all-tampered ->
Unresolvable; undeclared path -> PathNotInManifest without fetching).
Also adds quartz/plans/2026-06-19-napplet-nip5a-resolver.md documenting the
design and the open event-shape alignment questions (35128 vs 35129 manifest
kind, capability declaration vs NIP-89, aggregate build hash, server ordering)
to raise with the napplet author before the nsite/napplet event shape forks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C