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