Compare commits

...
Author SHA1 Message Date
Vitor Pamplona d18b7f770f perf(feed): defer animation transitions until there is something to animate
`updateTransition` and `AnimatedContent` allocate a Transition, its animation
list and its seeking state on *first* composition — but first composition has
nothing to animate, because target and initial state are the same value. In a
feed that is waste: every card scrolled in built six of them, and during a scroll
essentially none ever ran, since reaction counts and icons do not change in the
second a card is on screen.

`DeferredCrossfade` and `DeferredAnimatedContent` render the plain content until
the target actually moves, then build the transition seeded at the *original*
value via `MutableTransitionState` and immediately re-target it — so the first
real change still animates exactly as before, and later changes animate through
the now-live transition normally. The existing `isPerformanceMode()` branch,
which genuinely drops the animation, is untouched and still takes precedence.

Measured on an SM-T220 against a frozen corpus served by a local relay (a real
capture: 105 notes, 68 profiles, 501 reactions, 75 boosts, 22 zaps), interleaved
with the unmodified build, two runs per arm:

  frame duration P90    27.53 -> 26.90   -2.3%   (baseline spread 0.1%)
  frame overrun  P90    21.65 -> 17.44  -19.4%   (baseline spread 6.3%)
  frame duration P50                     -1.1%   (inside a 1.7% spread)

Modest at the frame level by nature: on this device the main thread sits blocked
in `postAndWait` on the RenderThread for roughly two-thirds of every frame, so
composition savings largely do not surface. Removing 24 flow subscriptions per
card, every clickable, or every counter each moved `postAndWait` by only ~2%.

`DeferredAnimationTest` drives the clock manually and asserts the outgoing and
incoming content coexist mid-transition, which only a running animation does; a
regression turning the deferral into a snap fails it.
2026-09-01 15:41:05 -04:00
826fc826db feat(notifications): surface NIP-34 PR replies, merges, closes, and drafts
Amethyst already notifies on NIP-34 issues (1621), patches (1617), pull
requests (1618), and PR updates (1619), but the four remaining
participant-facing kinds arrive on the device and go nowhere:

