mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4041 from vitorpamplona/claude/payment-targets-zap-integration-q52kn6
NIP-A3 payment targets in zap picker — v1
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
# NIP-A3 Payment Targets in the zap picker — v1
|
||||
|
||||
**Status:** proposal
|
||||
**Modules:** `quartz`, `commons`, `amethyst`
|
||||
**Scope:** when a note's author publishes a NIP-A3 payment target, **an
|
||||
installed app can handle it**, and the note carries no NIP-57 zap split — show
|
||||
one amount-less chip per such target that hands off to that app.
|
||||
|
||||
> **Revised after the first implementation.** This document originally gated the
|
||||
> chip on *symmetry* — both parties publishing the same protocol — and capped the
|
||||
> row at two chips. Both are gone: the gate is capability alone (can anything on
|
||||
> this phone open the URI), there is no cap, and the setting now defaults **on**.
|
||||
> Sections below that argue for symmetry are kept for the reasoning, but §5 is
|
||||
> the current rule.
|
||||
|
||||
Deliberately excluded from v1: amounts, in-app payment, receipts, fiat
|
||||
conversion, desktop.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why v1 has no amounts
|
||||
|
||||
Zap presets are **sats**. A `venmo` / `iban` / `upi` chip cannot send 1000
|
||||
sats, and there is **no FX or bitcoin-price service anywhere in this repo**
|
||||
(grepped `quartz`, `commons`, `amethyst`). So v1 does not pretend: the chip
|
||||
carries no number, emits no RFC-8905 `amount=`, and the amount is named in the
|
||||
external app. The UI has to *say* that rather than leave a suspicious blank —
|
||||
see §4.
|
||||
|
||||
Corollary: **the note's zap counter will not move.** No kind:9735, nothing to
|
||||
count. In code it is a rail; to the user it must read as *pay*, not *zap*.
|
||||
|
||||
---
|
||||
|
||||
## 2. The layout decision — and the refactor it deletes
|
||||
|
||||
> This is the one place v1 diverges from the sketch, and the reason is that it
|
||||
> makes the change roughly half the size.
|
||||
|
||||
The sketch was "add the icons to the toggle." The toggle is the segmented
|
||||
control **inside each amount pill** (`UnifiedZapAmountChip`,
|
||||
`ReactionsRow.kt:2362`). Putting an amount-less rail there has two costs:
|
||||
|
||||
1. **It repeats.** With presets of 1000/5000/10000, the identical amount-less
|
||||
Venmo segment renders three times and means the same thing each time.
|
||||
2. **It forces `ZapRail` to become a sealed interface.** The enum
|
||||
(`ReactionsRow.kt:2481`) is payload-free, so a segment can't know *which*
|
||||
target it opens. Making it data-carrying drags in `present`, `preferred`,
|
||||
`selectedRail`, `ZapRailIcon`, `previewPreferredRail`, `previewRailsFor`
|
||||
and the settings preview row — and, because `PaymentTarget` has no
|
||||
`equals`, breaks the `remember(preferred, present)` key so the user's
|
||||
selection resets on recompose.
|
||||
|
||||
**Instead: render the chip as a sibling of the amount pills**, appended to the
|
||||
existing `FlowRow` in `ZapAmountChoiceGrid` (`ReactionsRow.kt:2297`), next to
|
||||
the `Tune` preset-editor button. It wraps for free, it renders **once**, and
|
||||
`ZapRail`, `UnifiedZapAmountChip` and every preview stay **completely
|
||||
untouched**. Same popup, same place the user is already looking.
|
||||
|
||||
If an FX service ever lands and the amount becomes expressible, the chip moves
|
||||
into the toggle then — that is the natural migration, not a reason to pay for
|
||||
it now.
|
||||
|
||||
---
|
||||
|
||||
## 3. Intent discovery — the constraint that decides it
|
||||
|
||||
`targetSdk = 37`. Under Android 11+ package visibility,
|
||||
`queryIntentActivities` returns **empty** for any intent not covered by a
|
||||
`<queries>` declaration — so *without a manifest change this feature silently
|
||||
shows nothing on every modern device*. The existing `<queries>` block
|
||||
(`AndroidManifest.xml:4`) covers only `nostrsigner`, TTS, Health Connect and
|
||||
Tor.
|
||||
|
||||
### 3.1 Manifest
|
||||
|
||||
Add one `<intent>` per scheme we probe. The important economy: an arbitrary
|
||||
user-typed type (`iban`, `upi`, `pix`, …) always falls back to
|
||||
`payto://<type>/<authority>`, so **one `payto` entry covers every generic
|
||||
type**. Only the ~12 special-cased crypto schemes in `paymentTargetStyleFor`
|
||||
(`DisplayPaymentTargets.kt:190`) need their own entries.
|
||||
|
||||
```xml
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="payto" />
|
||||
</intent>
|
||||
<!-- + one each: bitcoin, lightning, liquidnetwork, ethereum, monero, dash,
|
||||
zcash, bitcoincash, litecoin, dogecoin, solana, tron -->
|
||||
```
|
||||
|
||||
Use **`<queries>`, never `QUERY_ALL_PACKAGES`** — the latter is a
|
||||
policy-restricted permission on Play and would need a declaration; specific
|
||||
`<intent>` filters need nothing. On minSdk 26–29 `<queries>` is ignored and
|
||||
everything resolves, which is a strict superset of the gated behaviour.
|
||||
|
||||
### 3.2 https targets are exempt
|
||||
|
||||
`cashapp` / `venmo` / `paypal` map to `https://…`, which a browser always
|
||||
resolves — discovery would be a tautology. **Skip discovery for https
|
||||
targets and always show them**: opening `venmo.com/<handle>` in a browser is a
|
||||
legitimate way to pay, so nothing is broken. For these three types the chip is
|
||||
therefore gated only on the author having published one.
|
||||
|
||||
**But §4.2 still needs the control probe here.** To tell a real app handler
|
||||
from a browser, resolve a control `https://<nonexistent-host>/` and treat the
|
||||
target as app-backed only if its resolver set contains a package outside that
|
||||
control set. It never gates the chip — it decides whether the chip wears the
|
||||
app's icon or the brand-colour glyph, and a Chrome icon on a Venmo chip is
|
||||
worse than no icon at all.
|
||||
|
||||
### 3.3 The cache — keyed by scheme+host, warmed from the open picker
|
||||
|
||||
The naive cache is per-post and lazy. With symmetry gone the probe set is the
|
||||
author's target list, so:
|
||||
|
||||
> **Probe the targets of the one author whose picker is open** — typically 1–5
|
||||
> entries — and **merge** the answers into the cache. Merging matters: replacing
|
||||
> would evict what was learned about every other author the moment a second
|
||||
> picker opened. Feed rendering still never triggers a probe.
|
||||
|
||||
- **Key:** `"<scheme>://<host>"`, e.g. `payto://iban`, `bitcoin://`. Scheme
|
||||
alone is too coarse — an app may declare `android:scheme="payto"
|
||||
android:host="iban"`, so a scheme-only hit would wrongly claim `payto://upi`
|
||||
is handled.
|
||||
- **Warm:** a `LaunchedEffect` keyed on the author's observed kind:10133 probes
|
||||
that handful of keys off the main thread when the picker opens.
|
||||
- **Read:** synchronous map lookup — required, because
|
||||
`RailCapabilityResolver.peek` is called from inside `remember {}`.
|
||||
- **Recomposition:** the map must be a `MutableStateFlow<Map<String, Boolean>>`,
|
||||
not a bare `ConcurrentHashMap`. A plain map write is invisible to Compose and
|
||||
the chip would not appear until something else recomposed.
|
||||
- **Invalidation:** clear on app foreground (`ProcessLifecycleOwner`
|
||||
`ON_START`) and re-warm — this is exactly the "user left, installed Venmo,
|
||||
came back" flow. A `PACKAGE_ADDED`/`REMOVED` receiver is more precise but is
|
||||
more moving parts than v1 needs.
|
||||
|
||||
Home: `amethyst/…/service/payments/PayToAppAvailability.kt` (Android-only;
|
||||
`PackageManager` has no KMP equivalent). The scheme mapping it needs moves out
|
||||
of the UI file into `commons` (§6.0).
|
||||
|
||||
---
|
||||
|
||||
## 4. The chip's face
|
||||
|
||||
### 4.1 Saying "the app decides the amount"
|
||||
|
||||
An amount-less chip beside pills that all show numbers reads as a bug unless
|
||||
it is visibly a *different kind of thing*. Three cues, no extra layout:
|
||||
|
||||
1. **No number.** Icon + protocol label only (`VENMO`).
|
||||
2. **A different terminal glyph.** `MaterialSymbols.OpenInNew` instead of the
|
||||
`ArrowForward` every amount segment uses — "this leaves the app."
|
||||
3. **A string that says it outright**, e.g. *"Amount set in %1$s"*, shown as
|
||||
the chip's `contentDescription` and as a toast on long-press.
|
||||
|
||||
**Both glyphs are already in `MaterialSymbols.kt`** (`OpenInNew:280`,
|
||||
`AccountBalanceWallet:27`) — **no `tools/material-symbols-subset/subset.sh`
|
||||
run is needed**, and §4.2 adds no new glyphs either.
|
||||
|
||||
Long-press must **not** inherit `onChangeAmount` (the sat-preset editor is
|
||||
meaningless here); it copies the authority, matching `PaymentTargetChip`'s
|
||||
long-press on the profile.
|
||||
|
||||
### 4.2 Which icon it wears — the installed app's, not a bundled logo
|
||||
|
||||
**This already works in this codebase.** `ExternalSignerButton.kt:118` renders
|
||||
installed NIP-55 signers with `it.loadLabel(pm)` / `it.loadIcon(pm)` →
|
||||
`toBitmap()` → Coil's `rememberAsyncImagePainter`, off the back of
|
||||
`getExternalSignersInstalled` (`quartz/…/IsExternalSignerInstalled.kt`), which
|
||||
is `queryIntentActivities(ACTION_VIEW, "nostrsigner:")` — **the same call
|
||||
§3 already makes for discovery.** The `ResolveInfo` we keep to answer "can
|
||||
anything open this?" also carries the icon and the app's own name. The icon is
|
||||
therefore very close to free; what it costs is care.
|
||||
|
||||
**Do not bundle brand logos.** Three reasons, in order of weight:
|
||||
|
||||
1. **Trademark, not licence.** `CLAUDE.md`'s dependency gate covers *code*
|
||||
licences; a Venmo or PayPal mark shipped inside an MIT APK is a separate
|
||||
trademark question. Referential use is usually permitted, redistribution of
|
||||
the mark often is not. That is a maintainer's call, not a silent one.
|
||||
2. **The type space is unbounded.** `PaymentTargetsViewModel.addTarget` accepts
|
||||
any `type.trim().lowercase()`, so a bundled set can never be complete —
|
||||
`pix`, `upi`, `swish`, `interac` and the next one all miss.
|
||||
3. **The codebase already decided this.** `paymentTargetStyleFor` pairs brand
|
||||
*colours* (`VENMO_BLUE #008CFF`, `PAYPAL_DEEP_BLUE #003087`,
|
||||
`CASHAPP_LIME #00E64D`) with the generic `AccountBalanceWallet` glyph.
|
||||
Brand colour + generic glyph is the established pattern; keep it as the
|
||||
fallback. Brand marks are also absent from Material Symbols, so each would
|
||||
be a hand-authored `ImageVector` like `CustomHashTagIcons.Cashu`.
|
||||
|
||||
So: **the installed app's icon *is* the brand icon**, sourced from the device
|
||||
instead of shipped. It is self-limiting in the right direction — the "popular
|
||||
options" are exactly the ones with an app installed.
|
||||
|
||||
**Four things the precedent gets away with and we would not:**
|
||||
|
||||
- **Load once, in the warm step.** `ExternalSignerButton` calls `loadIcon()` +
|
||||
`toBitmap()` inside a `LazyColumn` item, so it re-runs on recomposition —
|
||||
tolerable in a one-shot dialog, not in the zap popup. `loadIcon` reads the
|
||||
target APK's resources, so it is I/O: do it in §3.3's off-main warm and
|
||||
cache the **`ImageBitmap`**, never the `Drawable`.
|
||||
- **Size and mask it.** minSdk is 26, so any icon may be an
|
||||
`AdaptiveIconDrawable`: a 108×108 canvas whose outer margin the launcher
|
||||
masks away. A bare `toBitmap()` drawn at 18dp shows a small logo floating in
|
||||
padding. Use `toBitmap(px, px)` at the target size plus
|
||||
`Modifier.clip(CircleShape)` — what a launcher does. The precedent renders
|
||||
at 48dp and gets away with it.
|
||||
- **Pick one app, or none.** `payto://` can resolve to several. Ask
|
||||
`resolveActivity(intent, MATCH_DEFAULT_ONLY)` for the user's default; when
|
||||
Android hands back its `ResolverActivity` (no default set) there is no app
|
||||
to name — fall back to the glyph rather than showing the chooser's icon.
|
||||
- **Accept that it cannot be tinted.** Every other rail is a monochrome glyph
|
||||
tinted `BitcoinOrange` / `onSurface`. A full-colour raster can't join that
|
||||
scheme — which is arguably the point: it is the visual signal that this
|
||||
segment leaves the app. It needs the circular clip and a slightly smaller
|
||||
optical size to sit beside 18dp glyphs.
|
||||
|
||||
**This promotes the https control-probe from a nicety to v1 work.** §3.2 exempts
|
||||
`venmo` / `paypal` / `cashapp` from discovery because a browser always resolves
|
||||
`https://`. That is fine for *gating*, but not for *icons*: with only a browser
|
||||
installed, `resolveActivity` returns **Chrome**, and a Chrome icon on a Venmo
|
||||
chip is worse than no icon. So an https target needs the control probe
|
||||
(resolve `https://<nonexistent-host>/`, treat the target as app-backed only if
|
||||
its resolver set contains a package outside that control set) to decide
|
||||
**icon vs brand-colour glyph**, even though it never gates the chip.
|
||||
|
||||
---
|
||||
|
||||
## 5. Gates (all must hold)
|
||||
|
||||
1. Setting `showPayToZapChip` — **default on**. The chip only ever shows a
|
||||
target its author chose to publish, to a device that can already open it,
|
||||
so the discovery gate is doing the real narrowing (`UiSettings.kt:67` →
|
||||
`UiSettingsFlow.kt:59` → `UISharedPreferences.kt:192`).
|
||||
2. Note has **no** zap split: `zapSplitSetup().isNullOrEmpty()`. payto can't
|
||||
fan out and returns no receipt. `RailCapabilityResolver.peek` **already
|
||||
computes `splits`** — one-line reuse.
|
||||
3. Recipient (note author) publishes ≥1 handoff-class target.
|
||||
4. §3 says an app can handle it (or it's https). **This is the substantive
|
||||
gate**; everything else is a precondition.
|
||||
|
||||
No cap: every openable target is offered. Discovery is what bounds the row —
|
||||
a target with nothing to open it never reaches the picker.
|
||||
|
||||
**Handoff-class** excludes the wallet-covered types — `lightning`/`ln`/`lnurl`
|
||||
and `bitcoin`/`btc`/`onchain` *are* the existing LIGHTNING and ONCHAIN rails.
|
||||
Without this exclusion the picker grows a second Bolt icon beside the first.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation
|
||||
|
||||
### 6.0 Prep — no behaviour change
|
||||
- `quartz`: `PaymentTarget` → `data class` (it has no `equals` today; needed
|
||||
for list keys and dedupe, and it fixes the hand-rolled field-by-field
|
||||
compare in `PaymentTargetsViewModel.addTarget`).
|
||||
- `commons/…/model/payments/PaymentTargetTypes.kt` (package exists, holds
|
||||
`PaymentSourceResolver`): `canonical(raw)`, `isWalletCovered(canonical)`,
|
||||
`schemeFor(canonical)`. Move `LIGHTNING_TARGET_TYPES` /
|
||||
`BITCOIN_TARGET_TYPES` (`DisplayPaymentTargets.kt:67,70`) and the scheme half
|
||||
of `paymentTargetStyleFor` here — today they are duplicated twice inside one
|
||||
Android UI file, and discovery needs them too.
|
||||
- `commons/…/model/User.kt`: `paymentTargetsNote` + `paymentTargets()`,
|
||||
mirroring `nutzapInfoNote` (`User.kt:79`).
|
||||
|
||||
**No new relay subscription:** kind 10133 already rides in
|
||||
`UserMetadataForKeyKinds` beside kind:0 and kind:10019
|
||||
(`FilterUserMetadataForKey.kt:50`), so the recipient's targets are in cache by
|
||||
the time the note renders — same as the nutzap rail.
|
||||
|
||||
### 6.1 Matcher — pure, headless
|
||||
`commons/…/model/payments/PayToRailMatcher.kt`: canonicalize both sides, drop
|
||||
wallet-covered types, intersect on type, dedupe by type. No Android, no
|
||||
Compose.
|
||||
|
||||
### 6.2 Discovery
|
||||
`amethyst/…/service/payments/PayToAppAvailability.kt` per §3.3 + the manifest
|
||||
`<queries>` entries per §3.1. Each cache entry holds what §4.2 needs as well as
|
||||
the yes/no: `{ resolves: Boolean, label: String?, icon: ImageBitmap? }` —
|
||||
decoded once in the warm step at the 18dp target size, never per composition.
|
||||
Icon and label are null for the no-default (`ResolverActivity`) and
|
||||
browser-only cases, and the chip falls back to the brand-colour glyph.
|
||||
|
||||
### 6.3 Capability
|
||||
- `RailCapability` += `payToTargets: List<PaymentTarget> = emptyList()` —
|
||||
defaulted, so `RailCapabilityCashuStatusTest` and every existing call site
|
||||
compile untouched.
|
||||
- `peek(..., senderTargets = emptyList(), payToEnabled = false, available = emptyMap())`
|
||||
— **defaulted, because `zapClick` also calls `peek`**
|
||||
(`ReactionsRow.kt:1464`) for the one-tap fast path, which must stay
|
||||
Lightning-only. Returns empty when splits exist.
|
||||
- `observeZapRailCapability` (`ReactionsRow.kt:2098`) adds four inputs, each
|
||||
both a subscription trigger and a `remember` key — the contract spelled out
|
||||
in the "do NOT delete these as unused" comment at `ReactionsRow.kt:2105`:
|
||||
`paymentTargetsState.flow`, the author's `paymentTargetsNote`,
|
||||
`uiSettingsFlow.showPayToZapRail`, and the availability `StateFlow`.
|
||||
|
||||
### 6.4 UI
|
||||
One new `PayToHandoffChip` composable appended to `ZapAmountChoiceGrid`'s
|
||||
`FlowRow`. Action: `uriHandler.openUri(...)`; keep the existing try/catch →
|
||||
`no_payment_app_found_for_type` toast (string exists) as a belt-and-braces
|
||||
fallback for the race where the app is uninstalled between warm and tap. It
|
||||
must not touch `zappingProgress`, `zapStartingTime` or `accountViewModel.zap`.
|
||||
|
||||
### 6.5 Settings + strings
|
||||
`showPayToZapRail` through the `showOnchainWallet` chain + `SettingsCatalogBuilder`;
|
||||
new strings; changelog.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests
|
||||
|
||||
| Level | Test | Asserts |
|
||||
|---|---|---|
|
||||
| `commons/commonTest` | `PaymentTargetTypesTest` | alias collapse, case/whitespace, wallet-covered set, scheme mapping |
|
||||
| `commons/commonTest` | `PayToRailMatcherTest` | empty sender → empty; no overlap → empty; `ln` vs `lightning` → empty (wallet-covered); `Venmo` vs `venmo` → match; dedupe by type |
|
||||
| `amethyst/test` | sibling of `RailCapabilityCashuStatusTest` | split present → empty; setting off → empty; no author → empty; unavailable scheme → empty; https target → shown without probe; **existing rails unaffected** |
|
||||
| `amethyst/test` | `PayToAppAvailabilityTest` | key is scheme+host, not scheme; probe count == sender's target count, independent of post count; `ResolverActivity` default → null icon; browser-only https → null icon (control probe) |
|
||||
| Manual | | chip appears once (not per pill); tap opens the app; **counter does not move**; split note shows no chip; install app → background → foreground → chip appears; adaptive icon is masked round, not floating in padding; https target with no app shows the glyph, not Chrome |
|
||||
|
||||
---
|
||||
|
||||
## 8. Open decisions
|
||||
|
||||
1. **Chip placement** — sibling vs inside the toggle (§2). Recommend sibling:
|
||||
renders once and deletes the whole `ZapRail` refactor. Flagged because it
|
||||
diverges from the original sketch.
|
||||
2. **Default for `showPayToZapRail`** — recommend **off**, matching how
|
||||
`ReactionRowAction.Pay` already ships disabled.
|
||||
3. **Private rumors** — on-chain is suppressed there (it would e-tag the
|
||||
rumor). A payto handoff publishes nothing, so it is arguably safe.
|
||||
Recommend **allow**, noting the divergence from the on-chain precedent.
|
||||
4. **`ReactionRowAction.Pay` overlap** — recommend keeping both, `Pay`
|
||||
disabled by default: `Pay` browses *all* of a recipient's targets, this
|
||||
chip is the *matched, installed, splitless* shortcut.
|
||||
5. **Colour icon beside monochrome glyphs** (§4.2). The app icon can't be
|
||||
tinted, so the chip will be the one full-colour thing in the popup.
|
||||
Recommend **accepting** it as the "this leaves the app" signal — but it is a
|
||||
visible break from the rail iconography and worth an explicit yes.
|
||||
6. ~~**Symmetry heuristic**~~ — *removed; see the note at the top.* It was
|
||||
right for closed loops (Venmo, Cash App, UPI),
|
||||
arguably too strict for open ones (Monero: a sender needs a wallet, not a
|
||||
published address). Ship strict; relaxing later is additive. Note that
|
||||
intent discovery already covers much of what symmetry was proxying for, so
|
||||
dropping symmetry for scheme-based types is a live option.
|
||||
@@ -16,6 +16,69 @@
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
|
||||
<!-- NIP-A3 payment targets. Android 11+ package visibility means
|
||||
queryIntentActivities returns NOTHING for a scheme not declared here,
|
||||
so without these the zap picker's pay-to chip is invisible on every
|
||||
modern device. Specific <intent> filters rather than
|
||||
QUERY_ALL_PACKAGES, which is policy-restricted on Play.
|
||||
Unknown target types all fall back to payto://<type>/<authority>,
|
||||
so the single payto entry covers the open-ended tail.
|
||||
No <category>: a category here narrows visibility the same way it
|
||||
narrows an intent match, and would hide any app whose filter declares
|
||||
only DEFAULT - which is what our ACTION_VIEW hand-off actually uses. -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="payto" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="bitcoin" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="lightning" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="liquidnetwork" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="ethereum" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="monero" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="dash" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="zcash" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="bitcoincash" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="litecoin" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="dogecoin" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="solana" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="tron" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
|
||||
|
||||
@@ -60,6 +60,11 @@ data class UiSettings(
|
||||
// on-chain rail in the Send Payment screen. Defaults to true (shown) so the
|
||||
// behavior is unchanged for everyone who doesn't turn it off.
|
||||
val showOnchainWallet: Boolean = true,
|
||||
// Whether the zap picker offers a NIP-A3 pay-to hand-off chip when the sender
|
||||
// and recipient share a payment protocol. Defaults to false: those targets can
|
||||
// be bank or Venmo handles carrying legal names, and this puts them one tap
|
||||
// from every note in the feed.
|
||||
val showPayToZapChip: Boolean = true,
|
||||
)
|
||||
|
||||
enum class ThemeType(
|
||||
|
||||
@@ -56,6 +56,7 @@ class UiSettingsFlow(
|
||||
val fontSize: MutableStateFlow<FontSizeType> = MutableStateFlow(FontSizeType.NORMAL),
|
||||
val composeSignature: MutableStateFlow<String> = MutableStateFlow(""),
|
||||
val showOnchainWallet: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val showPayToZapChip: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
) {
|
||||
val listOfFlows: List<Flow<Any?>> =
|
||||
listOf<Flow<Any?>>(
|
||||
@@ -88,6 +89,7 @@ class UiSettingsFlow(
|
||||
fontSize,
|
||||
composeSignature,
|
||||
showOnchainWallet,
|
||||
showPayToZapChip,
|
||||
)
|
||||
|
||||
// emits at every change in any of the propertyes.
|
||||
@@ -124,6 +126,7 @@ class UiSettingsFlow(
|
||||
flows[26] as FontSizeType,
|
||||
flows[27] as String,
|
||||
flows[28] as Boolean,
|
||||
flows[29] as Boolean,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,6 +161,7 @@ class UiSettingsFlow(
|
||||
fontSize.value,
|
||||
composeSignature.value,
|
||||
showOnchainWallet.value,
|
||||
showPayToZapChip.value,
|
||||
)
|
||||
|
||||
fun update(torSettings: UiSettings): Boolean {
|
||||
@@ -279,6 +283,10 @@ class UiSettingsFlow(
|
||||
showOnchainWallet.tryEmit(torSettings.showOnchainWallet)
|
||||
any = true
|
||||
}
|
||||
if (showPayToZapChip.value != torSettings.showPayToZapChip) {
|
||||
showPayToZapChip.tryEmit(torSettings.showPayToZapChip)
|
||||
any = true
|
||||
}
|
||||
|
||||
return any
|
||||
}
|
||||
@@ -333,6 +341,7 @@ class UiSettingsFlow(
|
||||
MutableStateFlow(uiSettings.fontSize),
|
||||
MutableStateFlow(uiSettings.composeSignature),
|
||||
MutableStateFlow(uiSettings.showOnchainWallet),
|
||||
MutableStateFlow(uiSettings.showPayToZapChip),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -147,6 +147,7 @@ class UiSharedPreferences(
|
||||
val UI_FONT_SIZE = stringPreferencesKey("ui.font_size")
|
||||
val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature")
|
||||
val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet")
|
||||
val UI_SHOW_PAYTO_ZAP_CHIP = booleanPreferencesKey("ui.show_payto_zap_chip")
|
||||
|
||||
suspend fun uiPreferences(context: Context): UiSettings? =
|
||||
try {
|
||||
@@ -188,6 +189,7 @@ class UiSharedPreferences(
|
||||
fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL,
|
||||
composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "",
|
||||
showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true,
|
||||
showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: true,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
@@ -241,6 +243,7 @@ class UiSharedPreferences(
|
||||
preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name
|
||||
preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature
|
||||
preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet
|
||||
preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
@@ -22,10 +22,15 @@ package com.vitorpamplona.amethyst.model.zap
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PayToRailMatcher
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState
|
||||
import com.vitorpamplona.amethyst.service.payments.PayToAppAvailability
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.BaseZapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
|
||||
@@ -57,6 +62,12 @@ data class RailCapability(
|
||||
* balance hasn't loaded yet or the explorer is unreachable — not *empty*.
|
||||
*/
|
||||
val onchainMaxSpendableSats: Long? = null,
|
||||
/**
|
||||
* NIP-A3 targets the sender can hand off to: the author's published targets
|
||||
* that no wallet rail already covers and that an installed app can open, on a
|
||||
* note with no zap split. Empty by default so every existing caller is unchanged.
|
||||
*/
|
||||
val payToTargets: List<PaymentTarget> = emptyList(),
|
||||
) {
|
||||
/**
|
||||
* Classify a cashu nutzap of [amountSats] for the unified amount chip.
|
||||
@@ -131,6 +142,7 @@ object RailCapabilityResolver {
|
||||
fun peek(
|
||||
baseNote: Note,
|
||||
cashuState: CashuWalletState,
|
||||
payToEnabled: Boolean = false,
|
||||
): RailCapability {
|
||||
val author = baseNote.author?.pubkeyHex
|
||||
val splits = baseNote.event?.zapSplitSetup().orEmpty()
|
||||
@@ -171,6 +183,31 @@ object RailCapabilityResolver {
|
||||
hasOnchain = hasOnchain,
|
||||
cashuBestSingleMintSats = cashuFunding?.bestSingleMintSats ?: 0L,
|
||||
cashuTotalWalletSats = cashuFunding?.totalWalletSats ?: 0L,
|
||||
payToTargets = payToTargets(baseNote, splits, payToEnabled),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Targets for the pay-to hand-off chip. Reads the note-derived inputs and hands
|
||||
* the actual decision to [PayToRailMatcher.selectFor], which is pure and
|
||||
* separately tested. [splits] is already computed by [peek]; don't recompute it.
|
||||
*/
|
||||
private fun payToTargets(
|
||||
baseNote: Note,
|
||||
splits: List<BaseZapSplitSetup>,
|
||||
enabled: Boolean,
|
||||
): List<PaymentTarget> =
|
||||
PayToRailMatcher.selectFor(
|
||||
enabled = enabled,
|
||||
hasAuthor = baseNote.author != null,
|
||||
hasZapSplit = splits.isNotEmpty(),
|
||||
// An unresolvable URI would open nothing, so the chip is not offered.
|
||||
// Web targets always resolve; there the probe only decides the icon.
|
||||
canOpen = {
|
||||
PayToAppAvailability.peek(it.type)?.resolves == true ||
|
||||
PaymentTargetTypes.isWebTarget(it.type)
|
||||
},
|
||||
// Lazy: the tag walk only happens once the cheap gates have passed.
|
||||
recipientTargets = { baseNote.author?.paymentTargets().orEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.payments
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ResolveInfo
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** What the device can do with one `payto` target type. */
|
||||
@Immutable
|
||||
data class PayToAppInfo(
|
||||
/** An installed activity accepts the hand-off URI. */
|
||||
val resolves: Boolean,
|
||||
/** The chosen app's own name, null when no single default app applies. */
|
||||
val label: String? = null,
|
||||
/** The chosen app's launcher icon, already masked and sized. */
|
||||
val icon: ImageBitmap? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Answers "can anything on this phone open this payment target, and what does it
|
||||
* look like?" for the zap picker's hand-off chip.
|
||||
*
|
||||
* **Package visibility.** `targetSdk` is 37, so on Android 11+ every query here
|
||||
* returns nothing unless `AndroidManifest.xml`'s `<queries>` block declares the
|
||||
* scheme. The declarations are deliberately `<intent>` filters rather than
|
||||
* `QUERY_ALL_PACKAGES`, which is policy-restricted on Play.
|
||||
*
|
||||
* **Why this is not a per-post lookup.** [warm] probes the targets of the one
|
||||
* author whose zap picker is open — a handful of entries — and merges the answers
|
||||
* into a cache keyed by scheme+host, so a type already probed for someone else is
|
||||
* simply refreshed. Feed rendering never triggers a probe; it only reads [peek].
|
||||
*
|
||||
* The result is a [StateFlow] rather than a plain map because a bare map write
|
||||
* is invisible to Compose: the chip would stay missing until some unrelated
|
||||
* recomposition happened to run.
|
||||
*/
|
||||
object PayToAppAvailability {
|
||||
/** A host no registrar can delegate (RFC 2606), so only catch-all browsers match it. */
|
||||
private const val CONTROL_URL = "https://probe.invalid/"
|
||||
|
||||
private val state = MutableStateFlow<Map<String, PayToAppInfo>>(emptyMap())
|
||||
val flow: StateFlow<Map<String, PayToAppInfo>> = state.asStateFlow()
|
||||
|
||||
/**
|
||||
* Decoded icons, kept across warms and keyed by package and size.
|
||||
*
|
||||
* [warm] runs every time the picker opens, so that resolution stays fresh when
|
||||
* the user installs an app and comes back. Re-reading the APK's resources and
|
||||
* re-rasterising the icon each of those times is the expensive half and answers
|
||||
* the same thing, so only the cheap half repeats.
|
||||
*/
|
||||
private val icons = ConcurrentHashMap<String, ImageBitmap>()
|
||||
|
||||
/** Synchronous read for `RailCapabilityResolver.peek`, which runs inside `remember {}`. */
|
||||
fun peek(rawType: String): PayToAppInfo? = state.value[PaymentTargetTypes.probeKeyFor(rawType)]
|
||||
|
||||
/**
|
||||
* Probes every distinct type in [targets] and merges the answers into the cache.
|
||||
*
|
||||
* Merging rather than replacing: the probe set is one author's target list, so
|
||||
* replacing would evict what was learned about every other author the moment a
|
||||
* second picker opened. Re-probing an already-known type is the point — that is
|
||||
* how a newly installed app becomes visible — and the merge just overwrites it.
|
||||
*
|
||||
* Blocking: `loadIcon` reads the target APK's resources. Call from `Dispatchers.IO`.
|
||||
* [iconPx] is the size the chip draws at — decoding once here is what keeps
|
||||
* the icon out of the composition path.
|
||||
*/
|
||||
fun warm(
|
||||
context: Context,
|
||||
targets: List<PaymentTarget>,
|
||||
iconPx: Int,
|
||||
) {
|
||||
val pm = context.packageManager
|
||||
val keys =
|
||||
targets
|
||||
.asSequence()
|
||||
.map { it.type }
|
||||
.filterNot { PaymentTargetTypes.isWalletCovered(it) }
|
||||
.distinctBy { PaymentTargetTypes.probeKeyFor(it) }
|
||||
.toList()
|
||||
|
||||
if (keys.isEmpty()) return
|
||||
|
||||
// Only https targets need the control probe; skip the extra query otherwise.
|
||||
val browsers = if (keys.any(PaymentTargetTypes::isWebTarget)) browserPackages(pm) else emptySet()
|
||||
val probed =
|
||||
keys.associate { type ->
|
||||
PaymentTargetTypes.probeKeyFor(type) to probe(pm, type, browsers, iconPx)
|
||||
}
|
||||
state.update { it + probed }
|
||||
}
|
||||
|
||||
private fun probe(
|
||||
pm: PackageManager,
|
||||
rawType: String,
|
||||
browsers: Set<String>,
|
||||
iconPx: Int,
|
||||
): PayToAppInfo {
|
||||
val intent = viewIntent(PaymentTargetTypes.probeKeyFor(rawType))
|
||||
val handlers = queryActivities(pm, intent)
|
||||
val isWeb = PaymentTargetTypes.isWebTarget(rawType)
|
||||
|
||||
// A browser resolves any https:// URI, so for web targets "something
|
||||
// resolves" is trivially true and tells us nothing. Gate them open, but
|
||||
// only claim an app — and therefore an icon — when a handler exists that
|
||||
// is not merely a browser. Chrome's icon on a Venmo chip is worse than none.
|
||||
val appHandlers = if (isWeb) handlers.filterNot { it.packageName() in browsers } else handlers
|
||||
val resolves = isWeb || handlers.isNotEmpty()
|
||||
if (appHandlers.isEmpty()) return PayToAppInfo(resolves = resolves)
|
||||
|
||||
val chosen = defaultActivity(pm, intent, appHandlers) ?: return PayToAppInfo(resolves = resolves)
|
||||
|
||||
return PayToAppInfo(
|
||||
resolves = resolves,
|
||||
label = runCatching { chosen.loadLabel(pm).toString() }.getOrNull(),
|
||||
icon = loadIcon(pm, chosen, iconPx),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The single app the hand-off would open, or null when the system would show
|
||||
* a chooser instead. With several handlers and no user default, Android hands
|
||||
* back its own `ResolverActivity` — there is no app to name there, so the chip
|
||||
* falls back to the brand-coloured glyph.
|
||||
*/
|
||||
private fun defaultActivity(
|
||||
pm: PackageManager,
|
||||
intent: Intent,
|
||||
handlers: List<ResolveInfo>,
|
||||
): ResolveInfo? {
|
||||
if (handlers.size == 1) return handlers.first()
|
||||
val preferred =
|
||||
runCatching { pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) }.getOrNull()
|
||||
?: return null
|
||||
val pkg = preferred.packageName()
|
||||
if (pkg == "android" || preferred.activityInfo?.name?.contains("ResolverActivity") == true) return null
|
||||
return handlers.firstOrNull { it.packageName() == pkg }
|
||||
}
|
||||
|
||||
/**
|
||||
* minSdk is 26, so any icon may be an `AdaptiveIconDrawable`: a 108x108 canvas
|
||||
* whose outer margin the launcher masks away. Rasterising it at the chip's own
|
||||
* size — rather than at its intrinsic size — is what stops the logo from
|
||||
* arriving as a speck floating in that bleed. The circular mask is applied by
|
||||
* the composable, matching how a launcher presents the same icon.
|
||||
*/
|
||||
private fun loadIcon(
|
||||
pm: PackageManager,
|
||||
info: ResolveInfo,
|
||||
px: Int,
|
||||
): ImageBitmap? {
|
||||
val pkg = info.packageName() ?: return null
|
||||
icons["$pkg@$px"]?.let { return it }
|
||||
|
||||
return runCatching {
|
||||
info.loadIcon(pm).toBitmap(px, px).asImageBitmap()
|
||||
}.onSuccess {
|
||||
icons["$pkg@$px"] = it
|
||||
}.onFailure {
|
||||
Log.w("PayToAppAvailability", "Could not load icon for $pkg", it)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@SuppressLint("QueryPermissionsNeeded")
|
||||
private fun queryActivities(
|
||||
pm: PackageManager,
|
||||
intent: Intent,
|
||||
): List<ResolveInfo> =
|
||||
runCatching {
|
||||
// MATCH_DEFAULT_ONLY mirrors startActivity, which implies CATEGORY_DEFAULT.
|
||||
// Without it we would list activities the hand-off could never launch.
|
||||
pm.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** Packages that answer a URL nobody can own — i.e. general-purpose browsers. */
|
||||
@SuppressLint("QueryPermissionsNeeded")
|
||||
private fun browserPackages(pm: PackageManager): Set<String> = queryActivities(pm, viewIntent(CONTROL_URL)).mapNotNull { it.packageName() }.toSet()
|
||||
|
||||
/**
|
||||
* Deliberately carries **no** category. `IntentFilter.matchCategories` returns
|
||||
* the first category on the *intent* that the filter lacks, so every category
|
||||
* added here narrows the match — a probe carrying `BROWSABLE` would miss any
|
||||
* app whose filter declares only `DEFAULT`, and hide a chip that would have
|
||||
* opened fine. Paired with `MATCH_DEFAULT_ONLY` in [queryActivities], this
|
||||
* resolves exactly the set `startActivity` would.
|
||||
*/
|
||||
private fun viewIntent(uri: String) = Intent(Intent.ACTION_VIEW, uri.toUri())
|
||||
|
||||
private fun ResolveInfo.packageName(): String? = activityInfo?.packageName
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -42,6 +43,7 @@ import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -59,6 +61,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -93,8 +96,10 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -118,6 +123,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.fork
|
||||
import com.vitorpamplona.amethyst.commons.resources.nutzap
|
||||
@@ -134,6 +140,7 @@ import com.vitorpamplona.amethyst.model.zap.CashuRailStatus
|
||||
import com.vitorpamplona.amethyst.model.zap.RailCapability
|
||||
import com.vitorpamplona.amethyst.model.zap.RailCapabilityResolver
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.payments.PayToAppAvailability
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount
|
||||
@@ -153,6 +160,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
|
||||
@@ -160,6 +168,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.ShareOptionsBottomSheet
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.paymentTargetStyleFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -197,6 +206,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.reactionBox
|
||||
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
|
||||
@@ -216,6 +226,7 @@ import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
@@ -2125,6 +2136,27 @@ fun observeZapRailCapability(
|
||||
val showOnchainWallet by accountViewModel.settings.uiSettingsFlow.showOnchainWallet
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// Pay-to hand-off inputs. Same "read only to drive the recompute" contract as
|
||||
// the four above: the recipient's kind:10133 already rides in
|
||||
// UserMetadataForKeyKinds beside kind:0, so observing it here costs no extra
|
||||
// round-trip and only says *when* to re-run the resolver.
|
||||
val showPayToChip by accountViewModel.settings.uiSettingsFlow.showPayToZapChip
|
||||
.collectAsStateWithLifecycle()
|
||||
val recipientPayTo = author?.let { observeNoteEvent<PaymentTargetsEvent>(it.paymentTargetsNote, accountViewModel).value }
|
||||
val payToApps by PayToAppAvailability.flow.collectAsStateWithLifecycle()
|
||||
|
||||
// The probe set is this one author's target list — a handful of entries, and
|
||||
// only for the author whose picker is open. It runs when the picker opens,
|
||||
// never while scrolling.
|
||||
val context = LocalContext.current
|
||||
val iconPx = with(LocalDensity.current) { PayToIconSize.roundToPx() }
|
||||
LaunchedEffect(recipientPayTo, showPayToChip) {
|
||||
val targets = recipientPayTo?.paymentTargets().orEmpty()
|
||||
if (showPayToChip && targets.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) { PayToAppAvailability.warm(context, targets, iconPx) }
|
||||
}
|
||||
}
|
||||
|
||||
// Our own on-chain balance, so an amount we can't fund isn't offered at all.
|
||||
// Cached account-wide and refreshed at most once a minute, so opening the
|
||||
// picker again is instant; while it is still unknown (first open, explorer
|
||||
@@ -2136,8 +2168,19 @@ fun observeZapRailCapability(
|
||||
}
|
||||
val onchainFunds by onchainWallet.funds.collectAsStateWithLifecycle()
|
||||
|
||||
return remember(baseNote, onchainEnabled, onchainFunds, cashuMints, cashuEntries, recipientInfo, nutzapInfo) {
|
||||
val rc = RailCapabilityResolver.peek(baseNote, cashuState)
|
||||
return remember(
|
||||
baseNote,
|
||||
onchainEnabled,
|
||||
onchainFunds,
|
||||
cashuMints,
|
||||
cashuEntries,
|
||||
recipientInfo,
|
||||
nutzapInfo,
|
||||
showPayToChip,
|
||||
recipientPayTo,
|
||||
payToApps,
|
||||
) {
|
||||
val rc = RailCapabilityResolver.peek(baseNote, cashuState, showPayToChip)
|
||||
if (onchainEnabled) {
|
||||
rc.copy(onchainMaxSpendableSats = onchainFunds?.maxSpendableSats)
|
||||
} else {
|
||||
@@ -2274,6 +2317,9 @@ fun ZapAmountChoicePopup(
|
||||
visibilityState.targetState = false
|
||||
},
|
||||
onChangeAmount = onChangeAmount,
|
||||
// The hand-off sends the user to another app; leaving the popup
|
||||
// stacked behind it would be waiting for a tap that never comes.
|
||||
onHandedOff = { visibilityState.targetState = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2289,6 +2335,7 @@ fun ZapAmountChoicePopupContent(
|
||||
onOnchainAmount: (Long?) -> Unit,
|
||||
onChangeAmount: () -> Unit,
|
||||
onReloadNutzap: (Long) -> Unit = {},
|
||||
onHandedOff: () -> Unit = {},
|
||||
) {
|
||||
Box(HalfPadding, contentAlignment = Center) {
|
||||
ElevatedCard(
|
||||
@@ -2304,6 +2351,7 @@ fun ZapAmountChoicePopupContent(
|
||||
onOnchainAmount = onOnchainAmount,
|
||||
onChangeAmount = onChangeAmount,
|
||||
onReloadNutzap = onReloadNutzap,
|
||||
onHandedOff = onHandedOff,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2324,6 +2372,7 @@ fun ZapAmountChoiceGrid(
|
||||
onOnchainAmount: (Long?) -> Unit,
|
||||
onChangeAmount: () -> Unit,
|
||||
onReloadNutzap: (Long) -> Unit = {},
|
||||
onHandedOff: () -> Unit = {},
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
|
||||
@@ -2342,6 +2391,14 @@ fun ZapAmountChoiceGrid(
|
||||
onChangeAmount = onChangeAmount,
|
||||
)
|
||||
}
|
||||
// Rendered once, beside the amount pills rather than inside each one's rail
|
||||
// toggle: it carries no amount, so repeating it per preset would say the
|
||||
// same thing four times — and keeping it out of the toggle leaves ZapRail a
|
||||
// plain enum instead of a data-carrying sealed interface.
|
||||
railCapability.payToTargets.forEach { target ->
|
||||
PayToHandoffChip(target = target, onHandedOff = onHandedOff)
|
||||
}
|
||||
|
||||
ClickableBox(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -2360,6 +2417,122 @@ fun ZapAmountChoiceGrid(
|
||||
}
|
||||
}
|
||||
|
||||
/** The size the hand-off mark draws at, and the size its bitmap is decoded to. */
|
||||
internal val PayToIconSize = 18.dp
|
||||
|
||||
/**
|
||||
* The NIP-A3 hand-off chip: pay this person through a protocol you both publish,
|
||||
* in the app that owns it.
|
||||
*
|
||||
* Three things deliberately set it apart from the amount pills beside it, because
|
||||
* it is not a zap and must not read as one:
|
||||
* - **No amount.** Presets are sats and there is no rate to turn them into a
|
||||
* Venmo or IBAN figure, so no number is shown and no RFC-8905 `amount=` is
|
||||
* emitted — the receiving app asks.
|
||||
* - **[MaterialSymbols.OpenInNew], not the send arrow** every amount segment
|
||||
* ends in. This leaves Amethyst.
|
||||
* - **No zap receipt.** Nothing is published, so the note's zap counter will not
|
||||
* move. Nothing here touches the zap progress state.
|
||||
*
|
||||
* The mark is the installed app's own icon when one app owns the URI — the same
|
||||
* `ResolveInfo` the availability probe already keeps — masked round the way a
|
||||
* launcher draws it. It falls back to the brand-coloured glyph
|
||||
* [paymentTargetStyleFor] already assigns when the hand-off would open a chooser
|
||||
* or merely a browser.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PayToHandoffChip(
|
||||
target: PaymentTarget,
|
||||
onHandedOff: () -> Unit,
|
||||
) {
|
||||
val style = remember(target.type) { paymentTargetStyleFor(target.type) }
|
||||
val uri = remember(target) { PaymentTargetTypes.uriFor(target.type, target.authority) }
|
||||
|
||||
// Collected, not peeked once: a web target is offered before the probe has run
|
||||
// (any browser opens https), so a snapshot taken at first composition would pin
|
||||
// the fallback glyph and the real app icon would never arrive until the picker
|
||||
// was closed and reopened.
|
||||
val apps by PayToAppAvailability.flow.collectAsStateWithLifecycle()
|
||||
val app = remember(apps, target.type) { apps[PaymentTargetTypes.probeKeyFor(target.type)] }
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val copiedMessage = stringRes(R.string.copied_to_clipboard)
|
||||
val noAppMessage = stringRes(R.string.no_payment_app_found_for_type, style.label)
|
||||
val amountElsewhere = stringRes(R.string.payto_amount_set_in_app, app?.label ?: style.label)
|
||||
|
||||
Surface(
|
||||
shape = ButtonBorder,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(3.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.combinedClickable(
|
||||
onClickLabel = amountElsewhere,
|
||||
onClick = {
|
||||
// The probe can go stale between warming and this tap
|
||||
// (the app was uninstalled), so keep the catch.
|
||||
runCatching { uriHandler.openUri(uri) }
|
||||
.onSuccess { onHandedOff() }
|
||||
.onFailure { Toast.makeText(context, noAppMessage, Toast.LENGTH_SHORT).show() }
|
||||
},
|
||||
// NOT onChangeAmount: a sat-preset editor means nothing
|
||||
// here. Copies the authority, like the profile chip does.
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
clipboard.setText(target.authority)
|
||||
Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
).padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
val icon = app?.icon
|
||||
if (icon != null) {
|
||||
Image(
|
||||
bitmap = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(PayToIconSize).clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
symbol = style.symbol,
|
||||
contentDescription = null,
|
||||
tint = style.color,
|
||||
modifier = Modifier.size(PayToIconSize),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text(
|
||||
text = style.label,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.width(3.dp))
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(13.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One pill per amount, showing a tappable logo for every rail that can pay it:
|
||||
* - **Cashu** as a solid logo when a single shared mint already covers the
|
||||
@@ -2626,6 +2799,24 @@ fun ZapAmountChoicePopupPreview() {
|
||||
RailCapability(hasCashu = true, hasLightning = true, hasOnchain = true, cashuBestSingleMintSats = 10L, cashuTotalWalletSats = 1_000_000L),
|
||||
amounts,
|
||||
)
|
||||
// The pay-to hand-off sits beside the amount pills, not inside them, so
|
||||
// it renders once however many presets there are. No app is installed in
|
||||
// a preview, so this also shows the brand-coloured glyph fallback the
|
||||
// chip uses when no single app owns the URI.
|
||||
ZapChipPreviewRow(
|
||||
"Lightning + two pay-to hand-offs",
|
||||
RailCapability(
|
||||
hasCashu = false,
|
||||
hasLightning = true,
|
||||
hasOnchain = false,
|
||||
payToTargets =
|
||||
listOf(
|
||||
PaymentTarget("venmo", "vitorpamplona"),
|
||||
PaymentTarget("monero", "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRJ5AmD9"),
|
||||
),
|
||||
),
|
||||
amounts,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -55,6 +56,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -341,6 +343,38 @@ fun UpdateZapAmountContent(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Section 3: Pay-to hand-off ───────────────────────────────────────
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.zap_payto_section),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = SettingsCategorySpacingModifier,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.zap_payto_section_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { postViewModel.showPayToChip = !postViewModel.showPayToChip },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.zap_payto_toggle),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = postViewModel.showPayToChip,
|
||||
onCheckedChange = { postViewModel.showPayToChip = it },
|
||||
)
|
||||
}
|
||||
|
||||
trailingContent()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
+13
-1
@@ -46,6 +46,11 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
|
||||
var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE)
|
||||
|
||||
// A local UI preference rather than synced account state, but it is edited on
|
||||
// this screen, so it follows this screen's Save/Cancel contract instead of
|
||||
// applying instantly — a toggle that ignored Cancel would read as a bug.
|
||||
var showPayToChip by mutableStateOf(false)
|
||||
|
||||
fun copyFromClipboard(text: String) {
|
||||
if (text.isBlank()) {
|
||||
return
|
||||
@@ -61,6 +66,8 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value
|
||||
this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value
|
||||
|
||||
this.showPayToChip = uiSettings().showPayToZapChip.value
|
||||
|
||||
val nip47 = accountViewModel.account.settings.defaultZapPaymentRequest()
|
||||
|
||||
this.walletConnectPubkey = nip47?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
|
||||
@@ -132,14 +139,18 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
accountViewModel.account.updateZapAmounts(amountSet, selectedZapType, nip47Update)
|
||||
uiSettings().showPayToZapChip.tryEmit(showPayToChip)
|
||||
|
||||
nextAmount = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
nextAmount = TextFieldValue("")
|
||||
showPayToChip = uiSettings().showPayToZapChip.value
|
||||
}
|
||||
|
||||
private fun uiSettings() = accountViewModel.settings.uiSettingsFlow
|
||||
|
||||
fun hasChanged(): Boolean {
|
||||
val defaultUri = accountViewModel.account.settings.defaultZapPaymentRequest()
|
||||
return (
|
||||
@@ -147,7 +158,8 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value ||
|
||||
walletConnectPubkey.text != (defaultUri?.pubKeyHex ?: "") ||
|
||||
walletConnectRelay.text != (defaultUri?.relayUri?.url ?: "") ||
|
||||
walletConnectSecret.text != (defaultUri?.secret ?: "")
|
||||
walletConnectSecret.text != (defaultUri?.secret ?: "") ||
|
||||
showPayToChip != uiSettings().showPayToZapChip.value
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -691,6 +691,8 @@ private fun QuickZapAmountRow(
|
||||
nav.nav(Route.UpdateZapAmount())
|
||||
onDismiss()
|
||||
},
|
||||
// Hands off to another app; the sheet must not stay stacked behind it.
|
||||
onHandedOff = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-31
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
@@ -39,13 +40,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress
|
||||
|
||||
/** Lightning-family target types Amethyst can pay in-app through the Send Payment screen. */
|
||||
private val LIGHTNING_TARGET_TYPES = setOf("lightning", "ln", "lnurl")
|
||||
|
||||
/** Bitcoin-family target types the in-app on-chain wallet can pay directly. */
|
||||
private val BITCOIN_TARGET_TYPES = setOf("bitcoin", "btc", "onchain")
|
||||
|
||||
fun isLightningPaymentTarget(rawType: String): Boolean = rawType.trim().lowercase() in LIGHTNING_TARGET_TYPES
|
||||
fun isLightningPaymentTarget(rawType: String): Boolean = rawType.trim().lowercase() in PaymentTargetTypes.LIGHTNING_TYPES
|
||||
|
||||
/**
|
||||
* Route into the in-app Send Payment screen when one of the user's wallets can
|
||||
@@ -61,10 +56,10 @@ fun inAppPaymentRouteFor(
|
||||
): Route.SendPayment? {
|
||||
val type = target.type.trim().lowercase()
|
||||
return when {
|
||||
type in LIGHTNING_TARGET_TYPES ->
|
||||
type in PaymentTargetTypes.LIGHTNING_TYPES ->
|
||||
Route.SendPayment(userHex, ProfilePaymentMethod.LIGHTNING.routeKey, lnAddressOverride = target.authority)
|
||||
|
||||
type in BITCOIN_TARGET_TYPES &&
|
||||
type in PaymentTargetTypes.BITCOIN_TYPES &&
|
||||
LocalCache.onchainBackend != null &&
|
||||
SegwitAddress.isPayableMainnetAddress(target.authority.trim()) ->
|
||||
Route.SendPayment(userHex, ProfilePaymentMethod.ONCHAIN.routeKey, btcAddressOverride = target.authority.trim())
|
||||
@@ -80,7 +75,7 @@ fun inAppPaymentRouteFor(
|
||||
* payment-target dialog so the same pill hands off to the same app wherever
|
||||
* it is tapped.
|
||||
*/
|
||||
fun paymentTargetUri(target: PaymentTarget): String = paymentTargetStyleFor(target.type).uriFor(target.authority)
|
||||
fun paymentTargetUri(target: PaymentTarget): String = PaymentTargetTypes.uriFor(target.type, target.authority)
|
||||
|
||||
/**
|
||||
* Chip for a NIP-A3 payment target. Rendered inside [DisplayPaymentRailChips]'s
|
||||
@@ -108,7 +103,7 @@ fun PaymentTargetChip(
|
||||
if (inAppRoute != null) {
|
||||
nav.nav(inAppRoute)
|
||||
} else {
|
||||
runCatching { uriHandler.openUri(style.uriFor(target.authority)) }
|
||||
runCatching { uriHandler.openUri(paymentTargetUri(target)) }
|
||||
.onFailure {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.error_dialog_payment_error,
|
||||
@@ -150,52 +145,51 @@ fun PaymentTargetPill(
|
||||
}
|
||||
}
|
||||
|
||||
private data class PaymentTargetStyle(
|
||||
data class PaymentTargetStyle(
|
||||
val symbol: MaterialSymbol,
|
||||
val color: Color,
|
||||
val label: String,
|
||||
val uriFor: (String) -> String,
|
||||
)
|
||||
|
||||
private fun paymentTargetStyleFor(rawType: String): PaymentTargetStyle {
|
||||
fun paymentTargetStyleFor(rawType: String): PaymentTargetStyle {
|
||||
val type = rawType.trim().lowercase()
|
||||
val walletIcon = MaterialSymbols.AccountBalanceWallet
|
||||
return when (type) {
|
||||
"bitcoin", "btc", "onchain" ->
|
||||
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "BITCOIN") { "bitcoin:$it" }
|
||||
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "BITCOIN")
|
||||
"lightning", "ln" ->
|
||||
PaymentTargetStyle(MaterialSymbols.Bolt, BitcoinOrange, "LIGHTNING") { "lightning:$it" }
|
||||
PaymentTargetStyle(MaterialSymbols.Bolt, BitcoinOrange, "LIGHTNING")
|
||||
"lnurl" ->
|
||||
PaymentTargetStyle(MaterialSymbols.Bolt, BitcoinOrange, "LNURL") { "lightning:$it" }
|
||||
PaymentTargetStyle(MaterialSymbols.Bolt, BitcoinOrange, "LNURL")
|
||||
"liquid" ->
|
||||
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "LIQUID") { "liquidnetwork:$it" }
|
||||
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "LIQUID")
|
||||
"ethereum", "eth" ->
|
||||
PaymentTargetStyle(walletIcon, ETHEREUM_PURPLE, "ETHEREUM") { "ethereum:$it" }
|
||||
PaymentTargetStyle(walletIcon, ETHEREUM_PURPLE, "ETHEREUM")
|
||||
"monero", "xmr" ->
|
||||
PaymentTargetStyle(walletIcon, MONERO_ORANGE, "MONERO") { "monero:$it" }
|
||||
PaymentTargetStyle(walletIcon, MONERO_ORANGE, "MONERO")
|
||||
"dash" ->
|
||||
PaymentTargetStyle(walletIcon, DASH_BLUE, "DASH") { "dash:$it" }
|
||||
PaymentTargetStyle(walletIcon, DASH_BLUE, "DASH")
|
||||
"zcash", "zec" ->
|
||||
PaymentTargetStyle(walletIcon, ZCASH_YELLOW, "ZCASH") { "zcash:$it" }
|
||||
PaymentTargetStyle(walletIcon, ZCASH_YELLOW, "ZCASH")
|
||||
"bitcoincash", "bch" ->
|
||||
PaymentTargetStyle(walletIcon, BITCOINCASH_GREEN, "BITCOINCASH") { "bitcoincash:$it" }
|
||||
PaymentTargetStyle(walletIcon, BITCOINCASH_GREEN, "BITCOINCASH")
|
||||
"litecoin", "ltc" ->
|
||||
PaymentTargetStyle(walletIcon, LITECOIN_STEEL_BLUE, "LITECOIN") { "litecoin:$it" }
|
||||
PaymentTargetStyle(walletIcon, LITECOIN_STEEL_BLUE, "LITECOIN")
|
||||
"dogecoin", "doge" ->
|
||||
PaymentTargetStyle(walletIcon, DOGECOIN_SAND, "DOGECOIN") { "dogecoin:$it" }
|
||||
PaymentTargetStyle(walletIcon, DOGECOIN_SAND, "DOGECOIN")
|
||||
"solana", "sol" ->
|
||||
PaymentTargetStyle(walletIcon, SOLANA_PURPLE, "SOLANA") { "solana:$it" }
|
||||
PaymentTargetStyle(walletIcon, SOLANA_PURPLE, "SOLANA")
|
||||
"tron", "trx" ->
|
||||
PaymentTargetStyle(walletIcon, TRON_RED, "TRON") { "tron:$it" }
|
||||
PaymentTargetStyle(walletIcon, TRON_RED, "TRON")
|
||||
"cashapp" ->
|
||||
PaymentTargetStyle(walletIcon, CASHAPP_LIME, "CASHAPP") { "https://cash.app/$it" }
|
||||
PaymentTargetStyle(walletIcon, CASHAPP_LIME, "CASHAPP")
|
||||
"venmo" ->
|
||||
PaymentTargetStyle(walletIcon, VENMO_BLUE, "VENMO") { "https://venmo.com/$it" }
|
||||
PaymentTargetStyle(walletIcon, VENMO_BLUE, "VENMO")
|
||||
"paypal" ->
|
||||
PaymentTargetStyle(walletIcon, PAYPAL_DEEP_BLUE, "PAYPAL") { "https://paypal.me/$it" }
|
||||
PaymentTargetStyle(walletIcon, PAYPAL_DEEP_BLUE, "PAYPAL")
|
||||
else -> {
|
||||
val label = rawType.trim().ifEmpty { "PAY" }.uppercase()
|
||||
PaymentTargetStyle(walletIcon, GENERIC_TARGET_COLOR, label) { "payto://$type/$it" }
|
||||
PaymentTargetStyle(walletIcon, GENERIC_TARGET_COLOR, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,6 +877,10 @@
|
||||
<string name="payment_targets_section_explainer">Add payment addresses for different networks (e.g. bitcoin, lightning, ethereum).</string>
|
||||
<string name="no_payment_app_found">No app found to handle this payment. Please install a compatible wallet.</string>
|
||||
<string name="no_payment_app_found_for_type">No app installed to handle %1$s payments. Please install a compatible wallet.</string>
|
||||
<string name="payto_amount_set_in_app">Pay in %1$s — you choose the amount there</string>
|
||||
<string name="zap_payto_section">Pay-to hand-off</string>
|
||||
<string name="zap_payto_section_explainer">When the author publishes a payment method an app on this phone can open (Venmo, Monero, an IBAN…), the zap picker can offer it. Tapping opens that app, which asks for the amount — nothing is published to relays, so the note\'s zap count does not change.</string>
|
||||
<string name="zap_payto_toggle">Offer the author\'s payment methods</string>
|
||||
<string name="error_dialog_payment_error">Unable to open payment</string>
|
||||
|
||||
<string name="bolt12_offers">BOLT12 Offers</string>
|
||||
@@ -1306,7 +1310,7 @@
|
||||
<string name="ui_preferences_search_keywords" translatable="false">dark mode, light mode, theme, font size, language, appearance</string>
|
||||
<string name="notification_settings_search_keywords" translatable="false">push, alerts, sounds, vibration</string>
|
||||
<string name="security_filters_search_keywords" translatable="false">spam, block, mute, filter, warnings</string>
|
||||
<string name="zaps_search_keywords" translatable="false">lightning, sats, tips, wallet, amount</string>
|
||||
<string name="zaps_search_keywords" translatable="false">lightning, sats, tips, wallet, amount, payto, pay-to, venmo, paypal, cash app, monero, iban, hand-off</string>
|
||||
<string name="media_servers_search_keywords" translatable="false">blossom, uploads, images, photos, files, cdn, storage</string>
|
||||
<string name="event_sync_search_keywords" translatable="false">negentropy, sync, reconcile, backfill</string>
|
||||
<string name="import_follows_search_keywords" translatable="false">contacts, follows, follow list, import</string>
|
||||
|
||||
@@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.UserCards
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||
@@ -80,6 +82,8 @@ class User(
|
||||
|
||||
val bolt12OfferListNote: Note = context.addressableNote(Bolt12OfferListEvent.createAddress(pubkeyHex))
|
||||
|
||||
val paymentTargetsNote: Note = context.addressableNote(PaymentTargetsEvent.createAddress(pubkeyHex))
|
||||
|
||||
// These objects are designed to keep the cache
|
||||
// while this user obj is being used anywhere.
|
||||
//
|
||||
@@ -116,6 +120,12 @@ class User(
|
||||
|
||||
fun nutzapInfo() = nutzapInfoNote.event as? NutzapInfoEvent
|
||||
|
||||
/** This user's published NIP-A3 payment targets (kind:10133), or null if none seen. */
|
||||
fun paymentTargetsEvent() = paymentTargetsNote.event as? PaymentTargetsEvent
|
||||
|
||||
/** The `payto` targets this user publishes, empty when none. */
|
||||
fun paymentTargets(): List<PaymentTarget> = paymentTargetsEvent()?.paymentTargets().orEmpty()
|
||||
|
||||
/** This user's published BOLT12 offer list (NIP-B1 kind 10058), or null if none seen. */
|
||||
fun bolt12OfferList() = bolt12OfferListNote.event as? Bolt12OfferListEvent
|
||||
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.model.payments
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
|
||||
/**
|
||||
* Picks the NIP-A3 payment targets a sender can hand off to when paying a note's
|
||||
* author.
|
||||
*
|
||||
* The rule is capability, not symmetry: a target is offered when something on
|
||||
* this device can actually open its URI. Paying a Monero address needs a wallet,
|
||||
* not a published address of one — so what the sender happens to publish about
|
||||
* themselves says nothing about whether they can pay, and is not consulted.
|
||||
*/
|
||||
object PayToRailMatcher {
|
||||
/**
|
||||
* The recipient's payable targets, de-duplicated by canonical type and in the
|
||||
* recipient's published order.
|
||||
*
|
||||
* Wallet-covered types (lightning, bitcoin) are dropped: those are the
|
||||
* picker's existing Lightning and on-chain rails, and re-offering them as a
|
||||
* hand-off would draw a second bolt icon beside the first.
|
||||
*/
|
||||
fun match(recipientTargets: List<PaymentTarget>): List<PaymentTarget> {
|
||||
if (recipientTargets.isEmpty()) return emptyList()
|
||||
|
||||
val seen = mutableSetOf<String>()
|
||||
return recipientTargets.filter { target ->
|
||||
val type = PaymentTargetTypes.canonical(target.type)
|
||||
type.isNotEmpty() &&
|
||||
target.authority.isNotBlank() &&
|
||||
!PaymentTargetTypes.isWalletCovered(type) &&
|
||||
seen.add(type)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every gate on the hand-off chip, as one pure decision.
|
||||
*
|
||||
* Kept free of `Note`, `Context` and the availability singleton so the gates
|
||||
* are testable on their own — the caller supplies what it read from those.
|
||||
*
|
||||
* @param hasAuthor a note with no author pubkey has nobody to pay.
|
||||
* @param hasZapSplit a `payto` hand-off leaves with one authority and returns
|
||||
* no receipt, so it cannot honour a note that asks to divide the zap.
|
||||
* @param canOpen whether an installed app resolves this target's URI. This is
|
||||
* the substantive gate: everything else here is a precondition.
|
||||
* @param recipientTargets read lazily. Parsing the recipient's kind:10133 walks
|
||||
* its tag array and allocates, and the common case is that a gate has already
|
||||
* failed — the setting is off, or the note carries a split — so the cheap
|
||||
* checks must run first. `peek` is on the one-tap zap path too.
|
||||
*/
|
||||
fun selectFor(
|
||||
enabled: Boolean,
|
||||
hasAuthor: Boolean,
|
||||
hasZapSplit: Boolean,
|
||||
canOpen: (PaymentTarget) -> Boolean,
|
||||
recipientTargets: () -> List<PaymentTarget>,
|
||||
): List<PaymentTarget> {
|
||||
if (!enabled || !hasAuthor || hasZapSplit) return emptyList()
|
||||
return match(recipientTargets()).filter(canOpen)
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* 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.model.payments
|
||||
|
||||
/**
|
||||
* The NIP-A3 (`payto`) target-type vocabulary: how a free-text type string is
|
||||
* normalized, which types the app's own wallets already cover, and the URI a
|
||||
* type hands off to.
|
||||
*
|
||||
* Type strings come from user input ([PaymentTargetsViewModel.addTarget] only
|
||||
* trims and lowercases), so the space is unbounded — `iban`, `upi`, `pix`,
|
||||
* `swish` and whatever comes next all land here as themselves. The tables below
|
||||
* only collapse the aliases we know about; everything else passes through and
|
||||
* falls back to `payto://<type>/<authority>`.
|
||||
*
|
||||
* Single source of truth for three callers that would otherwise each keep their
|
||||
* own copy: the profile chips (which pay a target directly), the zap picker's
|
||||
* hand-off chip (which must exclude the types the wallet rails already own),
|
||||
* and the installed-app probe (which needs the URI before it has an authority).
|
||||
*/
|
||||
object PaymentTargetTypes {
|
||||
/** Lightning-family types Amethyst can pay in-app through the Send Payment screen. */
|
||||
val LIGHTNING_TYPES = setOf("lightning", "ln", "lnurl")
|
||||
|
||||
/** Bitcoin-family types the in-app on-chain wallet can pay directly. */
|
||||
val BITCOIN_TYPES = setOf("bitcoin", "btc", "onchain")
|
||||
|
||||
/**
|
||||
* Alias -> family. Only collapses spellings of the *same* rail, so matching a
|
||||
* sender's `btc` against a recipient's `bitcoin` succeeds while `monero` and
|
||||
* `bitcoin` stay apart.
|
||||
*/
|
||||
private val ALIASES =
|
||||
mapOf(
|
||||
"btc" to "bitcoin",
|
||||
"onchain" to "bitcoin",
|
||||
"ln" to "lightning",
|
||||
"lnurl" to "lightning",
|
||||
"eth" to "ethereum",
|
||||
"xmr" to "monero",
|
||||
"zec" to "zcash",
|
||||
"bch" to "bitcoincash",
|
||||
"ltc" to "litecoin",
|
||||
"doge" to "dogecoin",
|
||||
"sol" to "solana",
|
||||
"trx" to "tron",
|
||||
)
|
||||
|
||||
/** Types whose hand-off is a web page rather than a registered URI scheme. */
|
||||
private val WEB_TYPES = setOf("cashapp", "venmo", "paypal")
|
||||
|
||||
/** Types with a dedicated URI scheme, keyed by canonical name. */
|
||||
private val SCHEMES =
|
||||
mapOf(
|
||||
"bitcoin" to "bitcoin",
|
||||
"lightning" to "lightning",
|
||||
"liquid" to "liquidnetwork",
|
||||
"ethereum" to "ethereum",
|
||||
"monero" to "monero",
|
||||
"dash" to "dash",
|
||||
"zcash" to "zcash",
|
||||
"bitcoincash" to "bitcoincash",
|
||||
"litecoin" to "litecoin",
|
||||
"dogecoin" to "dogecoin",
|
||||
"solana" to "solana",
|
||||
"tron" to "tron",
|
||||
)
|
||||
|
||||
private val WEB_HOSTS =
|
||||
mapOf(
|
||||
"cashapp" to "https://cash.app/",
|
||||
"venmo" to "https://venmo.com/",
|
||||
"paypal" to "https://paypal.me/",
|
||||
)
|
||||
|
||||
/** Trims, lowercases and collapses known aliases onto one family name. */
|
||||
fun canonical(rawType: String): String {
|
||||
val trimmed = rawType.trim().lowercase()
|
||||
return ALIASES[trimmed] ?: trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a wallet rail already on the zap picker owns this type. Lightning
|
||||
* and bitcoin targets ARE the Lightning and on-chain rails, so offering them
|
||||
* again as a hand-off would just draw a second bolt beside the first.
|
||||
*/
|
||||
fun isWalletCovered(rawType: String): Boolean {
|
||||
val type = canonical(rawType)
|
||||
return type in LIGHTNING_TYPES || type in BITCOIN_TYPES
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the hand-off is an `https://` page. Any browser resolves those, so
|
||||
* they are never gated on an installed app — but for the same reason
|
||||
* `resolveActivity` would hand back the browser, so they cannot take an app
|
||||
* icon without the control probe.
|
||||
*/
|
||||
fun isWebTarget(rawType: String): Boolean = canonical(rawType) in WEB_TYPES
|
||||
|
||||
/**
|
||||
* The URI this target hands off to. Unknown types fall back to RFC 8905
|
||||
* `payto://<type>/<authority>`, which is why a single `payto` entry in the
|
||||
* manifest's `<queries>` covers the whole open-ended tail of the vocabulary.
|
||||
*
|
||||
* No `amount=` is ever emitted: zap presets are sats and there is no rate to
|
||||
* convert them with, so the amount is named in the receiving app.
|
||||
*/
|
||||
fun uriFor(
|
||||
rawType: String,
|
||||
authority: String,
|
||||
): String {
|
||||
val type = canonical(rawType)
|
||||
val value = authority.trim()
|
||||
SCHEMES[type]?.let { return "$it:$value" }
|
||||
WEB_HOSTS[type]?.let { return "$it$value" }
|
||||
return "payto://$type/$value"
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache key for "can any installed app open this type?" — the URI with the
|
||||
* authority stripped, i.e. scheme plus host.
|
||||
*
|
||||
* Scheme alone would be too coarse: an app may declare
|
||||
* `android:scheme="payto" android:host="iban"`, and a scheme-only hit would
|
||||
* then wrongly claim `payto://upi/...` is handled too.
|
||||
*/
|
||||
fun probeKeyFor(rawType: String): String = uriFor(rawType, "")
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* 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.model.payments
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PaymentTargetTypesTest {
|
||||
@Test
|
||||
fun canonicalTrimsAndLowercases() {
|
||||
assertEquals("venmo", PaymentTargetTypes.canonical(" VenMo "))
|
||||
assertEquals("iban", PaymentTargetTypes.canonical("IBAN"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalCollapsesAliasesWithinARailOnly() {
|
||||
assertEquals("bitcoin", PaymentTargetTypes.canonical("btc"))
|
||||
assertEquals("bitcoin", PaymentTargetTypes.canonical("onchain"))
|
||||
assertEquals("lightning", PaymentTargetTypes.canonical("ln"))
|
||||
assertEquals("lightning", PaymentTargetTypes.canonical("lnurl"))
|
||||
assertEquals("monero", PaymentTargetTypes.canonical("xmr"))
|
||||
// Different rails must never collapse together.
|
||||
assertTrue(PaymentTargetTypes.canonical("monero") != PaymentTargetTypes.canonical("bitcoin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownTypesPassThroughUntouched() {
|
||||
assertEquals("pix", PaymentTargetTypes.canonical("pix"))
|
||||
assertEquals("upi", PaymentTargetTypes.canonical("upi"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun walletCoveredIsExactlyTheLightningAndBitcoinFamilies() {
|
||||
listOf("lightning", "ln", "LNURL", "bitcoin", "btc", " onchain ").forEach {
|
||||
assertTrue(PaymentTargetTypes.isWalletCovered(it), "$it should be wallet covered")
|
||||
}
|
||||
listOf("venmo", "monero", "iban", "liquid", "ethereum").forEach {
|
||||
assertFalse(PaymentTargetTypes.isWalletCovered(it), "$it should not be wallet covered")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uriUsesTheDedicatedSchemeWhenThereIsOne() {
|
||||
assertEquals("bitcoin:bc1qxyz", PaymentTargetTypes.uriFor("btc", "bc1qxyz"))
|
||||
assertEquals("lightning:me@ln.tips", PaymentTargetTypes.uriFor("lnurl", "me@ln.tips"))
|
||||
assertEquals("liquidnetwork:lq1abc", PaymentTargetTypes.uriFor("liquid", "lq1abc"))
|
||||
assertEquals("monero:4Aaddr", PaymentTargetTypes.uriFor("XMR", "4Aaddr"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun webTypesBecomeHttpsPages() {
|
||||
assertEquals("https://venmo.com/vitor", PaymentTargetTypes.uriFor("venmo", "vitor"))
|
||||
assertEquals("https://cash.app/\$vitor", PaymentTargetTypes.uriFor("cashapp", "\$vitor"))
|
||||
assertTrue(PaymentTargetTypes.isWebTarget("PayPal"))
|
||||
assertFalse(PaymentTargetTypes.isWebTarget("iban"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownTypesFallBackToPayto() {
|
||||
assertEquals("payto://iban/DE75512108001245126199", PaymentTargetTypes.uriFor("IBAN", "DE75512108001245126199"))
|
||||
assertEquals("payto://upi/vitor@bank", PaymentTargetTypes.uriFor("upi", " vitor@bank "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeKeyKeepsHostSoPaytoTypesDoNotShareOneAnswer() {
|
||||
// An app may declare scheme="payto" host="iban"; a scheme-only key would
|
||||
// then wrongly report that payto://upi is handled too.
|
||||
assertEquals("payto://iban/", PaymentTargetTypes.probeKeyFor("iban"))
|
||||
assertEquals("payto://upi/", PaymentTargetTypes.probeKeyFor("upi"))
|
||||
assertTrue(PaymentTargetTypes.probeKeyFor("iban") != PaymentTargetTypes.probeKeyFor("upi"))
|
||||
// Aliases of one rail share a key, as they share a scheme.
|
||||
assertEquals(PaymentTargetTypes.probeKeyFor("btc"), PaymentTargetTypes.probeKeyFor("bitcoin"))
|
||||
}
|
||||
}
|
||||
|
||||
class PayToRailMatcherTest {
|
||||
private fun t(
|
||||
type: String,
|
||||
authority: String = "handle",
|
||||
) = PaymentTarget(type, authority)
|
||||
|
||||
@Test
|
||||
fun noRecipientTargetsMeansNoChips() {
|
||||
assertEquals(emptyList(), PayToRailMatcher.match(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTargetIsOfferedRegardlessOfWhatTheSenderPublishes() {
|
||||
// Capability, not symmetry: paying a Monero address needs a wallet, not a
|
||||
// published address of one, so the sender's own list is never consulted.
|
||||
val out = PayToRailMatcher.match(listOf(t("monero", "theirs")))
|
||||
assertEquals(listOf(t("monero", "theirs")), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun walletCoveredTypesNeverProduceAChip() {
|
||||
// Those ARE the existing rails — matching them would draw a second bolt
|
||||
// beside the first.
|
||||
val wallets = listOf(t("lightning", "a@b.c"), t("btc", "bc1q"), t("ln", "x@y.z"))
|
||||
assertEquals(emptyList(), PayToRailMatcher.match(wallets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aliasesCollapseToOneChip() {
|
||||
val out = PayToRailMatcher.match(listOf(t("xmr", "first"), t("monero", "second")))
|
||||
assertEquals(listOf(t("xmr", "first")), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneChipPerProtocolKeepingTheFirst() {
|
||||
val recipient = listOf(t("venmo", "first"), t("venmo", "second"), t("monero", "xmr1"))
|
||||
assertEquals(listOf(t("venmo", "first"), t("monero", "xmr1")), PayToRailMatcher.match(recipient))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankAuthoritiesAreSkipped() {
|
||||
assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo", " "))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recipientOrderIsPreserved() {
|
||||
val recipient = listOf(t("monero", "m"), t("venmo", "v"))
|
||||
assertEquals(listOf("monero", "venmo"), PayToRailMatcher.match(recipient).map { it.type })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typesAreNormalisedBeforeDeduping() {
|
||||
val out = PayToRailMatcher.match(listOf(t(" VENMO ", "first"), t("venmo", "second")))
|
||||
assertEquals(listOf(t(" VENMO ", "first")), out)
|
||||
}
|
||||
}
|
||||
|
||||
/** The gates on the hand-off chip, exercised without a Note or a PackageManager. */
|
||||
class PayToRailGateTest {
|
||||
private fun t(
|
||||
type: String,
|
||||
authority: String = "handle",
|
||||
) = PaymentTarget(type, authority)
|
||||
|
||||
private val theirs = listOf(t("venmo", "them"), t("monero", "theirxmr"))
|
||||
private val anyAppOpens: (PaymentTarget) -> Boolean = { true }
|
||||
|
||||
private fun select(
|
||||
enabled: Boolean = true,
|
||||
hasAuthor: Boolean = true,
|
||||
hasZapSplit: Boolean = false,
|
||||
recipient: List<PaymentTarget> = theirs,
|
||||
canOpen: (PaymentTarget) -> Boolean = anyAppOpens,
|
||||
) = PayToRailMatcher.selectFor(enabled, hasAuthor, hasZapSplit, canOpen) { recipient }
|
||||
|
||||
@Test
|
||||
fun offeredWhenEveryGatePasses() {
|
||||
assertEquals(listOf("venmo", "monero"), select().map { it.type })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingOffHidesIt() {
|
||||
assertEquals(emptyList(), select(enabled = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aZapSplitHidesIt() {
|
||||
// payto leaves with one authority and returns no receipt, so it cannot
|
||||
// honour a note that asks for the zap to be divided.
|
||||
assertEquals(emptyList(), select(hasZapSplit = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noAuthorHidesIt() {
|
||||
assertEquals(emptyList(), select(hasAuthor = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTargetNoInstalledAppCanOpenIsDropped() {
|
||||
val out = select(canOpen = { it.type == "venmo" })
|
||||
assertEquals(listOf("venmo"), out.map { it.type })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noInstalledAppAtAllHidesIt() {
|
||||
assertEquals(emptyList(), select(canOpen = { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyOpenableTargetIsOfferedWithNoCap() {
|
||||
val many = listOf(t("venmo"), t("monero"), t("pix"), t("upi"), t("iban"))
|
||||
assertEquals(many.size, select(recipient = many).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recipientTargetsAreNotReadWhenACheapGateAlreadyFailed() {
|
||||
// peek() runs on the one-tap zap path too, and reading the recipient's
|
||||
// kind:10133 walks its tag array. Nothing should touch it once a gate fails.
|
||||
var reads = 0
|
||||
val counted = {
|
||||
reads++
|
||||
theirs
|
||||
}
|
||||
|
||||
PayToRailMatcher.selectFor(false, true, false, anyAppOpens, counted)
|
||||
PayToRailMatcher.selectFor(true, false, false, anyAppOpens, counted)
|
||||
PayToRailMatcher.selectFor(true, true, true, anyAppOpens, counted)
|
||||
assertEquals(0, reads)
|
||||
|
||||
PayToRailMatcher.selectFor(true, true, false, anyAppOpens, counted)
|
||||
assertEquals(1, reads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lightningAndBitcoinStayWithTheirOwnRails() {
|
||||
assertEquals(emptyList(), select(recipient = listOf(t("lightning", "a@b.c"), t("btc", "bc1q"))))
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.nipA3
|
||||
|
||||
class PaymentTarget(
|
||||
data class PaymentTarget(
|
||||
val type: String,
|
||||
val authority: String,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user