diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt index 94875be170..17c22829bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt @@ -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, + ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt index e2a0daa355..c544bb1660 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt @@ -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?, ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index a65238c703..42f4ec93f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -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>, + 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>, + ): 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>?, + proposed: Array>, + 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>, + tagName: String, + ): Set { + val out = mutableSetOf() + 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>, + 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt index 1de97b9753..58d1cb0891 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt @@ -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 } } /** diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bc61271482..11cb80480c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -841,6 +841,8 @@ This nApplet wants to read events from your relays. This nApplet wants to use its private storage. This nApplet wants to pay a Lightning invoice. + + ⚠ 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. This nApplet wants to fetch a web resource. This nApplet wants to upload a file to your media server. This nApplet wants to show you notifications. @@ -849,6 +851,60 @@ This nApplet wants to pay a Lightning invoice for %1$d sat. This nApplet wants to pay a Lightning invoice for %1$d sats. + + + follows %1$d new account + follows %1$d new accounts + + + UNFOLLOWS %1$d account + UNFOLLOWS %1$d accounts + + + adds %1$d relay + adds %1$d relays + + + REMOVES %1$d relay + REMOVES %1$d relays + + + mutes %1$d more person + mutes %1$d more people + + + UNMUTES %1$d person + UNMUTES %1$d people + + + ⚠ This follows %1$s. + ⚠ This UNFOLLOWS %1$s. + ⚠ This mutes %1$s. + ⚠ This UNMUTES %1$s. + + ⚠ This rewrites your follow list: %1$s. + ⚠ This rewrites your relay list: %1$s. Your posts and reads move with it. + ⚠ This rewrites your mute list: %1$s. Muted words and hashtags are not shown here — tap “Show Event” for the full list. + %1$s and %2$s + + This republishes your existing list unchanged. + + + ⚠ This writes a list of %1$d entry. Amethyst has no cached copy to compare against. + ⚠ This writes a list of %1$d entries. Amethyst has no cached copy to compare against. + + + ⚠ This requests deletion of %1$d of your events. + ⚠ This requests deletion of %1$d of your events. + + + Carries %1$d tag. Tap “Show Event” to see exactly what would be signed. + Carries %1$d tags. Tap “Show Event” to see exactly what would be signed. + Connect to Nostr wants to connect to your Nostr account