- **1622 GitReplyEvent** — legacy comment. Deprecated by NIP-22 but still
  in the wild (any old-shape ngit/gitworkshop event, and freshly-signed
  ones from clients that haven't migrated). Was fetched by
  `NotificationsPerKeyKinds2` and stored in `LocalCache`, but no
  notification-tab kind-gate and no push consumer branch.
- **1630 / 1631 / 1632 / 1633 GitStatus{Open,Applied,Closed,Draft}** —
  merged, closed, reopened, drafted. Not fetched at all: no relay
  subscription anywhere in the app asks for them for the current user,
  and no repo-scoped fetch pulls them for a visible PR either. As a
  result `GitStatusIndex.latestByTarget` — the source of the
  "closed/merged" pill on the repo listing — could only ever populate
  for the local user's own drafts, since nothing else lands in cache.

Symptom on `main` today: someone merges your PR on a NIP-34 relay
(mine, in a recent example) and Amethyst is silent. No badge on the
notifications icon, no push, no pill on the repo row, nothing. Opening
the PR thread will surface the status through the reply pane's
engagement fetch, but the user has to know to look.

## Fix

Wire all five kinds through the four notification-plumbing layers they
have to pass through, matching the existing patch/issue/PR shape:

1. **`FilterNotificationsToPubkey.NotificationsPerKeyKinds2`** — add
   the four status kinds so `#p`=me on inbox relays actually pulls
   merges/closes for PRs and issues the user participates in. NIP-34
   status events p-tag every prior participant of the target, so a
   pubkey filter is the right primitive.

2. **`FilterRepliesAndReactionsToNotes.RepliesAndReactionsKinds2`** —
   add PR-update (1619) and the four status kinds so when a repo,
   PR, patch, or issue row is on screen the engagement `#e`=<target>
   fetch pulls their status transitions and revision chain. This is
   the wire that finally makes `GitStatusIndex` see data for anyone
   who isn't a p-tagged participant.

3. **`NotificationFeedFilter.NOTIFICATION_KINDS`** + `tagsAnEventByUser`
   short-circuit — add reply (1622) and the four status kinds so the
   in-app Notifications tab renders them. Trust the p-tag relay gate
   (same policy applied to patches/issues/PRs above), because chasing
   a chain of prior status events to reconfirm participant relevance
   would require walking events that aren't guaranteed to be in cache.

4. **`NotificationDispatcher.NOTIFICATION_KINDS`** — add the same five
   kinds so `LocalCache.observeEvents` fires the push consumer. Flip
   the constant from `private` to `internal` so the new contract test
   can pin it against the in-app feed's set without opening it to the
   whole world.

5. **`EventNotificationConsumer.consume()`** — route each of the five
   kinds to `CodeNotification.notify(...)`, matching the existing
   patch/issue/PR/PR-update branches.

6. **`CodeNotification`** — five new `notify(...)` overloads. Reply
   uses a single title string. Status kinds pick their title from the
   *target*'s kind so a 1631 on a kind-1618 PR reads "merged a pull
   request" but the same 1631 targeting a kind-1617 patch reads
   "applied a patch" (matches gitworkshop's conventions). Falls back
   to a generic wording when the target isn't yet in cache — rare,
   because the p-tag subscription pulls a status event regardless of
   whether its target has ever been seen.

7. **`LocalCache.computeReplyTo`** — add `GitStatusEvent` and
   `GitPullRequestUpdateEvent` branches so status/revision events
   thread under their target patch/PR/issue in `Note.replies`. Only
   the marked-`root` `e` tag (for status) / `parentPullRequestId()`
   (for PR update); the repository `a` tag is not a reply target.

8. **`KindDisplayName`** — wire the four status kinds plus PR + PR-
   Update into the kind→label mapping used by the relay debug screen
   (the pre-existing `kind_git_pr` / `kind_git_pr_update` strings
   already existed but weren't wired; the status labels are new).

9. **Strings** — new `app_notification_code_channel_message_reply`,
   four `_status_open/applied/closed/draft` titles plus target-kind-
   specialized applied/closed variants (`_status_applied_pr`,
   `_status_applied_patch`, `_status_applied_issue`, and the closed
   trio); new `kind_git_status_{open,applied,closed,draft}` labels.
   `translatable="true"` (Crowdin's default) so translators can pick
   up appropriate phrasing.

Nothing changes for events the user isn't p-tagged on: the relay-side
filter is still `#p`=me. Nothing changes for the four kinds already
covered: their existing branches are untouched.

## Tests

New `Nip34NotificationCoverageTest` pins the full NIP-34 collaboration
surface across the four independent kind lists that have to move
together (relay subscription, engagement fetch, in-app kind gate,
push kind gate). Miss any one and one specific transition silently
drops. Tests explain the failure mode in each assertion message.

Existing `NotificationKindsContractTest` and every other test under
`notifications/*` still passes.

`./gradlew :amethyst:compilePlayDebugKotlin` clean.
`./gradlew :amethyst:testPlayDebugUnitTest --tests
"…notifications.*"` all green (58 tests including the 4 new).
`./gradlew spotlessCheck` clean.

(cherry picked from commit 3f2c52b97a68e6e3274443682ddbeddb3dbd6fe9)

Applied from nostr proposal
819c0ccc881ced7753675f9ba6a262579eb9772d8b910d14727b5531ede52014
(branch feat/nip34-pr-notifications). Cherry-picked rather than merged via
`ngit pr merge` because that proposal is not surfaced by `ngit pr list` --
it is absent from every status and `ngit pr view` reports "proposal not
found", even though the event is well formed on relay.ngit.dev with the
correct a-tag, p-tag and r-tag.

One fix folded in on top of the original commit: the new test imported
`RepliesAndReactionsKinds2` from
`com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers`,
which no longer exists. `FilterRepliesAndReactionsToNotes.kt` moved to
`commons` (`com.vitorpamplona.amethyst.commons.relayClient.event.watchers`)
in the 537 commits since this branch's merge-base. Git followed the rename
for the production edit but not for the new test file's hardcoded import, so
the branch did not compile as submitted.

Verified on current main after that fix:
- Nip34NotificationCoverageTest: 4 tests, 0 failures.
- Full *notifications* unit-test package: 5 classes, 24 tests, 0 failures.

Premise confirmed against main before applying: NotificationsPerKeyKinds2
carried 1617/1618/1619/1621/1622 but no 1630-1633, and neither
NotificationFeedFilter.NOTIFICATION_KINDS nor
NotificationDispatcher.NOTIFICATION_KINDS listed the status kinds -- so a
merge/close on a thread you participate in was fetched nowhere and rendered
nowhere.

Open question left for follow-up, not a blocker: nothing checks that a status
event's author is a maintainer in the repo's kind-30617 announcement, so any
pubkey can p-tag you with a 1631 and produce a "merged a pull request"
notification. The notification strings name the actor, so the claim is at
least attributable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 15:53:12 -04:00
6cf09f0d75 feat(namecoin): add electrumx2.testls.space LE-cert server to default list
Add electrumx2.testls.space:50012 to DEFAULT_ELECTRUMX_SERVERS and
TOR_ELECTRUMX_SERVERS as a redundancy endpoint for the testls.space
operator. It runs on the same box as relay.testls.bit (23.158.233.10)
but terminates TLS at nginx with a publicly-trusted Let's Encrypt cert
(CN=electrumx2.testls.space, issuer LE YE1) instead of the self-signed
relay.testls.bit cert on the standard ports.

usePinnedTrustStore is left at the default (false) since the system
trust store is sufficient, same as electrum.nmc.ethicnology.com.

The same nginx vhost also exposes WSS on port 50014, making it the
second browser-viable public Namecoin ElectrumX endpoint (alongside
electrum.nmc.ethicnology.com) for pure-browser Nostr clients that
cannot use self-signed certs.

(cherry picked from commit 645382a95b9a314eb6a4e1221ba97dfc1c13f1ae)

Applied from nostr proposal
c0eb8d1a09651c377827f4aa97c1ed7a2f93fafceb823233c5650506b661b8ba
(branch feat/electrumx2-le-server). Cherry-picked rather than merged via
`ngit pr merge` because that proposal is not surfaced by `ngit pr list` --
it is absent from all statuses and `ngit pr view` reports "proposal not
found", though the event is well formed on relay.ngit.dev with the correct
a-tag and r-tag. It appears to collide with a stale earlier proposal for the
same branch name.

Endpoint verified before applying:
- electrumx2.testls.space resolves to 23.158.233.10, the same host as the
  existing relay.testls.bit / 23.158.233.10 entries, as the commit claims.
- TLS on :50012 presents CN=electrumx2.testls.space issued by Let's Encrypt
  (C=US, O=Let's Encrypt, CN=YE1), so usePinnedTrustStore = false is correct.
- server.version reports ElectrumX 1.16.0 and server.features reports
  genesis_hash 000000000062b72c5e2ceb45fbc8587e807c155b0da735e6483dfba2f0a9c770,
  i.e. it indexes Namecoin rather than Bitcoin.

Note this is the third default entry pointing at host 23.158.233.10, so it
adds certificate-path redundancy (works where a self-signed cert is stripped)
rather than host redundancy. Low risk: nameShowWithFallback tries servers
sequentially and returns on first success, and this entry is appended last in
both lists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 15:38:19 -04:00
Vitor PamplonaandClaude Opus 5 9ea7c36cf9 Merge PR: fix: narrow FileProvider external root to the app-specific dir
Merges nostr proposal 91f762d3 into main:
- fix: narrow FileProvider external root to the app-specific dir

Replaces `<external-path path=".">` with `<external-files-path>`, so the
FileProvider no longer roots at /storage/emulated/0, and adds
FileProviderPathsTest to pin both directions on device.

Supersedes proposal a5d172d8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 11:38:37 -04:00
Vitor PamplonaandClaude Opus 5 69532badee fix: narrow FileProvider external root to the app-specific dir
`<external-path path=".">` rooted the provider at
Environment.getExternalStorageDirectory() (/storage/emulated/0), which is far
broader than anything Amethyst hands out. The only external-storage consumer is
TakePicture's getPhotoUri/getVideoUri, both of which write into
getExternalFilesDir(...) — so `<external-files-path>` describes the actual
surface exactly.

Not a live vulnerability: the provider is exported="false", every
getUriForFile() call site builds its File from app-controlled constants under
cacheDir or getExternalFilesDir, and the one name derived from event content
(shareIcs) is passed through IcsExport.safeFilename, which strips '/' — so no
attacker-influenced path can reach the provider today. This is defence in
depth plus an accurate declaration.

Prefer external-files-path over hardcoding the path under
Android/data/<applicationId>/: the latter is wrong for the .debug and
.benchmark applicationIdSuffixes, while external-files-path resolves per
variant. The `external_files` name is kept so the generated content:// URI
shape does not change.

FileProviderPathsTest pins both halves on device: the capture URIs still
resolve under /external_files/, cacheDir still resolves under /cache/, and a
file at the external-storage root no longer maps. Against the old config that
last case fails with
content://com.vitorpamplona.amethyst.debug.provider/external_files/Download/not-ours.pdf.

Supersedes nostr proposal a5d172d8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 11:36:31 -04:00
Vitor PamplonaandGitHub a1f9cc96f1 Merge pull request #4023 from davotoula/fix/save-video-to-movies-dir
Save videos to Movies/ instead of Pictures/ (fixes #4009)
2026-08-31 10:13:16 -04:00
Vitor PamplonaandGitHub a0a55af174 Merge pull request #4022 from davotoula/fix/nwc-omit-null-request-params
Fix/nwc omit null request params
2026-08-31 08:50:46 -04:00
davotoula defbdfbb28 Code reviews: apply review cleanups to the media-save fix and its tests
- Extract the duplicated drive-the-save harness (temp file, runBlocking save,
  success/error assertions) from both instrumented e2e tests into
  MediaSaverTestSupport, following the AvifInstrumentedTestSupport precedent,
  with UUID-based filenames per the existing convention.
- MediaSaverToDiskMediaStoreTest: record inserted rows as item Uris
  (ContentUris.withAppendedId) instead of Pair + hand-built _ID selection; trim
  the KDoc paragraph that re-quoted the MediaProvider rejection verbatim - the
  canonical copy lives on MediaStoreTarget.
- MediaSaverToDiskLegacyStorageTest: derive watchedDirs from
  MediaStoreTarget.entries instead of a third hand-maintained directory list;
  move the exact run recipe (assemble, install -g, am instrument) into the class
  KDoc and point the skip message at it; unfold the write-probe .also puzzle.
- MediaSaverToDisk: drop the outer withContext in saveDownloadingIfNeeded (both
  delegates now dispatch themselves, leaving the decision in the leaf writers);
  scope `val extension` to the pre-Q branch that uses it; drop the rot-prone
  composable file name from save()'s KDoc.

Considered and left alone: isSaveableMimeType deriving from MediaStoreTarget.of
(kept - one definition of the accepted set beats re-spelling the prefix triple);
the nested Dispatchers.IO in save()/downloadAndSave (load-bearing for direct
call sites, fast-path no-op when nested); the redundant launch(Dispatchers.IO)
at two call sites outside this branch.
2026-08-31 14:10:07 +02:00
davotoula af49bcc396 On device testing
test: only ever delete MediaStore rows the test itself inserted
test: cover the pre-Q save path on API 26
test: cover #4009 end-to-end against a real MediaStore on API 29
2026-08-31 12:38:49 +02:00
davotoula cbd0a4a174 fix: save videos to Movies/ instead of Pictures/ (#4009)
MediaProvider validates the primary directory of RELATIVE_PATH against the
collection being inserted into. saveContentQ paired
MediaStore.Video.Media.EXTERNAL_CONTENT_URI with Environment.DIRECTORY_PICTURES,
so every video save built content://media/external/video/media +
"Pictures/Amethyst" and Android 10 rejected it with

    IllegalArgumentException: Primary directory Pictures not allowed for
    content://media/external/video/media; allowed directories are [DCIM, Movies]

Newer Android releases don't reject the mismatch, which is why the crash only
reproduces on older devices - but the file still landed under Pictures/ rather
than Movies/ everywhere, confirmed on a current device.

Collection and directory now travel together in a MediaStoreTarget enum, so the
two cannot drift apart again, and the catch-all falls through to Downloads
(which accepts any file) instead of the Video collection. The MIME type is
resolved above the SDK_INT fork and both writers route through the enum: the API
level now decides how a file is written, never which directory it belongs in, so
the pre-Q path stops filing videos and PDFs under Pictures/ too.

The directory names are spelled out as literals because Environment's DIRECTORY_*
are plain static fields that the unit-test android.jar nulls out. The JVM test
covers the routing; MediaStoreTargetInstrumentedTest pins the literals back to
the platform constants on-device.

Stop leaking a file descriptor and blocking the UI on local saves
2026-08-31 12:38:49 +02:00
davotoula 34c60fada1 Code review: omit nulls one level down too + make the null-omission guard cover every method
fix(nwc): omit nulls one level down too, inside pay_keysend's TLV records
refactor(nwc): make the null-omission guard cover every method, on every target
2026-08-31 11:39:07 +02:00
davotoula 0030432e20 fix(nwc): omit absent request params instead of sending them as null
Viewing transactions on one NWC wallet failed with

    Invalid list_transactions params: from must be an integer

because Amethyst sent every optional parameter explicitly:

    {"method":"list_transactions","params":{"from":null,"until":null,"limit":20,
     "offset":0,"unpaid":false,"unpaid_outgoing":null,"unpaid_incoming":null,"type":null}}

NIP-47 marks those optional, and a wallet is free to type `from` as an integer
and refuse a null. Nothing in the request was wrong except the nulls.

The two serialization backends had disagreed since they were written.
Nip47RequestKSerializer builds every params object with
`params.x?.let { put("x", it) }`, so kotlinx has always omitted nulls; Jackson
serializes the params classes reflectively and wrote them. The same request was
two different documents depending on the platform, and only JVM/Android was
broken — which is why it survived: the tests that cover this shape run against
the backend that was already correct.

A Jackson mixin now applies NON_NULL to all twelve NIP-47 params classes. A
mixin rather than an annotation because the classes live in commonMain and
Jackson annotations are JVM-only.

The regression test asserts the property rather than the symptom: no request
type may emit a null param, and both backends must produce the same document.
The second is the one that would have caught this.

Not new to any recent change — the reflective serialization predates it. What
changed is that 24a8540ad9 surfaces a NIP-47 refusal instead of rendering it as
an empty list, so users now see the error rather than an empty transaction
screen. Older builds sent the same request and were refused just as silently.
2026-08-31 10:52:15 +02:00
Vitor PamplonaandGitHub 36819b1011 Merge pull request #4021 from davotoula/feat/nwc-outgoing-attribution
Nwc outgoing attribution (NWC-06)
2026-08-30 17:26:12 -04:00
davotoula 985b4c2ea3 fix(nwc): only claim a binding for a zap request the provider accepted
Kotlin review found the feature's own soundness property could be false on
the wire.

lnAddressInvoice drops the zap request for a provider that does not advertise
`allowsNostr` — `nostrRequest = if (allowsNostr) nostrRequest else null` — but
assembleInvoice set Payable.zapRequest unconditionally from the request it had
built. So paying a lightning address whose provider ignores `nostr=` still
attached metadata.nostr to the payment, for an invoice whose description_hash
commits to nothing about it. Every claim the feature makes — the KDoc, the
byte-identity test, the wallet-side binding check we asked BrollyZapper to
keep strict — rests on those bytes being what the callback hashed. Here they
were not.

A conformant wallet refuses such a row, so no false attribution was displayed;
what was wrong is that we asserted a binding we could not support, and spent
the 4096-char budget doing it. lnAddressInvoice now reports the request it
actually sent, and only that is carried forward.

The size estimate also counted raw string length for `comment`, which is free
text a user typed. JSON escaping expands it — a quote or backslash to two
characters, a control character to six — so an escaping-heavy comment could
breach the ceiling unnoticed, and NWC-06 makes the wallet drop the WHOLE
object then, taking recipient_data with it. escapedLength() counts what
actually reaches the wire; KEY_OVERHEAD drops to the fixed punctuation cost
now that escaping is no longer hiding inside it.

Both paths were untested and now have regression tests.

Not changed: dropMetadataIfUnsupported still mutates the caller's Request. The
review confirmed every current call site builds a fresh request inline, and
the contract is documented on both public send functions.

Verified: quartz + amethyst suites, commons/desktopApp/cli/geode compile,
spotless clean.
2026-08-30 22:31:29 +02:00
davotoulaandClaude Opus 5 c25517c2d6 refactor(nwc): share anyToJsonElement, and drop a refresh that never refreshed
Cleanup pass over the squashed branch. Net -109 lines.

anyToJsonElement was a second copy of a private helper that already existed in
ClinkKSerializers, serializing the same Map<String, Any?> shape. Worse than
tidiness: RawJson is declared in nip01Core and registered globally for Jackson,
but the kotlinx half lived inside one NIP's package, so a RawJson routed
through Clink's copy would have been emitted as a quoted, escaped JSON string —
exactly the corruption RawJson exists to prevent. One declaration now, beside
the other kotlinx serializers at nip01Core level, and Clink picks up the RawJson
and Array branches its copy lacked.

The getFresh call in fetchTransactions is deleted. Its own KDoc claimed it
re-read capabilities "bypassing the info cache's TTL", and getFresh does no such
thing: it returns a fresh entry as-is, so the case it was written for — a wallet
that added `06` twenty minutes ago — was the one case it could not cover. It
also refreshed the SELECTED wallet while zaps read the DEFAULT one. The send
path's currentOrFetch already fetches on cold and background-refreshes on
stale, so nothing is lost. A real force-refresh would mean a relay request per
refresh press, which is a policy decision rather than a cleanup.

Three KDoc blocks documented behaviour their function no longer had after the
walletInfo refactor: prefersNip44 kept four paragraphs about waiting, and
supportsMetadata opened "WAITS ON A COLD CACHE" while doing neither. The
rationale now lives once, on the one function that waits, and supportsMetadata
is inlined into its only caller. Also deleted a comment claiming a metadata-free
method "returns before the info cache is consulted" — both call sites fetch
first, so it never did.

Smaller: RawJson becomes a data class; the unused metadata parameter comes off
PayInvoiceMethod.create(bolt11, amount); TransactionRowLabels drops a derivable
flag and a twice-computed fallback; KEY_OVERHEAD's comment now says what its
slack is for; the three blank-description tests become one loop; a test that
asserted the Kotlin stdlib now calls displayDescription(); and two test comments
had lost their backticked literal to a heredoc.

Verified: quartz + amethyst suites, commons/desktopApp/cli/geode compile,
spotless clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019upqJTtAMNNfxKCDV1xDn3
2026-08-30 22:31:29 +02:00
davotoula 790458f9a0 Field-testing fixes: timings + read request's own bytes
perf(nwc): read the wallet's info event once per send, not twice
fix(nwc): wait for the wallet's info event before deciding it lacks NWC-06
fix(nwc): send the zap request's own bytes, not a rebuild of it
2026-08-30 22:31:29 +02:00
davotoula 6f71eb0c4c Code review: seal Request + gate metadata at the choke point
refactor(nwc): seal Request so a metadata-bearing method cannot be missed
refactor(nwc): gate metadata at the choke point, not the call site
2026-08-30 22:31:29 +02:00
davotoula 04d506e81e feat(nwc): name the payee on outgoing wallet transactions
Outgoing rows in the NWC wallet history showed an arrow, an amount and a
date, with an invisible blank line where the label should be. Two causes.

The row rendered an empty string. `tx.description ?: fallback` only catches
null, and wallets send `"description": ""` for a payment with no memo, so the
row got a `Text("")` — a line with the height of a real one and nothing in it.
NwcPaymentNotifier already guarded this; the screen did not. Resolution moves
out of the composable into a pure `TransactionRowLabels`, so the behaviour is
a unit test rather than a Compose one.

And we never told the wallet who we were paying. `PayInvoiceParams.metadata`
existed and nothing set it, while a NIP-57 invoice commits to a
description_hash rather than a memo — so the wallet had nothing to lift
either. ZapPaymentHandler held the signed zap request, the lightning address
and the message at the moment it fetched the invoice, and dropped all three.

Amethyst now sends NWC-06 metadata: the zap request, the recipient's address
and the comment. `nostr` is built from the event's TYPED fields, never by
re-parsing its JSON — a verifying wallet recomputes the event id from those
values, and toAnyValue() resolves numbers with toDoubleOrNull() BEFORE
toLongOrNull(), so a round-trip would emit "kind": 9734.0 on the kotlinx path
while the JVM path stayed correct. Over NWC-06's 4096-character ceiling the
zap request is dropped and the much smaller recipient_data/comment pair
survives, so the row still names the payee instead of arriving blank.

SENT ONLY TO A WALLET THAT ADVERTISES `06` in the info event's extensions tag,
which NwcInfoEvent now parses. Users pair with wallets we do not control, and
one that types metadata narrowly would accept today's "metadata": null but
refuse an object — costing a payment for a cosmetic field. The gate sits in
NwcSignerState where the request is built rather than at the call site, so no
caller can route round it, and "not yet fetched" reads as no. Every wallet
that has not advertised receives a request byte-identical to today's; there is
a test for exactly that.

The blank-string guard is what fixes existing history, for every wallet, with
no wallet change at all.
2026-08-30 22:31:28 +02:00
Vitor PamplonaandGitHub b10be95a6e Merge pull request #4020 from vitorpamplona/claude/patch-review-apply-wen13f
Give Trusted Lists and contact cards their own extractor branches
2026-08-30 13:43:13 -04:00
Vitor PamplonaandClaude 514f4b26f0 Give Trusted Lists and contact cards their own extractor branches
kinds 30392-30395 and 30382 had no branch in SearchFieldExtractor, so
both fell through to the generic `is SearchableEvent ->` case, which puts
the whole of indexableContent() in the TERTIARY (body) tier.

For a Trusted List that whole content IS its title, and a title is not
body text. On a tiered backend the difference is large and measurable: on
search-staging, a 30392 titled exactly "Verified Human" matched the query
`Verified Human` on the same rung as a profile whose bio happens to say
"humans are amazing" - 550 against 130 000 on that schema's ladder, a
236x discount - and reached the title only through trigram substring
rather than the prefix/typo columns a title normally gets.

A contact card decomposes the same way every other kind in this file
does: petname() is a trust provider's NAME for a person - the direct
analogue of kind 0's `name`, and what a people search is looking for -
and summary() is the description beside it.

The card's topics change ROLE, and that is the one behaviour change here.
topics() is TopicTag, which is the `t` tag under another name - same
predicate, same array - so the tiers() funnel already carries every topic
as a hashtag. The old fallback therefore indexed them TWICE, once inside
the concatenated body and once in the hashtag role; they are now carried
once, in the role, and whether that is tokenized or kept as keywords is
the backend's call per IndexableFields. Since build() puts petname and
summary in the NIP-44 content, topics are the only public text on a card
this library authors, so that shape is pinned by its own test - including
that a hashtags-only extraction does not normalize to None.

Nothing else changes what is indexed, only which tier each accessor lands
in. indexableContent() is untouched, so the SQLite and filesystem stores
(the only in-tree consumers, both of which index the flat form) are
bit-identical. SearchFieldExtractor has no in-tree consumer at all - it
is the protocol surface external tiered backends read - so the app, both
flavours, and every feed are unaffected by construction.

The encrypted half of a contact card stays out of the index as before:
petName()/summary() read the public tag array only.
2026-08-30 17:26:07 +00:00
David KasparandGitHub 91d4d66461 Merge pull request #4019 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-30 17:17:58 +02:00
vitorpamplonaandgithub-actions[bot] 8a43d707e6 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-30 14:44:32 +00:00
Vitor PamplonaandGitHub de3ce56bcc Merge pull request #4018 from vitorpamplona/claude/org-json-kotlin-serialization-bxhfcr
Migrate JSON parsing from org.json to kotlinx.serialization
2026-08-30 10:41:53 -04:00
Claude 2aa4a43337 refactor: consolidate JsonObject tree accessors and pin ime envelope parsing
Follow-ups from the branch audit:

- Adds commons/util/JsonTreeUtils.kt: one shared set of total, null-safe
  JsonObject accessors (parseJsonObjectOrNull, stringOrNull, intOrNull,
  longOrNull, doubleOrNull, booleanOrNull, objectOrNull, withString) for
  ad-hoc JSON trees. Replaces the two near-identical private sets this
  branch had introduced (nappletHost's JsonEnvelope.kt, now deleted, and
  EmbeddedImeBridge's file-local helpers) and FeedDefinitionSerializer's
  identical bool/int/long copies. FeedDefinitionSerializer keeps its
  deliberately stricter isString-guarded string(), now documented, and
  NappletProtocolJson keeps its throwing accessors (rejecting malformed
  input at the trust boundary is its job). Quartz's copies stay: quartz
  cannot depend on commons.
- Adds EmbeddedImeBridgeTest (16 JVM tests) pinning parseImeEvent /
  parseSelectionGeometry: per-event parsing, defaulting of absent fields,
  the total-accessor behavior for mistyped fields, and the ime.resync
  envelope. This parser became JVM-testable when it moved off Android's
  org.json; the browser suite in tools/ime-test still owns the page side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVcZwp65oybotmq5o66foW
2026-08-30 14:29:28 +00:00
Claude 2415565ebf refactor: replace org.json with kotlinx.serialization in source files
Sweeps the last org.json usages out of the Kotlin sources and moves them
to kotlinx.serialization's JSON tree API (already the project standard):

- nappletHost: bridge/broker envelope handling in NappletHostActivity,
  NappletHostService, NappletBrowserActivity, NappletBrowserService and
  NappletFaviconSniffer now parses with Json.parseToJsonElement via new
  total helpers in JsonEnvelope.kt (absent/mistyped fields degrade to
  empty/false instead of throwing, matching the old opt* semantics).
  Adds the kotlinx-serialization-json runtime to the module (tree API
  only, so no serialization plugin needed).
- amethyst embed IME relay: EmbeddedImeBridge parses ime.* envelopes
  with JsonObject accessors; RemoteImeView and EmbeddedTabLayer build
  their outgoing envelopes with buildJsonObject.
- tools/ime-test: drops the now-stale "org.json is stubbed in JVM unit
  tests" rationale from the README and shim-events.mjs header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVcZwp65oybotmq5o66foW
2026-08-30 05:52:29 +00:00
David KasparandGitHub a370b1d8c5 Merge pull request #4016 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-30 06:19:22 +02:00
vitorpamplonaandgithub-actions[bot] e38981fd1d chore: sync Crowdin translations and seed translator npub placeholders 2026-08-30 00:13:08 +00:00
Vitor PamplonaandGitHub 7ff8e3b5ac Merge pull request #4017 from vitorpamplona/claude/ci-bugs-4npv9z
fix(i18n): unbreak main's lint; publish desktop test reports on failure
2026-08-29 20:10:11 -04:00
74 changed files with 2997 additions and 345 deletions
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import java.io.File
import java.util.UUID
/**
* Shared harness for the MediaSaverToDisk instrumented tests: writes a small payload
* file, drives [MediaSaverToDisk.save] with the given MIME type, and asserts the save
* reported success. Package-level support object per the AvifInstrumentedTestSupport
* precedent.
*/
object MediaSaverTestSupport {
/** Drives one save and fails the test if it reported an error or never succeeded. */
fun saveAndAssertSuccess(
context: Context,
mimeType: String,
) {
val localFile = File(context.cacheDir, "media-saver-${UUID.randomUUID()}.bin")
localFile.writeBytes(ByteArray(2048) { it.toByte() })
var failure: Throwable? = null
var succeeded = false
try {
runBlocking {
MediaSaverToDisk.save(
localFile = localFile,
mimeType = mimeType,
context = context,
onSuccess = { succeeded = true },
onError = { failure = it },
)
}
} finally {
localFile.delete()
}
// Surfaces e.g. the #4009 IllegalArgumentException as the test failure message.
assertNull("save() reported an error: ${failure?.message}", failure)
assertTrue("save() never reported success", succeeded)
}
}
@@ -0,0 +1,155 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import android.os.ParcelFileDescriptor
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
/**
* Covers the pre-Q writer, which MediaStore never sees: below API 29 saveContentDefault
* writes straight to a public directory and lets the media scanner index it.
*
* That path used to hardcode Pictures for every content type, so videos, audio and PDFs
* were all filed under Pictures/Amethyst. It now routes through the same MediaStoreTarget
* as the MediaStore path. minSdk is 26, so this range ships.
*
* There is no JVM coverage of any of this: Build.VERSION.SDK_INT is 0 under
* returnDefaultValues, so unit tests can only reach the routing function, never the writer.
*
* **Running this suite:** below Q the storage grant must exist before the app process
* forks (external storage is mounted at fork time), and Gradle's connectedAndroidTest
* installs and instruments with no window to grant in between - so these tests skip
* under it. Drive them manually on an API 26-28 device:
* ```
* ./gradlew :amethyst:assemblePlayDebug :amethyst:assemblePlayDebugAndroidTest
* adb install -r -g amethyst/build/outputs/apk/play/debug/amethyst-play-arm64-v8a-debug.apk
* adb install -r -g amethyst/build/outputs/apk/androidTest/play/debug/amethyst-play-debug-androidTest.apk
* adb shell am instrument -w -e class com.vitorpamplona.amethyst.ui.actions.MediaSaverToDiskLegacyStorageTest \
* com.vitorpamplona.amethyst.debug.test/androidx.test.runner.AndroidJUnitRunner
* ```
*/
@RunWith(AndroidJUnit4::class)
class MediaSaverToDiskLegacyStorageTest {
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
/** Every directory production can write to, straight from the routing table. */
private val watchedDirs = MediaSaverToDisk.MediaStoreTarget.entries.map { it.relativeDirectory }
private val createdFiles = mutableListOf<File>()
@Before
fun onlyBelowScopedStorage() {
assumeTrue("saveContentDefault only runs below API 29", Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
// The legacy writer needs the runtime permission; no androidx.test:rules on the
// classpath, so grant it through the instrumentation shell instead. The output has
// to be drained: executeShellCommand runs asynchronously and closing the descriptor
// early kills the command before it applies.
val fd =
InstrumentationRegistry
.getInstrumentation()
.uiAutomation
.executeShellCommand(
"pm grant ${context.packageName} android.permission.WRITE_EXTERNAL_STORAGE",
)
ParcelFileDescriptor.AutoCloseInputStream(fd).use { it.readBytes() }
assertEquals(
"WRITE_EXTERNAL_STORAGE was not granted; the legacy writer cannot be exercised",
PackageManager.PERMISSION_GRANTED,
context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE),
)
// Holding the permission is not enough below Q: external storage is mounted into
// the process when it forks, so a grant to an already-running process never
// reaches it and every write fails with EACCES. Probe for real writability and
// skip rather than report a routing failure that is really a harness problem.
assumeTrue(
"External storage is not writable by this process; below API 29 the grant must " +
"exist at install time. See this class's KDoc for the exact run recipe.",
canWriteToPublicStorage(),
)
}
private fun canWriteToPublicStorage(): Boolean =
try {
val dir = amethystDir("Movies").apply { if (!exists()) mkdirs() }
val probe = File(dir, ".write-probe-${System.nanoTime()}")
val writable = probe.createNewFile()
probe.delete()
writable
} catch (e: IOException) {
false
}
@After
fun cleanUp() {
createdFiles.forEach { it.delete() }
}
@Test
fun videoGoesToMovies() = assertRoutes("video/mp4", "Movies")
@Test
fun imageGoesToPictures() = assertRoutes("image/jpeg", "Pictures")
@Test
fun audioGoesToMusic() = assertRoutes("audio/mpeg", "Music")
@Test
fun pdfGoesToDownloads() = assertRoutes("application/pdf", "Download")
/**
* Saves one file and asserts it appeared under [expectedDir]/Amethyst and nowhere else.
* Checking the other directories is the point: the bug was everything landing in Pictures.
*/
private fun assertRoutes(
mimeType: String,
expectedDir: String,
) {
val before = snapshot()
MediaSaverTestSupport.saveAndAssertSuccess(context, mimeType)
val added = snapshot().mapValues { (dir, names) -> names - before.getValue(dir) }
added.forEach { (dir, names) -> names.forEach { createdFiles.add(File(amethystDir(dir), it)) } }
val dirsThatGrew = added.filterValues { it.isNotEmpty() }.keys
assertEquals("$mimeType should land only in $expectedDir/Amethyst", setOf(expectedDir), dirsThatGrew)
assertEquals("expected exactly one new file", 1, added.getValue(expectedDir).size)
}
private fun amethystDir(publicDir: String) = File(Environment.getExternalStoragePublicDirectory(publicDir), "Amethyst")
private fun snapshot(): Map<String, Set<String>> = watchedDirs.associateWith { amethystDir(it).list()?.toSet() ?: emptySet() }
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* End-to-end regression test for issue #4009: drives the real ContentResolver, so it
* catches both symptoms of a collection/directory mismatch - Android 10 rejects the
* insert outright (the quoted rejection lives in [MediaSaverToDisk.MediaStoreTarget]'s
* KDoc), and later releases accept it and silently misfile the video.
*/
@RunWith(AndroidJUnit4::class)
class MediaSaverToDiskMediaStoreTest {
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
private val resolver: ContentResolver get() = context.contentResolver
/** Only rows this test inserted, as item Uris in the collection they went into. */
private val created = mutableListOf<Uri>()
@Before
fun requiresScopedStorage() {
assumeTrue("saveContentQ only runs on API 29+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
}
@After
fun cleanUp() {
created.forEach { resolver.delete(it, null, null) }
}
@Test
fun savingAVideoLandsInMoviesAndNotPictures() {
val relativePath = saveAndReadBackRelativePath("video/mp4", MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
assertEquals("Movies/Amethyst/", relativePath)
}
@Test
fun savingAnImageStillLandsInPictures() {
val relativePath = saveAndReadBackRelativePath("image/jpeg", MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
assertEquals("Pictures/Amethyst/", relativePath)
}
private fun saveAndReadBackRelativePath(
mimeType: String,
collection: Uri,
): String? {
// Anything at or below this id predates the test and must never be read or deleted:
// this suite is meant to be runnable on a real device holding real media.
val highWaterMark = maxIdIn(collection)
MediaSaverTestSupport.saveAndAssertSuccess(context, mimeType)
return rowInsertedAfter(collection, highWaterMark)
}
private fun maxIdIn(collection: Uri): Long {
resolver
.query(collection, arrayOf(MediaStore.MediaColumns._ID), null, null, "${MediaStore.MediaColumns._ID} DESC")
?.use { cursor ->
if (cursor.moveToFirst()) return cursor.getLong(0)
}
return -1L
}
/** Reads back the row the save just inserted and records it for cleanup. */
private fun rowInsertedAfter(
collection: Uri,
highWaterMark: Long,
): String? {
resolver
.query(
collection,
arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.RELATIVE_PATH),
"${MediaStore.MediaColumns._ID} > ?",
arrayOf(highWaterMark.toString()),
"${MediaStore.MediaColumns._ID} ASC",
)?.use { cursor ->
assertTrue("save() reported success but inserted no row into $collection", cursor.moveToFirst())
created.add(ContentUris.withAppendedId(collection, cursor.getLong(0)))
return cursor.getString(1)
}
return null
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.os.Environment
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.MediaStoreTarget
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* [MediaStoreTarget] spells its directories out as literals because Environment's
* DIRECTORY_* fields are plain statics that the unit-test android.jar leaves null.
* This is the other half of that trade: on a real device the literals are checked
* against the platform constants they stand in for.
*/
@RunWith(AndroidJUnit4::class)
class MediaStoreTargetInstrumentedTest {
@Test
fun directoriesMatchThePlatformConstants() {
assertEquals(Environment.DIRECTORY_PICTURES, MediaStoreTarget.IMAGES.relativeDirectory)
assertEquals(Environment.DIRECTORY_MUSIC, MediaStoreTarget.AUDIO.relativeDirectory)
assertEquals(Environment.DIRECTORY_MOVIES, MediaStoreTarget.VIDEO.relativeDirectory)
assertEquals(Environment.DIRECTORY_DOWNLOADS, MediaStoreTarget.DOWNLOADS.relativeDirectory)
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import android.os.Environment
import androidx.core.content.FileProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Pins what `res/xml/file_paths.xml` is allowed to hand out.
*
* The provider root used to be `<external-path path=".">`, i.e. the whole of
* `Environment.getExternalStorageDirectory()`. It is now the app-specific
* `<external-files-path>`, which is the only external location Amethyst ever
* shares from (camera/video capture). These tests fail if either half of that
* regresses: the capture paths must still resolve, and the external-storage
* root must not.
*/
@RunWith(AndroidJUnit4::class)
class FileProviderPathsTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val authority = "${context.packageName}.provider"
@Test
fun photoCaptureUriResolves() {
val uri = getPhotoUri(context)
assertEquals("content", uri.scheme)
assertEquals(authority, uri.authority)
assertTrue("expected the external_files root, got $uri", uri.path!!.startsWith("/external_files/"))
}
@Test
fun videoCaptureUriResolves() {
val uri = getVideoUri(context)
assertEquals("content", uri.scheme)
assertEquals(authority, uri.authority)
assertTrue("expected the external_files root, got $uri", uri.path!!.startsWith("/external_files/"))
}
@Test
fun cacheDirStillResolves() {
val file = File(context.cacheDir, "amethyst_share_probe.png")
val uri = FileProvider.getUriForFile(context, authority, file)
assertEquals(authority, uri.authority)
assertTrue("expected the cache root, got $uri", uri.path!!.startsWith("/cache/"))
}
@Test
fun externalStorageRootIsNoLongerShareable() {
@Suppress("DEPRECATION")
val outside = File(Environment.getExternalStorageDirectory(), "Download/not-ours.pdf")
try {
val uri = FileProvider.getUriForFile(context, authority, outside)
fail("FileProvider should not map $outside, but produced $uri")
} catch (expected: IllegalArgumentException) {
// Correct: no configured root contains it.
}
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.core.tween
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The feed's animated elements defer building their `Transition` until a value actually changes,
* because first composition has nothing to animate and building one per card per scroll is pure
* waste (measured: roughly half the composition cost of every reaction-row button).
*
* The whole point of deferring rather than removing is that the animation must still play. These
* tests pin that: they drive the clock manually and assert that the **first** change — the one that
* happens right after the transition is lazily created — still shows outgoing and incoming content
* simultaneously, which only a running animation does. A regression that turned the deferral into a
* plain snap would show exactly one of them and fail here.
*/
@RunWith(AndroidJUnit4::class)
class DeferredAnimationTest {
@get:Rule
val rule = createComposeRule()
@Test
fun deferredCrossfadeStillAnimatesTheFirstChange() {
val state = mutableStateOf("A")
rule.mainClock.autoAdvance = false
rule.setContent {
DeferredCrossfade(
targetState = state.value,
modifier = Modifier,
contentAlignment = Alignment.TopStart,
animationSpec = tween(DURATION_MS),
label = "test",
) { value ->
Text(value, modifier = Modifier.testTag("text_$value"))
}
}
// Before any change the transition has not been built, and only the current value renders.
rule.onNodeWithTag("text_A").assertIsDisplayed()
rule.onNodeWithTag("text_B").assertDoesNotExist()
state.value = "B"
rule.mainClock.advanceTimeByFrame()
rule.mainClock.advanceTimeBy(DURATION_MS / 3L)
// Mid-crossfade both are in the tree. This is the assertion that a snap would fail.
rule.onNodeWithTag("text_A").assertExists()
rule.onNodeWithTag("text_B").assertExists()
rule.mainClock.advanceTimeBy(DURATION_MS * 3L)
rule.onNodeWithTag("text_B").assertIsDisplayed()
rule.onNodeWithTag("text_A").assertDoesNotExist()
}
companion object {
const val DURATION_MS = 300
}
}
@@ -139,9 +139,10 @@ class AccountZapActions(
bolt11: String,
zappedNote: Note?,
onTimeout: () -> Unit = {},
metadata: Map<String, Any?>? = null,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onTimeout, onResponse)
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onTimeout, metadata, onResponse)
account.client.publish(event, setOf(relay))
}
@@ -1278,6 +1278,22 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
}
is GitPullRequestUpdateEvent -> {
// Link the update to its parent PR so it lands in the PR's
// replies collection (and picks up its target for threading).
// The repository ATag isn't a reply target — skip it.
listOfNotNull(event.parentPullRequestId()?.let { checkGetOrCreateNote(it) })
}
is GitStatusEvent -> {
// A status event roots itself at a patch/PR/issue via a
// marked-`root` `e` tag; link only that so the transition
// appears in the target's replies (GitStatusIndex reduces the
// observed stream separately and doesn't need this wiring, but
// ThreadFeedView and the notifications-tab reply chain do).
listOfNotNull(event.rootEventId()?.let { checkGetOrCreateNote(it) })
}
is TextNoteEvent -> {
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
}
@@ -37,11 +37,14 @@ import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectReque
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -140,28 +143,55 @@ class NwcSignerState(
* The negotiated encryption preference for a wallet. NIP-47 says a client
* "should always prefer nip44 if supported by the wallet service", so a false
* here has to mean "the wallet does not offer NIP-44" — not "we have not asked
* yet".
*
* That distinction is why this waits. The info cache is per-account and held in
* memory only, so it starts empty on every app launch, and reading it without
* waiting made the first transaction to each wallet after every launch fall
* back to NIP-04 even against a wallet advertising `nip44_v2`. Only a cold
* cache waits: a stale entry still says what the wallet advertises and is used
* as-is while it refreshes in the background.
*
* The wait is capped, because this sits in front of a payment the user has
* already tapped. Its own fetch is bounded only by the relay accessory's 30s
* idle window, and the no-response timer below does not start until this
* returns. On expiry we send NIP-04 for this one request rather than hold the
* tap; the fetch keeps running in the cache's scope, so the next request gets
* the negotiated scheme. Never bound the fetch itself instead — a null from it
* is cached as a definitive "no info event" for the whole TTL, which would pin
* the wallet to NIP-04 for days.
* yet". [walletInfo] is what makes that distinction true.
*/
private suspend fun prefersNip44(uri: Nip47WalletConnect.Nip47URINorm?): Boolean {
uri ?: return false
val info = withTimeoutOrNull(NIP44_NEGOTIATION_WAIT_MS) { infoCache?.currentOrFetch(uri) }
return info?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } == true
private fun prefersNip44(info: NwcInfoEvent?): Boolean = info?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } == true
/**
* The wallet's advertised capabilities: the one place a send waits on them, and
* it waits AT MOST ONCE.
*
* WAITING IS THE POINT. The info cache is per-account and in memory only, so it
* starts empty on every app launch, and reading it without waiting makes "not
* fetched yet" indistinguishable from "not supported". That shipped twice: the
* first transaction to each wallet after a launch fell back to NIP-04 against a
* wallet advertising `nip44_v2`, and a payment to a wallet that had been
* advertising NWC-06 for twenty minutes still went out bare — with nothing, on
* either side, reporting an error.
*
* ONCE, because both questions read the same event. Each used to fetch for
* itself, which is free on a warm cache and doubles the stall on a cold one:
* [NwcInfoCache] deliberately does not cache a FAILED fetch, so with the relay
* down both waits ran in full and a 3s worst case became 6s.
*
* BOUNDED, because this sits in front of a payment the user has already tapped
* and the no-response timer does not start until it returns. On expiry the
* answer is null — read as NIP-04 and as no-metadata, both of them the safe
* direction — while the fetch keeps running in the cache's own scope so the next
* request gets the negotiated scheme. Never bound the fetch itself instead: a
* null from it is cached as a definitive "no info event" for the whole TTL,
* which would pin the wallet to NIP-04 for days.
*/
private suspend fun walletInfo(uri: Nip47WalletConnect.Nip47URINorm?): NwcInfoEvent? {
uri ?: return null
return withTimeoutOrNull(NIP44_NEGOTIATION_WAIT_MS) { infoCache?.currentOrFetch(uri) }
}
/**
* Strips NWC-06 `metadata` from a request bound for a wallet that never said it
* understands the field — [MetadataCarrying] has the reason that matters.
*
* APPLIED WHERE THE REQUEST IS BUILT rather than at each call site, so populating
* `metadata` anywhere upstream is safe by construction.
*
* MUTATES the request in place — see the callers' KDoc. Requests are built per
* send and not reused, and stripping a copy would mean rebuilding a params object
* whose field list would then drift from the original.
*/
private fun Request.dropMetadataIfUnsupported(info: NwcInfoEvent?) {
val carrier = metadataCarrier ?: return
if (carrier.metadata == null || info?.supportsExtension(ExtensionsTag.METADATA_CONVENTIONS) == true) return
carrier.metadata = null
}
fun hasWalletConnectSetup(): Boolean = settings.nwcWallets.value.isNotEmpty()
@@ -225,6 +255,10 @@ class NwcSignerState(
/**
* Sends a generic NIP-47 request to a specific wallet.
*
* [request] MAY BE MUTATED: NWC-06 `metadata` is stripped in place when the
* wallet has not advertised support for it. Build a fresh request per send
* rather than retaining or re-reading this one.
*/
suspend fun sendNwcRequestToWallet(
walletUri: Nip47WalletConnect.Nip47URINorm?,
@@ -235,7 +269,10 @@ class NwcSignerState(
val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup")
val walletSigner = buildSigner(walletService) ?: signer
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(walletService))
val info = walletInfo(walletService)
request.dropMetadataIfUnsupported(info)
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(info))
val filter =
NWCPaymentQueryState(
@@ -266,16 +303,30 @@ class NwcSignerState(
/**
* Sends a zap payment request to the default wallet.
*
* [metadata] is NWC-06's per-payment blob and is dropped unless the wallet
* advertises `06`; see [dropMetadataIfUnsupported].
*/
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
onTimeout: () -> Unit = {},
metadata: Map<String, Any?>? = null,
onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup")
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value, useNip44 = prefersNip44(walletService))
val info = walletInfo(walletService)
val request = PayInvoiceMethod.create(bolt11, metadata)
request.dropMetadataIfUnsupported(info)
val event =
LnZapPaymentRequestEvent.createRequest(
request,
walletService.pubKeyHex,
nip47Signer.value,
useNip44 = prefersNip44(info),
)
val filter =
NWCPaymentQueryState(
@@ -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.NwcTransactionMetadata
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
@@ -61,6 +62,11 @@ class ZapPaymentHandler(
val info: MyZapSplitSetup,
val amountMilliSats: Long,
val invoice: String,
// The signed kind 9734 this invoice was fetched with, and the message on it.
// Carried so the NWC payment can name the payee (NWC-06 `metadata`); null for
// a NONZAP split, which has no zap request to send.
val zapRequest: LnZapRequestEvent? = null,
val message: String = "",
)
data class UnverifiedZapSplitSetup(
@@ -418,6 +424,13 @@ class ZapPaymentHandler(
account.zaps.sendZapPaymentRequestFor(
bolt11 = payable.invoice,
zappedNote = note,
// Dropped unless the wallet advertises NWC-06 — see NwcSignerState.
metadata =
NwcTransactionMetadata.build(
zapRequest = payable.zapRequest,
recipientIdentifier = payable.info.lnAddress,
comment = payable.message,
),
onResponse = { response ->
progress.step()
response.nwcFailureDetail(context)?.let { detail ->
@@ -554,6 +567,10 @@ class ZapPaymentHandler(
): Payable {
var progressThisPayment = 0.00f
// Only the request the provider actually accepted may be claimed as bound to
// this invoice; see lnAddressInvoice's onZapRequestSent.
var sentZapRequest: LnZapRequestEvent? = null
val invoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lud16,
@@ -567,6 +584,7 @@ class ZapPaymentHandler(
onProgressStep(step)
},
context = context,
onZapRequestSent = { sentZapRequest = it },
)
onProgressStep(1 - progressThisPayment)
@@ -575,6 +593,8 @@ class ZapPaymentHandler(
info = splitSetup,
amountMilliSats = zapValue,
invoice = invoice,
zapRequest = sentZapRequest,
message = message,
)
}
}
@@ -201,6 +201,13 @@ class LightningAddressResolver {
?: response.code.toString()
}
/**
* @param onZapRequestSent receives the zap request that was ACTUALLY sent to the
* callback, or null when it was not. A provider that does not advertise
* `allowsNostr` never sees [nostrRequest], and its invoice therefore commits to
* nothing about it — so a caller must not go on to claim the two are bound. See
* the drop below.
*/
suspend fun lnAddressInvoice(
lnAddress: String,
milliSats: Long,
@@ -209,6 +216,7 @@ class LightningAddressResolver {
okHttpClient: (String) -> OkHttpClient,
onProgress: (percent: Float) -> Unit,
context: Context,
onZapRequestSent: (LnZapRequestEvent?) -> Unit = {},
): String {
val mapper = jacksonObjectMapper()
@@ -264,12 +272,19 @@ class LightningAddressResolver {
)
}
// NIP-57 binds a zap request to its invoice through `description_hash`, and a
// provider that ignores `nostr=` mints an invoice that commits to nothing about
// it. Report what actually went, so a caller cannot attach the event to a
// payment it was never bound to.
val sentZapRequest = nostrRequest?.takeIf { allowsNostr }
onZapRequestSent(sentZapRequest)
val invoice =
fetchLightningInvoice(
lnCallback = callbackUrl,
milliSats = milliSats,
message = message,
nostrRequest = if (allowsNostr) nostrRequest else null,
nostrRequest = sentZapRequest,
okHttpClient = okHttpClient,
context = context,
)
@@ -76,6 +76,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
@@ -278,6 +283,11 @@ class EventNotificationConsumer(
is GitPatchEvent -> CodeNotification.notify(applicationContext, account, event)
is GitPullRequestEvent -> CodeNotification.notify(applicationContext, account, event)
is GitPullRequestUpdateEvent -> CodeNotification.notify(applicationContext, account, event)
is GitReplyEvent -> CodeNotification.notify(applicationContext, account, event)
is GitStatusOpenEvent -> CodeNotification.notify(applicationContext, account, event)
is GitStatusAppliedEvent -> CodeNotification.notify(applicationContext, account, event)
is GitStatusClosedEvent -> CodeNotification.notify(applicationContext, account, event)
is GitStatusDraftEvent -> CodeNotification.notify(applicationContext, account, event)
is LiveChessGameAcceptEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_challenge_accepted)
is LiveChessMoveEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_your_turn)
@@ -47,6 +47,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
@@ -105,7 +110,9 @@ class NotificationDispatcher(
// consumeFromCache can't route it. It's delivered directly via
// [notifyWelcome] from processMarmotWelcomeFlow, which does know the
// recipient account.
private val NOTIFICATION_KINDS: Set<Int> =
// `internal` (was `private`) so the notification-kinds contract test
// can pin the push-side kind set against the in-app feed's kind set.
internal val NOTIFICATION_KINDS: Set<Int> =
setOf(
// Direct-arrival
PrivateDmEvent.KIND,
@@ -131,6 +138,14 @@ class NotificationDispatcher(
GitIssueEvent.KIND,
GitPullRequestEvent.KIND,
GitPullRequestUpdateEvent.KIND,
// NIP-34 threaded activity: legacy git-reply comment (1622)
// and the four status transitions (open/applied/closed/draft,
// kinds 1630-1633). Same push channel as issues/patches/PRs.
GitReplyEvent.KIND,
GitStatusOpenEvent.KIND,
GitStatusAppliedEvent.KIND,
GitStatusClosedEvent.KIND,
GitStatusDraftEvent.KIND,
HighlightEvent.KIND,
LongTextNoteEvent.KIND,
WikiNoteEvent.KIND,
@@ -35,12 +35,26 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
/**
* Git / code notifications — NIP-34 issues (1621), patches (1617), pull requests
* (1618) and PR updates (1619) on repos you maintain. Rendered as a slate card
* titled by the action ("X opened an issue" …) with the subject as the body.
* (1618), PR updates (1619), replies (1622, legacy), and status transitions
* (1630 open, 1631 applied/merged, 1632 closed, 1633 draft) on repos or threads
* you're p-tagged into. Rendered as a slate card titled by the action ("X opened
* an issue", "X merged a pull request", …) with the subject as the body.
* Author name + avatar enriched observably.
*
* Status kinds resolve their title from the *target* event's kind (patch/PR/issue)
* when it's in cache, so a merge on a PR reads "merged a pull request" but the
* same 1631 targeting a plain kind-1617 patch reads "applied a patch". Falls
* back to a generic wording when the target isn't yet resolved (rare: the
* notification lands after the target because the p-tag subscription pulls
* status events regardless of whether the target has been seen).
*/
object CodeNotification {
suspend fun notify(
@@ -67,6 +81,89 @@ object CodeNotification {
event: GitPullRequestUpdateEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitReplyEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_reply, event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitStatusOpenEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_open, event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitStatusAppliedEvent,
) = post(
context,
account,
event.id,
event.createdAt,
event.pubKey,
titleRes =
titleForStatusOnTarget(
event.rootEventId(),
pr = R.string.app_notification_code_channel_message_status_applied_pr,
patch = R.string.app_notification_code_channel_message_status_applied_patch,
issue = R.string.app_notification_code_channel_message_status_applied_issue,
fallback = R.string.app_notification_code_channel_message_status_applied,
),
subject = event.content,
)
suspend fun notify(
context: Context,
account: Account,
event: GitStatusClosedEvent,
) = post(
context,
account,
event.id,
event.createdAt,
event.pubKey,
titleRes =
titleForStatusOnTarget(
event.rootEventId(),
pr = R.string.app_notification_code_channel_message_status_closed_pr,
patch = R.string.app_notification_code_channel_message_status_closed_patch,
issue = R.string.app_notification_code_channel_message_status_closed_issue,
fallback = R.string.app_notification_code_channel_message_status_closed,
),
subject = event.content,
)
suspend fun notify(
context: Context,
account: Account,
event: GitStatusDraftEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_draft, event.content)
/**
* Pick a title string for a status event based on the *target*'s kind, so
* a 1631 on a kind-1618 PR reads "merged a pull request" while the same
* status kind on a kind-1617 patch reads "applied a patch". [rootId] is
* the marked-`root` `e` tag on the status event; when the target isn't in
* cache we return [fallback] which is deliberately generic.
*/
private fun titleForStatusOnTarget(
rootId: String?,
pr: Int,
patch: Int,
issue: Int,
fallback: Int,
): Int {
val targetKind = rootId?.let { LocalCache.getNoteIfExists(it)?.event?.kind } ?: return fallback
return when (targetKind) {
GitPullRequestEvent.KIND -> pr
GitPatchEvent.KIND -> patch
GitIssueEvent.KIND -> issue
else -> fallback
}
}
private suspend fun post(
context: Context,
account: Account,
@@ -58,7 +58,7 @@ object NwcPaymentNotifier {
val time = tx.settled_at ?: tx.created_at ?: TimeUtils.now()
val title = stringRes(context, R.string.app_notification_payments_channel_message, amount)
val comment = (tx.parsedMetadata()?.comment ?: tx.description)?.ifBlank { null }
val comment = tx.parsedMetadata()?.displayComment() ?: tx.displayDescription()
val body = comment ?: title
val accountNpub = NotificationRoutes.accountNpub(account)
@@ -44,6 +44,10 @@ import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
@@ -111,6 +115,14 @@ val NotificationsPerKeyKinds2 =
GitPatchEvent.KIND,
GitPullRequestEvent.KIND,
GitPullRequestUpdateEvent.KIND,
// NIP-34 status events (1630/1631/1632/1633): opened, applied/merged,
// closed, drafted. The status author p-tags every prior participant of
// the target patch/PR/issue, so a `#p`=me subscription surfaces
// "someone merged/closed a thread I'm on" without a repo-scoped query.
GitStatusOpenEvent.KIND,
GitStatusAppliedEvent.KIND,
GitStatusClosedEvent.KIND,
GitStatusDraftEvent.KIND,
HighlightEvent.KIND,
CommentEvent.KIND,
CalendarDateSlotEvent.KIND,
@@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.ui.actions
import androidx.collection.mutableScatterMapOf
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Box
@@ -54,7 +56,53 @@ fun <T> CrossfadeIfEnabled(
content(targetState)
}
} else {
MyCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
}
}
/** Latches the first time a crossfade's target moves off the value it was composed with. */
private class ChangeLatch {
var changed = false
}
/**
* A [MyCrossfade] that does not build its [androidx.compose.animation.core.Transition] until there
* is something to animate.
*
* `updateTransition` allocates a transition, its animation list and its seeking state on *first
* composition*, even though first composition has nothing to cross-fade — target and initial state
* are the same value. In a feed that is waste: every card scrolled in builds a transition per
* animated element, and during a scroll essentially none of them run, because the underlying counts
* and icons do not change in the second a card is on screen.
*
* So the plain content renders until the target actually moves. At that point the transition is
* built seeded at the *original* value via [MutableTransitionState] and immediately re-targeted at
* the new one, so the first real change still animates exactly as before; every later change
* animates through the now-live transition normally.
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
internal fun <T> DeferredCrossfade(
targetState: T,
modifier: Modifier,
contentAlignment: Alignment,
animationSpec: FiniteAnimationSpec<Float>,
label: String,
content: @Composable (T) -> Unit,
) {
val initial = remember { targetState }
val latch = remember { ChangeLatch() }
if (targetState != initial) latch.changed = true
if (!latch.changed) {
Box(modifier, contentAlignment) {
content(targetState)
}
} else {
val transitionState = remember { MutableTransitionState(initial) }
transitionState.targetState = targetState
val transition = rememberTransition(transitionState, label)
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
}
}
@@ -24,6 +24,7 @@ import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
@@ -57,10 +58,11 @@ object MediaSaverToDisk {
resolveBlossom: suspend (String) -> String? = { null },
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) = withContext(Dispatchers.IO) {
) {
// No dispatch here: save() and downloadAndSave() both move themselves to IO.
when {
videoUri.isNullOrBlank() -> {
return@withContext
return
}
videoUri.startsWith("file") -> {
@@ -131,18 +133,25 @@ object MediaSaverToDisk {
}
val trimmedUrl = trimInlineMetaData(downloadUrl)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val headerType =
response
.header("Content-Type")
?.substringBefore(";")
?.trim()
val headerType =
response
.header("Content-Type")
?.substringBefore(";")
?.trim()
val realType =
headerType?.takeIf(::isSaveableMimeType)
?: mimeType?.takeIf(::isSaveableMimeType)
?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isSaveableMimeType)
?: ""
// Resolved for both paths: the API level decides how the file is
// written, never which directory it belongs in.
val realType =
headerType?.takeIf(::isSaveableMimeType)
?: mimeType?.takeIf(::isSaveableMimeType)
?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isSaveableMimeType)
?: ""
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Deliberately Q-only: MediaStore refuses an insert without a usable
// type, so there is nothing to do but report it. The legacy path has
// always written whatever it downloaded and still does - an unresolved
// type lands in Downloads, which accepts any file.
check(realType.isNotBlank()) { "Can't find out the content type" }
saveContentQ(
@@ -154,6 +163,7 @@ object MediaSaverToDisk {
} else {
saveContentDefault(
fileName = File(trimmedUrl).name,
contentType = realType,
contentSource = response.body.source(),
context = context,
)
@@ -176,44 +186,53 @@ object MediaSaverToDisk {
private fun isSaveableMimeType(type: String): Boolean =
type.isNotBlank() &&
(
type.startsWith("image/", ignoreCase = true) ||
type.startsWith("video/", ignoreCase = true) ||
type.startsWith("audio/", ignoreCase = true) ||
MediaStoreTarget.of(type) != MediaStoreTarget.DOWNLOADS ||
type.equals(PDF_MIME_TYPE, ignoreCase = true)
)
/**
* Copies a local file into the gallery. Suspending and dispatched to IO like
* [downloadAndSave]: callers reach this from click handlers, and a
* storage-permission callback among them launches on the main dispatcher,
* where copying a whole video would block the UI thread.
*/
@OptIn(ExperimentalUuidApi::class)
fun save(
suspend fun save(
localFile: File,
mimeType: String?,
context: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
try {
val extension =
mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val buffer = localFile.inputStream().source().buffer()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveContentQ(
displayName = Uuid.random().toString(),
contentType = mimeType ?: "",
contentSource = buffer,
contentResolver = context.contentResolver,
)
} else {
saveContentDefault(
fileName = "${Uuid.random()}.$extension",
contentSource = buffer,
context = context,
)
withContext(Dispatchers.IO) {
try {
// use{}: readAll leaves its source open, so without this the file
// descriptor stays open until the finalizer runs.
localFile.inputStream().source().buffer().use { buffer ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveContentQ(
displayName = Uuid.random().toString(),
contentType = mimeType ?: "",
contentSource = buffer,
contentResolver = context.contentResolver,
)
} else {
val extension =
mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
saveContentDefault(
fileName = "${Uuid.random()}.$extension",
contentType = mimeType ?: "",
contentSource = buffer,
context = context,
)
}
}
onSuccess()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MediaSaverToDisk", "Unable to save", e)
onError(e)
}
onSuccess()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MediaSaverToDisk", "Unable to save", e)
onError(e)
}
}
@@ -225,29 +244,7 @@ object MediaSaverToDisk {
contentResolver: ContentResolver,
) {
val cleanMimeType = normalizeMimeTypeForMediaStore(contentType.substringBefore(";").trim())
val (masterUri, baseDir) =
when {
cleanMimeType.startsWith("image/", ignoreCase = true) -> {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES
}
cleanMimeType.startsWith("audio/", ignoreCase = true) -> {
// Audio content goes into the Music MediaStore + folder. Routing it through
// Video.EXTERNAL_CONTENT_URI (the previous fall-through behavior) crashes
// with IllegalArgumentException because MediaProvider rejects audio/* into
// the Video collection.
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_MUSIC
}
cleanMimeType.equals(PDF_MIME_TYPE, ignoreCase = true) -> {
MediaStore.Downloads.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_DOWNLOADS
}
else -> {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES
}
}
val target = MediaStoreTarget.of(cleanMimeType)
val contentValues =
ContentValues().apply {
@@ -255,11 +252,11 @@ object MediaSaverToDisk {
put(MediaStore.MediaColumns.MIME_TYPE, cleanMimeType)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
baseDir + File.separatorChar + AMETHYST_SUBDIRECTORY,
target.relativeDirectory + File.separatorChar + AMETHYST_SUBDIRECTORY,
)
}
val uri = contentResolver.insert(masterUri, contentValues)
val uri = contentResolver.insert(target.collectionUri(), contentValues)
checkNotNull(uri) { "Can't insert the new content" }
try {
@@ -276,12 +273,15 @@ object MediaSaverToDisk {
private fun saveContentDefault(
fileName: String,
contentType: String,
contentSource: BufferedSource,
context: Context,
) {
val baseDir = MediaStoreTarget.of(contentType).relativeDirectory
val subdirectory =
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
Environment.getExternalStoragePublicDirectory(baseDir),
AMETHYST_SUBDIRECTORY,
).apply {
if (!exists()) mkdirs()
@@ -307,6 +307,60 @@ object MediaSaverToDisk {
else -> mimeType
}
/**
* The MediaStore collection a download is filed under, together with the public
* directory it is written to.
*
* MediaProvider validates the primary directory of [MediaStore.MediaColumns.RELATIVE_PATH]
* against the collection being inserted into and rejects a mismatch with
* `IllegalArgumentException: Primary directory Pictures not allowed for
* content://media/external/video/media; allowed directories are [DCIM, Movies]`.
* A collection usually accepts more than one directory; these are the ones Amethyst
* files under.
*/
internal enum class MediaStoreTarget(
val relativeDirectory: String,
) {
// The directory names are the values of Environment.DIRECTORY_PICTURES, _MUSIC,
// _MOVIES and _DOWNLOADS. They are spelled out because those are plain static
// fields that the unit-test android.jar leaves null, which would make this
// mapping impossible to cover off-device. MediaStoreTargetInstrumentedTest pins
// them back to the platform constants on-device.
IMAGES("Pictures"),
AUDIO("Music"),
VIDEO("Movies"),
DOWNLOADS("Download"),
;
/**
* Has to stay a method. The EXTERNAL_CONTENT_URI fields are null under the same
* unit-test android.jar, and MediaStore.Downloads only exists from API 29, so
* reading them from the constructor would break class init off-device and below Q.
*/
@RequiresApi(Build.VERSION_CODES.Q)
fun collectionUri(): Uri =
when (this) {
IMAGES -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
AUDIO -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
DOWNLOADS -> MediaStore.Downloads.EXTERNAL_CONTENT_URI
}
companion object {
/**
* PDFs, and anything that isn't image, audio or video content, go to Downloads
* — the one collection that accepts every kind of file.
*/
fun of(mimeType: String): MediaStoreTarget =
when {
mimeType.startsWith("image/", ignoreCase = true) -> IMAGES
mimeType.startsWith("audio/", ignoreCase = true) -> AUDIO
mimeType.startsWith("video/", ignoreCase = true) -> VIDEO
else -> DOWNLOADS
}
}
}
private const val AMETHYST_SUBDIRECTORY = "Amethyst"
private const val PDF_MIME_TYPE = "application/pdf"
private const val BLOSSOM_SCHEME = "blossom:"
@@ -31,6 +31,7 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
@@ -857,11 +858,7 @@ private fun SlidingAnimationCount(
if (accountViewModel.settings.isPerformanceMode()) {
TextCount(baseCount, textColor)
} else {
AnimatedContent(
targetState = baseCount,
transitionSpec = AnimatedContentTransitionScope<Int>::transitionSpec,
label = "SlidingAnimationCount",
) { count ->
DeferredAnimatedContent(baseCount, "SlidingAnimationCount") { count ->
TextCount(count, textColor)
}
}
@@ -884,6 +881,48 @@ val slideAnimation: ContentTransform =
),
)
/** Latches the first time an animated counter's value moves off the one it was composed with. */
private class CountChangeLatch {
var changed = false
}
/**
* An [AnimatedContent] that does not build its transition until the value actually changes.
*
* Same reasoning as `DeferredCrossfade`: `AnimatedContent` builds a transition plus its content map
* and size animation on first composition, but first composition has nothing to animate. A reaction
* counter only slides when the count moves, which practically never happens in the second a card
* spends on screen during a scroll — so the apparatus was built and thrown away, once per counter
* per card.
*
* Rendering the bare content until the first change, then seeding a [MutableTransitionState] at the
* original value, keeps that first change animated exactly as before.
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun <T> DeferredAnimatedContent(
targetState: T,
label: String,
content: @Composable (T) -> Unit,
) {
val initial = remember { targetState }
val latch = remember { CountChangeLatch() }
if (targetState != initial) latch.changed = true
if (!latch.changed) {
content(targetState)
} else {
val transitionState = remember { MutableTransitionState(initial) }
transitionState.targetState = targetState
val transition = rememberTransition(transitionState, label)
transition.AnimatedContent(
transitionSpec = { transitionSpec() },
) { value ->
content(value)
}
}
}
@Composable
fun TextCount(
count: Int,
@@ -911,11 +950,7 @@ fun SlidingAnimationAmount(
maxLines = 1,
)
} else {
AnimatedContent(
targetState = amount,
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec,
label = "SlidingAnimationAmount",
) { count ->
DeferredAnimatedContent(amount, "SlidingAnimationAmount") { count ->
Text(
text = count,
fontSize = Font14SP,
@@ -2839,9 +2839,10 @@ class AccountViewModel(
zappedNote: Note?,
onSent: () -> Unit = {},
onTimeout: () -> Unit = {},
metadata: Map<String, Any?>? = null,
onResponse: (Response?) -> Unit,
) = launchSigner {
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote, onTimeout, onResponse)
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote, onTimeout, metadata, onResponse)
onSent()
}
@@ -20,25 +20,33 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import org.json.JSONObject
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
import com.vitorpamplona.amethyst.commons.util.doubleOrNull
import com.vitorpamplona.amethyst.commons.util.intOrNull
import com.vitorpamplona.amethyst.commons.util.objectOrNull
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/** Parses the `geom` object of an `ime.pagesel` payload into a [SelectionGeometry], or null if absent. */
fun parseSelectionGeometry(o: JSONObject?): SelectionGeometry? {
fun parseSelectionGeometry(o: JsonObject?): SelectionGeometry? {
if (o == null) return null
return SelectionGeometry(
left = o.optDouble("l", 0.0).toFloat(),
top = o.optDouble("t", 0.0).toFloat(),
right = o.optDouble("r", 0.0).toFloat(),
bottom = o.optDouble("b", 0.0).toFloat(),
startX = o.optDouble("sx", 0.0).toFloat(),
startBottom = o.optDouble("sb", 0.0).toFloat(),
endX = o.optDouble("ex", 0.0).toFloat(),
endBottom = o.optDouble("eb", 0.0).toFloat(),
viewportWidth = o.optDouble("vw", 0.0).toFloat(),
caretX = if (o.has("cx")) o.optDouble("cx").toFloat() else null,
caretTop = if (o.has("ct")) o.optDouble("ct").toFloat() else null,
caretBottom = if (o.has("cb")) o.optDouble("cb").toFloat() else null,
isRange = o.optBoolean("rng", false),
left = (o.doubleOrNull("l") ?: 0.0).toFloat(),
top = (o.doubleOrNull("t") ?: 0.0).toFloat(),
right = (o.doubleOrNull("r") ?: 0.0).toFloat(),
bottom = (o.doubleOrNull("b") ?: 0.0).toFloat(),
startX = (o.doubleOrNull("sx") ?: 0.0).toFloat(),
startBottom = (o.doubleOrNull("sb") ?: 0.0).toFloat(),
endX = (o.doubleOrNull("ex") ?: 0.0).toFloat(),
endBottom = (o.doubleOrNull("eb") ?: 0.0).toFloat(),
viewportWidth = (o.doubleOrNull("vw") ?: 0.0).toFloat(),
caretX = o.doubleOrNull("cx")?.toFloat(),
caretTop = o.doubleOrNull("ct")?.toFloat(),
caretBottom = o.doubleOrNull("cb")?.toFloat(),
isRange = o.booleanOrNull("rng") ?: false,
)
}
@@ -62,45 +70,45 @@ interface EmbeddedImeBridge {
* learns there is a field to put the keyboard back on. Sent when a tab becomes the active one again, and when
* an [ImeEvent.WantKeyboard] tap arrives for a field this host no longer mirrors.
*/
fun EmbeddedImeBridge.requestImeResync() = sendImeOp(JSONObject().put("type", "ime.resync").toString())
fun EmbeddedImeBridge.requestImeResync() = sendImeOp(buildJsonObject { put("type", "ime.resync") }.toString())
/** Parses one page → host `ime.*` envelope into an [ImeEvent], or null for anything unrecognized. */
fun parseImeEvent(payload: String): ImeEvent? {
val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null
return when (o.optString("type")) {
val o = parseJsonObjectOrNull(payload) ?: return null
return when (o.stringOrNull("type")) {
"ime.focus" -> parseFocus(o)
"ime.wantkb" -> ImeEvent.WantKeyboard
"ime.refocus" -> ImeEvent.ReFocus(parseFocus(o))
"ime.blur" -> ImeEvent.Blur
"ime.state" ->
ImeEvent.State(
text = o.optString("text", ""),
selStart = o.optInt("selStart", 0),
selEnd = o.optInt("selEnd", 0),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
text = o.stringOrNull("text") ?: "",
selStart = o.intOrNull("selStart") ?: 0,
selEnd = o.intOrNull("selEnd") ?: 0,
geometry = parseSelectionGeometry(o.objectOrNull("geom")),
)
"ime.pagesel" ->
ImeEvent.PageSelection(
active = o.optBoolean("active", false),
text = o.optString("text", ""),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
active = o.booleanOrNull("active") ?: false,
text = o.stringOrNull("text") ?: "",
geometry = parseSelectionGeometry(o.objectOrNull("geom")),
)
"ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false))
"ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom")))
"ime.scroll" -> ImeEvent.Scroll(active = o.booleanOrNull("active") ?: false)
"ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.objectOrNull("geom")))
else -> null
}
}
private fun parseFocus(o: JSONObject) =
private fun parseFocus(o: JsonObject) =
ImeEvent.Focus(
inputType = o.optString("inputType", "text"),
enterKeyHint = o.optString("enterKeyHint", ""),
multiline = o.optBoolean("multiline", false),
readOnly = o.optBoolean("readOnly", false),
text = o.optString("text", ""),
selStart = o.optInt("selStart", 0),
selEnd = o.optInt("selEnd", 0),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
inputType = o.stringOrNull("inputType") ?: "text",
enterKeyHint = o.stringOrNull("enterKeyHint") ?: "",
multiline = o.booleanOrNull("multiline") ?: false,
readOnly = o.booleanOrNull("readOnly") ?: false,
text = o.stringOrNull("text") ?: "",
selStart = o.intOrNull("selStart") ?: 0,
selEnd = o.intOrNull("selEnd") ?: 0,
geometry = parseSelectionGeometry(o.objectOrNull("geom")),
)
/** What the focused page field reports up to the host keyboard. */
@@ -83,7 +83,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import kotlinx.coroutines.delay
import org.json.JSONObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.math.roundToInt
// How far off-screen a parked (inactive) warm tab is shifted — well past any real screen width.
@@ -106,12 +107,12 @@ private fun EmbeddedImeBridge.sendFieldOp(
cssX: Float,
cssY: Float,
) = sendImeOp(
JSONObject()
.put("type", type)
.apply { if (edge != null) put("edge", edge) }
.put("x", cssX.toDouble())
.put("y", cssY.toDouble())
.toString(),
buildJsonObject {
put("type", type)
if (edge != null) put("edge", edge)
put("x", cssX.toDouble())
put("y", cssY.toDouble())
}.toString(),
)
/**
@@ -508,7 +509,14 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
fingerPx.y > oy + bounds.height - edgeZonePx -> AUTOSCROLL_STEP_CSS
else -> 0.0
}
if (dy != 0.0) imeBridge?.sendImeOp(JSONObject().put("type", "ime.autoscroll").put("dy", dy).toString())
if (dy != 0.0) {
imeBridge?.sendImeOp(
buildJsonObject {
put("type", "ime.autoscroll")
put("dy", dy)
}.toString(),
)
}
}
if (!active || magProbe == null) {
magnifier.hide()
@@ -32,7 +32,9 @@ import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputConnectionWrapper
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import org.json.JSONObject
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* The main-app-window home for the soft keyboard when a field is focused inside an embedded WebView. The
@@ -124,7 +126,7 @@ class RemoteImeView(
)
// The IME's "Go/Search/Send/Done" — the page submits/handles it (single-line has no newline).
setOnEditorActionListener { _, _, _ ->
bridge?.sendImeOp(JSONObject().put("type", "ime.action").toString())
bridge?.sendImeOp(buildJsonObject { put("type", "ime.action") }.toString())
true
}
}
@@ -365,12 +367,12 @@ class RemoteImeView(
schedule()
}
private fun stateJson(): JSONObject {
private fun stateJson(): JsonObject {
val editable = text
val composingStart = if (editable != null) BaseInputConnection.getComposingSpanStart(editable) else -1
val composingEnd = if (editable != null) BaseInputConnection.getComposingSpanEnd(editable) else -1
return JSONObject()
.put("type", "ime.set")
return buildJsonObject {
put("type", "ime.set")
// A readonly field's text must never travel back to the page. TYPE_NULL keeps the *user* from
// typing into the mirror, but the mirror still flushes on selection changes — a long-press
// select-all, then Chrome's collapse-to-endpoint, both emit one — and that flush is delivered
@@ -381,11 +383,12 @@ class RemoteImeView(
// Omitting the key (rather than sending the current text) makes the shim treat the message as
// selection-only — `var next = (msg.text != null) ? String(msg.text) : prev` — so the
// host-drawn handles and Copy keep working off a synced selection while nothing can be written.
.apply { if (!fieldReadOnly) put("text", editable?.toString() ?: "") }
.put("selStart", selectionStart)
.put("selEnd", selectionEnd)
.put("composingStart", composingStart)
.put("composingEnd", composingEnd)
if (!fieldReadOnly) put("text", editable?.toString() ?: "")
put("selStart", selectionStart)
put("selEnd", selectionEnd)
put("composingStart", composingStart)
put("composingEnd", composingEnd)
}
}
private fun flushState() {
@@ -67,6 +67,12 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
@@ -173,6 +179,18 @@ class NotificationFeedFilter(
GitPatchEvent.KIND,
GitPullRequestEvent.KIND,
GitPullRequestUpdateEvent.KIND,
// NIP-34 threaded activity: legacy comment (1622, deprecated by
// NIP-22 but still in the wild) and the four status transitions
// (open/applied/closed/draft, kinds 1630-1633). Included so
// "someone merged/closed my PR" and "someone commented on my
// patch" surface on the Notifications tab — without this the
// status kinds arrive via the p-tag subscription and sit in
// LocalCache invisible.
GitReplyEvent.KIND,
GitStatusOpenEvent.KIND,
GitStatusAppliedEvent.KIND,
GitStatusClosedEvent.KIND,
GitStatusDraftEvent.KIND,
HighlightEvent.KIND,
TextNoteEvent.KIND,
ReactionEvent.KIND,
@@ -262,8 +280,15 @@ class NotificationFeedFilter(
}
if (event is GitIssueEvent || event is GitPatchEvent ||
event is GitPullRequestEvent || event is GitPullRequestUpdateEvent
event is GitPullRequestEvent || event is GitPullRequestUpdateEvent ||
event is GitReplyEvent || event is GitStatusEvent
) {
// NIP-34 events reach the notifications tab only via the p-tag
// relay filter, which already selected them because the current
// user is on the participant list. Any further check would
// require walking a chain of prior status/reply events that
// aren't guaranteed to be in cache; short-circuit to trust the
// p-tag — same policy applied to issues/patches/PRs above.
return true
}
@@ -72,8 +72,14 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -248,8 +254,14 @@ fun kindDisplayName(kind: Int): Int =
EphemeralGiftWrapEvent.KIND -> R.string.kind_gift_wraps
GitIssueEvent.KIND -> R.string.kind_git_issue
GitPatchEvent.KIND -> R.string.kind_git_patch
GitPullRequestEvent.KIND -> R.string.kind_git_pr
GitPullRequestUpdateEvent.KIND -> R.string.kind_git_pr_update
GitRepositoryEvent.KIND -> R.string.kind_git_repo
GitReplyEvent.KIND -> R.string.kind_git_reply
GitStatusOpenEvent.KIND -> R.string.kind_git_status_open
GitStatusAppliedEvent.KIND -> R.string.kind_git_status_applied
GitStatusClosedEvent.KIND -> R.string.kind_git_status_closed
GitStatusDraftEvent.KIND -> R.string.kind_git_status_draft
GoalEvent.KIND -> R.string.kind_zap_goals
HashtagListEvent.KIND -> R.string.kind_hashtag_follows
HighlightEvent.KIND -> R.string.kind_highlights
@@ -0,0 +1,89 @@
/*
* 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.wallet
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType
/**
* What a transaction row has to say about its counterparty, resolved away from
* Compose so the blank-handling is a plain unit test.
*
* The direction label ("Received"/"Sent") is passed IN rather than looked up here,
* which is what keeps this Context-free while still letting it be the single place
* that decides what a row says.
*/
data class TransactionRowLabels(
val title: Title,
val subtitle: String?,
) {
sealed interface Title {
/** Render the counterparty's profile for this pubkey, falling back to [name]. */
data class User(
val pubkeyHex: String,
val name: String?,
) : Title
data class Literal(
val text: String,
) : Title
}
companion object {
/**
* WHY EVERY READ IS BLANK-GUARDED, not just null-checked. Wallets send
* `"description": ""` for a payment with no memo NIP-47 marks the field
* optional, but omitting it is a choice and several wallets emit the empty
* string instead. An elvis only catches null, so the row rendered an empty
* Text: an invisible line with the height of a real one, which is why
* outgoing rows looked like a bare arrow and a date.
*/
fun resolve(
tx: NwcTransaction,
directionLabel: String,
): TransactionRowLabels {
val isIncoming = tx.type == NwcTransactionType.INCOMING
val parsed = tx.parsedMetadata()
val description = tx.displayDescription()
// Incoming: who sent it. Outgoing: who received it — on a zap request the
// `p` tag is the payee, which is what makes an outgoing row resolvable.
val pubkeyHex = if (isIncoming) parsed?.senderPubkeyHex() else parsed?.recipientPubkeyHex()
val displayName = if (isIncoming) parsed?.senderDisplayName() else parsed?.recipientIdentifier()
// The zap comment, unless it merely repeats the description.
val comment =
parsed?.displayComment()?.takeIf { comment ->
description == null || !comment.equals(description, ignoreCase = true)
}
// A title that NAMES a counterparty wants a second line saying what the
// payment was; a title that IS the description must not repeat it below.
val named = pubkeyHex?.let { Title.User(it, displayName) } ?: displayName?.let { Title.Literal(it) }
val fallback = description ?: directionLabel
return TransactionRowLabels(
title = named ?: Title.Literal(fallback),
subtitle = comment ?: fallback.takeIf { named != null },
)
}
}
}
@@ -271,35 +271,14 @@ private fun TransactionItem(
tx.created_at?.let { formatMonthDayTime(it, context) } ?: ""
}
val parsed = remember(tx.metadata) { tx.parsedMetadata() }
val directionLabel =
if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing)
// For incoming: show who sent it (nostr pubkey or payer name/email)
// For outgoing: show who received it (nostr recipient or recipient identifier)
val counterpartyPubkeyHex =
remember(parsed) {
if (isIncoming) parsed?.senderPubkeyHex() else parsed?.recipientPubkeyHex()
}
val counterpartyDisplayName =
remember(parsed) {
if (isIncoming) {
parsed?.senderDisplayName()
} else {
parsed?.recipientIdentifier()
}
}
// Show comment only if it differs from description
val commentText =
remember(parsed, tx.description) {
parsed?.comment?.let { comment ->
if (tx.description == null || !comment.equals(tx.description, ignoreCase = true)) {
comment
} else {
null
}
}
val labels =
remember(tx.metadata, tx.description, tx.type, directionLabel) {
TransactionRowLabels.resolve(tx, directionLabel)
}
val counterpartyPubkeyHex = (labels.title as? TransactionRowLabels.Title.User)?.pubkeyHex
Row(
modifier =
@@ -337,37 +316,23 @@ private fun TransactionItem(
}
Column(modifier = Modifier.weight(1f)) {
if (counterpartyPubkeyHex != null) {
TransactionUserName(counterpartyPubkeyHex, counterpartyDisplayName, accountViewModel)
} else if (counterpartyDisplayName != null) {
Text(
text = counterpartyDisplayName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Text(
text = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
when (val title = labels.title) {
is TransactionRowLabels.Title.User ->
TransactionUserName(title.pubkeyHex, title.name, accountViewModel)
is TransactionRowLabels.Title.Literal ->
Text(
text = title.text,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (commentText != null) {
labels.subtitle?.let {
Text(
text = commentText,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else if (counterpartyPubkeyHex != null || counterpartyDisplayName != null) {
val descOrType = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing)
Text(
text = descOrType,
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -549,6 +549,7 @@ class WalletViewModel : ViewModel() {
val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return
val acc = account ?: return
val walletUri = getWalletUri(walletId) ?: return
viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true
_error.value = null
@@ -4849,7 +4849,6 @@
<string name="ai_tone_elaborate">Rozvést</string>
<string name="ai_tone_friendly">Přátelský</string>
<string name="ai_tone_professional">Profesionální</string>
<string name="ai_tone_emojify">+ Emoji</string>
<!-- Emoji packs -->
<string name="emoji_packs_title">Emoji balíčky</string>
<string name="emoji_pack_management_title">Přidat do seznamu emoji</string>
@@ -4643,7 +4643,6 @@
<string name="ai_tone_elaborate">Ausführen</string>
<string name="ai_tone_friendly">Freundlich</string>
<string name="ai_tone_professional">Professionell</string>
<string name="ai_tone_emojify">+ Emoji</string>
<!-- Emoji packs -->
<string name="emoji_packs_title">Emoji-Pakete</string>
<string name="emoji_pack_management_title">Zur Emoji-Liste hinzufügen</string>
@@ -4606,7 +4606,6 @@
<string name="ai_tone_elaborate">Uitbreiden</string>
<string name="ai_tone_friendly">Vriendelijk</string>
<string name="ai_tone_professional">Professioneel</string>
<string name="ai_tone_emojify">+ Emoji</string>
<!-- Emoji packs -->
<string name="emoji_packs_title">Emoji-pakketten</string>
<string name="emoji_pack_management_title">Toevoegen aan emoji-lijst</string>
@@ -4641,7 +4641,6 @@
<string name="ai_tone_elaborate">Elaborar</string>
<string name="ai_tone_friendly">Amigável</string>
<string name="ai_tone_professional">Profissional</string>
<string name="ai_tone_emojify">+ Emoji</string>
<!-- Emoji packs -->
<string name="emoji_packs_title">Pacotes de emojis</string>
<string name="emoji_pack_management_title">Adicionar à lista de emojis</string>
@@ -4849,7 +4849,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="ai_tone_elaborate">Razloži podrobneje</string>
<string name="ai_tone_friendly">Prijateljsko</string>
<string name="ai_tone_professional">Profesionalno</string>
<string name="ai_tone_emojify">+ Emoji</string>
<!-- Emoji packs -->
<string name="emoji_packs_title">Emoji paketi</string>
<string name="emoji_pack_management_title">Dodaj v seznam emojijev</string>
@@ -4641,7 +4641,6 @@
<string name="ai_tone_elaborate">Utveckla</string>
<string name="ai_tone_friendly">Vänlig</string>
<string name="ai_tone_professional">Professionell</string>
<string name="ai_tone_emojify">+ Emoji</string>
<!-- Emoji packs -->
<string name="emoji_packs_title">Emoji-paket</string>
<string name="emoji_pack_management_title">Lägg till i emoji-lista</string>
@@ -3885,7 +3885,6 @@
<string name="ai_tone_elaborate">详述</string>
<string name="ai_tone_friendly">友好</string>
<string name="ai_tone_professional">专业</string>
<string name="ai_tone_emojify">+ Emoji</string>
<!-- Emoji packs -->
<string name="emoji_packs_title">表情包</string>
<string name="emoji_pack_management_title">添加到表情列表</string>
+16 -1
View File
@@ -2085,11 +2085,22 @@
<!-- Code &amp; git -->
<string name="app_notification_code_channel_id" translatable="false">CodeID</string>
<string name="app_notification_code_channel_name">Code &amp; Git</string>
<string name="app_notification_code_channel_description">Notifies you about issues, patches, and pull requests</string>
<string name="app_notification_code_channel_description">Notifies you about issues, patches, pull requests, comments, merges, and closes</string>
<string name="app_notification_code_channel_message_issue">%1$s opened an issue</string>
<string name="app_notification_code_channel_message_patch">%1$s sent a patch</string>
<string name="app_notification_code_channel_message_pr">%1$s opened a pull request</string>
<string name="app_notification_code_channel_message_pr_update">%1$s updated a pull request</string>
<string name="app_notification_code_channel_message_reply">%1$s commented</string>
<string name="app_notification_code_channel_message_status_open">%1$s reopened a thread</string>
<string name="app_notification_code_channel_message_status_applied_pr">%1$s merged a pull request</string>
<string name="app_notification_code_channel_message_status_applied_patch">%1$s applied a patch</string>
<string name="app_notification_code_channel_message_status_applied_issue">%1$s resolved an issue</string>
<string name="app_notification_code_channel_message_status_applied">%1$s merged/resolved a thread</string>
<string name="app_notification_code_channel_message_status_closed_pr">%1$s closed a pull request</string>
<string name="app_notification_code_channel_message_status_closed_patch">%1$s closed a patch</string>
<string name="app_notification_code_channel_message_status_closed_issue">%1$s closed an issue</string>
<string name="app_notification_code_channel_message_status_closed">%1$s closed a thread</string>
<string name="app_notification_code_channel_message_status_draft">%1$s marked a thread as draft</string>
<string name="app_notification_code_summary">New code activity</string>
<!-- Badges -->
@@ -4518,6 +4529,10 @@
<string name="kind_git_reply">Git Reply</string>
<string name="kind_git_pr">Pull Request</string>
<string name="kind_git_pr_update">PR Update</string>
<string name="kind_git_status_open">Git Status: Open</string>
<string name="kind_git_status_applied">Git Status: Applied</string>
<string name="kind_git_status_closed">Git Status: Closed</string>
<string name="kind_git_status_draft">Git Status: Draft</string>
<string name="kind_zap_goals">Zap Goals</string>
<string name="kind_hashtag_follows">Hashtag Follows</string>
<string name="kind_highlights">Highlights</string>
+5 -1
View File
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path
<!-- Camera/video capture (TakePicture) writes into getExternalFilesDir(...),
the app-specific external dir. Declared precisely so the provider never
roots at the external-storage top level, and so it stays correct under
the .debug / .benchmark applicationIdSuffixes. -->
<external-files-path
name="external_files"
path="." />
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.MediaStoreTarget
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -47,4 +48,34 @@ class MediaSaverToDiskTest {
assertEquals("image/png", MediaSaverToDisk.normalizeMimeTypeForMediaStore("image/png"))
assertEquals("audio/mpeg", MediaSaverToDisk.normalizeMimeTypeForMediaStore("audio/mpeg"))
}
@Test
fun routesEachMediaKindToItsOwnCollection() {
assertEquals(MediaStoreTarget.IMAGES, MediaStoreTarget.of("image/jpeg"))
assertEquals(MediaStoreTarget.AUDIO, MediaStoreTarget.of("audio/mpeg"))
assertEquals(MediaStoreTarget.VIDEO, MediaStoreTarget.of("video/mp4"))
}
@Test
fun routesPdfsAndUnknownTypesToDownloads() {
assertEquals(MediaStoreTarget.DOWNLOADS, MediaStoreTarget.of("application/pdf"))
assertEquals(MediaStoreTarget.DOWNLOADS, MediaStoreTarget.of("application/zip"))
assertEquals(MediaStoreTarget.DOWNLOADS, MediaStoreTarget.of(""))
}
@Test
fun routingIsCaseInsensitive() {
assertEquals(MediaStoreTarget.IMAGES, MediaStoreTarget.of("Image/PNG"))
assertEquals(MediaStoreTarget.AUDIO, MediaStoreTarget.of("Audio/MPEG"))
assertEquals(MediaStoreTarget.VIDEO, MediaStoreTarget.of("Video/MP4"))
}
@Test
fun eachCollectionIsPairedWithADirectoryMediaProviderAcceptsForIt() {
// Video content filed under "Pictures" is the rejection reported in #4009.
assertEquals("Pictures", MediaStoreTarget.IMAGES.relativeDirectory)
assertEquals("Music", MediaStoreTarget.AUDIO.relativeDirectory)
assertEquals("Movies", MediaStoreTarget.VIDEO.relativeDirectory)
assertEquals("Download", MediaStoreTarget.DOWNLOADS.relativeDirectory)
}
}
@@ -0,0 +1,217 @@
/*
* 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.embed
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the host-side half of the pagehost IME contract: how [parseImeEvent] and
* [parseSelectionGeometry] read the shim's `ime.*` envelopes, including the defaulting
* behavior for absent/mistyped fields the kotlinx.serialization migration settled on
* (total accessors a field the shim never sent, or sent malformed, degrades to the
* documented default instead of throwing). The page-side half (which envelopes real
* browser gestures produce) lives in `tools/ime-test/shim-events.mjs`.
*/
class EmbeddedImeBridgeTest {
private fun geom(raw: String) = parseSelectionGeometry(Json.parseToJsonElement(raw).jsonObject)
// ---- malformed / unrecognized payloads ----
@Test
fun rejectsPayloadsThatAreNotJsonObjects() {
assertNull(parseImeEvent("not json"))
assertNull(parseImeEvent(""))
assertNull(parseImeEvent("[1,2]"))
assertNull(parseImeEvent("\"ime.blur\""))
assertNull(parseImeEvent("42"))
assertNull(parseImeEvent("null"))
}
@Test
fun rejectsUnknownOrMissingType() {
assertNull(parseImeEvent("""{"type":"ime.unknown"}"""))
assertNull(parseImeEvent("""{"id":"1"}"""))
assertNull(parseImeEvent("""{"type":7}""")) // coerces to "7", which matches nothing
}
// ---- payload-free events ----
@Test
fun parsesPayloadFreeEvents() {
assertEquals(ImeEvent.WantKeyboard, parseImeEvent("""{"type":"ime.wantkb"}"""))
assertEquals(ImeEvent.Blur, parseImeEvent("""{"type":"ime.blur"}"""))
}
// ---- focus / refocus ----
@Test
fun parsesFocusWithAllFields() {
val event =
parseImeEvent(
"""{"type":"ime.focus","inputType":"email","enterKeyHint":"send","multiline":true,
"readOnly":true,"text":"gm","selStart":1,"selEnd":2,
"geom":{"l":1,"t":2,"r":3,"b":4,"sx":5,"sb":6,"ex":7,"eb":8,"vw":360}}""",
) as ImeEvent.Focus
assertEquals("email", event.inputType)
assertEquals("send", event.enterKeyHint)
assertTrue(event.multiline)
assertTrue(event.readOnly)
assertEquals("gm", event.text)
assertEquals(1, event.selStart)
assertEquals(2, event.selEnd)
assertEquals(360f, event.geometry!!.viewportWidth)
}
@Test
fun focusDefaultsEveryAbsentField() {
val event = parseImeEvent("""{"type":"ime.focus"}""") as ImeEvent.Focus
assertEquals("text", event.inputType)
assertEquals("", event.enterKeyHint)
assertFalse(event.multiline)
assertFalse(event.readOnly)
assertEquals("", event.text)
assertEquals(0, event.selStart)
assertEquals(0, event.selEnd)
assertNull(event.geometry)
}
@Test
fun refocusWrapsAFocus() {
val event = parseImeEvent("""{"type":"ime.refocus","inputType":"url","text":"a"}""") as ImeEvent.ReFocus
assertEquals("url", event.focus.inputType)
assertEquals("a", event.focus.text)
}
// ---- state / pagesel / scroll / carettap ----
@Test
fun parsesState() {
val event = parseImeEvent("""{"type":"ime.state","text":"abc","selStart":1,"selEnd":3}""") as ImeEvent.State
assertEquals("abc", event.text)
assertEquals(1, event.selStart)
assertEquals(3, event.selEnd)
assertNull(event.geometry)
}
@Test
fun stateToleratesExplicitNullGeom() {
val event = parseImeEvent("""{"type":"ime.state","text":"a","selStart":0,"selEnd":0,"geom":null}""") as ImeEvent.State
assertNull(event.geometry)
}
@Test
fun parsesPageSelection() {
val event =
parseImeEvent(
"""{"type":"ime.pagesel","active":true,"text":"copied",
"geom":{"l":0,"t":0,"r":10,"b":10,"sx":0,"sb":10,"ex":10,"eb":10,"vw":360}}""",
) as ImeEvent.PageSelection
assertTrue(event.active)
assertEquals("copied", event.text)
assertEquals(10f, event.geometry!!.right)
}
@Test
fun parsesScroll() {
assertTrue((parseImeEvent("""{"type":"ime.scroll","active":true}""") as ImeEvent.Scroll).active)
assertFalse((parseImeEvent("""{"type":"ime.scroll"}""") as ImeEvent.Scroll).active)
}
@Test
fun parsesCaretTap() {
val event =
parseImeEvent(
"""{"type":"ime.carettap","geom":{"l":1,"t":2,"r":3,"b":4,"sx":1,"sb":4,"ex":3,"eb":4,"vw":360}}""",
) as ImeEvent.CaretTap
assertEquals(1f, event.geometry!!.left)
assertNull((parseImeEvent("""{"type":"ime.carettap"}""") as ImeEvent.CaretTap).geometry)
}
// ---- geometry ----
@Test
fun geometryNullForAbsentObject() {
assertNull(parseSelectionGeometry(null))
}
@Test
fun geometryDefaultsAbsentFieldsToZero() {
val g = geom("""{}""")!!
assertEquals(0f, g.left)
assertEquals(0f, g.viewportWidth)
assertFalse(g.isRange)
}
@Test
fun geometryCaretFieldsAreNullOnlyWhenAbsent() {
val without = geom("""{"l":1}""")!!
assertNull(without.caretX)
assertNull(without.caretTop)
assertNull(without.caretBottom)
val with = geom("""{"cx":10.5,"ct":20,"cb":30,"rng":true}""")!!
assertEquals(10.5f, with.caretX)
assertEquals(20f, with.caretTop)
assertEquals(30f, with.caretBottom)
assertTrue(with.isRange)
}
// ---- coercion behavior for mistyped fields (deliberate: total accessors, no throwing) ----
@Test
fun mistypedFieldsDegradeToDefaultsInsteadOfThrowing() {
val event =
parseImeEvent(
// selStart as a numeric string parses; selEnd as a fraction does NOT truncate
// (unlike org.json's optInt) — it falls back to 0; text as a number coerces to
// its literal text; readOnly as the string "true" parses as a boolean.
"""{"type":"ime.focus","text":7,"selStart":"5","selEnd":5.7,"readOnly":"true","geom":[1,2]}""",
) as ImeEvent.Focus
assertEquals("7", event.text)
assertEquals(5, event.selStart)
assertEquals(0, event.selEnd)
assertTrue(event.readOnly)
assertNull(event.geometry) // an array where an object belongs is treated as absent
}
// ---- host → page envelopes ----
@Test
fun resyncRequestSendsTheBareEnvelope() {
var sent: String? = null
val bridge =
object : EmbeddedImeBridge {
override var onImeEvent: ((ImeEvent) -> Unit)? = null
override fun sendImeOp(json: String) {
sent = json
}
}
bridge.requestImeResync()
assertEquals("""{"type":"ime.resync"}""", sent)
}
}
@@ -0,0 +1,147 @@
/*
* 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.notifications.dal
import com.vitorpamplona.amethyst.commons.relayClient.event.watchers.RepliesAndReactionsKinds2
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.NotificationsPerKeyKinds2
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the full NIP-34 collaboration surface across the four notification-plumbing
* lists that have to move together miss any one and either the event is never
* asked for, or it arrives but never renders:
*
* 1. [NotificationsPerKeyKinds2] the inbox-relay `#p`=me subscription. If the
* kind isn't listed here, the event never reaches this device unless it
* happens to arrive through some other subscription (a repo thread view,
* home-feed spillover). This is what gives you a merge notification when
* you close the app and come back an hour later.
* 2. [RepliesAndReactionsKinds2] the `#e`=<targetId> engagement subscription
* that fires when a patch/PR/issue row is on screen. This is what makes
* [com.vitorpamplona.amethyst.model.GitStatusIndex] actually see status
* events so the closed/merged pill can render on the repo page.
* 3. [NotificationFeedFilter.NOTIFICATION_KINDS] the in-app Notifications tab
* kind gate. Without this, the event arrives from (1), sits in LocalCache,
* and never renders a row.
* 4. [NotificationDispatcher.NOTIFICATION_KINDS] the push/tray observer's
* kind gate. Without this, the event arrives from (1), sits in LocalCache,
* and never fires a system notification.
*
* A regression on any of (1)(4) silently drops one specific transition
* (comment on your PR, PR merged, patch closed, ) and there is no other
* place to catch it.
*/
class Nip34NotificationCoverageTest {
/**
* Every NIP-34 event that participants care about patch, issue, PR,
* PR update (revision), legacy git-reply comment (1622, deprecated by
* NIP-22 but still in the wild), and the four status transitions.
* A NIP-22 [com.vitorpamplona.quartz.nip22Comments.CommentEvent] handles
* modern comments through its own separate wiring.
*/
private val nip34ParticipantKinds =
setOf(
GitPatchEvent.KIND,
GitIssueEvent.KIND,
GitPullRequestEvent.KIND,
GitPullRequestUpdateEvent.KIND,
GitReplyEvent.KIND,
GitStatusOpenEvent.KIND,
GitStatusAppliedEvent.KIND,
GitStatusClosedEvent.KIND,
GitStatusDraftEvent.KIND,
)
@Test
fun `every NIP-34 participant kind is subscribed on inbox relays`() {
val missing = nip34ParticipantKinds - NotificationsPerKeyKinds2.toSet()
assertTrue(
"NIP-34 kinds $missing are missing from NotificationsPerKeyKinds2. Without a " +
"`#p`=me subscription for these kinds, the event never lands on the device — " +
"so no merge/close notification can ever fire.",
missing.isEmpty(),
)
}
@Test
fun `every NIP-34 participant kind renders on the Android notifications tab`() {
val missing = nip34ParticipantKinds - NotificationFeedFilter.NOTIFICATION_KINDS.toSet()
assertTrue(
"NIP-34 kinds $missing are missing from NotificationFeedFilter.NOTIFICATION_KINDS. " +
"The event arrives from the p-tag subscription but the kind gate drops it " +
"before it can render on the Notifications tab.",
missing.isEmpty(),
)
}
@Test
fun `every NIP-34 participant kind fires a push notification`() {
val missing = nip34ParticipantKinds - NotificationDispatcher.NOTIFICATION_KINDS
assertTrue(
"NIP-34 kinds $missing are missing from NotificationDispatcher.NOTIFICATION_KINDS. " +
"The event arrives and renders in-app but no system-tray push fires — " +
"the user has to open the app to see it.",
missing.isEmpty(),
)
}
/**
* A status/update event's discovery path when a repo or PR is on screen: the
* [`e`=<targetId>][RepliesAndReactionsKinds2] engagement subscription. Without
* this the closed/merged pill on a repo listing can never populate the
* status event's only other route to the device is the `#p`=me subscription,
* which only fires for accounts that were pre-tagged as participants.
* Patches/issues/PRs are self-anchored (they ARE the target, not events
* about the target), so they are intentionally NOT expected here.
*/
@Test
fun `status and PR-update kinds are pulled by the engagement subscription`() {
val threadedActivityKinds =
setOf(
GitPullRequestUpdateEvent.KIND,
GitReplyEvent.KIND,
GitStatusOpenEvent.KIND,
GitStatusAppliedEvent.KIND,
GitStatusClosedEvent.KIND,
GitStatusDraftEvent.KIND,
)
val missing = threadedActivityKinds - RepliesAndReactionsKinds2.toSet()
assertTrue(
"Kinds $missing are missing from RepliesAndReactionsKinds2. When a repo/PR row is " +
"on screen the app fetches replies + reactions targeting the visible events — " +
"this is where GitStatusIndex gets its data. Missing kinds mean the closed/" +
"merged pill on a repo listing never populates for anyone who isn't a p-tagged " +
"participant of the PR.",
missing.isEmpty(),
)
}
}
@@ -0,0 +1,119 @@
/*
* 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.wallet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.TransactionRowLabels
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class TransactionRowLabelsTest {
private val recipientHex = "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b"
/**
* The bug this class exists for. Wallets send `"description": ""` for a payment
* with no memo; the old row did `tx.description ?: fallback`, which only catches
* null, so it rendered an empty Text a line with the height of a real one and
* nothing in it. Outgoing rows looked like a bare arrow and a date.
*/
@Test
fun aDescriptionWithNothingInItFallsBackToTheDirection() {
// Empty and whitespace are what wallets actually send for a payment with no
// memo; absent is the spec-clean form. All three must reach the fallback.
listOf("", " ", null).forEach { description ->
val labels = TransactionRowLabels.resolve(NwcTransaction(type = "outgoing", description = description), "Sent")
assertEquals("description=<$description>", TransactionRowLabels.Title.Literal("Sent"), labels.title)
assertNull("description=<$description>", labels.subtitle)
}
}
@Test
fun aRealDescriptionIsTheTitle() {
val labels = TransactionRowLabels.resolve(NwcTransaction(type = "outgoing", description = "Coffee"), "Sent")
assertEquals(TransactionRowLabels.Title.Literal("Coffee"), labels.title)
assertNull(labels.subtitle)
}
/** An outgoing zap resolves the payee from the zap request's `p` tag. */
@Test
fun anOutgoingZapNamesThePayee() {
val labels =
TransactionRowLabels.resolve(
NwcTransaction(
type = "outgoing",
description = "",
metadata =
mapOf(
"recipient_data" to mapOf("identifier" to "user@domain.com"),
"nostr" to
mapOf(
"pubkey" to "f512822a89d2369a386bfeb1e687ccd26ceb6bb33e73b98417499bb9054bff1f",
"content" to "great post",
"tags" to listOf(listOf("p", recipientHex)),
),
),
),
"Sent",
)
assertEquals(TransactionRowLabels.Title.User(recipientHex, "user@domain.com"), labels.title)
assertEquals("great post", labels.subtitle)
}
/** With no zap request, the lightning address alone still labels the row. */
@Test
fun theLeanPairAloneStillNamesThePayee() {
val labels =
TransactionRowLabels.resolve(
NwcTransaction(
type = "outgoing",
description = "",
metadata = mapOf("recipient_data" to mapOf("identifier" to "user@domain.com")),
),
"Sent",
)
assertEquals(TransactionRowLabels.Title.Literal("user@domain.com"), labels.title)
// Named but undescribed: the second line says what the row was.
assertEquals("Sent", labels.subtitle)
}
/** Incoming rows keep working off the payer, which is what they did before. */
@Test
fun anIncomingZapStillNamesTheSender() {
val labels =
TransactionRowLabels.resolve(
NwcTransaction(
type = "incoming",
description = "Test",
metadata = mapOf("nostr" to mapOf("pubkey" to recipientHex, "content" to "Test")),
),
"Received",
)
assertTrue(labels.title is TransactionRowLabels.Title.User)
// The comment merely repeats the description, so it is not shown twice.
assertEquals("Test", labels.subtitle)
}
}
@@ -20,17 +20,18 @@
*/
package com.vitorpamplona.amethyst.commons.feeds.custom
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
import com.vitorpamplona.amethyst.commons.util.intOrNull
import com.vitorpamplona.amethyst.commons.util.longOrNull
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.intOrNull as primitiveIntOrNull
object FeedDefinitionSerializer {
private val json = Json { ignoreUnknownKeys = true }
@@ -65,8 +66,8 @@ object FeedDefinitionSerializer {
val id = node.string("id") ?: return null
val name = node.string("name") ?: return null
val emoji = node.string("emoji") ?: ""
val pinned = node.bool("pinned") ?: false
val pinOrder = node.int("pinOrder") ?: Int.MAX_VALUE
val pinned = node.booleanOrNull("pinned") ?: false
val pinOrder = node.intOrNull("pinOrder") ?: Int.MAX_VALUE
val refreshMode =
node.string("refreshMode")?.let {
try {
@@ -75,7 +76,7 @@ object FeedDefinitionSerializer {
RefreshMode.LIVE_STREAM
}
} ?: RefreshMode.LIVE_STREAM
val createdAt = node.long("createdAt") ?: 0L
val createdAt = node.longOrNull("createdAt") ?: 0L
val source = (node["source"] as? JsonObject)?.let { deserializeSource(it) } ?: return null
return FeedDefinition(
@@ -158,7 +159,7 @@ object FeedDefinitionSerializer {
"people_list" -> {
FeedSource.PeopleList(
kind = node.int("kind") ?: 30000,
kind = node.intOrNull("kind") ?: 30000,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
@@ -166,7 +167,7 @@ object FeedDefinitionSerializer {
"interest_set" -> {
FeedSource.InterestSet(
kind = node.int("kind") ?: 30015,
kind = node.intOrNull("kind") ?: 30015,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
@@ -174,7 +175,7 @@ object FeedDefinitionSerializer {
"dvm" -> {
FeedSource.DVM(
kind = node.int("kind") ?: 31990,
kind = node.intOrNull("kind") ?: 31990,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
@@ -194,14 +195,10 @@ object FeedDefinitionSerializer {
private fun stringArray(values: Iterable<String>): JsonArray = buildJsonArray { values.forEach { add(JsonPrimitive(it)) } }
// Deliberately stricter than the shared stringOrNull: a persisted feed field must be an actual
// quoted string — a number or boolean here is a corrupt record, not a value to coerce.
private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull
private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
private fun JsonObject.stringList(key: String) =
(this[key] as? JsonArray)
?.map { (it as? JsonPrimitive)?.content.orEmpty() }
@@ -209,6 +206,6 @@ object FeedDefinitionSerializer {
private fun JsonObject.intList(key: String) =
(this[key] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.intOrNull }
?.mapNotNull { (it as? JsonPrimitive)?.primitiveIntOrNull }
?.toImmutableList()
}
@@ -38,7 +38,12 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -73,6 +78,16 @@ val RepliesAndReactionsKinds2 =
NIP90StatusEvent.KIND,
TorrentCommentEvent.KIND,
GitReplyEvent.KIND,
// NIP-34 PR revision (1619) and status events (1630/1631/1632/1633).
// Rooted at the target patch/PR/issue via a `root`-marked `e` tag, so
// an `e=<targetId>` engagement fetch surfaces the PR's revision chain
// and every open/applied/closed/draft transition — the signal
// GitStatusIndex needs to answer isClosedOrResolved() for repo rows.
GitPullRequestUpdateEvent.KIND,
GitStatusOpenEvent.KIND,
GitStatusAppliedEvent.KIND,
GitStatusClosedEvent.KIND,
GitStatusDraftEvent.KIND,
PollResponseEvent.KIND,
ZapPollEvent.KIND,
)
@@ -0,0 +1,67 @@
/*
* 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.commons.util
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
// Total accessors for ad-hoc JSON trees (kotlinx.serialization) — bridge envelopes, persisted
// blobs, evaluateJavascript results. Every field is optional and possibly attacker-controlled,
// so each accessor degrades to null instead of throwing on an absent, JSON-null, or mistyped
// value. A codec that WANTS to reject malformed input (e.g. NappletProtocolJson) should keep
// throwing accessors instead of these.
/** Parses [raw] as a JSON object, or null when it is malformed or not an object. */
fun parseJsonObjectOrNull(raw: String): JsonObject? = runCatching { Json.parseToJsonElement(raw) as? JsonObject }.getOrNull()
/**
* The primitive at [key] rendered as a string, or null when absent, JSON-null, or not a
* primitive. Numbers and booleans coerce to their literal text (org.json `optString` style);
* use a `isString`-guarded read instead where a quoted string must be told apart from them.
*/
fun JsonObject.stringOrNull(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull
/** The primitive at [key] as an Int, or null when absent or not parseable as one. */
fun JsonObject.intOrNull(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull
/** The primitive at [key] as a Long, or null when absent or not parseable as one. */
fun JsonObject.longOrNull(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
/** The primitive at [key] as a Double, or null when absent or not parseable as one. */
fun JsonObject.doubleOrNull(key: String): Double? = (this[key] as? JsonPrimitive)?.doubleOrNull
/** The primitive at [key] as a Boolean, or null when absent or not parseable as one. */
fun JsonObject.booleanOrNull(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
/** The nested object at [key], or null when absent or not an object. */
fun JsonObject.objectOrNull(key: String): JsonObject? = this[key] as? JsonObject
/** A copy of this object with [key] set to [value] ([JsonObject] is immutable). */
fun JsonObject.withString(
key: String,
value: String,
): JsonObject = JsonObject(this + (key to JsonPrimitive(value)))
+43 -9
View File
@@ -85,20 +85,54 @@
]
},
{
"user": "Bardesss",
"user": "vitorpamplona",
"languages": [
"Dutch"
"Arabic, Saudi Arabia",
"Bengali",
"Chinese Simplified",
"Chinese Simplified, Singapore",
"Chinese Traditional",
"Chinese Traditional, Hong Kong",
"Czech",
"Dutch",
"Esperanto",
"Finnish",
"French",
"French, Canada",
"German",
"Greek",
"Hindi",
"Hungarian",
"Indonesian",
"Italian",
"Japanese",
"Korean",
"Latvian",
"Persian",
"Polish",
"Portuguese",
"Portuguese, Brazilian",
"Russian",
"Rսssian, Սkraine",
"Serbian (Cyrillic)",
"Slovenian",
"Spanish",
"Spanish, Mexico",
"Spanish, United States",
"Swahili, Kenya",
"Swedish",
"Tamil",
"Thai",
"Turkish",
"Ukrainian",
"Uzbek",
"Vietnamese"
]
},
{
"user": "vitorpamplona",
"user": "Bardesss",
"languages": [
"Czech",
"German",
"Polish",
"Portuguese, Brazilian",
"Slovenian",
"Swedish"
"Dutch"
]
},
{
+3
View File
@@ -44,6 +44,9 @@ dependencies {
implementation(libs.androidx.webkit)
implementation(libs.okhttp)
// Tree-level JSON for the bridge/broker envelopes (no @Serializable codegen, so no plugin needed).
implementation(libs.kotlinx.serialization.json)
// Provider side of the cross-process UI embedding: hosts the browser WebView in this keyless
// `:napplet` process and ships its rendered surface to the main app via SurfaceControlViewHost.
implementation(libs.androidx.privacysandbox.ui.core)
@@ -63,8 +63,11 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.amethyst.commons.util.withString
import com.vitorpamplona.quartz.utils.Log
import org.json.JSONObject
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
import java.lang.ref.WeakReference
import java.util.concurrent.Executor
@@ -616,13 +619,13 @@ class NappletBrowserActivity : ComponentActivity() {
if (!isMainFrame) return
bridgeReplyProxy = replyProxy
val raw = message.data ?: return
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
val envelope = parseJsonObjectOrNull(raw) ?: return
val scheme = sourceOrigin.scheme ?: return
val host = sourceOrigin.host ?: return
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""
val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" }
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = replyMessenger
@@ -667,8 +670,7 @@ class NappletBrowserActivity : ComponentActivity() {
NappletIpc.MSG_RESPONSE -> {
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
result.put("id", id)
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
bridgeReplyProxy?.postMessage(result.toString())
}
NappletIpc.MSG_PUSH -> {
@@ -55,8 +55,11 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.amethyst.commons.util.withString
import com.vitorpamplona.quartz.utils.Log
import org.json.JSONObject
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
/**
@@ -591,10 +594,10 @@ class NappletBrowserService : Service() {
if (!isMainFrame) return
tab.bridgeReplyProxy = replyProxy
val raw = message.data ?: return
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
val envelope = parseJsonObjectOrNull(raw) ?: return
// IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client.
if (envelope.optString("type").startsWith("ime.")) {
if (envelope.stringOrNull("type").orEmpty().startsWith("ime.")) {
val reply =
Message.obtain(null, NappletBrowserContract.MSG_IME_EVENT).apply {
data = Bundle().apply { putString(NappletBrowserContract.KEY_IME_PAYLOAD, raw) }
@@ -607,7 +610,7 @@ class NappletBrowserService : Service() {
val host = sourceOrigin.host ?: return
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""
val id = envelope.optString("id").ifEmpty { "fire-${tab.fireSeq++}" }
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = tab.replyMessenger
@@ -716,8 +719,7 @@ class NappletBrowserService : Service() {
NappletIpc.MSG_RESPONSE -> {
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
result.put("id", id)
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) }
}
NappletIpc.MSG_PUSH -> {
@@ -25,8 +25,9 @@ import android.os.Looper
import android.util.Base64
import android.webkit.WebView
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.quartz.utils.Log
import org.json.JSONObject
/**
* Declared-favicon capture for the sandboxed browser WebViews, complementing
@@ -111,9 +112,9 @@ internal object NappletFaviconSniffer {
/** `state` to base64 payload, or null when the page has not produced a result for this seq yet. */
private fun parse(raw: String?): Pair<String, String>? {
if (raw == null || raw == "null") return null
val json = runCatching { JSONObject(raw) }.getOrNull() ?: return null
val state = json.optString("state").ifBlank { return null }
return state to json.optString("data")
val json = parseJsonObjectOrNull(raw) ?: return null
val state = json.stringOrNull("state")?.ifBlank { null } ?: return null
return state to json.stringOrNull("data").orEmpty()
}
/**
@@ -64,6 +64,10 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.amethyst.commons.util.withString
import com.vitorpamplona.amethyst.napplethost.R
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
@@ -78,7 +82,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import kotlinx.serialization.json.JsonObject
import java.lang.ref.WeakReference
import java.util.concurrent.Executor
import com.vitorpamplona.amethyst.commons.R as CommonsR
@@ -744,17 +748,17 @@ class NappletHostActivity : ComponentActivity() {
val raw = message.data ?: return
// The applet sends a full upstream envelope {type, id, ...}; we forward it verbatim and
// correlate on its id. The broker reads `type` to decode and to build the .result reply.
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
val envelope = parseJsonObjectOrNull(raw) ?: return
// Unbind a keyboard action as soon as the applet drops it (the broker's Done reply carries no
// actionId, so the binding is removed here from the envelope itself).
if (envelope.optString("type") == "keys.unregisterAction") {
envelope.optString("actionId").takeIf { it.isNotEmpty() }?.let { keyActions.unregister(it) }
if (envelope.stringOrNull("type") == "keys.unregisterAction") {
envelope.stringOrNull("actionId")?.takeIf { it.isNotEmpty() }?.let { keyActions.unregister(it) }
}
// Fire-and-forget messages (inc.emit, keys.unregisterAction) have no id; synthesize one so
// they still reach the broker. Any reply is harmless — the applet has nothing to correlate.
val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" }
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
@@ -791,13 +795,12 @@ class NappletHostActivity : ComponentActivity() {
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
// payload is the broker's {type:"...result", ok, ...}; inject the correlation id for the shim.
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
result.put("id", id)
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
// The broker authorized a keyboard action: bind the honored key combo so dispatchKeyEvent
// can fire it. Only ok'd registrations bind (a denied KEYS request never reaches here).
if (result.optString("type") == "keys.registerAction.result" && result.optBoolean("ok")) {
val actionId = result.optString("actionId")
if (actionId.isNotEmpty()) keyActions.register(actionId, result.optString("binding").ifEmpty { null })
if (result.stringOrNull("type") == "keys.registerAction.result" && result.booleanOrNull("ok") == true) {
val actionId = result.stringOrNull("actionId").orEmpty()
if (actionId.isNotEmpty()) keyActions.register(actionId, result.stringOrNull("binding")?.ifEmpty { null })
}
notifyIfSensitive(result)
bridgeReplyProxy?.postMessage(result.toString())
@@ -989,10 +992,10 @@ class NappletHostActivity : ComponentActivity() {
* Surfaces an "allow always" capability acting on the user's behalf, so a granted RELAY/UPLOAD/VALUE
* op can never run completely silently. Read-only ops (identity/storage/resource) don't toast.
*/
private fun notifyIfSensitive(result: JSONObject) {
if (!result.optBoolean("ok")) return
private fun notifyIfSensitive(result: JsonObject) {
if (result.booleanOrNull("ok") != true) return
val message =
when (result.optString("type")) {
when (result.stringOrNull("type")) {
"relay.publish.result", "relay.publishEncrypted.result" -> getString(R.string.napplet_action_published, barTitle())
"upload.upload.result" -> getString(R.string.napplet_action_uploaded, barTitle())
"value.payInvoice.result" -> getString(R.string.napplet_action_paid, barTitle())
@@ -58,11 +58,15 @@ import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.amethyst.commons.util.withString
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.sha256.sha256
import org.json.JSONObject
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.Executor
@@ -583,10 +587,10 @@ class NappletHostService : Service() {
tab.bridgeReplyProxy = replyProxy
val raw = message.data ?: return
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
val envelope = parseJsonObjectOrNull(raw) ?: return
// IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client.
if (envelope.optString("type").startsWith("ime.")) {
if (envelope.stringOrNull("type").orEmpty().startsWith("ime.")) {
val reply =
Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply {
data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) }
@@ -595,7 +599,7 @@ class NappletHostService : Service() {
return
}
val id = envelope.optString("id").ifEmpty { "fire-${tab.fireSeq++}" }
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = tab.replyMessenger
@@ -630,8 +634,7 @@ class NappletHostService : Service() {
NappletIpc.MSG_RESPONSE -> {
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
result.put("id", id)
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
notifyIfSensitive(tab, result)
runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) }
}
@@ -647,11 +650,11 @@ class NappletHostService : Service() {
/** Pushes a notice to the main process for a granted "allow always" sensitive op, so it can toast. */
private fun notifyIfSensitive(
tab: NappletTab,
result: JSONObject,
result: JsonObject,
) {
if (!result.optBoolean("ok")) return
if (result.booleanOrNull("ok") != true) return
val notice =
when (result.optString("type")) {
when (result.stringOrNull("type")) {
"relay.publish.result", "relay.publishEncrypted.result" -> NappletEmbedContract.NOTICE_PUBLISHED
"upload.upload.result" -> NappletEmbedContract.NOTICE_UPLOADED
"value.payInvoice.result" -> NappletEmbedContract.NOTICE_PAID
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields
import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt
import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement
import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.toAnyMap
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
@@ -45,7 +46,6 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
@@ -63,18 +63,6 @@ import kotlinx.serialization.json.put
* (Jackson's `ACCEPT_SINGLE_VALUE_AS_ARRAY`) so native targets parse the same wire shapes.
*/
private fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> JsonNull
is JsonElement -> value
is String -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is Number -> JsonPrimitive(value)
is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> put(k.toString(), anyToJsonElement(v)) } }
is Iterable<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } }
else -> JsonPrimitive(value.toString())
}
private fun JsonObject.stringOrNull(key: String): String? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.content }
private fun JsonObject.longOrNull(key: String): Long? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.longOrNull }
@@ -0,0 +1,42 @@
/*
* 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.quartz.nip01Core.core
/**
* JSON that is already serialized and must reach the wire as-is.
*
* Used where the exact BYTES matter and not merely the value. The case that
* forced it: NIP-57 sets a zap invoice's `description_hash` to the sha256 of the
* raw zap-request JSON the LNURL callback received, so a wallet binding a stored
* zap request to the invoice it labels hashes those same bytes. Handing the
* serializer a decomposed `Map` and hoping it reassembles them identically makes
* that binding depend on key order, escaping and number formatting agreeing by
* coincidence and it fails silently, as an unlabelled row, when they do not.
*
* Both backends emit the string verbatim: Jackson via `writeRawValue`, kotlinx via
* `JsonUnquotedLiteral`. [json] MUST already be well-formed JSON; nothing
* validates it, and an invalid value corrupts the whole document.
*/
data class RawJson(
val json: String,
) {
override fun toString() = json
}
@@ -0,0 +1,58 @@
/*
* 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.quartz.nip01Core.kotlinSerialization
import com.vitorpamplona.quartz.nip01Core.core.RawJson
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonUnquotedLiteral
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* Encodes an untyped `Any?` tree the shape several Nostr RPCs carry as a free-form
* `Map<String, Any?>` into a [JsonElement].
*
* `Json.encodeToJsonElement` CANNOT do this: it resolves a serializer from the STATIC
* type, and `Any` has none, so it throws `SerializationException: Serializer for class
* 'Any' is not found` at runtime for every populated map. This walks the value instead.
*
* DECLARED AT nip01Core LEVEL, beside the other kotlinx serializers, because [RawJson]
* is: it is registered globally on the Jackson side, so a per-NIP copy of this function
* would leave the kotlinx backend supporting raw JSON in some packages and silently
* quoting it into a string in others the exact corruption [RawJson] exists to prevent.
*/
fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> JsonNull
is RawJson -> JsonUnquotedLiteral(value.json)
is JsonElement -> value
is String -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is Number -> JsonPrimitive(value)
is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> put(k.toString(), anyToJsonElement(v)) } }
is Iterable<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } }
is Array<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } }
else -> JsonPrimitive(value.toString())
}
@@ -122,6 +122,21 @@ val DEFAULT_ELECTRUMX_SERVERS =
// self-signed peer above is unreachable (e.g. corporate networks that
// strip unknown CAs but allow LE).
ElectrumxServer("electrum.nmc.ethicnology.com", 50002, useSsl = true, usePinnedTrustStore = false),
// electrumx2.testls.space — redundancy endpoint for the testls.space
// operator, on the same box as relay.testls.bit (23.158.233.10) but
// terminating TLS at nginx with a publicly-trusted Let's Encrypt cert
// (CN=electrumx2.testls.space, issuer LE YE1) instead of the self-signed
// relay.testls.bit cert served on the standard ports. usePinnedTrustStore
// is left at the default (false) because the system trust store is
// sufficient.
//
// Port 50012 is the TCP+TLS endpoint (what this client uses). The same
// nginx vhost also exposes WSS on port 50014 for browser-based Nostr
// clients that want to do Namecoin NIP-05 lookups without a backend
// proxy — browsers refuse WSS to self-signed certs, so the LE cert on
// electrumx2 makes it the first browser-viable public Namecoin
// ElectrumX endpoint alongside electrum.nmc.ethicnology.com.
ElectrumxServer("electrumx2.testls.space", 50012, useSsl = true, usePinnedTrustStore = false),
// Note: no bare-IP companion entry for electrum.nmc.ethicnology.com.
// Unlike the 46.229.238.187 / 23.158.233.10 peers above (which use
// usePinnedTrustStore=true and DER-SHA256 pinning that doesn't care
@@ -157,4 +172,6 @@ val TOR_ELECTRUMX_SERVERS =
ElectrumxServer("23.158.233.10", 50002, useSsl = true, usePinnedTrustStore = true),
// electrum.nmc.ethicnology.com — public LE-cert ElectrumX. See clearnet list above.
ElectrumxServer("electrum.nmc.ethicnology.com", 50002, useSsl = true, usePinnedTrustStore = false),
// electrumx2.testls.space — LE-cert redundancy endpoint. See clearnet list above.
ElectrumxServer("electrumx2.testls.space", 50012, useSsl = true, usePinnedTrustStore = false),
)
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip47WalletConnect.tags.EncryptionTag
import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag
import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -47,19 +48,28 @@ class NwcInfoEvent(
// NIP-47 carries the schemes/types as a single space-separated string in one
// tag value (e.g. ["encryption", "nip44_v2 nip04"]). Split on whitespace so we
// return individual tokens, while still tolerating a multi-element tag.
fun encryptionSchemes() =
private fun spaceSeparatedTag(parse: (Array<String>) -> List<String>?) =
tags
.mapNotNull(EncryptionTag::parse)
.mapNotNull(parse)
.flatten()
.flatMap { it.split(" ") }
.filter { it.isNotBlank() }
fun notificationTypes() =
tags
.mapNotNull(NotificationsTag::parse)
.flatten()
.flatMap { it.split(" ") }
.filter { it.isNotBlank() }
fun encryptionSchemes() = spaceSeparatedTag(EncryptionTag::parse)
fun notificationTypes() = spaceSeparatedTag(NotificationsTag::parse)
/** The optional NWC extension specs this wallet advertises (eg. `["05", "06"]`). */
fun extensions() = spaceSeparatedTag(ExtensionsTag::parse)
/**
* Whether the wallet advertises a given NWC extension spec.
*
* A wallet that says nothing reads as **no**. That direction is deliberate:
* the caller is deciding whether to send something the wallet may not
* understand, so silence must not be read as permission.
*/
fun supportsExtension(id: String) = extensions().contains(id)
companion object {
const val KIND = 13194
@@ -68,11 +78,13 @@ class NwcInfoEvent(
capabilities: List<String>,
encryptionSchemes: List<String>? = null,
notificationTypes: List<String>? = null,
extensions: List<String>? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<NwcInfoEvent>.() -> Unit = {},
) = eventTemplate(KIND, capabilities.joinToString(" "), createdAt) {
encryptionSchemes?.let { addUnique(EncryptionTag.assemble(it)) }
notificationTypes?.let { addUnique(NotificationsTag.assemble(it)) }
extensions?.let { addUnique(ExtensionsTag.assemble(it)) }
initializer()
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
@@ -55,7 +56,6 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
@@ -65,7 +65,6 @@ import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
@@ -148,7 +147,7 @@ object Nip47RequestKSerializer : KSerializer<Request> {
buildJsonObject {
params.invoice?.let { put("invoice", it) }
params.amount?.let { put("amount", it) }
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
private fun serializePayParams(params: PayParams): JsonObject =
@@ -156,14 +155,14 @@ object Nip47RequestKSerializer : KSerializer<Request> {
params.payment?.let { put("payment", it) }
params.amount?.let { put("amount", it) }
params.payer_note?.let { put("payer_note", it) }
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
private fun serializeReceiveParams(params: ReceiveParams): JsonObject =
buildJsonObject {
params.amount?.let { put("amount", it) }
params.description?.let { put("description", it) }
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
private fun serializePayKeysendParams(params: PayKeysendParams): JsonObject =
@@ -194,7 +193,7 @@ object Nip47RequestKSerializer : KSerializer<Request> {
params.description?.let { put("description", it) }
params.description_hash?.let { put("description_hash", it) }
params.expiry?.let { put("expiry", it) }
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
private fun serializeLookupInvoiceParams(params: LookupInvoiceParams): JsonObject =
@@ -234,7 +233,7 @@ object Nip47RequestKSerializer : KSerializer<Request> {
params.budget_renewal?.let { put("budget_renewal", it) }
params.expires_at?.let { put("expires_at", it) }
params.isolated?.let { put("isolated", it) }
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
private fun serializeMakeHoldInvoiceParams(params: MakeHoldInvoiceParams): JsonObject =
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
@@ -47,7 +48,6 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
@@ -56,7 +56,6 @@ import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@@ -226,7 +225,7 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
result.notifications?.let { notifications ->
put("notifications", buildJsonArray { notifications.forEach { add(it) } })
}
result.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
result.metadata?.let { put("metadata", anyToJsonElement(it)) }
result.lud16?.let { put("lud16", it) }
}
@@ -373,7 +372,7 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
transaction.expires_at?.let { put("expires_at", it) }
transaction.settled_at?.let { put("settled_at", it) }
transaction.settle_deadline?.let { put("settle_deadline", it) }
transaction.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
transaction.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
}
@@ -65,6 +65,18 @@ class NwcTransaction(
var metadata: Map<String, Any?>? = null,
) {
fun parsedMetadata(): NwcTransactionMetadata? = NwcTransactionMetadata.parse(metadata)
/**
* The description, or null when the wallet had nothing to say.
*
* Wallets send `"description": ""` for a payment with no memo rather than
* omitting the field, so an elvis on [description] yields an empty string that
* renders as a blank line. Normalized HERE rather than in the parser because
* [com.vitorpamplona.quartz.nip47WalletConnect.Nip47Server] serializes this same
* class back onto the wire rewriting blank to null at parse time would change
* what a wallet service echoes.
*/
fun displayDescription(): String? = description?.ifBlank { null }
}
class TlvRecord(
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect.rpc
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.RawJson
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
class NwcTransactionMetadata(
@@ -41,16 +43,26 @@ class NwcTransactionMetadata(
class NostrZapData(
val pubkeyHex: String?,
val recipientPubkeyHex: String?,
val content: String?,
)
fun senderPubkeyHex(): String? = nostr?.pubkeyHex ?: payerData?.pubkey?.let { decodePublicKeyAsHexOrNull(it) }
fun senderDisplayName(): String? = payerData?.name ?: payerData?.email
fun senderDisplayName(): String? = payerData?.name?.ifBlank { null } ?: payerData?.email?.ifBlank { null }
fun recipientIdentifier(): String? = recipientData?.identifier
fun recipientIdentifier(): String? = recipientData?.identifier?.ifBlank { null }
fun recipientPubkeyHex(): String? = nostr?.recipientPubkeyHex
/**
* The message to show for this transaction.
*
* A wallet that stores only `nostr` still carries the message: a zap request's
* `content` IS the public zap comment. Private-zap messages are encrypted into
* the `anon` tag rather than content, so nothing encrypted can surface here.
*/
fun displayComment(): String? = comment?.ifBlank { null } ?: nostr?.content?.ifBlank { null }
companion object {
fun parse(metadata: Any?): NwcTransactionMetadata? {
val map = metadata as? Map<*, *> ?: return null
@@ -92,6 +104,7 @@ class NwcTransactionMetadata(
NostrZapData(
pubkeyHex = pubkeyHex,
recipientPubkeyHex = recipientHex,
content = n["content"] as? String,
)
}
@@ -106,5 +119,90 @@ class NwcTransactionMetadata(
nostr = nostr,
)
}
/**
* NWC-06: "The metadata MUST be no more than 4096 characters, otherwise MUST
* be dropped." A wallet is required to discard an over-long object wholesale,
* so breaching this loses the recipient entirely rather than degrading.
*/
const val MAX_METADATA_CHARS = 4096
// The keys and punctuation around the values:
// `{"recipient_data":{"identifier":""},"comment":"","nostr":}` is 58 chars.
// Escaping is counted separately by [escapedLength], so this is a fixed cost.
private const val KEY_OVERHEAD = 64
/**
* The length a string occupies once JSON-escaped.
*
* `comment` is free text a user typed, so its raw length is not what reaches
* the wire: a quote or backslash becomes two characters and a control
* character becomes six. Counting the raw length instead would let an
* escaping-heavy comment breach [MAX_METADATA_CHARS] unnoticed and NWC-06
* makes the wallet drop the WHOLE object then, losing `recipient_data` too,
* which is the degradation this budget exists to protect.
*
* Over-counts the short forms (`\n` is two characters, not six), which is
* the safe direction.
*/
private fun escapedLength(value: String): Int =
value.sumOf { char ->
when {
char == '"' || char == '\\' -> 2
char < ' ' -> 6
else -> 1
}
}
/**
* Assembles NWC-06 `metadata` for an outgoing payment, or null when there is
* nothing worth saying.
*
* `nostr` carries the zap request's OWN serialization verbatim, as [RawJson].
*
* NIP-57 sets a zap invoice's `description_hash` to the sha256 of the raw
* JSON the LNURL callback received in `nostr=`, and that is
* `LnZapRequestEvent.toJson()` the exact string used here. A wallet can
* therefore bind this stored event to the invoice it labels, which is what
* turns "the client says it paid X" into something the wallet checked.
*
* Rebuilding the object from typed fields would put that binding at the mercy
* of key order, escaping and number formatting matching by coincidence, and
* it fails as a silently unlabelled row rather than as an error. Passing the
* bytes through also sidesteps the number-widening hazard in
* [com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.toAnyValue],
* which resolves untyped numbers with `toDoubleOrNull()` BEFORE
* `toLongOrNull()`: nothing here decomposes the event at all.
*
* When the whole object would breach [MAX_METADATA_CHARS], `nostr` is dropped
* and the much smaller `recipient_data`/`comment` pair survives, so the row
* still names the payee instead of arriving blank.
*/
fun build(
zapRequest: Event?,
recipientIdentifier: String?,
comment: String?,
): Map<String, Any?>? {
val lean = mutableMapOf<String, Any?>()
recipientIdentifier?.ifBlank { null }?.let {
lean["recipient_data"] = mapOf("identifier" to it)
}
comment?.ifBlank { null }?.let { lean["comment"] = it }
if (zapRequest != null) {
// The serialized length of the `nostr` member, exactly — it is the
// string that gets embedded, not a reconstruction of it.
val raw = zapRequest.toJson()
val chars =
escapedLength(recipientIdentifier.orEmpty()) + escapedLength(comment.orEmpty()) +
raw.length + KEY_OVERHEAD
if (chars <= MAX_METADATA_CHARS) {
lean["nostr"] = RawJson(raw)
}
}
return lean.ifEmpty { null }
}
}
}
@@ -22,23 +22,55 @@ package com.vitorpamplona.quartz.nip47WalletConnect.rpc
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
/**
* A request parameter block carrying NIP-47's optional `metadata`, whose keys
* NWC-06 defines.
*
* The field is `var` so a client can strip it before sending: it may only go to a
* wallet that advertised `06` in its info event's `extensions` tag, because a
* wallet that types `metadata` narrowly accepts a null but cannot decode an object
* and answers a params error instead of paying.
*/
interface MetadataCarrying {
var metadata: Map<String, Any?>?
}
// REQUEST OBJECTS
abstract class Request(
sealed class Request(
var method: String? = null,
) : OptimizedSerializable
) : OptimizedSerializable {
/**
* This request's NWC-06 metadata carrier, or null for a method that has none.
*
* Declared HERE, and overridden beside each method that carries one, so adding a
* metadata-bearing method is a decision made where the method is written. The
* alternative a `when` over request types in the client needs an `else`, and
* an `else` silently leaks the field to a wallet that never opted in.
*
* create_connection is deliberately absent: its `metadata` names the connection
* and is not NWC-06's per-payment blob.
*/
open val metadataCarrier: MetadataCarrying? get() = null
}
// pay_invoice
class PayInvoiceParams(
var invoice: String? = null,
var amount: Long? = null,
var metadata: Map<String, Any?>? = null,
)
override var metadata: Map<String, Any?>? = null,
) : MetadataCarrying
class PayInvoiceMethod(
var params: PayInvoiceParams? = null,
) : Request(NwcMethod.PAY_INVOICE) {
override val metadataCarrier get() = params
companion object {
fun create(bolt11: String): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11))
// `metadata` may only travel to a wallet that advertised it — see [MetadataCarrying].
fun create(
bolt11: String,
metadata: Map<String, Any?>? = null,
): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11, metadata = metadata))
fun create(
bolt11: String,
@@ -56,12 +88,14 @@ class PayParams(
var payment: String? = null,
var amount: Long? = null,
var payer_note: String? = null,
var metadata: Map<String, Any?>? = null,
)
override var metadata: Map<String, Any?>? = null,
) : MetadataCarrying
class PayMethod(
var params: PayParams? = null,
) : Request(NwcMethod.PAY) {
override val metadataCarrier get() = params
companion object {
fun create(
payment: String,
@@ -75,12 +109,14 @@ class PayMethod(
class ReceiveParams(
var amount: Long? = null,
var description: String? = null,
var metadata: Map<String, Any?>? = null,
)
override var metadata: Map<String, Any?>? = null,
) : MetadataCarrying
class ReceiveMethod(
var params: ReceiveParams? = null,
) : Request(NwcMethod.RECEIVE) {
override val metadataCarrier get() = params
companion object {
fun create(
amount: Long? = null,
@@ -116,12 +152,14 @@ class MakeInvoiceParams(
var description: String? = null,
var description_hash: String? = null,
var expiry: Long? = null,
var metadata: Map<String, Any?>? = null,
)
override var metadata: Map<String, Any?>? = null,
) : MetadataCarrying
class MakeInvoiceMethod(
var params: MakeInvoiceParams? = null,
) : Request(NwcMethod.MAKE_INVOICE) {
override val metadataCarrier get() = params
companion object {
fun create(
amount: Long,
@@ -0,0 +1,53 @@
/*
* 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.quartz.nip47WalletConnect.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-47's `extensions` tag on the kind 13194 info event: the optional NWC
* extension specs a wallet service supports, space-separated (eg. `02 03 04`).
*
* This is how a client learns it may use anything beyond the core command set
* without guessing. It matters most for request fields a wallet might not
* understand sending one to a wallet that never advertised support risks a
* refusal on a method that would otherwise have worked.
*/
class ExtensionsTag {
companion object {
const val TAG_NAME = "extensions"
// The specs this client knows how to use, so a caller names a constant
// rather than a bare string at each gate.
const val TRANSACTION_HISTORY = "05"
const val METADATA_CONVENTIONS = "06"
fun parse(tag: Array<String>): List<String>? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag.drop(1)
}
fun assemble(extensions: List<String>) = arrayOf(TAG_NAME, *extensions.toTypedArray())
}
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent
import com.vitorpamplona.quartz.feedDefinition.FeedDefinitionEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -83,6 +84,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
@@ -346,6 +348,38 @@ object SearchFieldExtractor {
tiers(event, event.title(), event.description(), null)
}
// kinds 30392-30395 -- a Trusted List's title is the ONLY
// human-authored text the family carries (`content` is a machine
// echo of the membership, the member tags are hex ids, and
// `metric`/`d` name the computation), so `indexableContent()` is
// exactly `title()`. Without this branch the fallback below put
// that title in the TERTIARY tier: a list titled "Verified Human"
// matched the words on the same rung as a bio that happens to
// mention them, and lost the prefix/typo columns a title gets.
is TrustedListEvent -> {
tiers(event, event.title(), null, null)
}
// kind 30382 -- a contact card's petname is a trust provider's
// NAME for a person, the direct analogue of kind 0's `name`, and
// its summary is the description beside it. The encrypted half of
// the card stays out, as it always has: petName()/summary() read
// the public tag array only, and build() puts both in the NIP-44
// content, so a card authored by this library has NO public text
// beyond its topics.
//
// topics() is deliberately absent: it is `TopicTag`, which is the
// `t` tag under another name (same predicate, same array), so the
// tiers() funnel already carries every topic in the hashtag role.
// Passing them again would index the same words twice -- which is
// what the fallback below was doing, since indexableContent()
// concatenates the topics INTO the body while the funnel added
// them as hashtags. Whether that role is tokenized or kept as
// keywords is the backend's call, per IndexableFields.
is ContactCardEvent -> {
tiers(event, event.petName(), event.summary(), null)
}
is FeedDefinitionEvent -> {
tiers(event, event.title(), null, null)
}
@@ -34,9 +34,13 @@ import kotlin.test.assertNull
/**
* Exercises the **kotlinx** (native/iOS) NWC serializers directly not through
* `OptimizedJsonMapper`, whose JVM actual is Jackson to cover the cross-backend
* asymmetry: Jackson (JVM/Android) writes explicit `null` for every null field, and a
* native peer parsing that output must read those as real nulls, not the string "null",
* and must not crash on a null `metadata` object.
* asymmetry on the DECODE side: a peer may write an explicit `null` for a field it
* has nothing to say about, and a native client parsing that must read it as a real
* null, not the string "null", and must not crash on a null `metadata` object.
*
* Our own Jackson backend no longer emits those on request params see
* [com.vitorpamplona.quartz.nip01Core.jackson.OmitNullsMixin] but a third-party
* wallet still may, so tolerating them on the way in remains required.
*/
class Nip47KotlinSerializationNullTest {
@Test
@@ -0,0 +1,144 @@
/*
* 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.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47RequestKSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* NIP-47 marks request parameters optional, and a wallet may type one strictly.
* Sending an absent parameter as an explicit `null` earned
* `Invalid list_transactions params: from must be an integer` from a real wallet
* and failed the request outright.
*
* EVERY params-bearing method is listed here on purpose. The Jackson mixin
* registrations that fix this are a hand-maintained list, and unlike the two
* `when` blocks over the sealed `Request` they are not compiler-checked so a
* thirteenth method can be added, serialize correctly on native, and regress on
* JVM/Android alone. This list is the only thing that would catch that.
*/
class Nip47NullParamOmissionTest {
private val requests: List<Pair<String, Request>> =
listOf(
// The reported failure: five of eight fields absent.
"list_transactions" to ListTransactionsMethod.create(limit = 20, offset = 0, unpaid = false),
"pay_invoice" to PayInvoiceMethod.create("lnbc50n1abc"),
"pay" to PayMethod.create("bitcoin:?lno=lno1abc"),
"receive" to ReceiveMethod.create(amount = 21000L),
"pay_keysend" to PayKeysendMethod.create(amount = 21000L, pubkey = "0266e4"),
// Reaches the NESTED TlvRecord, with one of its two optional fields absent.
// A record is only checked when the list is non-empty, so the plain
// pay_keysend fixture above never executes this path.
"pay_keysend+tlv" to
PayKeysendMethod.create(
amount = 21000L,
pubkey = "0266e4",
tlvRecords = listOf(TlvRecord(type = 5482373484L)),
),
"make_invoice" to MakeInvoiceMethod.create(amount = 21000L),
"lookup_invoice" to LookupInvoiceMethod.createByHash("31afdf1"),
"make_hold_invoice" to MakeHoldInvoiceMethod.create(amount = 21000L, paymentHash = "31afdf1"),
"cancel_hold_invoice" to CancelHoldInvoiceMethod.create("31afdf1"),
"settle_hold_invoice" to SettleHoldInvoiceMethod.create("0123456789abcdef"),
"sign_message" to SignMessageMethod.create("hello"),
"create_connection" to CreateConnectionMethod.create(pubkey = "abc123", name = "app"),
)
@Test
fun noRequestEverSendsANullParam() {
requests.forEach { (name, request) ->
val json = OptimizedJsonMapper.toJson(request)
val params = Json.parseToJsonElement(json).jsonObject["params"] as? JsonObject
// RECURSIVE: `tlv_records` holds objects with optional fields of their own,
// so a null can hide a level below the params object.
params?.let { assertNoNulls(it, name, json) }
}
}
private fun assertNoNulls(
element: JsonElement,
name: String,
json: String,
) {
when (element) {
is JsonObject ->
element.forEach { (key, value) ->
assertTrue(value !is JsonNull, "$name sent \"$key\": null - omit it instead. Full: $json")
assertNoNulls(value, name, json)
}
is JsonArray -> element.forEach { assertNoNulls(it, name, json) }
else -> Unit
}
}
/**
* The document the failing wallet rejected, now minimal. Asserted as a KEY SET
* rather than a literal string: which keys travel is the property under test,
* while their order is each backend's own business.
*/
@Test
fun listTransactionsCarriesOnlyWhatWasAsked() {
val json = OptimizedJsonMapper.toJson(ListTransactionsMethod.create(limit = 20, offset = 0, unpaid = false))
val params = assertNotNull(Json.parseToJsonElement(json).jsonObject["params"], "no params in $json").jsonObject
assertEquals(setOf("limit", "offset", "unpaid"), params.keys, "unexpected keys in $json")
}
/**
* The invariant the bug broke: one wire format, two backends, same document.
* This is the actual fix the mixin is only the mechanism that restores it.
*/
@Test
fun bothBackendsProduceTheSameDocument() {
requests.forEach { (name, request) ->
val viaJackson = Json.parseToJsonElement(OptimizedJsonMapper.toJson(request)).jsonObject
val viaKotlinx = Json.parseToJsonElement(Json.encodeToString(Nip47RequestKSerializer, request)).jsonObject
assertEquals(viaKotlinx, viaJackson, "$name differs between backends")
}
}
}
@@ -0,0 +1,260 @@
/*
* 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.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* NWC-06 metadata on OUTGOING payments: what we send, when we are allowed to send
* it, and what a row makes of it coming back.
*/
class NwcOutgoingMetadataTest {
private val recipientHex = "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b"
private val payerHex = "f512822a89d2369a386bfeb1e687ccd26ceb6bb33e73b98417499bb9054bff1f"
private fun zapRequest(
content: String = "great post",
relays: List<String> = listOf("wss://relay.damus.io"),
) = Event(
id = "a".repeat(64),
pubKey = payerHex,
createdAt = 1756000000L,
kind = 9734,
tags = arrayOf(arrayOf("p", recipientHex), arrayOf("relays", *relays.toTypedArray())),
content = content,
sig = "b".repeat(128),
)
// --- build ---
@Test
fun nothingToSayProducesNoMetadataAtAll() {
assertNull(NwcTransactionMetadata.build(null, null, null))
assertNull(NwcTransactionMetadata.build(null, " ", ""))
}
@Test
fun leanPairIsSentWithoutAZapRequest() {
val meta = assertNotNull(NwcTransactionMetadata.build(null, "user@domain.com", "thanks"))
assertEquals(mapOf("identifier" to "user@domain.com"), meta["recipient_data"])
assertEquals("thanks", meta["comment"])
assertFalse(meta.containsKey("nostr"))
}
/**
* THE INTEROP PROPERTY. NIP-57 sets a zap invoice's `description_hash` to the
* sha256 of the raw JSON the LNURL callback received in `nostr=` which is
* `LnZapRequestEvent.toJson()` (see LightningAddressResolver). A wallet that
* binds a stored zap request to the invoice it labels hashes the bytes of the
* `nostr` member, so anything short of byte-identity reads as a forged event
* and the row is silently stored unlabelled.
*/
@Test
fun theNostrMemberIsByteIdenticalToWhatTheLnurlCallbackReceived() {
val event = zapRequest(content = "quoted \" and & <angled> \u00fcn\u00efcode \ud83d\ude00")
val callbackBytes = event.toJson()
val wire =
OptimizedJsonMapper.toJson(
PayInvoiceMethod.create("lnbc50n1abc", NwcTransactionMetadata.build(event, "user@domain.com", "hi")),
)
assertTrue(
wire.contains("\"nostr\":" + callbackBytes),
"metadata.nostr must be the callback's own bytes.\n sent: $callbackBytes\n wire: $wire",
)
}
@Test
fun theZapRequestSurvivesAsAReadableObject() {
val event = zapRequest()
val wire = OptimizedJsonMapper.toJson(PayInvoiceMethod.create("lnbc1", NwcTransactionMetadata.build(event, null, null)))
val back = OptimizedJsonMapper.fromJsonTo<Request>(wire) as PayInvoiceMethod
val parsed = assertNotNull(NwcTransactionMetadata.parse(back.params?.metadata))
// Raw on the way out, a normal object on the way back in.
assertEquals(recipientHex, parsed.recipientPubkeyHex())
assertEquals(payerHex, parsed.senderPubkeyHex())
}
@Test
fun anOversizeZapRequestIsDroppedButTheRowStillNamesThePayee() {
// NWC-06: over 4096 characters a wallet MUST drop the WHOLE object, so
// breaching it would lose the payee entirely rather than degrade.
val many = List(200) { "wss://relay-with-a-fairly-long-hostname-.example.com" }
val meta = assertNotNull(NwcTransactionMetadata.build(zapRequest(relays = many), "user@domain.com", "hi"))
assertFalse(meta.containsKey("nostr"))
assertEquals(mapOf("identifier" to "user@domain.com"), meta["recipient_data"])
assertEquals("hi", meta["comment"])
}
/**
* A provider that does not advertise `allowsNostr` never receives the zap request,
* so its invoice commits to nothing about it. Passing null here is how the caller
* says so, and the metadata must then make no claim it cannot support while the
* payee's address still labels the row.
*/
@Test
fun withoutAZapRequestTheRowIsStillNamedButClaimsNoBinding() {
val meta = assertNotNull(NwcTransactionMetadata.build(null, "user@domain.com", "great post"))
assertFalse(meta.containsKey("nostr"))
assertEquals(mapOf("identifier" to "user@domain.com"), meta["recipient_data"])
assertEquals("great post", meta["comment"])
}
/**
* `comment` is free text, and JSON escaping expands it on the way out. Counting the
* raw length would let an escaping-heavy comment breach the 4096 ceiling unnoticed
* and NWC-06 makes the wallet drop the WHOLE object then, taking `recipient_data`
* with it.
*/
@Test
fun theCeilingCountsEscapedLengthNotRawLength() {
// Every character escapes to six, so 900 raw chars occupy ~5400 on the wire.
val controlHeavy = "\u0001".repeat(900)
val meta = assertNotNull(NwcTransactionMetadata.build(zapRequest(), "user@domain.com", controlHeavy))
assertFalse(meta.containsKey("nostr"), "the escaped comment alone exceeds the ceiling")
// The same length in plain characters leaves room for the zap request.
val plain = "a".repeat(900)
assertTrue(assertNotNull(NwcTransactionMetadata.build(zapRequest(), "user@domain.com", plain)).containsKey("nostr"))
}
@Test
fun whatWeSendStaysUnderTheSpecCeiling() {
val meta = NwcTransactionMetadata.build(zapRequest(), "user@domain.com", "great post")
val serialized = OptimizedJsonMapper.toJson(PayInvoiceMethod.create("lnbc50n1abc", meta))
assertTrue(serialized.length < NwcTransactionMetadata.MAX_METADATA_CHARS)
}
// --- the wire ---
@Test
fun metadataSurvivesASerializationRoundTrip() {
val meta = NwcTransactionMetadata.build(zapRequest(), "user@domain.com", "great post")
val json = OptimizedJsonMapper.toJson(PayInvoiceMethod.create("lnbc50n1abc", meta))
assertTrue(json.contains("\"kind\":9734"), "kind must stay an integer: $json")
assertTrue(json.contains("\"created_at\":1756000000"), "created_at must stay an integer: $json")
val back = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<PayInvoiceMethod>(back)
val parsed = assertNotNull(NwcTransactionMetadata.parse(back.params?.metadata))
assertEquals(recipientHex, parsed.recipientPubkeyHex())
assertEquals("great post", parsed.displayComment())
}
@Test
fun noMetadataMeansTheRequestIsUnchanged() {
// The regression test that protects every wallet which has not advertised
// NWC-06: what they receive must be byte-identical to what they receive today.
val expected = OptimizedJsonMapper.toJson(PayInvoiceMethod.create("lnbc50n1abc"))
val actual = OptimizedJsonMapper.toJson(PayInvoiceMethod.create("lnbc50n1abc", null))
assertEquals(expected, actual)
assertFalse(actual.contains("recipient_data"))
}
// --- the gate ---
@Test
fun extensionsTagIsReadFromTheInfoEvent() {
assertEquals(listOf("02", "05", "06"), infoWith(arrayOf("extensions", "02 05 06")).extensions())
assertTrue(infoWith(arrayOf("extensions", "05 06")).supportsExtension(ExtensionsTag.METADATA_CONVENTIONS))
}
@Test
fun aWalletThatSaysNothingReadsAsNo() {
assertFalse(infoWith().supportsExtension(ExtensionsTag.METADATA_CONVENTIONS))
assertFalse(infoWith(arrayOf("extensions", "")).supportsExtension(ExtensionsTag.METADATA_CONVENTIONS))
assertFalse(infoWith(arrayOf("extensions", "02 03")).supportsExtension(ExtensionsTag.METADATA_CONVENTIONS))
assertTrue(infoWith().extensions().isEmpty())
}
private fun infoWith(vararg tags: Array<String>) =
NwcInfoEvent(
id = "c".repeat(64),
pubKey = payerHex,
createdAt = 1756000000L,
tags = arrayOf(*tags),
content = "pay_invoice get_balance",
sig = "d".repeat(128),
)
// --- reading it back ---
@Test
fun anOutgoingRowResolvesThePayeeFromThePTag() {
val wire =
OptimizedJsonMapper.toJson(
PayInvoiceMethod.create("lnbc1", NwcTransactionMetadata.build(zapRequest(content = "for the article"), "user@domain.com", "")),
)
val back = OptimizedJsonMapper.fromJsonTo<Request>(wire) as PayInvoiceMethod
val parsed = assertNotNull(NwcTransactionMetadata.parse(back.params?.metadata))
// The p tag, not the pubkey: on an outgoing zap the pubkey is US.
assertEquals(recipientHex, parsed.recipientPubkeyHex())
assertEquals("user@domain.com", parsed.recipientIdentifier())
// A wallet storing only `nostr` still yields the message.
assertEquals("for the article", parsed.displayComment())
}
@Test
fun blankFieldsReadAsAbsent() {
val parsed =
assertNotNull(
NwcTransactionMetadata.parse(
mapOf(
"comment" to " ",
"recipient_data" to mapOf("identifier" to ""),
"nostr" to mapOf("content" to ""),
),
),
)
assertNull(parsed.recipientIdentifier())
assertNull(parsed.displayComment())
}
@Test
fun emptyDescriptionIsNotAName() {
// The wallet-side habit this exists for: an empty `description` string
// rather than an omitted field. The row must fall back, not render an empty line.
val tx = NwcTransaction(type = "outgoing", description = "", amount = 21000L)
assertEquals("", tx.description, "the raw field keeps what the wallet sent")
assertNull(tx.displayDescription(), "but nothing downstream sees an empty name")
}
}
@@ -285,6 +285,9 @@ class RequestTest {
assertEquals(1000L, request.params?.from)
assertEquals(2000L, request.params?.until)
assertEquals(10, request.params?.limit)
// Omitted is how an absent optional param arrives, and it must read as null
// rather than as a default — this is the shape we now send.
assertNull(request.params?.offset)
}
// --- GetBalance ---
@@ -21,12 +21,14 @@
package com.vitorpamplona.quartz.nip50Search
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import kotlin.test.Test
@@ -110,6 +112,76 @@ class SearchFieldExtractorTest {
assertEquals(IndexableFields.Profile(name = "CoolApp", about = "an app", website = "https://coolapp.example"), fields)
}
@Test
fun trustedListsDecomposeIntoTheirTitle() {
// The title is the whole of indexableContent() for the family, and it
// is a title: primary, not the body tier. Everything else the list
// carries -- content echo, member tags, metric, d -- stays out.
val tags =
arrayOf(
arrayOf("d", "tl-pin-verified-human"),
arrayOf("title", "Verified Human"),
arrayOf("metric", "pinned-tag-membership"),
arrayOf("p", alice, "", "87"),
)
val fields = SearchFieldExtractor.extract(UserTrustedListEvent("d".repeat(64), alice, 1L, tags, """{"members":[]}""", ""))
assertEquals(IndexableFields.Tiered(primary = listOf("Verified Human")), fields)
}
@Test
fun titlelessTrustedListsExtractNothing() {
// Most machine-published lists have no title. The branch reads title()
// directly rather than indexableContent(), so the None comes from the
// tiers funnel finding nothing to clean -- not from title() ?: "".
val tags = arrayOf(arrayOf("d", "tl-pin-untitled"), arrayOf("p", alice, "", "50"))
assertEquals(IndexableFields.None, SearchFieldExtractor.extract(UserTrustedListEvent("e".repeat(64), alice, 1L, tags, "", "")))
}
@Test
fun contactCardsDecomposeIntoPetnameSummaryAndTopics() {
// A provider's petname for a person is that provider's NAME for them,
// so it lands where kind 0's name does. topics() reads `t` tags, so
// the tiers() funnel carries them once, as hashtags.
val tags =
arrayOf(
arrayOf("d", alice),
arrayOf("petname", "Verified Human"),
arrayOf("summary", "vouched by two independent raters"),
arrayOf("t", "bitcoin"),
arrayOf("rank", "87"),
)
val fields = SearchFieldExtractor.extract(ContactCardEvent("f".repeat(64), alice, 1L, tags, "", ""))
assertEquals(
IndexableFields.Tiered(
primary = listOf("Verified Human"),
secondary = listOf("vouched by two independent raters"),
hashtags = listOf("bitcoin"),
),
fields,
)
}
@Test
fun contactCardsCarryTopicsEvenWithNoPublicPetname() {
// THE SHAPE THIS LIBRARY ITSELF PUBLISHES: build() puts petname and
// summary in the NIP-44 content, so a card's only public text is its
// topics. They must still reach the backend -- through the hashtag
// role, once -- and a hashtags-only extraction must not normalize to
// None (Tiered.isEmpty() compares against a fully-empty Tiered).
val tags = arrayOf(arrayOf("d", alice), arrayOf("t", "bitcoin"), arrayOf("t", "nostr"), arrayOf("rank", "87"))
val fields = SearchFieldExtractor.extract(ContactCardEvent("2a".repeat(32), alice, 1L, tags, "encrypted", ""))
assertEquals(IndexableFields.Tiered(hashtags = listOf("bitcoin", "nostr")), fields)
}
@Test
fun contactCardsWithNoPublicTextExtractNothing() {
// The petname and summary of a private card live in the NIP-44
// encrypted content, which is never indexed -- so a card carrying only
// scores has nothing to search.
val tags = arrayOf(arrayOf("d", alice), arrayOf("rank", "87"), arrayOf("followers", "1200"))
assertEquals(IndexableFields.None, SearchFieldExtractor.extract(ContactCardEvent("1a".repeat(32), alice, 1L, tags, "encrypted", "")))
}
@Test
fun nonSearchableKindsExtractNothing() {
// Kind 7 reactions are not SearchableEvent.
@@ -31,6 +31,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonTypeRef
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
import com.vitorpamplona.quartz.nip01Core.core.RawJson
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MessageDeserializer
@@ -58,9 +59,22 @@ import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer
@@ -84,6 +98,7 @@ class JacksonMapper {
.registerModule(
SimpleModule()
// nip 01
.addSerializer(RawJson::class.java, RawJsonSerializer())
.addSerializer(Event::class.java, EventSerializer())
.addDeserializer(Event::class.java, EventDeserializer())
.addSerializer(Filter::class.java, FilterSerializer())
@@ -106,6 +121,24 @@ class JacksonMapper {
.addDeserializer(Request::class.java, RequestDeserializer())
.addSerializer(Notification::class.java, NotificationSerializer())
.addDeserializer(Notification::class.java, NotificationDeserializer())
// NIP-47's optional params are OMITTED when null — see OmitNullsMixin.
// Matches what the kotlinx backend has always done.
.setMixInAnnotation(PayInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(PayParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(ReceiveParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(PayKeysendParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(MakeInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(LookupInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(ListTransactionsParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(MakeHoldInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(CancelHoldInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(SettleHoldInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(SignMessageParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(CreateConnectionParams::class.java, OmitNullsMixin::class.java)
// NESTED, and the only params field that is not a primitive or an
// already-registered type: a TlvRecord inside pay_keysend's
// `tlv_records` has two independently optional fields of its own.
.setMixInAnnotation(TlvRecord::class.java, OmitNullsMixin::class.java)
// nip 46
.addDeserializer(BunkerMessage::class.java, BunkerMessageDeserializer())
.addSerializer(BunkerRequest::class.java, BunkerRequestSerializer())
@@ -0,0 +1,42 @@
/*
* 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.quartz.nip01Core.jackson
import com.fasterxml.jackson.annotation.JsonInclude
/**
* Applied to a reflectively-serialized DTO so Jackson OMITS a null field instead
* of writing it.
*
* Optional protocol fields are absent, not null. A peer is free to type one
* strictly: sending `"from": null` for an absent `from` earned
* `Invalid list_transactions params: from must be an integer` from a NIP-47
* wallet, and the request failed.
*
* A MIXIN rather than an annotation on the class, because these DTOs live in
* `commonMain` and Jackson annotations are JVM-only. Class-level rather than the
* mapper-wide `setSerializationInclusion`, which in Jackson 2.x also suppresses
* null MAP ENTRIES and [com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement]
* deliberately keeps those, so a global setting would close one backend
* divergence by opening another.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
abstract class OmitNullsMixin
@@ -0,0 +1,37 @@
/*
* 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.quartz.nip01Core.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.vitorpamplona.quartz.nip01Core.core.RawJson
/** Writes [RawJson.json] straight into the output, unquoted and unescaped. */
class RawJsonSerializer : StdSerializer<RawJson>(RawJson::class.java) {
override fun serialize(
value: RawJson,
gen: JsonGenerator,
provider: SerializerProvider,
) {
gen.writeRawValue(value.json)
}
}
@@ -25,6 +25,9 @@ import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
@@ -119,6 +122,14 @@ class RequestSerializer : StdSerializer<Request>(Request::class.java) {
gen.writeObjectField("params", value.params)
}
}
// Parameterless: `method` alone is the whole request. Spelled out rather
// than left to fall through, because Request is sealed and the compiler
// now makes every method state which of the two shapes it is.
is GetBalanceMethod,
is GetBudgetMethod,
is GetInfoMethod,
-> Unit
}
gen.writeEndObject()
}
+5 -6
View File
@@ -73,12 +73,11 @@ node shim-events.mjs /path/to/other/shim.js # diff a candidate against it
Set `CHROMIUM_PATH` if your Chromium lives somewhere other than
`/opt/pw-browsers/chromium-1194/chrome-linux/chrome`.
**Why this and not a JVM unit test.** The host-side parser
(`parseImeEvent`) runs on Android's `org.json`, which the unit tests stub out
(`unitTests.isReturnDefaultValues = true` in `amethyst/build.gradle.kts`, and
there is no Robolectric); a Kotlin test would "pass" without parsing anything.
The half worth protecting is the page↔host contract, and that only exists in a
browser.
**Why this and not a JVM unit test.** The half worth protecting is the
page↔host contract — real browser focus/gesture behavior and the envelopes the
shim emits for it — and that only exists in a browser. A JVM test of the
host-side parser (`parseImeEvent`, kotlinx.serialization) would only re-parse
envelopes the test itself fabricated.
## `perf.html` — why does the embed feel slower than the full-screen browser?
+3 -3
View File
@@ -2,9 +2,9 @@
//
// Loads the REAL shim (commons/src/commonMain/composeResources/files/napplet/shim.js) into real Chromium
// with the embedded-surface flags set, drives genuine focus/tap/blur gestures, and asserts the `ime.*`
// envelopes it emits. This is the only honest automated coverage for this code: the host-side parser runs
// on Android's `org.json`, which the JVM unit tests stub out (`unitTests.isReturnDefaultValues = true`), so
// a Kotlin test of it would pass without parsing anything.
// envelopes it emits. This is the only honest automated coverage for this code: the half worth protecting
// is the page↔host contract (real browser focus/gesture behavior), which no JVM unit test of the host-side
// parser (`parseImeEvent`, kotlinx.serialization) can exercise.
//
// cd tools/ime-test && npm i playwright-core && node shim-events.mjs
//