From 6cb485cda8f6e8754f31316d593a2c8f834a30c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:12:17 +0000 Subject: [PATCH] feat(zap): offer a NIP-A3 pay-to hand-off in the zap picker When the sender and the note's author both publish a payment target of the same protocol, the zap picker now offers a chip that hands off to the app that owns it. Gated on a new opt-in setting (default off), on the note carrying no NIP-57 zap split, and on an installed app actually resolving the URI. The chip carries no amount. Zap presets are sats and there is no rate anywhere in the repo 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. It ends in OpenInNew rather than the send arrow every amount segment uses, and long-press copies the authority instead of opening the sat-preset editor, which would mean nothing here. Nothing is published, so the zap counter does not move and none of the zap progress state is touched. It renders beside the amount pills rather than inside each pill's rail toggle. Carrying no amount, it would otherwise repeat identically once per preset, and keeping it out of the toggle leaves ZapRail a plain enum instead of forcing it into a data-carrying sealed interface. Discovery needs the new entries: targetSdk is 37, so Android 11+ package visibility returns nothing from queryIntentActivities for an undeclared scheme, and the chip would be invisible on every modern device. Unknown types all fall back to payto:///, so one payto entry covers the open-ended tail of the vocabulary. Specific filters, never QUERY_ALL_PACKAGES. The mark is the resolved app's own icon, from the same ResolveInfo the probe already holds, decoded once at the chip's size during the warm step and 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: https targets resolve to any browser, so a control probe against an unownable host separates a real app handler from Chrome. The availability cache is keyed on scheme plus host, not scheme, because an app may declare host="iban" and a scheme-only hit would wrongly claim payto://upi is handled. It is warmed from the sender's own target list when the picker opens, so it is bounded by how many ways the user says they can be paid rather than growing with the feed, and it is a StateFlow because a plain map write is invisible to Compose. Shared plumbing moves to commons: PaymentTargetTypes now owns the alias and scheme tables that were duplicated inside the profile UI file, and PayToRailMatcher holds the matching and the gate decision as pure functions, free of Note, Context and the availability singleton so the gates are testable on their own. RailCapability gains a defaulted payToTargets, and peek gains defaulted parameters so zapClick's one-tap fast path stays Lightning-only. PaymentTarget becomes a data class: without value equality it compares by identity, which breaks list keys and dedupe. 25 new tests in commons; amethyst, commons and quartz suites all pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS --- amethyst/src/main/AndroidManifest.xml | 73 ++++++ .../amethyst/model/UiSettings.kt | 5 + .../amethyst/model/UiSettingsFlow.kt | 9 + .../model/preferences/UISharedPreferences.kt | 3 + .../amethyst/model/zap/RailCapability.kt | 39 +++ .../service/payments/PayToAppAvailability.kt | 199 ++++++++++++++++ .../amethyst/ui/note/ReactionsRow.kt | 173 +++++++++++++- .../chats/feed/ChatMessageActionSheet.kt | 2 + .../profile/header/DisplayPaymentTargets.kt | 54 ++--- .../settings/ProfileUiSettingsScreen.kt | 9 + amethyst/src/main/res/values/strings.xml | 3 + .../amethyst/commons/model/User.kt | 10 + .../model/payments/PayToRailMatcher.kt | 99 ++++++++ .../model/payments/PaymentTargetTypes.kt | 146 ++++++++++++ .../model/payments/PaymentTargetTypesTest.kt | 222 ++++++++++++++++++ .../experimental/nipA3/PaymentTarget.kt | 2 +- 16 files changed, 1015 insertions(+), 33 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 0801863b12..1b1c5074e4 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -16,6 +16,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..bd7a6d0057 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 = false, ) 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..baa3d31268 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(false), ) { 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..e2371b53ee 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] ?: false, ) } 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 ffc8de96d4..2a8fe43b08 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 @@ -21,10 +21,15 @@ package com.vitorpamplona.amethyst.model.zap import androidx.compose.runtime.Immutable +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.Note 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 @@ -50,6 +55,12 @@ data class RailCapability( val cashuBestSingleMintSats: Long = 0L, /** Total cashu balance across all our mints (reachable via reload/rebalance). */ val cashuTotalWalletSats: Long = 0L, + /** + * NIP-A3 targets the sender can hand off to: a protocol both parties publish, + * that no wallet rail already covers, 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. @@ -105,6 +116,8 @@ object RailCapabilityResolver { fun peek( baseNote: Note, cashuState: CashuWalletState, + senderPayToTargets: List = emptyList(), + payToEnabled: Boolean = false, ): RailCapability { val author = baseNote.author?.pubkeyHex val splits = baseNote.event?.zapSplitSetup().orEmpty() @@ -145,6 +158,32 @@ object RailCapabilityResolver { hasOnchain = hasOnchain, cashuBestSingleMintSats = cashuFunding?.bestSingleMintSats ?: 0L, cashuTotalWalletSats = cashuFunding?.totalWalletSats ?: 0L, + payToTargets = payToTargets(baseNote, splits, senderPayToTargets, 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, + senderTargets: List, + enabled: Boolean, + ): List = + PayToRailMatcher.selectFor( + enabled = enabled, + hasAuthor = baseNote.author != null, + hasZapSplit = splits.isNotEmpty(), + senderTargets = senderTargets, + recipientTargets = baseNote.author?.paymentTargets().orEmpty(), + // 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) + }, + ) } 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..8558b919be --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt @@ -0,0 +1,199 @@ +/* + * 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 + +/** 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.** The chip only ever appears for + * protocols the *sender themself* publishes, so [warm] probes the sender's own + * target list — a handful of entries, refreshed when that list changes or the + * app returns to the foreground. 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() + + /** Synchronous read for `RailCapabilityResolver.peek`, which runs inside `remember {}`. */ + fun peek(rawType: String): PayToAppInfo? = state.value[PaymentTargetTypes.probeKeyFor(rawType)] + + /** + * Probes every distinct type in [myTargets] and replaces the cache. + * + * 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, + myTargets: List, + iconPx: Int, + ) { + val pm = context.packageManager + val keys = + myTargets + .asSequence() + .map { it.type } + .filterNot { PaymentTargetTypes.isWalletCovered(it) } + .distinctBy { PaymentTargetTypes.probeKeyFor(it) } + .toList() + + if (keys.isEmpty()) { + state.value = emptyMap() + return + } + + val browsers = browserPackages(pm) + state.value = + keys.associate { type -> + PaymentTargetTypes.probeKeyFor(type) to probe(pm, type, browsers, iconPx) + } + } + + /** Drops everything, so the next [warm] re-reads a changed set of installed apps. */ + fun clear() { + state.value = emptyMap() + } + + 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? = + runCatching { + info.loadIcon(pm).toBitmap(px, px).asImageBitmap() + }.onFailure { + Log.w("PayToAppAvailability") { "Could not load icon for ${info.packageName()}: ${it.message}" } + }.getOrNull() + + @SuppressLint("QueryPermissionsNeeded") + private fun queryActivities( + pm: PackageManager, + intent: Intent, + ): List = runCatching { pm.queryIntentActivities(intent, 0) }.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() + + private fun viewIntent(uri: String) = + Intent(Intent.ACTION_VIEW, uri.toUri()).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + } + + 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 6ba63fe27f..eb16082c3e 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 @@ -116,6 +121,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS @@ -127,6 +133,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 @@ -146,6 +153,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 @@ -153,6 +161,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 @@ -190,6 +199,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 @@ -209,6 +219,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 @@ -2118,8 +2129,42 @@ fun observeZapRailCapability( val showOnchainWallet by accountViewModel.settings.uiSettingsFlow.showOnchainWallet .collectAsStateWithLifecycle() - return remember(baseNote, onchainSupported, showOnchainWallet, cashuMints, cashuEntries, recipientInfo, nutzapInfo) { - val rc = RailCapabilityResolver.peek(baseNote, cashuState) + // 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 myPayToTargets by accountViewModel.account.paymentTargetsState.flow + .collectAsStateWithLifecycle() + val recipientPayTo = author?.let { observeNoteEvent(it.paymentTargetsNote, accountViewModel).value } + val payToApps by PayToAppAvailability.flow.collectAsStateWithLifecycle() + + // The probe set is the *sender's* target list, so this is bounded by how many + // ways the user says they can be paid — not by anything that grows with the + // feed. It runs when the picker opens, never while scrolling. + val context = LocalContext.current + val iconPx = with(LocalDensity.current) { PayToIconSize.roundToPx() } + LaunchedEffect(myPayToTargets, showPayToChip) { + if (showPayToChip && myPayToTargets.isNotEmpty()) { + withContext(Dispatchers.IO) { PayToAppAvailability.warm(context, myPayToTargets, iconPx) } + } + } + + return remember( + baseNote, + onchainSupported, + showOnchainWallet, + cashuMints, + cashuEntries, + recipientInfo, + nutzapInfo, + showPayToChip, + myPayToTargets, + recipientPayTo, + payToApps, + ) { + val rc = RailCapabilityResolver.peek(baseNote, cashuState, myPayToTargets, showPayToChip) if (onchainSupported && showOnchainWallet) rc else rc.copy(hasOnchain = false) } } @@ -2252,6 +2297,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 }, ) } } @@ -2267,6 +2315,7 @@ fun ZapAmountChoicePopupContent( onOnchainAmount: (Long?) -> Unit, onChangeAmount: () -> Unit, onReloadNutzap: (Long) -> Unit = {}, + onHandedOff: () -> Unit = {}, ) { Box(HalfPadding, contentAlignment = Center) { ElevatedCard( @@ -2282,6 +2331,7 @@ fun ZapAmountChoicePopupContent( onOnchainAmount = onOnchainAmount, onChangeAmount = onChangeAmount, onReloadNutzap = onReloadNutzap, + onHandedOff = onHandedOff, ) } } @@ -2302,6 +2352,7 @@ fun ZapAmountChoiceGrid( onOnchainAmount: (Long?) -> Unit, onChangeAmount: () -> Unit, onReloadNutzap: (Long) -> Unit = {}, + onHandedOff: () -> Unit = {}, ) { FlowRow( modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp), @@ -2320,6 +2371,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 @@ -2338,6 +2397,116 @@ 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 app = remember(target.type) { PayToAppAvailability.peek(target.type) } + val uri = remember(target) { PaymentTargetTypes.uriFor(target.type, target.authority) } + + 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 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 263823e3e9..0e1fab3179 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 @@ -682,6 +682,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 8d0b0193f5..5f3c3d30fb 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 @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.R 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.payments.PaymentTargetTypes import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.util.setText @@ -63,13 +64,7 @@ import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress import kotlinx.coroutines.launch -/** 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 @@ -85,10 +80,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()) @@ -132,7 +127,7 @@ fun PaymentTargetChip( if (inAppRoute != null) { nav.nav(inAppRoute) } else { - runCatching { uriHandler.openUri(style.uriFor(target.authority)) } + runCatching { uriHandler.openUri(PaymentTargetTypes.uriFor(target.type, target.authority)) } .onFailure { accountViewModel.toastManager.toast( R.string.error_dialog_payment_error, @@ -180,52 +175,51 @@ fun PaymentTargetChip( } } -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/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt index b3e10a008b..fdec1cd1b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt @@ -80,6 +80,7 @@ fun ProfileUiSettingsContent( val showZapReceived by ui.showProfileZapReceivedFeed.collectAsStateWithLifecycle() val showFollowers by ui.showProfileFollowersFeed.collectAsStateWithLifecycle() val showOnchainWallet by ui.showOnchainWallet.collectAsStateWithLifecycle() + val showPayToZapChip by ui.showPayToZapChip.collectAsStateWithLifecycle() val gallery by ui.gallerySet.collectAsStateWithLifecycle() Column( @@ -125,6 +126,14 @@ fun ProfileUiSettingsContent( checked = showOnchainWallet, onCheckedChange = { ui.showOnchainWallet.tryEmit(it) }, ) + SettingsDivider() + SettingsSwitchTile( + icon = MaterialSymbols.AutoMirrored.OpenInNew, + title = R.string.profile_ui_setting_payto_zap_chip, + description = R.string.profile_ui_setting_payto_zap_chip_description, + checked = showPayToZapChip, + onCheckedChange = { ui.showPayToZapChip.tryEmit(it) }, + ) } SettingsSection(R.string.settings_section_appearance) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1c2b7e3e82..e39c2d6819 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1866,6 +1866,9 @@ Delete payment target 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 shortcut in the zap picker + When you and the author both publish the same payment method, offer it in the zap picker. Opens the other app, which asks for the amount — no zap receipt is created. Unable to open payment BOLT12 Offers 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..acb26e15f0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt @@ -0,0 +1,99 @@ +/* + * 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 plausibly use to pay a + * recipient: the recipient's targets whose protocol the sender also publishes. + * + * The symmetry rule is a proxy for "I can actually pay this way" and is exactly + * right for closed loops — both parties need Venmo accounts for a Venmo + * transfer to mean anything. It is arguably too strict for open protocols + * (paying a Monero address needs a wallet, not a published address of one), but + * it starts conservative: relaxing it later only ever adds chips. + * + * It also bounds the installed-app probe. Because only protocols the *sender* + * declares can ever be shown, the probe set is the sender's own target list — + * a handful of entries — rather than anything that grows with the feed. + */ +object PayToRailMatcher { + /** + * Recipient targets payable by symmetry, 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( + senderTargets: List, + recipientTargets: List, + ): List { + if (senderTargets.isEmpty() || recipientTargets.isEmpty()) return emptyList() + + val senderTypes = + senderTargets + .asSequence() + .map { PaymentTargetTypes.canonical(it.type) } + .filterNot { it.isEmpty() || PaymentTargetTypes.isWalletCovered(it) } + .toSet() + + if (senderTypes.isEmpty()) return emptyList() + + val seen = mutableSetOf() + return recipientTargets.filter { target -> + val type = PaymentTargetTypes.canonical(target.type) + type.isNotEmpty() && + target.authority.isNotBlank() && + !PaymentTargetTypes.isWalletCovered(type) && + type in senderTypes && + seen.add(type) + } + } + + /** With discovery filtering, 0-1 is the normal case; the cap stops a wide popup. */ + const val MAX_CHIPS = 2 + + /** + * 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. + */ + fun selectFor( + enabled: Boolean, + hasAuthor: Boolean, + hasZapSplit: Boolean, + senderTargets: List, + recipientTargets: List, + canOpen: (PaymentTarget) -> Boolean, + ): List { + if (!enabled || !hasAuthor || hasZapSplit) return emptyList() + return match(senderTargets, recipientTargets).filter(canOpen).take(MAX_CHIPS) + } +} 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..13e42dd133 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt @@ -0,0 +1,222 @@ +/* + * 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 noSenderTargetsMeansNoChips() { + assertEquals(emptyList(), PayToRailMatcher.match(emptyList(), listOf(t("venmo")))) + } + + @Test + fun noRecipientTargetsMeansNoChips() { + assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo")), emptyList())) + } + + @Test + fun noSharedProtocolMeansNoChips() { + assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo")), listOf(t("paypal")))) + } + + @Test + fun sharedProtocolMatchesAcrossCaseAndWhitespace() { + val out = PayToRailMatcher.match(listOf(t(" VENMO ")), listOf(t("venmo", "vitor"))) + assertEquals(listOf(t("venmo", "vitor")), out) + } + + @Test + fun walletCoveredTypesNeverProduceAChip() { + // Both sides publish lightning and bitcoin, but those ARE the existing + // rails — matching them would draw a second bolt beside the first. + val both = listOf(t("lightning", "a@b.c"), t("btc", "bc1q"), t("ln", "x@y.z")) + assertEquals(emptyList(), PayToRailMatcher.match(both, both)) + } + + @Test + fun aliasesOnEitherSideStillMatch() { + val out = PayToRailMatcher.match(listOf(t("xmr", "mine")), listOf(t("monero", "theirs"))) + assertEquals(listOf(t("monero", "theirs")), out) + } + + @Test + fun oneChipPerProtocolKeepingTheFirst() { + val recipient = listOf(t("venmo", "first"), t("venmo", "second"), t("monero", "xmr1")) + val out = PayToRailMatcher.match(listOf(t("venmo"), t("monero")), recipient) + assertEquals(listOf(t("venmo", "first"), t("monero", "xmr1")), out) + } + + @Test + fun blankAuthoritiesAreSkipped() { + assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo")), listOf(t("venmo", " ")))) + } + + @Test + fun recipientOrderIsPreserved() { + val recipient = listOf(t("monero", "m"), t("venmo", "v")) + val out = PayToRailMatcher.match(listOf(t("venmo"), t("monero")), recipient) + assertEquals(listOf("monero", "venmo"), out.map { it.type }) + } +} + +/** 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 mine = listOf(t("venmo", "me"), t("monero", "myxmr")) + 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, + sender: List = mine, + recipient: List = theirs, + canOpen: (PaymentTarget) -> Boolean = anyAppOpens, + ) = PayToRailMatcher.selectFor(enabled, hasAuthor, hasZapSplit, sender, recipient, canOpen) + + @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 cappedSoThePopupCannotGrowWithoutBound() { + val many = listOf(t("venmo"), t("monero"), t("pix"), t("upi"), t("iban")) + assertEquals(PayToRailMatcher.MAX_CHIPS, select(sender = many, recipient = many).size) + } + + @Test + fun lightningAndBitcoinStayWithTheirOwnRails() { + val wallets = listOf(t("lightning", "a@b.c"), t("btc", "bc1q")) + assertEquals(emptyList(), select(sender = wallets, recipient = wallets)) + } +} 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, )