feat: show error dialog when payto:// URI fails to open

- Use Intent(ACTION_VIEW) + startActivity instead of LocalUriHandler so
  ActivityNotFoundException is catchable
- Show ErrorMessageDialog with title/message when no app handles the URI,
  matching the pattern used by zap payment errors
- Always show the wallet icon (profile button + reactions row), showing a
  disabled "no targets" row in the dialog when the author has none set
- Add no_payment_app_found and error_dialog_payment_error string resources

https://claude.ai/code/session_018G4g1cChqjeNEM5TztD3FE
This commit is contained in:
Claude
2026-04-03 19:54:29 +00:00
parent ef9b1b468f
commit c803c06607
3 changed files with 75 additions and 32 deletions
@@ -87,7 +87,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -99,6 +98,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
@@ -132,6 +132,7 @@ import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorM
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -346,20 +347,14 @@ fun PayReaction(
iconSizeModifier: Modifier = Size20Modifier, iconSizeModifier: Modifier = Size20Modifier,
) { ) {
val authorPubkey = baseNote.author?.pubkeyHex ?: return val authorPubkey = baseNote.author?.pubkeyHex ?: return
val address = val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) }
remember(authorPubkey) { val context = LocalContext.current
PaymentTargetsEvent.createAddress(authorPubkey)
}
LoadAddressableNote(address, accountViewModel) { note -> LoadAddressableNote(address, accountViewModel) { note ->
val targets = val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() }
remember(note) {
(note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList()
}
if (targets.isEmpty()) return@LoadAddressableNote
val uri = LocalUriHandler.current
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
ClickableBox( ClickableBox(
modifier = iconSizeModifier, modifier = iconSizeModifier,
@@ -379,14 +374,27 @@ fun PayReaction(
onDismiss = { expanded = false }, onDismiss = { expanded = false },
) { ) {
M3ActionSection { M3ActionSection {
if (targets.isEmpty()) {
M3ActionRow(
icon = Icons.Outlined.AccountBalanceWallet,
text = stringRes(R.string.no_payment_targets_message),
enabled = false,
onClick = {},
)
} else {
targets.forEach { target -> targets.forEach { target ->
M3ActionRow( M3ActionRow(
icon = Icons.Outlined.AccountBalanceWallet, icon = Icons.Outlined.AccountBalanceWallet,
text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}", text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}",
onClick = { onClick = {
expanded = false expanded = false
runCatching { try {
uri.openUri("payto://${target.type}/${target.authority}") val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
errorMessage = stringRes(context, R.string.no_payment_app_found)
} }
}, },
) )
@@ -395,6 +403,15 @@ fun PayReaction(
} }
} }
} }
errorMessage?.let { msg ->
ErrorMessageDialog(
title = stringRes(R.string.error_dialog_payment_error),
textContent = msg,
onDismiss = { errorMessage = null },
)
}
}
} }
@Composable @Composable
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.content.Intent
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -32,13 +33,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -61,16 +64,15 @@ fun PaymentButton(
remember(note) { remember(note) {
(note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList()
} }
if (targets.isNotEmpty()) {
PaymentButtonWithTargets(targets) PaymentButtonWithTargets(targets)
} }
}
} }
@Composable @Composable
fun PaymentButtonWithTargets(targets: List<PaymentTarget>) { fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
val uri = LocalUriHandler.current val context = LocalContext.current
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
FilledTonalButton( FilledTonalButton(
modifier = modifier =
@@ -92,14 +94,27 @@ fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
onDismiss = { expanded = false }, onDismiss = { expanded = false },
) { ) {
M3ActionSection { M3ActionSection {
if (targets.isEmpty()) {
M3ActionRow(
icon = Icons.Outlined.AccountBalanceWallet,
text = stringRes(R.string.no_payment_targets_message),
enabled = false,
onClick = {},
)
} else {
targets.forEach { target -> targets.forEach { target ->
M3ActionRow( M3ActionRow(
icon = Icons.Outlined.AccountBalanceWallet, icon = Icons.Outlined.AccountBalanceWallet,
text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}", text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}",
onClick = { onClick = {
expanded = false expanded = false
runCatching { try {
uri.openUri("payto://${target.type}/${target.authority}") val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
errorMessage = stringRes(context, R.string.no_payment_app_found)
} }
}, },
) )
@@ -107,4 +122,13 @@ fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
} }
} }
} }
}
errorMessage?.let { msg ->
ErrorMessageDialog(
title = stringRes(R.string.error_dialog_payment_error),
textContent = msg,
onDismiss = { errorMessage = null },
)
}
} }
+2
View File
@@ -583,6 +583,8 @@
<string name="payment_target_type">Network type (e.g. bitcoin)</string> <string name="payment_target_type">Network type (e.g. bitcoin)</string>
<string name="payment_target_authority">Address</string> <string name="payment_target_authority">Address</string>
<string name="delete_payment_target">Delete payment target</string> <string name="delete_payment_target">Delete payment target</string>
<string name="no_payment_app_found">No app found to handle this payment. Please install a compatible wallet.</string>
<string name="error_dialog_payment_error">Unable to open payment</string>
<string name="uploading_state_ready">Not Started</string> <string name="uploading_state_ready">Not Started</string>
<string name="uploading_state_compressing">Compressing</string> <string name="uploading_state_compressing">Compressing</string>