fix: polish napplet signer permission UIs

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
This commit is contained in:
Claude
2026-06-27 22:58:50 +00:00
parent b48cad67e5
commit e5763b947c
6 changed files with 258 additions and 58 deletions
@@ -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,
) {
@@ -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,
)
}
}
@@ -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 = "",
)
/**
@@ -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<Array<String>>,
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,
@@ -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)
}
+8 -1
View File
@@ -767,6 +767,9 @@
<string name="napplet_policy_paranoid_desc">Do not sign anything without asking me!</string>
<!-- Signer per-op consent dialog -->
<string name="napplet_consent_wants_to">wants to %1$s</string>
<string name="napplet_consent_see_more">See more</string>
<string name="napplet_consent_see_less">See less</string>
<string name="napplet_signer_allow_once">Allow once</string>
<string name="napplet_signer_allow_op">Don\'t ask again to %1$s</string>
<string name="napplet_signer_allow_all">Don\'t ask again for any Nostr requests</string>
@@ -776,7 +779,8 @@
<!-- Signer op labels -->
<string name="napplet_op_sign_kind">sign kind %1$d event</string>
<string name="napplet_op_encrypt">encrypt a message</string>
<string name="napplet_op_decrypt">decrypt a message</string>
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<string name="napplet_op_decrypt">read your private messages</string>
<!-- Permissions management screen -->
<string name="napplet_permissions_title">Connected Apps</string>
@@ -784,6 +788,9 @@
<string name="napplet_permissions_overrides">Operation overrides</string>
<string name="napplet_signer_permissions_empty">No apps have connected yet.</string>
<string name="napplet_signer_permissions_revoke_all">Revoke all permissions</string>
<string name="napplet_decision_allow">Allow</string>
<string name="napplet_decision_ask">Ask</string>
<string name="napplet_decision_deny">Deny</string>
<string name="nip82_repository_label">Source: %1$s</string>
<string name="nip82_version_label">v%1$s</string>