From e5763b947cbc94cb9f68adece4746ad04a7ae01b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 17:33:55 +0000 Subject: [PATCH] fix: polish napplet signer permission UIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NappletSignerConsentActivity: replace AlertDialog (broken 5-button layout) with a custom Dialog + Surface using heightIn + verticalScroll; color-coded allow (primary) / deny (error) action rows; monospace "See more" toggle that reveals full raw event JSON with SelectionContainer so users can inspect and copy the data being signed/encrypted - NappletConnectActivity: wrap column in verticalScroll so the trust-level options are not clipped on small screens or large font sizes - NappletSignerPermissionsScreen: show localized op labels ("sign kind 1 event") and decision labels ("Allow"/"Ask"/"Deny") instead of raw key strings and enum names; fix per-op delete touch target to 48dp (M3 min) - NappletSignerConsentInfo: add rawData field carrying full event JSON for sign/encrypt or decrypted plaintext for future decrypt operations - NostrSignerOpLabels: populate rawData; add buildEventJson helper - strings: napplet_op_decrypt → "read your private messages" (the consent is to expose already-decrypted content, not to perform decryption); add napplet_consent_wants_to, see_more/see_less, decision labels Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../napplet/NappletConnectActivity.kt | 3 + .../napplet/NappletSignerConsentActivity.kt | 203 +++++++++++++----- .../NappletSignerConsentCoordinator.kt | 6 + .../amethyst/napplet/NostrSignerOpLabels.kt | 54 +++++ .../NappletSignerPermissionsScreen.kt | 41 +++- amethyst/src/main/res/values/strings.xml | 9 +- 6 files changed, 258 insertions(+), 58 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt index f14797e10e..5ac982732a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt @@ -34,8 +34,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme @@ -121,6 +123,7 @@ private fun NappletConnectScreen( modifier = Modifier .fillMaxWidth() + .verticalScroll(rememberScrollState()) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt index 2a01c87fa2..c35eca9b26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -23,18 +23,35 @@ package com.vitorpamplona.amethyst.napplet import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material3.AlertDialog +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +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.Modifier +import androidx.compose.ui.graphics.Color +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 +import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant import com.vitorpamplona.amethyst.ui.theme.AmethystTheme @@ -84,68 +101,154 @@ private fun NappletSignerConsentDialog( onGrant: (SignerOpGrant) -> Unit, onDismiss: () -> Unit, ) { - AlertDialog( + var showRawData by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f + + Dialog( onDismissRequest = onDismiss, - title = { Text(info.appletTitle) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(info.operationSummary) - if (info.contentPreview.isNotBlank()) { + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(max = maxHeight), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column( + modifier = + Modifier + .verticalScroll(scrollState) + .padding(vertical = 24.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 24.dp)) { Text( - "“${info.contentPreview}”", - style = MaterialTheme.typography.bodySmall, + info.appletTitle, + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.napplet_consent_wants_to, info.operationSummary), + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + + val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank() + if (hasContent) { + Spacer(Modifier.height(12.dp)) + Surface( + modifier = + Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(12.dp)) { + if (info.contentPreview.isNotBlank()) { + Text( + "“${info.contentPreview}”", + style = MaterialTheme.typography.bodySmall, + ) + } + if (info.rawData.isNotBlank()) { + if (showRawData) { + Spacer(Modifier.height(8.dp)) + SelectionContainer { + Text( + info.rawData, + style = + MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + TextButton( + onClick = { showRawData = !showRawData }, + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp), + ) { + Text( + if (showRawData) { + stringResource(R.string.napplet_consent_see_less) + } else { + stringResource(R.string.napplet_consent_see_more) + }, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + } + + Spacer(Modifier.height(8.dp)) Text( info.coordinate, + modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - } - }, - confirmButton = { - Column(modifier = Modifier.fillMaxWidth()) { - TextButton( + + Spacer(Modifier.height(12.dp)) + HorizontalDivider() + + ConsentActionButton( + text = stringResource(R.string.napplet_signer_allow_once), + color = MaterialTheme.colorScheme.primary, onClick = { onGrant(SignerOpGrant.AllowOnce) }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_signer_allow_once)) } - TextButton( + ) + ConsentActionButton( + text = stringResource(R.string.napplet_signer_allow_op, info.operationSummary), + color = MaterialTheme.colorScheme.primary, onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_signer_allow_op, info.operationSummary)) } - TextButton( + ) + ConsentActionButton( + text = stringResource(R.string.napplet_signer_allow_all), + color = MaterialTheme.colorScheme.primary, onClick = { onGrant(SignerOpGrant.AllowAll) }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_signer_allow_all)) } - } - }, - dismissButton = { - Column(modifier = Modifier.fillMaxWidth()) { - TextButton( + ) + + HorizontalDivider() + + ConsentActionButton( + text = stringResource(R.string.napplet_signer_deny_once), + color = MaterialTheme.colorScheme.error, onClick = { onGrant(SignerOpGrant.DenyOnce) }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_signer_deny_once)) } - TextButton( + ) + ConsentActionButton( + text = stringResource(R.string.napplet_signer_deny_op, info.operationSummary), + color = MaterialTheme.colorScheme.error, onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary)) } + ) } - }, - ) + } + } +} + +@Composable +private fun ConsentActionButton( + text: String, + color: Color, + onClick: () -> Unit, +) { + TextButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp), + ) { + Text( + text, + color = color, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt index 13eb58bc5d..f9658576e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt @@ -34,7 +34,13 @@ data class NappletSignerConsentInfo( val coordinate: String, val op: NostrSignerOp, val operationSummary: String, + /** Short excerpt shown in the dialog body (≤ 160 chars). */ val contentPreview: String, + /** + * Full raw content for the "See more" toggle — event JSON for sign/encrypt operations, + * decrypted plaintext for decrypt (Amethyst decrypts first, then asks permission to expose). + */ + val rawData: String = "", ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index 84f6773b93..5b3f15a703 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -47,6 +47,21 @@ fun buildSignerConsentInfo( when (request) { is NappletRequest.Publish -> request.content.take(160).trim() is NappletRequest.SignEvent -> request.content.take(160).trim() + is NappletRequest.PublishEncrypted -> request.content.take(160).trim() + else -> "" + } + val rawData = + when (request) { + is NappletRequest.Publish -> buildEventJson(request.kind, request.tags, request.content) + is NappletRequest.SignEvent -> buildEventJson(request.kind, request.tags, request.content, request.createdAt) + is NappletRequest.PublishEncrypted -> + buildEventJson( + request.kind, + request.tags, + request.content, + recipient = request.recipient, + encryption = request.encryption, + ) else -> "" } return NappletSignerConsentInfo( @@ -55,9 +70,48 @@ fun buildSignerConsentInfo( op = op, operationSummary = summary, contentPreview = preview, + rawData = rawData, ) } +private fun buildEventJson( + kind: Int, + tags: Array>, + content: String, + createdAt: Long? = null, + recipient: String? = null, + encryption: String? = null, +): String = + buildString { + append("{\n") + append(" \"kind\": $kind") + if (createdAt != null) append(",\n \"created_at\": $createdAt") + if (recipient != null) append(",\n \"recipient\": \"$recipient\"") + if (encryption != null) append(",\n \"encryption\": \"$encryption\"") + append(",\n \"tags\": [") + if (tags.isEmpty()) { + append("]") + } else { + append("\n") + tags.forEachIndexed { i, tag -> + append(" [") + append(tag.joinToString(", ") { "\"${it.replace("\\", "\\\\").replace("\"", "\\\"")}\"" }) + append("]") + if (i < tags.size - 1) append(",") + append("\n") + } + append(" ]") + } + val escaped = + content + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + append(",\n \"content\": \"$escaped\"") + append("\n}") + } + /** Creates a [NappletConnectInfo] for the first-connect dialog. */ fun buildConnectInfo( context: Context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt index 03086d3f27..02e185f4ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card @@ -187,14 +186,26 @@ private fun AppSignerPermissionCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(opKey, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) Text( - decision.name, - style = MaterialTheme.typography.labelSmall, - color = if (decision == NostrOpDecision.DENY) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + NostrSignerOp.fromKey(opKey)?.label() ?: opKey, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), ) - IconButton(onClick = { onRevokeOp(opKey) }, modifier = Modifier.size(32.dp)) { - Icon(symbol = MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Text( + decision.label(), + style = MaterialTheme.typography.labelSmall, + color = + if (decision == NostrOpDecision.DENY) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + ) + IconButton(onClick = { onRevokeOp(opKey) }) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = null, + ) } } } @@ -210,3 +221,19 @@ private fun AppSignerPolicy.label(): String = AppSignerPolicy.REASONABLE -> stringResource(R.string.napplet_policy_reasonable) AppSignerPolicy.PARANOID -> stringResource(R.string.napplet_policy_paranoid) } + +@Composable +private fun NostrSignerOp.label(): String = + when (this) { + is NostrSignerOp.SignKind -> stringResource(R.string.napplet_op_sign_kind, kind) + NostrSignerOp.Encrypt -> stringResource(R.string.napplet_op_encrypt) + NostrSignerOp.Decrypt -> stringResource(R.string.napplet_op_decrypt) + } + +@Composable +private fun NostrOpDecision.label(): String = + when (this) { + NostrOpDecision.ALLOW -> stringResource(R.string.napplet_decision_allow) + NostrOpDecision.ASK -> stringResource(R.string.napplet_decision_ask) + NostrOpDecision.DENY -> stringResource(R.string.napplet_decision_deny) + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 66d53ea25a..2f8e54ab49 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -767,6 +767,9 @@ Do not sign anything without asking me! + wants to %1$s + See more + See less Allow once Don\'t ask again to %1$s Don\'t ask again for any Nostr requests @@ -776,7 +779,8 @@ sign kind %1$d event encrypt a message - decrypt a message + + read your private messages Connected Apps @@ -784,6 +788,9 @@ Operation overrides No apps have connected yet. Revoke all permissions + Allow + Ask + Deny Source: %1$s v%1$s