fix(napplet): make the consent dialog say what a signature really does

The dialog rendered `kind` plus a 160-char content preview and nothing
else. For the kinds whose payload lives entirely in the TAGS, that is
technically true and practically useless — the user saw "publish an event
of kind 3" while approving a replacement of their whole social graph.
kind 10002 redirects every future read and write to attacker relays;
kind 5 deletes notes.

Now, for a replaceable list, it diffs the proposed tags against the copy
already cached on the account and reports what actually changes, rather
than a raw total that hides the dangerous case (a list that silently
drops 130 follows). A single-account edit — by far the common one — names
and pictures that account, so the user can recognize who it is at a
glance. Republishing an identical list says so plainly instead of raising
a false alarm, and a missing baseline falls back to the total and admits
it could not compare. Mute lists diff people only, so the string points
at "Show Event" for muted words and hashtags.

Adds a "Show Event" raw-event toggle mirroring the NIP-46 dialog, which
already had one; the napplet dialog had no way to inspect the full event.

Also fixes an amountless-invoice display bug in the same family:
`LnInvoiceUtil.getAmountInSats` returns ZERO (not null, not a throw) for
a BOLT11 with no amount, so both this dialog and the Blossom pay dialog
affirmatively rendered "0 sats" — telling the user a payment was free
when the amount is in fact unspecified and chosen by the payee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-19 18:57:57 -04:00
co-authored by Claude Opus 4.8
parent 389b460f7a
commit 5bce87d76e
5 changed files with 357 additions and 14 deletions
@@ -23,15 +23,19 @@ package com.vitorpamplona.amethyst.napplet
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
@@ -41,11 +45,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -54,6 +65,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
/**
@@ -168,20 +180,74 @@ private fun NappletConsentDialog(
)
}
// Operation detail box (may include content preview)
if (info.operationSummary.isNotBlank()) {
// Operation detail box (may include content preview), plus the full event behind a
// toggle: the summary truncates content and cannot spell out every tag, so for kinds
// whose payload IS the tags (3, 5, 10000, 10002) this is the only complete disclosure.
if (info.operationSummary.isNotBlank() || info.rawData.isNotBlank()) {
Spacer(Modifier.height(12.dp))
Surface(
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
) {
SelectionContainer {
Text(
info.operationSummary,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
Column(modifier = Modifier.padding(12.dp)) {
if (info.operationSummary.isNotBlank()) {
SelectionContainer {
Text(
info.operationSummary,
style = MaterialTheme.typography.bodySmall,
)
}
}
// A single-account follow/mute change: show who, so the user recognizes
// the face rather than parsing a name they may not read carefully.
info.subject?.let { subject ->
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
RobohashFallbackAsyncImage(
robot = subject.pubKey,
model = subject.pictureUrl,
contentDescription = subject.name,
modifier = Modifier.size(36.dp).clip(CircleShape),
loadProfilePicture = true,
loadRobohash = true,
)
Spacer(Modifier.width(8.dp))
Text(
subject.name,
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (info.rawData.isNotBlank()) {
var showRawData by remember { mutableStateOf(false) }
if (showRawData) {
Spacer(Modifier.height(8.dp))
Surface(modifier = Modifier.horizontalScroll(rememberScrollState())) {
SelectionContainer {
Text(
info.rawData,
style =
MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
)
}
}
}
TextButton(onClick = { showRawData = !showRawData }) {
Text(
if (showRawData) {
stringResource(R.string.napplet_consent_hide_event)
} else {
stringResource(R.string.napplet_consent_show_event)
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
@@ -36,6 +36,26 @@ data class NappletConsentInfo(
/** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */
val allowAlways: Boolean,
val iconUrl: String? = null,
/**
* The full unsigned event the applet asked us to sign, pretty-printed, shown behind a
* "Show Event" toggle. Blank for requests that sign nothing. [operationSummary] is a lossy
* rendering — it truncates content and cannot spell out every tag — so this is the only place
* the user can see exactly what a signature would cover.
*/
val rawData: String = "",
/**
* The one account a follow/mute change is about, when the change names exactly one. Rendered as
* an avatar + name so the user can recognize *who* at a glance instead of reading a bare count.
* Null for multi-account edits and every other request.
*/
val subject: ConsentSubject? = null,
)
/** A single account a consent dialog is about: enough to draw an avatar and a name. */
data class ConsentSubject(
val pubKey: String,
val name: String,
val pictureUrl: String?,
)
/**
@@ -21,22 +21,35 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
/**
* Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog
* shows — the applet's title, the capability label, and a per-operation summary (e.g. a note preview
* or a sat amount). Localized via app resources; holds only a [Context], no account state.
* or a sat amount). Localized via app resources.
*
* Reads [account] only to diff a proposed replaceable list (follows, relays, mutes) against the copy
* already cached there, so the dialog can say what a signature would actually change. It never signs,
* mutates, or exposes account state — the values it reads are the user's own public lists.
*/
class NappletConsentSummary(
private val context: Context,
private val account: Account,
) {
fun info(
identity: NappletIdentity,
@@ -51,16 +64,196 @@ class NappletConsentSummary(
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
val consequence = consequenceFor(request)
return NappletConsentInfo(
appletTitle = title,
coordinate = identity.coordinate,
capabilityLabel = context.getString(capability.labelRes()),
operationSummary = summaryFor(request),
operationSummary = listOfNotNull(summaryFor(request).ifBlank { null }, consequence?.text).joinToString("\n\n"),
allowAlways = capability.canGrantAlways,
iconUrl = iconUrl,
rawData = rawEventFor(request),
subject = consequence?.subject,
)
}
/**
* Pretty-prints the unsigned event behind the consent dialog's "Show Event" toggle. Only the
* signing requests carry one; everything else has nothing to disclose.
*/
private fun rawEventFor(request: NappletRequest): String =
when (request) {
is NappletRequest.Publish -> rawEvent(request.kind, request.tags, request.content, null)
is NappletRequest.SignEvent -> rawEvent(request.kind, request.tags, request.content, request.createdAt)
else -> ""
}
private fun rawEvent(
kind: Int,
tags: Array<Array<String>>,
content: String,
createdAt: Long?,
): String =
buildString {
append("kind: ").append(kind).append('\n')
createdAt?.let { append("created_at: ").append(it).append('\n') }
append("tags:")
if (tags.isEmpty()) {
append(" []\n")
} else {
append('\n')
tags.fastForEach { tag -> append(" ").append(tag.joinToString(", ", "[", "]")).append('\n') }
}
append("content: ").append(content.ifEmpty { "(empty)" })
}
/** A consequence line, plus the single account it is about when the change names exactly one. */
private data class Consequence(
val text: String,
val subject: ConsentSubject? = null,
)
/**
* A plain-language warning for the kinds whose payload lives entirely in the tags. Without this
* the dialog reads "publish a kind 3 event" while the user is actually about to replace their
* whole social graph — the summary would be technically true and practically useless.
*/
private fun consequenceFor(request: NappletRequest): Consequence? =
when (request) {
is NappletRequest.Publish -> consequenceFor(request.kind, request.tags)
is NappletRequest.SignEvent -> consequenceFor(request.kind, request.tags)
else -> null
}
private fun consequenceFor(
kind: Int,
tags: Array<Array<String>>,
): Consequence? =
when (kind) {
ContactListEvent.KIND ->
diffOf(
current = account.kind3FollowList.getFollowListEvent()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_follows,
added = R.plurals.napplet_consent_diff_follow_added,
removed = R.plurals.napplet_consent_diff_follow_removed,
oneAdded = R.string.napplet_consent_diff_follow_one,
oneRemoved = R.string.napplet_consent_diff_unfollow_one,
)
AdvertisedRelayListEvent.KIND ->
diffOf(
current = account.nip65RelayList.getNIP65RelayList()?.tags,
proposed = tags,
tagName = "r",
template = R.string.napplet_consent_diff_relays,
added = R.plurals.napplet_consent_diff_relay_added,
removed = R.plurals.napplet_consent_diff_relay_removed,
)
// Public entries only: a mute list also carries encrypted ones, which are not in `tags`
// and so cannot be diffed here.
MuteListEvent.KIND ->
diffOf(
current = account.muteList.getMuteList()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_mutes,
added = R.plurals.napplet_consent_diff_mute_added,
removed = R.plurals.napplet_consent_diff_mute_removed,
oneAdded = R.string.napplet_consent_diff_mute_one,
oneRemoved = R.string.napplet_consent_diff_unmute_one,
)
// Deletions have no prior version to compare against — the tags are the whole request.
DeletionEvent.KIND ->
pluralFor(R.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
?.let { Consequence(it) }
// Any other kind: at least tell the user tags exist and can be inspected, so an empty
// content preview never reads as "there is nothing else here".
else ->
if (tags.isNotEmpty()) {
pluralFor(R.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
} else {
null
}
}
/**
* Describes what a proposed replaceable list changes relative to the copy already on the account.
* A bare total ("a list of 12 accounts") hides the dangerous case: the alarming edit is a list
* that silently drops 130 follows, and only a diff surfaces that. Falls back to the total when
* nothing is cached to compare against.
*/
private fun diffOf(
current: Array<Array<String>>?,
proposed: Array<Array<String>>,
tagName: String,
@StringRes template: Int,
@PluralsRes added: Int,
@PluralsRes removed: Int,
@StringRes oneAdded: Int? = null,
@StringRes oneRemoved: Int? = null,
): Consequence {
val next = valuesOf(proposed, tagName)
val previous =
current?.let { valuesOf(it, tagName) }
?: return Consequence(pluralStringRes(context, R.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
val addedKeys = next.filter { it !in previous }
val removedKeys = previous.filter { it !in next }
if (addedKeys.isEmpty() && removedKeys.isEmpty()) {
return Consequence(context.getString(R.string.napplet_consent_diff_none))
}
// The overwhelmingly common edit is a single follow/unfollow. Naming and picturing that one
// account is far more use than "follows 1 new account" — the user can tell at a glance
// whether it is who they expected.
if (oneAdded != null && addedKeys.size == 1 && removedKeys.isEmpty()) {
subjectOf(addedKeys.first())?.let { return Consequence(context.getString(oneAdded, it.name), it) }
}
if (oneRemoved != null && removedKeys.size == 1 && addedKeys.isEmpty()) {
subjectOf(removedKeys.first())?.let { return Consequence(context.getString(oneRemoved, it.name), it) }
}
val parts = listOfNotNull(pluralFor(added, addedKeys.size), pluralFor(removed, removedKeys.size))
val summary =
if (parts.size == 2) {
context.getString(R.string.napplet_consent_diff_joiner, parts[0], parts[1])
} else {
parts.first()
}
return Consequence(context.getString(template, summary))
}
/** Resolves a pubkey to a name + picture for the dialog, or null when the user isn't cached. */
private fun subjectOf(pubKey: String): ConsentSubject? {
val user = account.cache.getUserIfExists(pubKey) ?: return null
return ConsentSubject(
pubKey = pubKey,
name = user.toBestDisplayName(),
pictureUrl = user.profilePicture(),
)
}
/** The distinct values of every `[tagName, value, …]` tag. */
private fun valuesOf(
tags: Array<Array<String>>,
tagName: String,
): Set<String> {
val out = mutableSetOf<String>()
tags.fastForEach { if (it.size > 1 && it[0] == tagName) out.add(it[1]) }
return out
}
private fun pluralFor(
resId: Int,
count: Int,
): String? = if (count <= 0) null else pluralStringRes(context, resId, count, count)
private fun countTag(
tags: Array<Array<String>>,
name: String,
): Int = tags.count { it.isNotEmpty() && it[0] == name }
private fun summaryFor(request: NappletRequest): String =
when (request) {
is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey)
@@ -91,11 +284,14 @@ class NappletConsentSummary(
}
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify)
is NappletRequest.PayInvoice -> {
// getAmountInSats returns ZERO (not null, not a throw) for an amountless BOLT11, so a
// naive read renders "pay 0 sats" — telling the user a payment is free when the amount
// is in fact unspecified and decided by the payee. Treat non-positive as "no amount".
val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull()
if (sats != null) {
if (sats != null && sats > 0) {
pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
} else {
context.getString(R.string.napplet_consent_pay)
context.getString(R.string.napplet_consent_pay_no_amount)
}
}
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)
@@ -46,10 +46,15 @@ object BlossomPaymentHandler {
payment: BlossomPaymentRequired,
): Boolean = payment.lightning != null && account.nip47SignerState.hasWalletConnectSetup()
/** The invoice amount in sats, for display in a confirmation prompt. */
/**
* The invoice amount in sats for display in a confirmation prompt, or null when it is absent or
* unreadable. `getAmountInSats` returns ZERO for an amountless BOLT11 rather than null, so a bare
* read renders "Pay 0 sats" — telling the user a payment is free when the amount is actually
* unspecified and chosen by the payee.
*/
fun amountSats(payment: BlossomPaymentRequired): Long? =
payment.lightning?.let {
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()?.takeIf { sats -> sats > 0 }
}
/**
+56
View File
@@ -841,6 +841,8 @@
<string name="napplet_consent_query">This nApplet wants to read events from your relays.</string>
<string name="napplet_consent_storage">This nApplet wants to use its private storage.</string>
<string name="napplet_consent_pay">This nApplet wants to pay a Lightning invoice.</string>
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
<string name="napplet_consent_pay_no_amount">⚠ This nApplet wants to pay a Lightning invoice that specifies NO amount — the payee decides how much is taken. Only allow this if you trust it.</string>
<string name="napplet_consent_resource">This nApplet wants to fetch a web resource.</string>
<string name="napplet_consent_upload">This nApplet wants to upload a file to your media server.</string>
<string name="napplet_consent_notify">This nApplet wants to show you notifications.</string>
@@ -849,6 +851,60 @@
<item quantity="one">This nApplet wants to pay a Lightning invoice for %1$d sat.</item>
<item quantity="other">This nApplet wants to pay a Lightning invoice for %1$d sats.</item>
</plurals>
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
kind-only summary would hide what is actually being signed. These replaceable lists are
already cached on the account, so the dialog diffs the proposed list against the current
one and reports what actually changes rather than a raw total. -->
<plurals name="napplet_consent_diff_follow_added">
<item quantity="one">follows %1$d new account</item>
<item quantity="other">follows %1$d new accounts</item>
</plurals>
<plurals name="napplet_consent_diff_follow_removed">
<item quantity="one">UNFOLLOWS %1$d account</item>
<item quantity="other">UNFOLLOWS %1$d accounts</item>
</plurals>
<plurals name="napplet_consent_diff_relay_added">
<item quantity="one">adds %1$d relay</item>
<item quantity="other">adds %1$d relays</item>
</plurals>
<plurals name="napplet_consent_diff_relay_removed">
<item quantity="one">REMOVES %1$d relay</item>
<item quantity="other">REMOVES %1$d relays</item>
</plurals>
<plurals name="napplet_consent_diff_mute_added">
<item quantity="one">mutes %1$d more person</item>
<item quantity="other">mutes %1$d more people</item>
</plurals>
<plurals name="napplet_consent_diff_mute_removed">
<item quantity="one">UNMUTES %1$d person</item>
<item quantity="other">UNMUTES %1$d people</item>
</plurals>
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
the display name, shown next to their avatar. -->
<string name="napplet_consent_diff_follow_one">⚠ This follows %1$s.</string>
<string name="napplet_consent_diff_unfollow_one">⚠ This UNFOLLOWS %1$s.</string>
<string name="napplet_consent_diff_mute_one">⚠ This mutes %1$s.</string>
<string name="napplet_consent_diff_unmute_one">⚠ This UNMUTES %1$s.</string>
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
<string name="napplet_consent_diff_follows">⚠ This rewrites your follow list: %1$s.</string>
<string name="napplet_consent_diff_relays">⚠ This rewrites your relay list: %1$s. Your posts and reads move with it.</string>
<string name="napplet_consent_diff_mutes">⚠ This rewrites your mute list: %1$s. Muted words and hashtags are not shown here — tap “Show Event” for the full list.</string>
<string name="napplet_consent_diff_joiner">%1$s and %2$s</string>
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
<string name="napplet_consent_diff_none">This republishes your existing list unchanged.</string>
<!-- No cached copy to compare against, so the whole list is what gets written. -->
<plurals name="napplet_consent_diff_no_baseline">
<item quantity="one">⚠ This writes a list of %1$d entry. Amethyst has no cached copy to compare against.</item>
<item quantity="other">⚠ This writes a list of %1$d entries. Amethyst has no cached copy to compare against.</item>
</plurals>
<plurals name="napplet_consent_effect_deletes">
<item quantity="one">⚠ This requests deletion of %1$d of your events.</item>
<item quantity="other">⚠ This requests deletion of %1$d of your events.</item>
</plurals>
<plurals name="napplet_consent_effect_tags">
<item quantity="one">Carries %1$d tag. Tap “Show Event” to see exactly what would be signed.</item>
<item quantity="other">Carries %1$d tags. Tap “Show Event” to see exactly what would be signed.</item>
</plurals>
<!-- Signer permissions: first-connect dialog -->
<string name="napplet_connect_title">Connect to Nostr</string>
<string name="napplet_connect_subtitle">wants to connect to your Nostr account</string>