diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 1b1c5074e4..2bf34594e9 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -23,70 +23,60 @@ modern device. Specific filters rather than QUERY_ALL_PACKAGES, which is policy-restricted on Play. Unknown target types all fall back to payto:///, - so the single payto entry covers the open-ended tail. --> + so the single payto entry covers the open-ended tail. + No : a category here narrows visibility the same way it + narrows an intent match, and would hide any app whose filter declares + only DEFAULT - which is what our ACTION_VIEW hand-off actually uses. --> - - - - - - - - - - - - - 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 2a8fe43b08..b2c744351f 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 @@ -178,12 +178,13 @@ object RailCapabilityResolver { 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) }, + // Lazy: the tag walk only happens once the cheap gates have passed. + recipientTargets = { baseNote.author?.paymentTargets().orEmpty() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt index 8558b919be..6ae52fd735 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap /** What the device can do with one `payto` target type. */ @Immutable @@ -74,6 +75,16 @@ object PayToAppAvailability { private val state = MutableStateFlow>(emptyMap()) val flow: StateFlow> = state.asStateFlow() + /** + * Decoded icons, kept across warms and keyed by package and size. + * + * [warm] runs every time the picker opens, so that resolution stays fresh when + * the user installs an app and comes back. Re-reading the APK's resources and + * re-rasterising the icon each of those times is the expensive half and answers + * the same thing, so only the cheap half repeats. + */ + private val icons = ConcurrentHashMap() + /** Synchronous read for `RailCapabilityResolver.peek`, which runs inside `remember {}`. */ fun peek(rawType: String): PayToAppInfo? = state.value[PaymentTargetTypes.probeKeyFor(rawType)] @@ -103,18 +114,14 @@ object PayToAppAvailability { return } - val browsers = browserPackages(pm) + // Only https targets need the control probe; skip the extra query otherwise. + val browsers = if (keys.any(PaymentTargetTypes::isWebTarget)) browserPackages(pm) else emptySet() 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, @@ -173,27 +180,43 @@ object PayToAppAvailability { pm: PackageManager, info: ResolveInfo, px: Int, - ): ImageBitmap? = - runCatching { + ): ImageBitmap? { + val pkg = info.packageName() ?: return null + icons["$pkg@$px"]?.let { return it } + + return runCatching { info.loadIcon(pm).toBitmap(px, px).asImageBitmap() + }.onSuccess { + icons["$pkg@$px"] = it }.onFailure { - Log.w("PayToAppAvailability") { "Could not load icon for ${info.packageName()}: ${it.message}" } + Log.w("PayToAppAvailability", "Could not load icon for $pkg", it) }.getOrNull() + } @SuppressLint("QueryPermissionsNeeded") private fun queryActivities( pm: PackageManager, intent: Intent, - ): List = runCatching { pm.queryIntentActivities(intent, 0) }.getOrDefault(emptyList()) + ): List = + runCatching { + // MATCH_DEFAULT_ONLY mirrors startActivity, which implies CATEGORY_DEFAULT. + // Without it we would list activities the hand-off could never launch. + pm.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + }.getOrDefault(emptyList()) /** Packages that answer a URL nobody can own — i.e. general-purpose browsers. */ @SuppressLint("QueryPermissionsNeeded") private fun browserPackages(pm: PackageManager): Set = queryActivities(pm, viewIntent(CONTROL_URL)).mapNotNull { it.packageName() }.toSet() - private fun viewIntent(uri: String) = - Intent(Intent.ACTION_VIEW, uri.toUri()).apply { - addCategory(Intent.CATEGORY_BROWSABLE) - } + /** + * Deliberately carries **no** category. `IntentFilter.matchCategories` returns + * the first category on the *intent* that the filter lacks, so every category + * added here narrows the match — a probe carrying `BROWSABLE` would miss any + * app whose filter declares only `DEFAULT`, and hide a chip that would have + * opened fine. Paired with `MATCH_DEFAULT_ONLY` in [queryActivities], this + * resolves exactly the set `startActivity` would. + */ + private fun viewIntent(uri: String) = Intent(Intent.ACTION_VIEW, uri.toUri()) private fun ResolveInfo.packageName(): String? = activityInfo?.packageName } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index eb16082c3e..d9af4afa4f 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 @@ -2427,9 +2427,15 @@ private fun PayToHandoffChip( 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) } + // Collected, not peeked once: a web target is offered before the probe has run + // (any browser opens https), so a snapshot taken at first composition would pin + // the fallback glyph and the real app icon would never arrive until the picker + // was closed and reopened. + val apps by PayToAppAvailability.flow.collectAsStateWithLifecycle() + val app = remember(apps, target.type) { apps[PaymentTargetTypes.probeKeyFor(target.type)] } + val uriHandler = LocalUriHandler.current val context = LocalContext.current val clipboard = LocalClipboard.current 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 index acb26e15f0..fc51fb8d7c 100644 --- 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 @@ -84,16 +84,20 @@ object PayToRailMatcher { * @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. + * @param recipientTargets read lazily. Parsing the recipient's kind:10133 walks + * its tag array and allocates, and the common case is that a gate has already + * failed — the setting is off, or the note carries a split — so the cheap + * checks must run first. `peek` is on the one-tap zap path too. */ fun selectFor( enabled: Boolean, hasAuthor: Boolean, hasZapSplit: Boolean, senderTargets: List, - recipientTargets: List, canOpen: (PaymentTarget) -> Boolean, + recipientTargets: () -> List, ): List { - if (!enabled || !hasAuthor || hasZapSplit) return emptyList() - return match(senderTargets, recipientTargets).filter(canOpen).take(MAX_CHIPS) + if (!enabled || !hasAuthor || hasZapSplit || senderTargets.isEmpty()) return emptyList() + return match(senderTargets, recipientTargets()).filter(canOpen).take(MAX_CHIPS) } } 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 index 13e42dd133..3596f1cca5 100644 --- 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 @@ -173,7 +173,7 @@ class PayToRailGateTest { sender: List = mine, recipient: List = theirs, canOpen: (PaymentTarget) -> Boolean = anyAppOpens, - ) = PayToRailMatcher.selectFor(enabled, hasAuthor, hasZapSplit, sender, recipient, canOpen) + ) = PayToRailMatcher.selectFor(enabled, hasAuthor, hasZapSplit, sender, canOpen) { recipient } @Test fun offeredWhenEveryGatePasses() { @@ -214,6 +214,26 @@ class PayToRailGateTest { assertEquals(PayToRailMatcher.MAX_CHIPS, select(sender = many, recipient = many).size) } + @Test + fun recipientTargetsAreNotReadWhenACheapGateAlreadyFailed() { + // peek() runs on the one-tap zap path too, and reading the recipient's + // kind:10133 walks its tag array. Nothing should touch it once a gate fails. + var reads = 0 + val counted = { + reads++ + theirs + } + + PayToRailMatcher.selectFor(false, true, false, mine, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, false, false, mine, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, true, true, mine, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, true, false, emptyList(), anyAppOpens, counted) + assertEquals(0, reads) + + PayToRailMatcher.selectFor(true, true, false, mine, anyAppOpens, counted) + assertEquals(1, reads) + } + @Test fun lightningAndBitcoinStayWithTheirOwnRails() { val wallets = listOf(t("lightning", "a@b.c"), t("btc", "bc1q"))