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.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.style.TextAlign
@@ -99,6 +98,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
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.routes.Route
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.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -346,20 +347,14 @@ fun PayReaction(
iconSizeModifier: Modifier = Size20Modifier,
) {
val authorPubkey = baseNote.author?.pubkeyHex ?: return
val address =
remember(authorPubkey) {
PaymentTargetsEvent.createAddress(authorPubkey)
}
val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) }
val context = LocalContext.current
LoadAddressableNote(address, accountViewModel) { note ->
val targets =
remember(note) {
(note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList()
}
if (targets.isEmpty()) return@LoadAddressableNote
val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() }
val uri = LocalUriHandler.current
var expanded by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
ClickableBox(
modifier = iconSizeModifier,
@@ -379,21 +374,43 @@ fun PayReaction(
onDismiss = { expanded = false },
) {
M3ActionSection {
targets.forEach { target ->
if (targets.isEmpty()) {
M3ActionRow(
icon = Icons.Outlined.AccountBalanceWallet,
text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}",
onClick = {
expanded = false
runCatching {
uri.openUri("payto://${target.type}/${target.authority}")
}
},
text = stringRes(R.string.no_payment_targets_message),
enabled = false,
onClick = {},
)
} else {
targets.forEach { target ->
M3ActionRow(
icon = Icons.Outlined.AccountBalanceWallet,
text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}",
onClick = {
expanded = false
try {
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)
}
},
)
}
}
}
}
}
errorMessage?.let { msg ->
ErrorMessageDialog(
title = stringRes(R.string.error_dialog_payment_error),
textContent = msg,
onDismiss = { errorMessage = null },
)
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.content.Intent
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
@@ -32,13 +33,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -61,16 +64,15 @@ fun PaymentButton(
remember(note) {
(note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList()
}
if (targets.isNotEmpty()) {
PaymentButtonWithTargets(targets)
}
PaymentButtonWithTargets(targets)
}
}
@Composable
fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
val uri = LocalUriHandler.current
val context = LocalContext.current
var expanded by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
FilledTonalButton(
modifier =
@@ -92,19 +94,41 @@ fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
onDismiss = { expanded = false },
) {
M3ActionSection {
targets.forEach { target ->
if (targets.isEmpty()) {
M3ActionRow(
icon = Icons.Outlined.AccountBalanceWallet,
text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}",
onClick = {
expanded = false
runCatching {
uri.openUri("payto://${target.type}/${target.authority}")
}
},
text = stringRes(R.string.no_payment_targets_message),
enabled = false,
onClick = {},
)
} else {
targets.forEach { target ->
M3ActionRow(
icon = Icons.Outlined.AccountBalanceWallet,
text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}",
onClick = {
expanded = false
try {
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)
}
},
)
}
}
}
}
}
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_authority">Address</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_compressing">Compressing</string>