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, )