diff --git a/amethyst/plans/2026-09-01-payment-targets-zap-rail.md b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md new file mode 100644 index 0000000000..ab46a9115c --- /dev/null +++ b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md @@ -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 +`` declaration — so *without a manifest change this feature silently +shows nothing on every modern device*. The existing `` block +(`AndroidManifest.xml:4`) covers only `nostrsigner`, TTS, Health Connect and +Tor. + +### 3.1 Manifest + +Add one `` per scheme we probe. The important economy: an arbitrary +user-typed type (`iban`, `upi`, `pix`, …) always falls back to +`payto:///`, 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 + + + + + +``` + +Use **``, never `QUERY_ALL_PACKAGES`** — the latter is a +policy-restricted permission on Play and would need a declaration; specific +`` filters need nothing. On minSdk 26–29 `` 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/` 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:///` 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:** `"://"`, 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>`, + 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:///`, 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 +`` 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 = 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. diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 0801863b12..2bf34594e9 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -16,6 +16,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index c3b9d7a1ed..c8998243e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 8faab9450d..d2528689c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -56,6 +56,7 @@ class UiSettingsFlow( val fontSize: MutableStateFlow = MutableStateFlow(FontSizeType.NORMAL), val composeSignature: MutableStateFlow = MutableStateFlow(""), val showOnchainWallet: MutableStateFlow = MutableStateFlow(true), + val showPayToZapChip: MutableStateFlow = MutableStateFlow(true), ) { val listOfFlows: List> = listOf>( @@ -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), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 01f955a8c9..8d075bf688 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index f227825846..f53cc2144a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -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 = 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, + enabled: Boolean, + ): List = + 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() }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt new file mode 100644 index 0000000000..4970e9aec4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt @@ -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 `` block declares the + * scheme. The declarations are deliberately `` 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>(emptyMap()) + val flow: StateFlow> = 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() + + /** 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, + 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, + 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? { + 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 = + 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 = 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 89a55c114d..8304775453 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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(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, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index d38987f33a..84e48444dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt index 0a5931f26b..b1b44291e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt @@ -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 ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 217cf8fefa..cd02a0939a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt index 40b6b263f5..51739bcf4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt @@ -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) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1cf6382cf4..31c37ef9e9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -877,6 +877,10 @@ Add payment addresses for different networks (e.g. bitcoin, lightning, ethereum). No app found to handle this payment. Please install a compatible wallet. No app installed to handle %1$s payments. Please install a compatible wallet. + Pay in %1$s — you choose the amount there + Pay-to hand-off + 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. + Offer the author\'s payment methods Unable to open payment BOLT12 Offers @@ -1306,7 +1310,7 @@ dark mode, light mode, theme, font size, language, appearance push, alerts, sounds, vibration spam, block, mute, filter, warnings - lightning, sats, tips, wallet, amount + lightning, sats, tips, wallet, amount, payto, pay-to, venmo, paypal, cash app, monero, iban, hand-off blossom, uploads, images, photos, files, cdn, storage negentropy, sync, reconcile, backfill contacts, follows, follow list, import diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index e148a7b529..ac221fef95 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -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 = 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt new file mode 100644 index 0000000000..fa8bccab2f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt @@ -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): List { + if (recipientTargets.isEmpty()) return emptyList() + + val seen = mutableSetOf() + 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, + ): List { + if (!enabled || !hasAuthor || hasZapSplit) return emptyList() + return match(recipientTargets()).filter(canOpen) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt new file mode 100644 index 0000000000..e82aa23c43 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt @@ -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:///`. + * + * 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:///`, which is why a single `payto` entry in the + * manifest's `` 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, "") +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt new file mode 100644 index 0000000000..3c63157b04 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt @@ -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 = 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")))) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt index cea99d5b04..66e17243ca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.nipA3 -class PaymentTarget( +data class PaymentTarget( val type: String, val authority: String, )