From b48cad67e50c66ebd76ceb9f2d0b93eefd969827 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 16:35:00 +0000 Subject: [PATCH 01/33] feat: add Nostr signer permission system for napplets/nsites Implements per-app permission management for the internal nsec signer when webapps/napplets/nsites connect via Amethyst's built-in key: - Three trust levels on first connect (FULL_TRUST, REASONABLE, PARANOID) with a UI dialog (NappletConnectActivity) matching the design spec - Per-operation consent dialogs (NappletSignerConsentActivity) for sign-kind/encrypt/decrypt with Allow once, Don't ask again, Deny options - Per-app DataStore storage (DataStoreNostrSignerPermissionStore) using SHA-256-hashed filenames so 1000s of apps don't bloat a single file - NostrSignerPermissionLedger applies policy decisions: REASONABLE auto-allows kinds 1/6/7; FULL_TRUST auto-allows all non-payment ops - NappletBroker extended with first-connect gate and per-op signer gate, serialized by a dedicated signerConsentLock mutex - Permission management screen (NappletSignerPermissionsScreen) to review and revoke stored per-app signer permissions Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- amethyst/src/main/AndroidManifest.xml | 14 + .../DataStoreNostrSignerPermissionStore.kt | 154 +++++++++++ .../amethyst/napplet/NappletBrokerService.kt | 6 + .../napplet/NappletConnectActivity.kt | 251 ++++++++++++++++++ .../napplet/NappletConnectCoordinator.kt | 85 ++++++ .../napplet/NappletSignerConsentActivity.kt | 151 +++++++++++ .../NappletSignerConsentCoordinator.kt | 87 ++++++ .../amethyst/napplet/NostrSignerOpLabels.kt | 69 +++++ .../gateways/AccountNappletGateways.kt | 26 +- .../NappletSignerPermissionsScreen.kt | 212 +++++++++++++++ amethyst/src/main/res/values/strings.xml | 33 +++ .../amethyst/commons/napplet/NappletBroker.kt | 103 +++++++ .../napplet/signers/AppSignerPolicy.kt | 40 +++ .../napplet/signers/NostrOpDecision.kt | 36 +++ .../signers/NostrSignerConsentPrompt.kt | 105 ++++++++ .../commons/napplet/signers/NostrSignerOp.kt | 71 +++++ .../signers/NostrSignerPermissionLedger.kt | 91 +++++++ .../signers/NostrSignerPermissionStore.kt | 130 +++++++++ 18 files changed, 1663 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 242aa6698e..72bdeefe46 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -431,6 +431,20 @@ android:excludeFromRecents="true" android:launchMode="singleTop" android:theme="@android:style/Theme.Translucent.NoTitleBar" /> + + + + >() + + private fun storeFor(coordinate: String): DataStore = + cache.getOrCreate(coordinate) { + PreferenceDataStoreFactory.create( + produceFile = { File(filesDir, "datastore/nsp_${hash(coordinate)}.preferences_pb") }, + ) + } + + override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? { + val raw = storeFor(coordinate).data.first()[KEY_POLICY] ?: return null + return runCatching { AppSignerPolicy.valueOf(raw) }.getOrNull() + } + + override suspend fun storePolicy( + coordinate: String, + policy: AppSignerPolicy, + ) { + storeFor(coordinate).edit { + it[KEY_COORDINATE] = coordinate + it[KEY_POLICY] = policy.name + } + } + + override suspend fun clearPolicy(coordinate: String) { + storeFor(coordinate).edit { it.remove(KEY_POLICY) } + } + + override suspend fun loadOpDecision( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision? { + val raw = storeFor(coordinate).data.first()[opKey(op)] ?: return null + return runCatching { NostrOpDecision.valueOf(raw) }.getOrNull() + } + + override suspend fun storeOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) { + storeFor(coordinate).edit { + it[KEY_COORDINATE] = coordinate + it[opKey(op)] = decision.name + } + } + + override suspend fun clearOpDecision( + coordinate: String, + op: NostrSignerOp, + ) { + storeFor(coordinate).edit { it.remove(opKey(op)) } + } + + override suspend fun allPolicies(): Map { + val dir = File(filesDir, "datastore") + if (!dir.exists()) return emptyMap() + val result = mutableMapOf() + for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) { + val coordinate = + file.nameWithoutExtension.let { name -> + // Derive the DataStore by re-opening the file; we stored the coordinate inside. + val ds = + cache.getOrCreate(name) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + ds.data.first()[KEY_COORDINATE] + } ?: continue + val policy = loadPolicy(coordinate) ?: continue + result[coordinate] = policy + } + return result + } + + override suspend fun allOpDecisions(coordinate: String): Map { + val prefs = storeFor(coordinate).data.first() + val result = mutableMapOf() + for ((key, value) in prefs.asMap()) { + val name = key.name + if (!name.startsWith(OP_PREFIX)) continue + val opKey = name.removePrefix(OP_PREFIX) + val decision = runCatching { NostrOpDecision.valueOf(value as String) }.getOrNull() ?: continue + result[opKey] = decision + } + return result + } + + override suspend fun clearAll(coordinate: String) { + storeFor(coordinate).edit { it.clear() } + } + + private fun opKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}") + + companion object { + private val KEY_COORDINATE = stringPreferencesKey("coordinate") + private val KEY_POLICY = stringPreferencesKey("policy") + private const val OP_PREFIX = "op:" + + private fun hash(coordinate: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray()) + return digest.take(8).joinToString("") { "%02x".format(it) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 06bc236b3f..eeaa67157f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry @@ -77,6 +78,10 @@ class NappletBrokerService : Service() { // One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM. private val ledger by lazy { NappletPermissionLedger(DataStoreNappletPermissionStore(applicationContext)) } + // Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only + // instantiated in the main process where the signer lives; never touched from :napplet. + private val signerLedger by lazy { NostrSignerPermissionLedger(DataStoreNostrSignerPermissionStore(applicationContext)) } + // Per-applet sandboxed key-value store (namespaced by coordinate inside the impl). private val storage by lazy { DataStoreNappletStorage(applicationContext) } @@ -323,6 +328,7 @@ class NappletBrokerService : Service() { // Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes // through Tor when asked + active, and falls back to clearnet otherwise. httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) }, + signerLedger = signerLedger, ).broker() cachedBroker = account to broker return broker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt new file mode 100644 index 0000000000..f14797e10e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.fillMaxSize +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme + +class NappletConnectActivity : ComponentActivity() { + private var token: String? = null + private var decided = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val token = intent.getStringExtra(NappletConnectCoordinator.EXTRA_TOKEN) + this.token = token + val info = token?.let { NappletConnectCoordinator.infoFor(it) } + if (token == null || info == null) { + finish() + return + } + + setContent { + AmethystTheme { + NappletConnectScreen( + info = info, + onConnect = { policy -> + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy)) + finish() + }, + onBlock = { + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Blocked) + finish() + }, + onCancel = { + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled) + finish() + }, + ) + } + } + } + + override fun finish() { + if (!decided) token?.let { NappletConnectCoordinator.cancel(it) } + super.finish() + } +} + +@Composable +private fun NappletConnectScreen( + info: NappletConnectInfo, + onConnect: (AppSignerPolicy) -> Unit, + onBlock: () -> Unit, + onCancel: () -> Unit, +) { + var selected by remember { mutableStateOf(AppSignerPolicy.REASONABLE) } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background.copy(alpha = 0.85f), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(32.dp)) + Text( + stringResource(R.string.napplet_connect_title), + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(16.dp)) + + // App card + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + modifier = Modifier.size(40.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + ) {} + Column { + Text(info.appletTitle, style = MaterialTheme.typography.titleMedium) + Text(info.domain, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + + Spacer(Modifier.height(24.dp)) + Text( + stringResource(R.string.napplet_connect_how_handle), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + + // Trust level options + PolicyOption( + selected = selected == AppSignerPolicy.FULL_TRUST, + icon = "ā¤", + label = stringResource(R.string.napplet_policy_full_trust), + description = stringResource(R.string.napplet_policy_full_trust_desc), + onClick = { selected = AppSignerPolicy.FULL_TRUST }, + ) + Spacer(Modifier.height(8.dp)) + PolicyOption( + selected = selected == AppSignerPolicy.REASONABLE, + icon = "šŸ‘", + label = stringResource(R.string.napplet_policy_reasonable), + description = stringResource(R.string.napplet_policy_reasonable_desc), + onClick = { selected = AppSignerPolicy.REASONABLE }, + ) + Spacer(Modifier.height(8.dp)) + PolicyOption( + selected = selected == AppSignerPolicy.PARANOID, + icon = "šŸ•¶", + label = stringResource(R.string.napplet_policy_paranoid), + description = stringResource(R.string.napplet_policy_paranoid_desc), + onClick = { selected = AppSignerPolicy.PARANOID }, + ) + + Spacer(Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.cancel)) + } + Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.napplet_connect_button)) + } + } + + Spacer(Modifier.height(16.dp)) + TextButton(onClick = onBlock) { + Text( + stringResource(R.string.napplet_connect_block, info.domain), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +private fun PolicyOption( + selected: Boolean, + icon: String, + label: String, + description: String, + onClick: () -> Unit, +) { + val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface + + Surface( + modifier = + Modifier + .fillMaxWidth() + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = bgColor, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(icon, style = MaterialTheme.typography.headlineSmall) + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.titleSmall) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (selected) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt new file mode 100644 index 0000000000..0f8f244526 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Everything the "Connect to Nostr" dialog needs to render. */ +data class NappletConnectInfo( + val appletTitle: String, + val coordinate: String, + val domain: String, +) + +/** + * Bridges the broker to the "Connect to Nostr" first-connect UI. Suspends in [requestConnect]; + * the Activity resolves the deferred with the user's choice. + * A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant. + */ +object NappletConnectCoordinator { + private class Pending( + val info: NappletConnectInfo, + val deferred: CompletableDeferred, + ) + + private val pending = ConcurrentHashMap() + + suspend fun requestConnect( + context: Context, + info: NappletConnectInfo, + ): AppConnectResult { + val token = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[token] = Pending(info, deferred) + + context.startActivity( + Intent(context, NappletConnectActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token), + ) + + return try { + deferred.await() + } finally { + pending.remove(token) + } + } + + fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info + + fun complete( + token: String, + result: AppConnectResult, + ) { + pending[token]?.deferred?.complete(result) + } + + fun cancel(token: String) { + pending[token]?.deferred?.complete(AppConnectResult.Cancelled) + } + + const val EXTRA_TOKEN = "napplet_connect_token" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt new file mode 100644 index 0000000000..2a01c87fa2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme + +class NappletSignerConsentActivity : ComponentActivity() { + private var token: String? = null + private var decided = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val token = intent.getStringExtra(NappletSignerConsentCoordinator.EXTRA_TOKEN) + this.token = token + val info = token?.let { NappletSignerConsentCoordinator.infoFor(it) } + if (token == null || info == null) { + finish() + return + } + + setContent { + AmethystTheme { + NappletSignerConsentDialog( + info = info, + onGrant = { grant -> + decided = true + NappletSignerConsentCoordinator.complete(token, grant) + finish() + }, + onDismiss = { + decided = true + NappletSignerConsentCoordinator.cancel(token) + finish() + }, + ) + } + } + } + + override fun finish() { + if (!decided) token?.let { NappletSignerConsentCoordinator.cancel(it) } + super.finish() + } +} + +@Composable +private fun NappletSignerConsentDialog( + info: NappletSignerConsentInfo, + onGrant: (SignerOpGrant) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(info.appletTitle) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(info.operationSummary) + if (info.contentPreview.isNotBlank()) { + Text( + "ā€œ${info.contentPreview}ā€", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + info.coordinate, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Column(modifier = Modifier.fillMaxWidth()) { + TextButton( + onClick = { onGrant(SignerOpGrant.AllowOnce) }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) { Text(stringResource(R.string.napplet_signer_allow_once)) } + TextButton( + onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) { Text(stringResource(R.string.napplet_signer_allow_op, info.operationSummary)) } + TextButton( + 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( + onClick = { onGrant(SignerOpGrant.DenyOnce) }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) { Text(stringResource(R.string.napplet_signer_deny_once)) } + TextButton( + onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) { Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary)) } + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt new file mode 100644 index 0000000000..13eb58bc5d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Everything the per-operation consent dialog needs to render. */ +data class NappletSignerConsentInfo( + val appletTitle: String, + val coordinate: String, + val op: NostrSignerOp, + val operationSummary: String, + val contentPreview: String, +) + +/** + * Bridges the broker to the per-operation signer consent UI. + * A dismissed dialog resolves to [SignerOpGrant.DenyOnce] — fails closed. + */ +object NappletSignerConsentCoordinator { + private class Pending( + val info: NappletSignerConsentInfo, + val deferred: CompletableDeferred, + ) + + private val pending = ConcurrentHashMap() + + suspend fun requestConsent( + context: Context, + info: NappletSignerConsentInfo, + ): SignerOpGrant { + val token = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[token] = Pending(info, deferred) + + context.startActivity( + Intent(context, NappletSignerConsentActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token), + ) + + return try { + deferred.await() + } finally { + pending.remove(token) + } + } + + fun infoFor(token: String): NappletSignerConsentInfo? = pending[token]?.info + + fun complete( + token: String, + grant: SignerOpGrant, + ) { + pending[token]?.deferred?.complete(grant) + } + + fun cancel(token: String) { + pending[token]?.deferred?.complete(SignerOpGrant.DenyOnce) + } + + const val EXTRA_TOKEN = "napplet_signer_consent_token" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt new file mode 100644 index 0000000000..84f6773b93 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp + +/** Human-readable label for a [NostrSignerOp]. */ +fun NostrSignerOp.label(context: Context): String = + when (this) { + is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind, kind) + NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt) + NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt) + } + +/** Builds the [NappletSignerConsentInfo] needed by the per-op consent dialog. */ +fun buildSignerConsentInfo( + context: Context, + identity: NappletIdentity, + op: NostrSignerOp, + request: NappletRequest, +): NappletSignerConsentInfo { + val title = identity.identifier.ifBlank { context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) } + val summary = op.label(context) + val preview = + when (request) { + is NappletRequest.Publish -> request.content.take(160).trim() + is NappletRequest.SignEvent -> request.content.take(160).trim() + else -> "" + } + return NappletSignerConsentInfo( + appletTitle = title, + coordinate = identity.coordinate, + op = op, + operationSummary = summary, + contentPreview = preview, + ) +} + +/** Creates a [NappletConnectInfo] for the first-connect dialog. */ +fun buildConnectInfo( + context: Context, + identity: NappletIdentity, +): NappletConnectInfo { + val title = identity.identifier.ifBlank { context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) } + val domain = identity.coordinate.substringBefore(":") + return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index cee3ccde5e..89bf3ecf31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -41,10 +41,17 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.napplet.NappletConnectCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentSummary import com.vitorpamplona.amethyst.napplet.NappletNotificationStore +import com.vitorpamplona.amethyst.napplet.NappletSignerConsentCoordinator +import com.vitorpamplona.amethyst.napplet.buildConnectInfo +import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -72,6 +79,7 @@ class AccountNappletGateways( private val ledger: NappletPermissionLedger, private val storage: NappletStorage, private val httpClient: (useProxy: Boolean) -> OkHttpClient, + private val signerLedger: NostrSignerPermissionLedger? = null, ) { private val consentSummary = NappletConsentSummary(context) @@ -129,7 +137,23 @@ class AccountNappletGateways( } } - return NappletBroker(account.signer, ledger, consent, relay, storage, wallet, resource, upload = upload, identityReads = identityReads, theme = theme, notify = notify) + val connectPrompt = + NostrConnectPrompt { identity -> + NappletConnectCoordinator.requestConnect( + context = context, + info = buildConnectInfo(context, identity), + ) + } + + val signerConsent = + NostrSignerConsentPrompt { identity, op, request -> + NappletSignerConsentCoordinator.requestConsent( + context = context, + info = buildSignerConsentInfo(context, identity, op, request), + ) + } + + return NappletBroker(account.signer, ledger, consent, signerLedger = signerLedger, nostrConnectPrompt = connectPrompt, signerConsentPrompt = signerConsent, relay = relay, storage = storage, wallet = wallet, resource = resource, upload = upload, identityReads = identityReads, theme = theme, notify = notify) } /** 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 new file mode 100644 index 0000000000..03086d3f27 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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 +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class AppSignerPermissionEntry( + val coordinate: String, + val policy: AppSignerPolicy, + val opOverrides: Map, +) + +class NappletSignerPermissionsViewModel( + private val ledger: NostrSignerPermissionLedger, +) : ViewModel() { + private val _entries = MutableStateFlow>(emptyList()) + val entries: StateFlow> = _entries.asStateFlow() + + init { + reload() + } + + fun reload() { + viewModelScope.launch { + val policies = ledger.store.allPolicies() + _entries.value = + policies.map { (coord, policy) -> + AppSignerPermissionEntry( + coordinate = coord, + policy = policy, + opOverrides = ledger.store.allOpDecisions(coord), + ) + } + } + } + + fun revokeAll(coordinate: String) { + viewModelScope.launch { + ledger.revokeAll(coordinate) + reload() + } + } + + fun revokeOp( + coordinate: String, + opKey: String, + ) { + viewModelScope.launch { + NostrSignerOp.fromKey(opKey)?.let { ledger.revokeOpDecision(coordinate, it) } + reload() + } + } +} + +@Composable +fun NappletSignerPermissionsScreen(viewModel: NappletSignerPermissionsViewModel) { + val entries by viewModel.entries.collectAsState() + + if (entries.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.napplet_signer_permissions_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(entries, key = { it.coordinate }) { entry -> + AppSignerPermissionCard( + entry = entry, + onRevokeAll = { viewModel.revokeAll(entry.coordinate) }, + onRevokeOp = { opKey -> viewModel.revokeOp(entry.coordinate, opKey) }, + ) + } + } + } +} + +@Composable +private fun AppSignerPermissionCard( + entry: AppSignerPermissionEntry, + onRevokeAll: () -> Unit, + onRevokeOp: (String) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + entry.coordinate.substringAfter(":").ifBlank { entry.coordinate }, + style = MaterialTheme.typography.titleSmall, + ) + Text( + entry.coordinate, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onRevokeAll) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringResource(R.string.napplet_signer_permissions_revoke_all), + ) + } + } + + SuggestionChip( + onClick = {}, + label = { Text(entry.policy.label()) }, + ) + + if (entry.opOverrides.isNotEmpty()) { + HorizontalDivider() + Text( + stringResource(R.string.napplet_permissions_overrides), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + for ((opKey, decision) in entry.opOverrides) { + Row( + modifier = Modifier.fillMaxWidth(), + 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, + ) + IconButton(onClick = { onRevokeOp(opKey) }, modifier = Modifier.size(32.dp)) { + Icon(symbol = MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + } + } + } + } + } + } +} + +@Composable +private fun AppSignerPolicy.label(): String = + when (this) { + AppSignerPolicy.FULL_TRUST -> stringResource(R.string.napplet_policy_full_trust) + AppSignerPolicy.REASONABLE -> stringResource(R.string.napplet_policy_reasonable) + AppSignerPolicy.PARANOID -> stringResource(R.string.napplet_policy_paranoid) + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fde413a38a..66d53ea25a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -752,6 +752,39 @@ This nApplet wants to pay a Lightning invoice for %1$d sat. This nApplet wants to pay a Lightning invoice for %1$d sats. + + Connect to Nostr + How should this app\'s requests be handled? + Connect + Block and ignore %1$s + + + I fully trust it + Auto-sign all requests (except payments) + Let\'s be reasonable + Auto-approve most common requests + I\'m a bit paranoid + Do not sign anything without asking me! + + + Allow once + Don\'t ask again to %1$s + Don\'t ask again for any Nostr requests + Deny + Always deny %1$s + + + sign kind %1$d event + encrypt a message + decrypt a message + + + Connected Apps + Revoke all permissions + Operation overrides + No apps have connected yet. + Revoke all permissions + Source: %1$s v%1$s Download diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 0d035a4ad2..85188cfbe2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -25,6 +25,15 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant +import com.vitorpamplona.amethyst.commons.napplet.signers.toSignerOp import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -71,12 +80,19 @@ class NappletBroker( private val identityReads: NappletIdentityGateway? = null, private val theme: NappletThemeGateway? = null, private val notify: NappletNotifyGateway? = null, + private val signerLedger: NostrSignerPermissionLedger? = null, + private val nostrConnectPrompt: NostrConnectPrompt? = null, + private val signerConsentPrompt: NostrSignerConsentPrompt? = null, ) { // Serializes the consent-prompt path so concurrent requests queue into one dialog at a time // (see [authorizeWithConsent]). Only the prompt is held here; non-prompting paths and execute() // run unserialized. private val consentLock = Mutex() + // Serializes first-connect and per-op signer consent dialogs so concurrent signing requests + // queue into one dialog at a time rather than launching several dialogs simultaneously. + private val signerConsentLock = Mutex() + /** * Authorizes and runs [request] on behalf of [identity]. [declared] is the capability set the * manifest's `requires` resolved to; a request outside it is refused before any prompt. @@ -105,6 +121,13 @@ class NappletBroker( return NappletResponse.Denied(capability, "Blocked by a standing denial.") } + // For internal signers, show the first-connect dialog if the app has no signer policy yet. + if (signer is NostrSignerInternal && signerLedger != null && !signerLedger.hasPolicy(identity.coordinate)) { + if (!ensureConnected(identity, declared)) { + return NappletResponse.Denied(capability, "Connection not authorized.") + } + } + val authorized = when { // Keyboard/command action registration is a shell-mediated UI affordance, not key @@ -121,6 +144,14 @@ class NappletBroker( if (!authorized) return NappletResponse.Denied(capability, "The user declined.") + // Additional per-operation gate for internal signer signing/encryption. + if (signer is NostrSignerInternal && signerLedger != null) { + val op = request.toSignerOp() + if (op != null && !authorizeSignerOp(identity, op, request)) { + return NappletResponse.Denied(capability, "Signing operation declined.") + } + } + return try { execute(identity, request) } catch (e: CancellationException) { @@ -310,4 +341,76 @@ class NappletBroker( } else { tags + arrayOf(arrayOf("p", recipient)) } + + /** + * Shows the first-connect "Connect to Nostr" dialog if no signer policy exists yet. + * On success, stores the chosen policy and bulk-grants all declared non-payment capabilities. + * Returns false if the user cancelled or blocked the app. + */ + private suspend fun ensureConnected( + identity: NappletIdentity, + declared: Set, + ): Boolean = + signerConsentLock.withLock { + val sl = signerLedger ?: return@withLock true + // Re-check after acquiring lock: a sibling request may have set the policy while we waited. + if (sl.hasPolicy(identity.coordinate)) return@withLock true + + val prompt = nostrConnectPrompt ?: return@withLock true + when (val result = prompt.request(identity)) { + is AppConnectResult.Connected -> { + sl.setPolicy(identity.coordinate, result.policy) + // Bulk-grant all declared capabilities except payments so the app works immediately. + for (cap in declared) { + if (!cap.requiresPerUseConsent) { + ledger.record(identity, cap, GrantState.ALLOW_ALWAYS) + } + } + true + } + AppConnectResult.Blocked -> { + sl.setPolicy(identity.coordinate, AppSignerPolicy.PARANOID) + for (cap in declared) { + ledger.record(identity, cap, GrantState.DENY) + } + false + } + AppConnectResult.Cancelled -> false + } + } + + /** + * Gates a specific signing/encryption operation through the signer permission ledger. + * If the ledger says ASK, prompts the user and records their choice. + */ + private suspend fun authorizeSignerOp( + identity: NappletIdentity, + op: NostrSignerOp, + request: NappletRequest, + ): Boolean = + signerConsentLock.withLock { + val sl = signerLedger ?: return@withLock true + when (sl.decide(identity.coordinate, op)) { + NostrOpDecision.ALLOW -> true + NostrOpDecision.DENY -> false + NostrOpDecision.ASK -> { + val prompt = signerConsentPrompt ?: return@withLock true + when (val grant = prompt.request(identity, op, request)) { + is SignerOpGrant.AllowAll -> { + sl.setPolicy(identity.coordinate, AppSignerPolicy.FULL_TRUST) + true + } + is SignerOpGrant.AllowForOp -> { + sl.setOpDecision(identity.coordinate, op, NostrOpDecision.ALLOW) + true + } + is SignerOpGrant.DenyForOp -> { + sl.setOpDecision(identity.coordinate, op, NostrOpDecision.DENY) + false + } + else -> grant.isAllowed + } + } + } + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt new file mode 100644 index 0000000000..bfd36e6af5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.signers + +/** + * The user's top-level trust decision for one app's access to the internal Nostr signer. + * Set once on first connection; governs all future signing/encryption operations unless + * overridden by a per-operation [NostrOpDecision] in the [NostrSignerPermissionLedger]. + */ +enum class AppSignerPolicy { + /** Auto-approve all signing and encryption operations (except payments, which always prompt). */ + FULL_TRUST, + + /** + * Auto-approve the most common operations (kind 1 short notes, kind 6 reposts, kind 7 + * reactions); ask before anything else. A reasonable default for most apps. + */ + REASONABLE, + + /** Prompt before every single signing or encryption operation. */ + PARANOID, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt new file mode 100644 index 0000000000..a6cbda8f6b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.signers + +/** + * A per-operation standing decision stored in [NostrSignerPermissionStore]. + * Overrides [AppSignerPolicy] for the specific [NostrSignerOp] it is keyed to. + */ +enum class NostrOpDecision { + /** Automatically allow this operation without prompting. */ + ALLOW, + + /** Prompt the user on each request (default behavior). */ + ASK, + + /** Always deny this operation without prompting. */ + DENY, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt new file mode 100644 index 0000000000..f991342c7b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.signers + +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest + +// --------------------------------------------------------------------------- +// First-connect dialog +// --------------------------------------------------------------------------- + +/** The user's response to the "Connect to Nostr" first-connection dialog. */ +sealed interface AppConnectResult { + /** The user accepted and chose a trust level. */ + data class Connected( + val policy: AppSignerPolicy, + ) : AppConnectResult + + /** The user chose to block this app permanently. */ + data object Blocked : AppConnectResult + + /** The user dismissed the dialog without making a choice. */ + data object Cancelled : AppConnectResult +} + +/** + * Shows the "Connect to Nostr" first-connection dialog for [identity] and suspends + * until the user makes a choice. The result drives the [AppSignerPolicy] stored in + * [NostrSignerPermissionLedger] and the bulk capability grant in [NappletBroker][com.vitorpamplona.amethyst.commons.napplet.NappletBroker]. + */ +fun interface NostrConnectPrompt { + suspend fun request(identity: NappletIdentity): AppConnectResult +} + +// --------------------------------------------------------------------------- +// Per-operation consent dialog +// --------------------------------------------------------------------------- + +/** + * The user's response to a per-signing-operation consent dialog. + * The broker records any "remember" variant before returning [isAllowed]. + */ +sealed interface SignerOpGrant { + /** Whether the in-flight request may proceed. */ + val isAllowed: Boolean + + /** Allow this one request; prompt again next time. */ + data object AllowOnce : SignerOpGrant { + override val isAllowed = true + } + + /** Allow and remember: don't ask again for [op]. */ + data class AllowForOp( + val op: NostrSignerOp, + ) : SignerOpGrant { + override val isAllowed = true + } + + /** Allow and upgrade to [AppSignerPolicy.FULL_TRUST] for all future requests. */ + data object AllowAll : SignerOpGrant { + override val isAllowed = true + } + + /** Deny this one request; prompt again next time. */ + data object DenyOnce : SignerOpGrant { + override val isAllowed = false + } + + /** Deny and remember: always deny [op]. */ + data class DenyForOp( + val op: NostrSignerOp, + ) : SignerOpGrant { + override val isAllowed = false + } +} + +/** + * Prompts the user to authorize (or deny) a specific Nostr operation for [identity]. + * Suspends until the user answers. [DenyOnce] is the safe default when dismissed. + */ +fun interface NostrSignerConsentPrompt { + suspend fun request( + identity: NappletIdentity, + op: NostrSignerOp, + request: NappletRequest, + ): SignerOpGrant +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt new file mode 100644 index 0000000000..bdb31e981f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.signers + +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest + +/** + * A Nostr-specific cryptographic operation that requires the internal signer. + * Used to gate signing and encryption independently, per app. + */ +sealed interface NostrSignerOp { + /** Sign (and optionally publish) an event of the given [kind]. */ + data class SignKind( + val kind: Int, + ) : NostrSignerOp + + /** Encrypt a message (NIP-04 or NIP-44). */ + data object Encrypt : NostrSignerOp + + /** Decrypt a message (NIP-04 or NIP-44). */ + data object Decrypt : NostrSignerOp + + /** Stable storage key for this operation, used as a DataStore key fragment. */ + val key: String + get() = + when (this) { + is SignKind -> "sign:$kind" + Encrypt -> "encrypt" + Decrypt -> "decrypt" + } + + companion object { + fun fromKey(key: String): NostrSignerOp? = + when { + key == "encrypt" -> Encrypt + key == "decrypt" -> Decrypt + key.startsWith("sign:") -> key.removePrefix("sign:").toIntOrNull()?.let { SignKind(it) } + else -> null + } + } +} + +/** + * Maps a [NappletRequest] to the [NostrSignerOp] it represents, or `null` if the request + * does not involve signing or encryption. + */ +fun NappletRequest.toSignerOp(): NostrSignerOp? = + when (this) { + is NappletRequest.Publish -> NostrSignerOp.SignKind(kind) + is NappletRequest.SignEvent -> NostrSignerOp.SignKind(kind) + is NappletRequest.PublishEncrypted -> NostrSignerOp.Encrypt + else -> null + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt new file mode 100644 index 0000000000..e2e7b6812d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.signers + +/** + * The per-app Nostr signer permission ledger. Decides whether a signing or encryption + * operation should auto-allow, auto-deny, or ask the user, by consulting: + * + * 1. Per-operation overrides ([NostrSignerPermissionStore.loadOpDecision]) — these always win. + * 2. The app's [AppSignerPolicy] trust level ([NostrSignerPermissionStore.loadPolicy]). + * 3. The built-in "reasonable" set (kind 1/6/7 are auto-allowed) when policy is [AppSignerPolicy.REASONABLE]. + * + * When no policy has been set (`null`), [decide] returns [NostrOpDecision.ASK], which triggers the + * first-connect dialog in the broker. + */ +class NostrSignerPermissionLedger( + val store: NostrSignerPermissionStore, +) { + /** + * `true` when a trust level has been set for [coordinate] — i.e. the "Connect to Nostr" + * dialog has already been shown and the user made a choice. + */ + suspend fun hasPolicy(coordinate: String): Boolean = store.loadPolicy(coordinate) != null + + /** The authorization verdict for ([coordinate], [op]) based on stored policy + per-op overrides. */ + suspend fun decide( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision { + store.loadOpDecision(coordinate, op)?.let { return it } + return when (store.loadPolicy(coordinate)) { + AppSignerPolicy.FULL_TRUST -> NostrOpDecision.ALLOW + AppSignerPolicy.PARANOID -> NostrOpDecision.ASK + AppSignerPolicy.REASONABLE -> reasonableDecision(op) + null -> NostrOpDecision.ASK + } + } + + /** Stores the user's chosen trust level for [coordinate]. */ + suspend fun setPolicy( + coordinate: String, + policy: AppSignerPolicy, + ) = store.storePolicy(coordinate, policy) + + /** Stores a per-operation override for ([coordinate], [op]). */ + suspend fun setOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) = store.storeOpDecision(coordinate, op, decision) + + /** Removes a per-operation override, reverting to the policy-level decision. */ + suspend fun revokeOpDecision( + coordinate: String, + op: NostrSignerOp, + ) = store.clearOpDecision(coordinate, op) + + /** Removes all signer permissions for [coordinate] — trust level and all per-op overrides. */ + suspend fun revokeAll(coordinate: String) = store.clearAll(coordinate) + + private fun reasonableDecision(op: NostrSignerOp): NostrOpDecision = + when (op) { + is NostrSignerOp.SignKind -> + when (op.kind) { + 1 -> NostrOpDecision.ALLOW // Short text notes: common, low-risk + 6 -> NostrOpDecision.ALLOW // Reposts + 7 -> NostrOpDecision.ALLOW // Reactions / emoji + else -> NostrOpDecision.ASK + } + NostrSignerOp.Encrypt -> NostrOpDecision.ASK + NostrSignerOp.Decrypt -> NostrOpDecision.ASK + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt new file mode 100644 index 0000000000..f409183ebe --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.signers + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock + +/** + * Persistence for the per-app internal-signer permissions: each app's [AppSignerPolicy] + * trust level and any [NostrOpDecision] per-operation overrides. Keyed by napplet + * coordinate (e.g. `":"`). + * + * The Android implementation uses one DataStore file per coordinate so loading one app's + * permissions never reads another app's data — essential at scale (1000s of apps). + * Tests use [InMemoryNostrSignerPermissionStore]. + */ +interface NostrSignerPermissionStore { + /** The stored trust level for [coordinate], or `null` if no policy has been set yet. */ + suspend fun loadPolicy(coordinate: String): AppSignerPolicy? + + /** Persist [policy] as the trust level for [coordinate]. */ + suspend fun storePolicy( + coordinate: String, + policy: AppSignerPolicy, + ) + + /** Remove the stored trust level for [coordinate]. */ + suspend fun clearPolicy(coordinate: String) + + /** The stored per-operation decision for ([coordinate], [op]), or `null` if none set. */ + suspend fun loadOpDecision( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision? + + /** Persist [decision] for ([coordinate], [op]). */ + suspend fun storeOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) + + /** Remove the per-operation decision for ([coordinate], [op]). */ + suspend fun clearOpDecision( + coordinate: String, + op: NostrSignerOp, + ) + + /** All stored trust levels, keyed by coordinate — for the permissions-management screen. */ + suspend fun allPolicies(): Map + + /** + * All per-operation overrides for [coordinate], keyed by [NostrSignerOp.key] — for the + * permissions-management screen. + */ + suspend fun allOpDecisions(coordinate: String): Map + + /** Remove all signer permissions (policy + all op overrides) for [coordinate]. */ + suspend fun clearAll(coordinate: String) +} + +/** A thread-safe in-memory [NostrSignerPermissionStore] for tests and ephemeral sessions. */ +class InMemoryNostrSignerPermissionStore : NostrSignerPermissionStore { + private val lock = KmpLock() + private val policies = mutableMapOf() + private val opDecisions = mutableMapOf>() + + override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? = lock.withLock { policies[coordinate] } + + override suspend fun storePolicy( + coordinate: String, + policy: AppSignerPolicy, + ) = lock.withLock { policies[coordinate] = policy } + + override suspend fun clearPolicy(coordinate: String) = + lock.withLock { + policies.remove(coordinate) + Unit + } + + override suspend fun loadOpDecision( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision? = lock.withLock { opDecisions[coordinate]?.get(op.key) } + + override suspend fun storeOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) = lock.withLock { + opDecisions.getOrPut(coordinate) { mutableMapOf() }[op.key] = decision + } + + override suspend fun clearOpDecision( + coordinate: String, + op: NostrSignerOp, + ) = lock.withLock { + opDecisions[coordinate]?.remove(op.key) + Unit + } + + override suspend fun allPolicies(): Map = lock.withLock { policies.toMap() } + + override suspend fun allOpDecisions(coordinate: String): Map = lock.withLock { opDecisions[coordinate]?.toMap() ?: emptyMap() } + + override suspend fun clearAll(coordinate: String) = + lock.withLock { + policies.remove(coordinate) + opDecisions.remove(coordinate) + Unit + } +} From e5763b947cbc94cb9f68adece4746ad04a7ae01b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 17:33:55 +0000 Subject: [PATCH 02/33] 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 From e03fef36e49a0f376f3221962235230be1e1b5bb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 17:44:43 +0000 Subject: [PATCH 03/33] refactor: replace hand-rolled JSON builder with org.json.JSONObject buildEventJson was manually escaping quotes, newlines, and backslashes which is fragile. org.json.JSONObject.toString(2) handles all escaping correctly and produces the same pretty-printed output. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../amethyst/napplet/NostrSignerOpLabels.kt | 46 +++++++------------ 1 file changed, 17 insertions(+), 29 deletions(-) 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 5b3f15a703..209cfd225a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import org.json.JSONArray +import org.json.JSONObject /** Human-readable label for a [NostrSignerOp]. */ fun NostrSignerOp.label(context: Context): String = @@ -81,36 +83,22 @@ private fun buildEventJson( 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}") +): String { + val obj = JSONObject() + obj.put("kind", kind) + if (createdAt != null) obj.put("created_at", createdAt) + if (recipient != null) obj.put("recipient", recipient) + if (encryption != null) obj.put("encryption", encryption) + val tagsArray = JSONArray() + for (tag in tags) { + val tagArray = JSONArray() + for (item in tag) tagArray.put(item) + tagsArray.put(tagArray) } + obj.put("tags", tagsArray) + obj.put("content", content) + return obj.toString(2) +} /** Creates a [NappletConnectInfo] for the first-connect dialog. */ fun buildConnectInfo( From 77dacd9e686b6184795806e4d0b1fea156a42a86 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 17:55:47 +0000 Subject: [PATCH 04/33] feat: add Connected Apps settings screen with per-app permission detail Adds a unified "Connected Apps" entry to Account Settings that surfaces all web apps that have connected to the user's Nostr key (both capability grants and signer trust levels) in one place. - Route.ConnectedApps (list) + Route.ConnectedAppDetail(coordinate) (detail) - ConnectedAppsScreen: merges NappletPermissionLedger + NostrSignerPermissionLedger, shows one card per app sorted alphabetically with trust-level chip - ConnectedAppDetailScreen: identity header, editable trust-level picker (Full Trust / Reasonable / Paranoid), op override rows with revoke, capability switches, and a "Forget this app" button that clears all permissions - Settings catalog entry under Account (Apps symbol, keyword-searchable) - NappletsTopBar Tune icon now navigates to ConnectedApps instead of the old capability-only NappletPermissions screen Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../amethyst/ui/navigation/AppNavigation.kt | 4 + .../amethyst/ui/navigation/routes/Routes.kt | 6 + .../napplets/ConnectedAppDetailScreen.kt | 534 ++++++++++++++++++ .../loggedIn/napplets/ConnectedAppsScreen.kt | 274 +++++++++ .../loggedIn/napplets/NappletsTopBar.kt | 2 +- .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 6 + 7 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index be7aaa5cc1..7025de0ecb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -159,6 +159,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicPlaylistsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicTracksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicPlaylistScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicTrackScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppDetailScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletPermissionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen @@ -336,6 +338,8 @@ fun BuildNavigation( composableFromEndArgs { WebAppScreen(it.url, accountViewModel, nav) } composableFromEndArgs { NostrAppScreen(it.coordinate, accountViewModel, nav) } composableFromEnd { NappletPermissionsScreen(accountViewModel, nav) } + composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } + composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } composableFromEnd { CalendarCollectionsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 063791d81b..9cb3751dda 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -107,6 +107,12 @@ sealed class Route { @Serializable object NappletPermissions : Route() + @Serializable object ConnectedApps : Route() + + @Serializable data class ConnectedAppDetail( + val coordinate: String, + ) : Route() + @Serializable data class SoftwareAppDetail( val kind: Int, val pubKeyHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt new file mode 100644 index 0000000000..5fee9e3448 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -0,0 +1,534 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.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.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState +import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore +import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore +import com.vitorpamplona.amethyst.napplet.descriptionRes +import com.vitorpamplona.amethyst.napplet.labelRes +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private data class ConnectedAppDetailState( + val title: String, + val coordinate: String, + val signerPolicy: AppSignerPolicy?, + val opOverrides: Map, + val capabilities: List>, +) + +@Composable +fun ConnectedAppDetailScreen( + coordinate: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val context = LocalContext.current + val capabilityLedger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) } + val signerLedger = remember { NostrSignerPermissionLedger(DataStoreNostrSignerPermissionStore(context)) } + val untitled = stringResource(R.string.napplet_untitled) + + var state by remember { mutableStateOf(null) } + var reload by remember { mutableIntStateOf(0) } + val scope = rememberCoroutineScope() + + LaunchedEffect(coordinate, reload) { + state = + withContext(Dispatchers.Default) { + loadDetailState(coordinate, capabilityLedger, signerLedger, untitled) + } + } + + fun mutate(block: suspend () -> Unit) { + scope.launch { + block() + reload++ + } + } + + val identity = + remember(coordinate) { + NappletIdentity( + authorPubKey = coordinate.substringBefore(':'), + identifier = coordinate.substringAfter(':', ""), + ) + } + + Scaffold( + topBar = { TopBarWithBackButton(state?.title ?: coordinate.substringAfter(':', "").ifBlank { coordinate.take(12) + "…" }, nav) }, + ) { padding -> + val current = state + if (current == null) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Scaffold + } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // App identity header + AppIdentityHeader(current) + + // Signing trust level section + if (current.signerPolicy != null) { + SectionHeader(stringResource(R.string.napplet_connected_app_trust_level)) + PolicyPicker( + selected = current.signerPolicy, + onSelect = { newPolicy -> + mutate { signerLedger.setPolicy(coordinate, newPolicy) } + }, + ) + } + + // Signing operation overrides section + if (current.opOverrides.isNotEmpty()) { + SectionHeader(stringResource(R.string.napplet_connected_app_op_overrides)) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(4.dp)) { + current.opOverrides.entries.forEachIndexed { index, (opKey, decision) -> + if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + OpOverrideRow( + opKey = opKey, + decision = decision, + onRevoke = { mutate { signerLedger.revokeOpDecision(coordinate, NostrSignerOp.fromKey(opKey) ?: return@mutate) } }, + ) + } + } + } + } + + // Capabilities section + if (current.capabilities.isNotEmpty()) { + SectionHeader(stringResource(R.string.napplet_connected_app_capabilities)) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(4.dp)) { + current.capabilities.forEachIndexed { index, (cap, grant) -> + if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + CapabilityDetailRow( + capability = cap, + grant = grant, + onSetAllowed = { allowed -> + mutate { capabilityLedger.record(identity, cap, if (allowed) GrantState.ALLOW_ALWAYS else GrantState.DENY) } + }, + onRevoke = { mutate { capabilityLedger.revoke(identity, cap) } }, + ) + } + } + } + } + + // Forget button + Spacer(Modifier.size(8.dp)) + Button( + onClick = { + mutate { + signerLedger.revokeAll(coordinate) + capabilityLedger.revokeAll(identity) + } + nav.popBack() + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer), + ) { + Icon(MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.napplet_connected_app_forget)) + } + } + } +} + +@Composable +private fun AppIdentityHeader(state: ConnectedAppDetailState) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(48.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + MaterialSymbols.Apps, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(28.dp), + ) + } + } + Column(modifier = Modifier.weight(1f)) { + Text( + state.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + state.coordinate, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +private fun PolicyPicker( + selected: AppSignerPolicy, + onSelect: (AppSignerPolicy) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + PolicyOption( + selected = selected == AppSignerPolicy.FULL_TRUST, + icon = "ā¤", + label = stringResource(R.string.napplet_policy_full_trust), + description = stringResource(R.string.napplet_policy_full_trust_desc), + onClick = { onSelect(AppSignerPolicy.FULL_TRUST) }, + ) + PolicyOption( + selected = selected == AppSignerPolicy.REASONABLE, + icon = "šŸ‘", + label = stringResource(R.string.napplet_policy_reasonable), + description = stringResource(R.string.napplet_policy_reasonable_desc), + onClick = { onSelect(AppSignerPolicy.REASONABLE) }, + ) + PolicyOption( + selected = selected == AppSignerPolicy.PARANOID, + icon = "šŸ•¶", + label = stringResource(R.string.napplet_policy_paranoid), + description = stringResource(R.string.napplet_policy_paranoid_desc), + onClick = { onSelect(AppSignerPolicy.PARANOID) }, + ) + } +} + +@Composable +private fun PolicyOption( + selected: Boolean, + icon: String, + label: String, + description: String, + onClick: () -> Unit, +) { + val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface + + Surface( + modifier = + Modifier + .fillMaxWidth() + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = bgColor, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(icon, style = MaterialTheme.typography.headlineSmall) + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.titleSmall) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (selected) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +@Composable +private fun OpOverrideRow( + opKey: String, + decision: NostrOpDecision, + onRevoke: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + NostrSignerOp.fromKey(opKey)?.opLabel() ?: opKey, + style = MaterialTheme.typography.bodyMedium, + ) + } + Text( + decision.decisionLabel(), + style = MaterialTheme.typography.labelSmall, + color = + when (decision) { + NostrOpDecision.DENY -> MaterialTheme.colorScheme.error + NostrOpDecision.ALLOW -> MaterialTheme.colorScheme.primary + NostrOpDecision.ASK -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + IconButton(onClick = onRevoke) { + Icon( + MaterialSymbols.Delete, + contentDescription = stringResource(R.string.napplet_signer_permissions_revoke_all), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun CapabilityDetailRow( + capability: NappletCapability, + grant: GrantState, + onSetAllowed: (Boolean) -> Unit, + onRevoke: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + capability.capSymbol(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.size(12.dp)) + Column(Modifier.weight(1f)) { + Text(stringResource(capability.labelRes()), style = MaterialTheme.typography.bodyMedium) + Text( + stringResource(capability.descriptionRes()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (capability.requiresPerUseConsent) { + Text( + stringResource(R.string.napplet_permissions_blocked), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + Switch( + checked = grant == GrantState.ALLOW_ALWAYS, + onCheckedChange = onSetAllowed, + ) + } + + Spacer(Modifier.size(4.dp)) + IconButton(onClick = onRevoke) { + Icon( + MaterialSymbols.Block, + contentDescription = stringResource(R.string.napplet_permissions_revoke), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun NostrSignerOp.opLabel(): 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.decisionLabel(): 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) + } + +private fun NappletCapability.capSymbol(): MaterialSymbol = + when (this) { + NappletCapability.SHELL -> MaterialSymbols.Tune + NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle + NappletCapability.KEYS -> MaterialSymbols.Key + NappletCapability.RELAY -> MaterialSymbols.Public + NappletCapability.STORAGE -> MaterialSymbols.Storage + NappletCapability.VALUE -> MaterialSymbols.Bolt + NappletCapability.RESOURCE -> MaterialSymbols.Language + NappletCapability.UPLOAD -> MaterialSymbols.Upload + NappletCapability.THEME -> MaterialSymbols.Image + NappletCapability.NOTIFY -> MaterialSymbols.Notifications + NappletCapability.INC -> MaterialSymbols.SwapHoriz + } + +private suspend fun loadDetailState( + coordinate: String, + capabilityLedger: NappletPermissionLedger, + signerLedger: NostrSignerPermissionLedger, + untitled: String, +): ConnectedAppDetailState { + val author = coordinate.substringBefore(':') + val identifier = coordinate.substringAfter(':', "") + val identity = NappletIdentity(authorPubKey = author, identifier = identifier) + + val allGrants = capabilityLedger.allPersistedGrants() + val capGrants = + allGrants[coordinate] + ?.entries + ?.sortedBy { it.key.ordinal } + ?.map { it.key to it.value } + ?: emptyList() + val signerPolicy = signerLedger.store.loadPolicy(coordinate) + val opOverrides = signerLedger.store.allOpDecisions(coordinate) + + return ConnectedAppDetailState( + title = resolveAppTitle(author, identifier, untitled), + coordinate = coordinate, + signerPolicy = signerPolicy, + opOverrides = opOverrides, + capabilities = capGrants, + ) +} + +private fun resolveAppTitle( + author: String, + identifier: String, + untitled: String, +): String { + val events = + Amethyst.instance.cache + .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) + .mapNotNull { it.event } + val match = + events.firstOrNull { ev -> + when (ev) { + is NamedNappletEvent -> ev.identifier() == identifier + is RootNappletEvent -> identifier.isEmpty() + else -> false + } + } + return (match as? NappletManifest)?.title()?.ifBlank { null } + ?: identifier.ifBlank { untitled } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt new file mode 100644 index 0000000000..8f9daa9a53 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -0,0 +1,274 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore +import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private data class ConnectedAppEntry( + val coordinate: String, + val title: String, + val domain: String, + val signerPolicy: AppSignerPolicy?, + val capabilityCount: Int, +) + +@Composable +fun ConnectedAppsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val context = LocalContext.current + val capabilityLedger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) } + val signerLedger = remember { NostrSignerPermissionLedger(DataStoreNostrSignerPermissionStore(context)) } + val untitled = stringResource(R.string.napplet_untitled) + + var items by remember { mutableStateOf?>(null) } + var reload by remember { mutableIntStateOf(0) } + + LaunchedEffect(reload) { + items = + withContext(Dispatchers.Default) { + loadConnectedApps(capabilityLedger, signerLedger, untitled) + } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringResource(R.string.napplet_permissions_title), nav) }, + ) { padding -> + val current = items + when { + current == null -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + current.isEmpty() -> + Box(Modifier.fillMaxSize().padding(padding).padding(32.dp), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + MaterialSymbols.Apps, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(56.dp), + ) + Text( + stringResource(R.string.napplet_connected_app_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } + } + + else -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(current, key = { it.coordinate }) { entry -> + ConnectedAppCard( + entry = entry, + onClick = { nav.nav(Route.ConnectedAppDetail(entry.coordinate)) }, + ) + } + } + } + } +} + +@Composable +private fun ConnectedAppCard( + entry: ConnectedAppEntry, + onClick: () -> Unit, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + MaterialSymbols.Apps, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + entry.title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.domain, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (entry.signerPolicy != null) { + SuggestionChip( + onClick = {}, + label = { Text(entry.signerPolicy.shortLabel(), style = MaterialTheme.typography.labelSmall) }, + ) + } + } + + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun AppSignerPolicy.shortLabel(): String = + when (this) { + AppSignerPolicy.FULL_TRUST -> stringResource(R.string.napplet_policy_full_trust) + AppSignerPolicy.REASONABLE -> stringResource(R.string.napplet_policy_reasonable) + AppSignerPolicy.PARANOID -> stringResource(R.string.napplet_policy_paranoid) + } + +private suspend fun loadConnectedApps( + capabilityLedger: NappletPermissionLedger, + signerLedger: NostrSignerPermissionLedger, + untitled: String, +): List { + val capGrants = capabilityLedger.allPersistedGrants() + val signerPolicies = signerLedger.store.allPolicies() + + val allCoordinates = (capGrants.keys + signerPolicies.keys).toSet() + + return allCoordinates + .map { coordinate -> + val author = coordinate.substringBefore(':') + val identifier = coordinate.substringAfter(':', "") + ConnectedAppEntry( + coordinate = coordinate, + title = resolveAppTitle(author, identifier, untitled), + domain = identifier.ifBlank { author.take(12) + "…" }, + signerPolicy = signerPolicies[coordinate], + capabilityCount = capGrants[coordinate]?.size ?: 0, + ) + }.sortedBy { it.title.lowercase() } +} + +private fun resolveAppTitle( + author: String, + identifier: String, + untitled: String, +): String { + val events = + Amethyst.instance.cache + .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) + .mapNotNull { it.event } + val match = + events.firstOrNull { ev -> + when (ev) { + is NamedNappletEvent -> ev.identifier() == identifier + is RootNappletEvent -> identifier.isEmpty() + else -> false + } + } + return (match as? NappletManifest)?.title()?.ifBlank { null } + ?: identifier.ifBlank { untitled } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt index df1c7f2e9d..e8183abcc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt @@ -78,7 +78,7 @@ fun NappletsTopBar( } }, actions = { - IconButton(onClick = { nav.nav(Route.NappletPermissions) }) { + IconButton(onClick = { nav.nav(Route.ConnectedApps) }) { Icon(MaterialSymbols.Tune, contentDescription = stringResource(R.string.napplet_manage_permissions)) } IconButton(onClick = { nav.nav(Route.Search) }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 023e57df6f..278a3e50ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -74,6 +74,7 @@ fun buildSettingsCatalog( symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds), symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges), symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets), + symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps), symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters), symEntry(R.string.call_settings, MaterialSymbols.Phone, R.string.call_settings_search_keywords, Route.CallSettings), symEntry(R.string.translations, MaterialSymbols.Translate, R.string.translations_search_keywords, Route.UserSettings), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2f8e54ab49..aa9f19b319 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -791,6 +791,12 @@ Allow Ask Deny + apps permissions signer napplet nsite webapp trust connected + Signing trust level + Capabilities + Forget this app + Signing operation overrides + No apps have connected yet.\n\nWhen a web app connects to your Nostr key, it will appear here. Source: %1$s v%1$s From 328790ac9f3f97901bcab12267bd290ef9512808 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 19:27:14 +0000 Subject: [PATCH 05/33] feat: time-bound signer grants, last-used tracking, and relay AUTH settings (NIP-42) - Add AllowForSession and AllowUntil(expiresAt) signer grant types so users can grant temporary access (session, 24h, 30d) from the consent dialog - Track per-app lastUsed timestamp in NostrSignerPermissionStore and update it on every granted signing operation - Auto-expire timed grants: decide() clears expired op decisions before returning, so no background sweep is needed - Add NappletBroker.sessionAllows in-memory set for session grants (cleared on broker destroy, never persisted) - Implement full NIP-42 relay auth policy system: - RelayAuthPolicy enum (ALWAYS / NEVER / IF_IN_MY_LIST) stored in AccountSettings and persisted in LocalPreferences - RelayAuthDecision (ALLOW / DENY) per-relay overrides in DataStore - RelayAuthPermissionLedger combining global policy + per-relay overrides - DataStoreRelayAuthPermissionStore writing to relay_auth.preferences_pb - Wire relay auth into AuthCoordinator: subscribeLedger/unsubscribeLedger lets each logged-in account contribute its own policy; signWithAllLoggedInUsers now receives the relay URL so it can check the ledger before signing - Update RelayAuthenticator (quartz) to pass relay URL in the signing lambda - Add RelayAuthSubscription composable that subscribes both the account and its ledger when a screen is active - Add RelayAuthSettingsScreen: global policy radio picker + per-relay override list with toggle and remove; reachable from Settings - Add Route.RelayAuthSettings, AppNavigation wiring, and SettingsCatalog entry Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../com/vitorpamplona/amethyst/AppModules.kt | 6 + .../amethyst/LocalPreferences.kt | 8 + .../amethyst/model/AccountSettings.kt | 9 + .../DataStoreNostrSignerPermissionStore.kt | 41 +++ .../napplet/NappletSignerConsentActivity.kt | 16 ++ .../compose/AccountDataSourceSubscription.kt | 24 +- .../authCommand/model/AuthCoordinator.kt | 57 ++-- .../DataStoreRelayAuthPermissionStore.kt | 100 +++++++ .../model/RelayAuthPermissionLedger.kt | 64 +++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../relayauth/RelayAuthSettingsScreen.kt | 251 ++++++++++++++++++ .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 21 ++ .../amethyst/commons/napplet/NappletBroker.kt | 26 +- .../signers/NostrSignerConsentPrompt.kt | 15 ++ .../signers/NostrSignerPermissionLedger.kt | 46 +++- .../signers/NostrSignerPermissionStore.kt | 58 ++++ .../relayauth/RelayAuthPermissionStore.kt | 43 +++ .../commons/relayauth/RelayAuthPolicy.kt | 42 +++ .../relay/client/auth/RelayAuthenticator.kt | 9 +- 22 files changed, 816 insertions(+), 27 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 766b24794c..57e109a3a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState @@ -529,6 +530,11 @@ class AppModules( // Show messages from the Relay and controls their dismissal val notifyCoordinator = NotifyCoordinator(client) + // Persists per-relay NIP-42 ALLOW/DENY overrides across app restarts. + val relayAuthPermissionStore by lazy { + DataStoreRelayAuthPermissionStore(appContext) + } + // Authenticates with relays. val authCoordinator = AuthCoordinator(client, applicationIOScope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 22ded89efe..c853fc7cc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -28,6 +28,7 @@ import androidx.core.content.edit import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.UiSettings @@ -155,6 +156,7 @@ private object PrefKeys { const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service" + const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy" const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled" const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications" @@ -506,6 +508,7 @@ object LocalPreferences { putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value) putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) + putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name) putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value) putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value) // Any account that reaches a save has its notification filter in its @@ -622,6 +625,10 @@ object LocalPreferences { val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true) val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false) + val defaultRelayAuthPolicy = + getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null) + ?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() } + ?: RelayAuthPolicy.IF_IN_MY_LIST val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false) val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() @@ -825,6 +832,7 @@ object LocalPreferences { hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), + defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy), splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled), showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications), backupUserMetadata = latestUserMetadataResolved, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index a9b0a91781..ea25cf75ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListR import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -267,6 +268,7 @@ class AccountSettings( var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720, var callMaxBitrateBps: Int = 1_500_000, val callsEnabled: MutableStateFlow = MutableStateFlow(true), + val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST), ) : EphemeralChatRepository, PublicChatListRepository { val saveable = MutableStateFlow(AccountSettingsUpdater(null)) @@ -1476,6 +1478,13 @@ class AccountSettings( saveAccountSettings() } } + + fun changeDefaultRelayAuthPolicy(policy: RelayAuthPolicy) { + if (defaultRelayAuthPolicy.value != policy) { + defaultRelayAuthPolicy.tryEmit(policy) + saveAccountSettings() + } + } } @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt index 11fbaf6c4d..0812466778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt @@ -128,6 +128,8 @@ class DataStoreNostrSignerPermissionStore( for ((key, value) in prefs.asMap()) { val name = key.name if (!name.startsWith(OP_PREFIX)) continue + // Skip expiry metadata keys — they end with the expiry suffix + if (name.endsWith(OP_EXPIRY_SUFFIX)) continue val opKey = name.removePrefix(OP_PREFIX) val decision = runCatching { NostrOpDecision.valueOf(value as String) }.getOrNull() ?: continue result[opKey] = decision @@ -135,16 +137,55 @@ class DataStoreNostrSignerPermissionStore( return result } + override suspend fun loadOpExpiry( + coordinate: String, + op: NostrSignerOp, + ): Long? { + val raw = storeFor(coordinate).data.first()[opExpiryKey(op)] ?: return null + return raw.toLongOrNull() + } + + override suspend fun storeOpExpiry( + coordinate: String, + op: NostrSignerOp, + expiresAt: Long, + ) { + storeFor(coordinate).edit { it[opExpiryKey(op)] = expiresAt.toString() } + } + + override suspend fun clearOpExpiry( + coordinate: String, + op: NostrSignerOp, + ) { + storeFor(coordinate).edit { it.remove(opExpiryKey(op)) } + } + + override suspend fun loadLastUsed(coordinate: String): Long? { + val raw = storeFor(coordinate).data.first()[KEY_LAST_USED] ?: return null + return raw.toLongOrNull() + } + + override suspend fun storeLastUsed( + coordinate: String, + epochSeconds: Long, + ) { + storeFor(coordinate).edit { it[KEY_LAST_USED] = epochSeconds.toString() } + } + override suspend fun clearAll(coordinate: String) { storeFor(coordinate).edit { it.clear() } } private fun opKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}") + private fun opExpiryKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}$OP_EXPIRY_SUFFIX") + companion object { private val KEY_COORDINATE = stringPreferencesKey("coordinate") private val KEY_POLICY = stringPreferencesKey("policy") + private val KEY_LAST_USED = stringPreferencesKey("lastused") private const val OP_PREFIX = "op:" + private const val OP_EXPIRY_SUFFIX = ":exp" private fun hash(coordinate: String): String { val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray()) 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 c35eca9b26..28323c2443 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -55,6 +55,7 @@ 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 +import com.vitorpamplona.quartz.utils.TimeUtils class NappletSignerConsentActivity : ComponentActivity() { private var token: String? = null @@ -204,6 +205,21 @@ private fun NappletSignerConsentDialog( color = MaterialTheme.colorScheme.primary, onClick = { onGrant(SignerOpGrant.AllowOnce) }, ) + ConsentActionButton( + text = stringResource(R.string.napplet_signer_allow_session), + color = MaterialTheme.colorScheme.primary, + onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) }, + ) + ConsentActionButton( + text = stringResource(R.string.napplet_signer_allow_24h), + color = MaterialTheme.colorScheme.primary, + onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) }, + ) + ConsentActionButton( + text = stringResource(R.string.napplet_signer_allow_30d), + color = MaterialTheme.colorScheme.primary, + onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) }, + ) ConsentActionButton( text = stringResource(R.string.napplet_signer_allow_op, info.operationSummary), color = MaterialTheme.colorScheme.primary, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt index 9c5c15f1cc..5f914d45ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -36,17 +37,32 @@ fun RelayAuthSubscription( accountViewModel: AccountViewModel, dataSource: AuthCoordinator, ) { - // different screens get different states - // even if they are tracking the same tag. + val account = accountViewModel.account + val state = remember(accountViewModel) { - ScreenAuthAccount(accountViewModel.account) + ScreenAuthAccount(account) } - DisposableEffect(state) { + val ledger = + remember(accountViewModel) { + RelayAuthPermissionLedger( + store = Amethyst.instance.relayAuthPermissionStore, + globalPolicy = { account.settings.defaultRelayAuthPolicy.value }, + isInMyRelayList = { relayUrl -> + account.settings.localRelayServers.value.any { + it.trimEnd('/') == relayUrl.trimEnd('/') + } + }, + ) + } + + DisposableEffect(state, ledger) { dataSource.subscribe(state) + dataSource.subscribeLedger(ledger) onDispose { dataSource.unsubscribe(state) + dataSource.unsubscribeLedger(ledger) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 80c11d79d5..e8e1252cea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -43,30 +44,56 @@ class AuthCoordinator( NostrSignerSync() } + @Volatile private var relayLedgers: List = emptyList() + + fun subscribeLedger(ledger: RelayAuthPermissionLedger) { + synchronized(this) { relayLedgers = relayLedgers + ledger } + } + + fun unsubscribeLedger(ledger: RelayAuthPermissionLedger) { + synchronized(this) { relayLedgers = relayLedgers - ledger } + } + val receiver = RelayAuthenticator( client, scope, - signWithAllLoggedInUsers = { authTemplate -> - val results = - authWithAccounts.distinct().mapNotNull { - if (it.signer.isWriteable()) { - try { - it.signer.sign(authTemplate) - } catch (e: Exception) { - Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) - null + signWithAllLoggedInUsers = { relayUrl, authTemplate -> + val currentLedgers = relayLedgers + val shouldAuth = + if (currentLedgers.isEmpty()) { + true + } else { + var allow = false + for (ledger in currentLedgers) { + if (ledger.decide(relayUrl.url) == RelayAuthDecision.ALLOW) { + allow = true + break } - } else { - null } + allow } - // Always auth, even with random keys - if (!results.isEmpty()) { - results + if (shouldAuth) { + // distinct() returns Set (the key type U of ListWithUniqueSetCache) + val results = + authWithAccounts.distinct().mapNotNull { + if (it.signer.isWriteable()) { + try { + it.signer.sign(authTemplate) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) + null + } + } else { + null + } + } + + // Always auth, even with random keys + if (results.isNotEmpty()) results else listOf(tempAccount.sign(authTemplate)) } else { - listOf(tempAccount.sign(authTemplate)) + emptyList() } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt new file mode 100644 index 0000000000..67e2b22817 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import kotlinx.coroutines.flow.first +import java.io.File +import java.security.MessageDigest + +/** + * Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides + * live in one `datastore/relay_auth.preferences_pb` file; a SHA-256 prefix of the URL is the + * key so the URL itself is safe in the file (stored separately for reverse-lookup in [allDecisions]). + */ +class DataStoreRelayAuthPermissionStore( + private val filesDir: File, +) : RelayAuthPermissionStore { + constructor(context: Context) : this(context.applicationContext.filesDir) + + private val store: DataStore by lazy { + PreferenceDataStoreFactory.create( + produceFile = { File(filesDir, "datastore/relay_auth.preferences_pb") }, + ) + } + + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { + val raw = store.data.first()[decisionKey(relayUrl)] ?: return null + return runCatching { RelayAuthDecision.valueOf(raw) }.getOrNull() + } + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + store.edit { + it[urlKey(relayUrl)] = relayUrl + it[decisionKey(relayUrl)] = decision.name + } + } + + override suspend fun clearDecision(relayUrl: String) { + store.edit { + it.remove(decisionKey(relayUrl)) + it.remove(urlKey(relayUrl)) + } + } + + override suspend fun allDecisions(): Map { + val prefs = store.data.first() + val result = mutableMapOf() + for ((key, value) in prefs.asMap()) { + val name = key.name + if (!name.startsWith(DECISION_PREFIX)) continue + val hash = name.removePrefix(DECISION_PREFIX) + val url = prefs[stringPreferencesKey("$URL_PREFIX$hash")] ?: continue + val decision = runCatching { RelayAuthDecision.valueOf(value as String) }.getOrNull() ?: continue + result[url] = decision + } + return result + } + + private fun decisionKey(relayUrl: String) = stringPreferencesKey("$DECISION_PREFIX${hash(relayUrl)}") + + private fun urlKey(relayUrl: String) = stringPreferencesKey("$URL_PREFIX${hash(relayUrl)}") + + companion object { + private const val DECISION_PREFIX = "allow:" + private const val URL_PREFIX = "url:" + + private fun hash(relayUrl: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(relayUrl.toByteArray()) + return digest.take(8).joinToString("") { "%02x".format(it) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt new file mode 100644 index 0000000000..e7c5c69ca2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy + +/** + * Decides whether Amethyst should authenticate with a given relay (NIP-42). + * + * Decision order: + * 1. Per-relay override stored in [store] — always wins. + * 2. [globalPolicy]: + * - [RelayAuthPolicy.ALWAYS] → [RelayAuthDecision.ALLOW] + * - [RelayAuthPolicy.NEVER] → [RelayAuthDecision.DENY] + * - [RelayAuthPolicy.IF_IN_MY_LIST] → [RelayAuthDecision.ALLOW] iff [isInMyRelayList] returns true. + */ +class RelayAuthPermissionLedger( + val store: RelayAuthPermissionStore, + val globalPolicy: () -> RelayAuthPolicy, + val isInMyRelayList: (String) -> Boolean = { false }, +) { + /** The authorization verdict for [relayUrl]. */ + suspend fun decide(relayUrl: String): RelayAuthDecision { + store.loadDecision(relayUrl)?.let { return it } + return when (globalPolicy()) { + RelayAuthPolicy.ALWAYS -> RelayAuthDecision.ALLOW + RelayAuthPolicy.NEVER -> RelayAuthDecision.DENY + RelayAuthPolicy.IF_IN_MY_LIST -> + if (isInMyRelayList(relayUrl)) RelayAuthDecision.ALLOW else RelayAuthDecision.DENY + } + } + + /** Stores a per-relay override for [relayUrl]. */ + suspend fun setDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) = store.storeDecision(relayUrl, decision) + + /** Removes the per-relay override for [relayUrl], reverting to the global policy. */ + suspend fun clearDecision(relayUrl: String) = store.clearDecision(relayUrl) + + /** All per-relay overrides — for the settings screen. */ + suspend fun allDecisions(): Map = store.allDecisions() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 7025de0ecb..809b7703f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -185,6 +185,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.PublicChatsScre import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth.RelayAuthSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen @@ -340,6 +341,7 @@ fun BuildNavigation( composableFromEnd { NappletPermissionsScreen(accountViewModel, nav) } composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } + composableFromEnd { RelayAuthSettingsScreen(accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } composableFromEnd { CalendarCollectionsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9cb3751dda..664ce24c84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -109,6 +109,8 @@ sealed class Route { @Serializable object ConnectedApps : Route() + @Serializable object RelayAuthSettings : Route() + @Serializable data class ConnectedAppDetail( val coordinate: String, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 23def5aa6b..2392174c4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -308,7 +308,7 @@ class AccountViewModel( RelayAuthenticator( newClient, customScope, - signWithAllLoggedInUsers = { authTemplate -> + signWithAllLoggedInUsers = { _, authTemplate -> if (account.signer.isWriteable()) { try { listOf(account.signer.sign(authTemplate)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt new file mode 100644 index 0000000000..005cbcae76 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun RelayAuthSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val store: DataStoreRelayAuthPermissionStore = Amethyst.instance.relayAuthPermissionStore + val ledger = remember { RelayAuthPermissionLedger(store, { account.settings.defaultRelayAuthPolicy.value }) } + val scope = rememberCoroutineScope() + + val globalPolicy by account.settings.defaultRelayAuthPolicy.collectAsState() + + var perRelayOverrides by remember { mutableStateOf>(emptyMap()) } + var reloadKey by remember { mutableIntStateOf(0) } + + LaunchedEffect(reloadKey) { + perRelayOverrides = withContext(Dispatchers.IO) { store.allDecisions() } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringResource(R.string.relay_auth_settings_title), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.relay_auth_global_policy), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(4.dp)) + + Column(Modifier.selectableGroup()) { + RelayAuthPolicy.entries.forEach { policy -> + val (titleRes, descRes) = + when (policy) { + RelayAuthPolicy.ALWAYS -> R.string.relay_auth_policy_always to R.string.relay_auth_policy_always_desc + RelayAuthPolicy.NEVER -> R.string.relay_auth_policy_never to R.string.relay_auth_policy_never_desc + RelayAuthPolicy.IF_IN_MY_LIST -> R.string.relay_auth_policy_if_in_my_list to R.string.relay_auth_policy_if_in_my_list_desc + } + Row( + modifier = + Modifier + .fillMaxWidth() + .selectable( + selected = globalPolicy == policy, + onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) }, + role = Role.RadioButton, + ).padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RadioButton( + selected = globalPolicy == policy, + onClick = null, + ) + Column { + Text(text = stringResource(titleRes), style = MaterialTheme.typography.bodyLarge) + Text( + text = stringResource(descRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + if (perRelayOverrides.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.relay_auth_per_relay_overrides), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(4.dp)) + + perRelayOverrides.entries.sortedBy { it.key }.forEach { (url, decision) -> + PerRelayOverrideRow( + url = url, + decision = decision, + onRemove = { + scope.launch { + ledger.clearDecision(url) + reloadKey++ + } + }, + onToggle = { + scope.launch { + val next = + if (decision == RelayAuthDecision.ALLOW) { + RelayAuthDecision.DENY + } else { + RelayAuthDecision.ALLOW + } + ledger.setDecision(url, next) + reloadKey++ + } + }, + ) + } + } + + if (perRelayOverrides.isEmpty()) { + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.relay_auth_no_overrides), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun PerRelayOverrideRow( + url: String, + decision: RelayAuthDecision, + onRemove: () -> Unit, + onToggle: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f)) { + Text( + text = url, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + } + SuggestionChip( + onClick = onToggle, + label = { + Text( + text = + if (decision == RelayAuthDecision.ALLOW) { + stringResource(R.string.relay_auth_decision_allow) + } else { + stringResource(R.string.relay_auth_decision_deny) + }, + style = MaterialTheme.typography.labelSmall, + ) + }, + colors = + if (decision == RelayAuthDecision.ALLOW) { + SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } else { + SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + labelColor = MaterialTheme.colorScheme.onErrorContainer, + ) + }, + ) + IconButton(onClick = onRemove) { + Icon(MaterialSymbols.Close, contentDescription = stringResource(R.string.relay_auth_remove_override)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 278a3e50ef..b6aab37179 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -75,6 +75,7 @@ fun buildSettingsCatalog( symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges), symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets), symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps), + symEntry(R.string.relay_auth_settings_title, MaterialSymbols.Lock, R.string.relay_auth_search_keywords, Route.RelayAuthSettings), symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters), symEntry(R.string.call_settings, MaterialSymbols.Phone, R.string.call_settings_search_keywords, Route.CallSettings), symEntry(R.string.translations, MaterialSymbols.Translate, R.string.translations_search_keywords, Route.UserSettings), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index aa9f19b319..3e33eb2334 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -771,10 +771,15 @@ See more See less Allow once + Allow for this session + Allow for 24 hours + Allow for 30 days Don\'t ask again to %1$s Don\'t ask again for any Nostr requests Deny Always deny %1$s + Last used + Expires sign kind %1$d event @@ -798,6 +803,22 @@ Signing operation overrides No apps have connected yet.\n\nWhen a web app connects to your Nostr key, it will appear here. + + Relay Authentication + auth authentication relay sign verify nip-42 identity + Global policy + Always authenticate + Sign auth challenges for every relay that requests it + Never authenticate + Ignore auth challenges from all relays + My relays only + Only authenticate with relays in your relay list + Per-relay overrides + No per-relay overrides — global policy applies everywhere + Allow + Deny + Remove override + Source: %1$s v%1$s Download diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 85188cfbe2..62ecb4321c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -93,6 +93,10 @@ class NappletBroker( // queue into one dialog at a time rather than launching several dialogs simultaneously. private val signerConsentLock = Mutex() + // In-memory session grants (AllowForSession): cleared when this broker instance is destroyed. + // Only accessed under signerConsentLock. + private val sessionAllows = mutableSetOf() + /** * Authorizes and runs [request] on behalf of [identity]. [declared] is the capability set the * manifest's `requires` resolved to; a request outside it is refused before any prompt. @@ -390,18 +394,38 @@ class NappletBroker( ): Boolean = signerConsentLock.withLock { val sl = signerLedger ?: return@withLock true + // Session grants win immediately without touching storage. + if (op.key in sessionAllows) { + sl.updateLastUsed(identity.coordinate) + return@withLock true + } when (sl.decide(identity.coordinate, op)) { - NostrOpDecision.ALLOW -> true + NostrOpDecision.ALLOW -> { + sl.updateLastUsed(identity.coordinate) + true + } NostrOpDecision.DENY -> false NostrOpDecision.ASK -> { val prompt = signerConsentPrompt ?: return@withLock true when (val grant = prompt.request(identity, op, request)) { is SignerOpGrant.AllowAll -> { sl.setPolicy(identity.coordinate, AppSignerPolicy.FULL_TRUST) + sl.updateLastUsed(identity.coordinate) true } is SignerOpGrant.AllowForOp -> { sl.setOpDecision(identity.coordinate, op, NostrOpDecision.ALLOW) + sl.updateLastUsed(identity.coordinate) + true + } + is SignerOpGrant.AllowForSession -> { + sessionAllows.add(op.key) + sl.updateLastUsed(identity.coordinate) + true + } + is SignerOpGrant.AllowUntil -> { + sl.setTimedOpDecision(identity.coordinate, op, NostrOpDecision.ALLOW, grant.expiresAt) + sl.updateLastUsed(identity.coordinate) true } is SignerOpGrant.DenyForOp -> { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt index f991342c7b..ea6f87bd34 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt @@ -74,6 +74,21 @@ sealed interface SignerOpGrant { override val isAllowed = true } + /** Allow for the current broker session only — not persisted across app restarts. */ + data class AllowForSession( + val op: NostrSignerOp, + ) : SignerOpGrant { + override val isAllowed = true + } + + /** Allow and remember until [expiresAt] (Unix epoch seconds). */ + data class AllowUntil( + val op: NostrSignerOp, + val expiresAt: Long, + ) : SignerOpGrant { + override val isAllowed = true + } + /** Allow and upgrade to [AppSignerPolicy.FULL_TRUST] for all future requests. */ data object AllowAll : SignerOpGrant { override val isAllowed = true diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt index e2e7b6812d..c750a2614b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.napplet.signers +import com.vitorpamplona.quartz.utils.TimeUtils + /** * The per-app Nostr signer permission ledger. Decides whether a signing or encryption * operation should auto-allow, auto-deny, or ask the user, by consulting: @@ -40,12 +42,25 @@ class NostrSignerPermissionLedger( */ suspend fun hasPolicy(coordinate: String): Boolean = store.loadPolicy(coordinate) != null - /** The authorization verdict for ([coordinate], [op]) based on stored policy + per-op overrides. */ + /** + * The authorization verdict for ([coordinate], [op]) based on stored policy + per-op overrides. + * Checks expiry: if a timed override has passed [now], it is cleared and the policy-level decision + * is returned instead. + */ suspend fun decide( coordinate: String, op: NostrSignerOp, + now: Long = TimeUtils.now(), ): NostrOpDecision { - store.loadOpDecision(coordinate, op)?.let { return it } + store.loadOpDecision(coordinate, op)?.let { decision -> + val expiresAt = store.loadOpExpiry(coordinate, op) + if (expiresAt != null && now > expiresAt) { + store.clearOpDecision(coordinate, op) + store.clearOpExpiry(coordinate, op) + } else { + return decision + } + } return when (store.loadPolicy(coordinate)) { AppSignerPolicy.FULL_TRUST -> NostrOpDecision.ALLOW AppSignerPolicy.PARANOID -> NostrOpDecision.ASK @@ -67,11 +82,34 @@ class NostrSignerPermissionLedger( decision: NostrOpDecision, ) = store.storeOpDecision(coordinate, op, decision) - /** Removes a per-operation override, reverting to the policy-level decision. */ + /** Stores a time-bound per-operation override that expires at [expiresAt] (Unix epoch seconds). */ + suspend fun setTimedOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + expiresAt: Long, + ) { + store.storeOpDecision(coordinate, op, decision) + store.storeOpExpiry(coordinate, op, expiresAt) + } + + /** Records the current time as the last-used timestamp for [coordinate]. */ + suspend fun updateLastUsed( + coordinate: String, + now: Long = TimeUtils.now(), + ) = store.storeLastUsed(coordinate, now) + + /** The last-used timestamp for [coordinate], or `null` if never used. */ + suspend fun lastUsed(coordinate: String): Long? = store.loadLastUsed(coordinate) + + /** Removes a per-operation override (and any expiry), reverting to the policy-level decision. */ suspend fun revokeOpDecision( coordinate: String, op: NostrSignerOp, - ) = store.clearOpDecision(coordinate, op) + ) { + store.clearOpDecision(coordinate, op) + store.clearOpExpiry(coordinate, op) + } /** Removes all signer permissions for [coordinate] — trust level and all per-op overrides. */ suspend fun revokeAll(coordinate: String) = store.clearAll(coordinate) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt index f409183ebe..cc0e2bca8a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt @@ -75,6 +75,34 @@ interface NostrSignerPermissionStore { /** Remove all signer permissions (policy + all op overrides) for [coordinate]. */ suspend fun clearAll(coordinate: String) + + /** The stored expiry timestamp (Unix epoch seconds) for [op] of [coordinate], or `null` = no expiry. */ + suspend fun loadOpExpiry( + coordinate: String, + op: NostrSignerOp, + ): Long? + + /** Persist [expiresAt] (Unix epoch seconds) for ([coordinate], [op]). */ + suspend fun storeOpExpiry( + coordinate: String, + op: NostrSignerOp, + expiresAt: Long, + ) + + /** Remove the expiry for ([coordinate], [op]). */ + suspend fun clearOpExpiry( + coordinate: String, + op: NostrSignerOp, + ) + + /** The last time (Unix epoch seconds) any auto-approved operation ran for [coordinate], or `null`. */ + suspend fun loadLastUsed(coordinate: String): Long? + + /** Persist [epochSeconds] as the last-used time for [coordinate]. */ + suspend fun storeLastUsed( + coordinate: String, + epochSeconds: Long, + ) } /** A thread-safe in-memory [NostrSignerPermissionStore] for tests and ephemeral sessions. */ @@ -82,6 +110,8 @@ class InMemoryNostrSignerPermissionStore : NostrSignerPermissionStore { private val lock = KmpLock() private val policies = mutableMapOf() private val opDecisions = mutableMapOf>() + private val opExpiries = mutableMapOf>() + private val lastUsedMap = mutableMapOf() override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? = lock.withLock { policies[coordinate] } @@ -125,6 +155,34 @@ class InMemoryNostrSignerPermissionStore : NostrSignerPermissionStore { lock.withLock { policies.remove(coordinate) opDecisions.remove(coordinate) + opExpiries.remove(coordinate) + lastUsedMap.remove(coordinate) Unit } + + override suspend fun loadOpExpiry( + coordinate: String, + op: NostrSignerOp, + ): Long? = lock.withLock { opExpiries[coordinate]?.get(op.key) } + + override suspend fun storeOpExpiry( + coordinate: String, + op: NostrSignerOp, + expiresAt: Long, + ) = lock.withLock { opExpiries.getOrPut(coordinate) { mutableMapOf() }[op.key] = expiresAt } + + override suspend fun clearOpExpiry( + coordinate: String, + op: NostrSignerOp, + ) = lock.withLock { + opExpiries[coordinate]?.remove(op.key) + Unit + } + + override suspend fun loadLastUsed(coordinate: String): Long? = lock.withLock { lastUsedMap[coordinate] } + + override suspend fun storeLastUsed( + coordinate: String, + epochSeconds: Long, + ) = lock.withLock { lastUsedMap[coordinate] = epochSeconds } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt new file mode 100644 index 0000000000..00ee6d66bd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayauth + +/** + * Per-relay [RelayAuthDecision] overrides. Used by [RelayAuthPermissionLedger] after + * checking the global [RelayAuthPolicy]. The Android implementation uses a single + * shared DataStore file so a relay URL lookup never touches another relay's data. + */ +interface RelayAuthPermissionStore { + /** The stored per-relay override for [relayUrl], or `null` if no override is set. */ + suspend fun loadDecision(relayUrl: String): RelayAuthDecision? + + /** Persist [decision] as the override for [relayUrl]. */ + suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) + + /** Remove the per-relay override for [relayUrl]. */ + suspend fun clearDecision(relayUrl: String) + + /** All per-relay overrides — for the relay auth settings screen. */ + suspend fun allDecisions(): Map +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt new file mode 100644 index 0000000000..4287267c36 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayauth + +/** + * The default policy for authenticating with relays (NIP-42). + * Per-relay overrides stored in [RelayAuthPermissionStore] always take precedence. + */ +enum class RelayAuthPolicy { + /** Authenticate with every relay that requests it. Equivalent to current behavior. */ + ALWAYS, + + /** Never authenticate; do not reveal your identity to relay operators via NIP-42. */ + NEVER, + + /** Authenticate only with relays explicitly listed in the user's relay list. */ + IF_IN_MY_LIST, +} + +/** A persisted per-relay override decision. */ +enum class RelayAuthDecision { + ALLOW, + DENY, +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index 8124f1aeb0..e53eb05adb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -51,7 +51,12 @@ object EmptyIAuthStatus : IAuthStatus { class RelayAuthenticator( val client: INostrClient, val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - val signWithAllLoggedInUsers: suspend (EventTemplate) -> List, + /** + * Signs the auth template for every currently-logged-in account and returns the signed events. + * The [relay] parameter allows callers to check per-relay auth policy before signing. + * Returns an empty list to skip authentication for this relay. + */ + val signWithAllLoggedInUsers: suspend (relay: NormalizedRelayUrl, EventTemplate) -> List, ) : IAuthStatus { // Connection callbacks fire on the per-relay OkHttp dispatcher thread, so // this state is mutated concurrently — LargeCache wraps a platform-tuned @@ -93,7 +98,7 @@ class RelayAuthenticator( // so an uncaught throwable here crashes the whole app. Swallow + log them. try { val ev = RelayAuthEvent.build(relay.url, msg.challenge) - signWithAllLoggedInUsers(ev).forEach { authEvent -> + signWithAllLoggedInUsers(relay.url, ev).forEach { authEvent -> // only send replies to new challenges to avoid infinite loop: if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) { relay.sendIfConnected(AuthCmd(authEvent)) From 0e6e7901c29727711bae7a317dad4c16e1453c26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 19:33:51 +0000 Subject: [PATCH 06/33] fix: update KtorRelayTest to match new signWithAllLoggedInUsers signature The lambda now receives (relay, template) after RelayAuthenticator was updated to pass the relay URL for per-relay auth policy checks. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt index 4f3245b045..322b964699 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt @@ -246,7 +246,7 @@ class KtorRelayTest { com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator( client = client, scope = scope, - ) { template -> + ) { _, template -> listOf(signer.sign(template)) } try { From d57c3f71dbe120c620f1add8b7e9dccf09b3ff81 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 19:38:21 +0000 Subject: [PATCH 07/33] fix: update quartz auth tests to match new two-arg signWithAllLoggedInUsers Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../relay/client/auth/RelayAuthenticatorConcurrencyTest.kt | 2 +- .../relay/client/auth/RelayAuthenticatorTimeoutTest.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt index 67fa00c2b7..b375d78031 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt @@ -90,7 +90,7 @@ class RelayAuthenticatorConcurrencyTest { val authenticator = RelayAuthenticator( client = client, - signWithAllLoggedInUsers = { emptyList() }, + signWithAllLoggedInUsers = { _, _ -> emptyList() }, ) val listener = client.captured diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt index a6d9522abb..bbd7ad35cf 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt @@ -102,7 +102,7 @@ class RelayAuthenticatorTimeoutTest { RelayAuthenticator( client = client, scope = scope, - signWithAllLoggedInUsers = { + signWithAllLoggedInUsers = { _, _ -> throw SignerExceptions.TimedOutException("User didn't accept or reject in time.") }, ) @@ -130,7 +130,7 @@ class RelayAuthenticatorTimeoutTest { RelayAuthenticator( client = client, scope = scope, - signWithAllLoggedInUsers = { template -> + signWithAllLoggedInUsers = { _, _ -> listOf(RelayAuthEvent.create(relay.url, "challenge-123", signer)) }, ) From 9a117d61665b5249009fb1af09ed17b046094e93 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 21:28:47 +0000 Subject: [PATCH 08/33] feat(napplets): show app icon in Connected Apps list and detail Replace the static placeholder glyph with the napplet manifest's icon() URL loaded via FavoriteAppIcon (Coil AsyncImage + glyph fallback). Both ConnectedAppsScreen and ConnectedAppDetailScreen now resolve iconUrl alongside title from the event cache and display the real app icon when available. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../napplets/ConnectedAppDetailScreen.kt | 36 ++++++++---------- .../loggedIn/napplets/ConnectedAppsScreen.kt | 37 ++++++++----------- 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 5fee9e3448..e15bc2ca7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.fillMaxWidth 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 @@ -62,6 +61,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -91,6 +92,7 @@ import kotlinx.coroutines.withContext private data class ConnectedAppDetailState( val title: String, val coordinate: String, + val iconUrl: String?, val signerPolicy: AppSignerPolicy?, val opOverrides: Map, val capabilities: List>, @@ -245,20 +247,11 @@ private fun AppIdentityHeader(state: ConnectedAppDetailState) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, + FavoriteAppIcon( + app = FavoriteApp.NostrApp(state.coordinate, state.title, 0L, state.iconUrl), + tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - MaterialSymbols.Apps, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(28.dp), - ) - } - } + ) Column(modifier = Modifier.weight(1f)) { Text( state.title, @@ -503,20 +496,22 @@ private suspend fun loadDetailState( val signerPolicy = signerLedger.store.loadPolicy(coordinate) val opOverrides = signerLedger.store.allOpDecisions(coordinate) + val (title, iconUrl) = resolveAppMeta(author, identifier, untitled) return ConnectedAppDetailState( - title = resolveAppTitle(author, identifier, untitled), + title = title, coordinate = coordinate, + iconUrl = iconUrl, signerPolicy = signerPolicy, opOverrides = opOverrides, capabilities = capGrants, ) } -private fun resolveAppTitle( +private fun resolveAppMeta( author: String, identifier: String, untitled: String, -): String { +): Pair { val events = Amethyst.instance.cache .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) @@ -528,7 +523,8 @@ private fun resolveAppTitle( is RootNappletEvent -> identifier.isEmpty() else -> false } - } - return (match as? NappletManifest)?.title()?.ifBlank { null } - ?: identifier.ifBlank { untitled } + } as? NappletManifest + val title = match?.title()?.ifBlank { null } ?: identifier.ifBlank { untitled } + val iconUrl = match?.icon()?.ifBlank { null } + return title to iconUrl } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 8f9daa9a53..4c6755be48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -32,13 +32,11 @@ 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.foundation.shape.CircleShape import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SuggestionChip -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -56,6 +54,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger @@ -78,6 +78,7 @@ private data class ConnectedAppEntry( val coordinate: String, val title: String, val domain: String, + val iconUrl: String?, val signerPolicy: AppSignerPolicy?, val capabilityCount: Int, ) @@ -166,20 +167,11 @@ private fun ConnectedAppCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, + FavoriteAppIcon( + app = FavoriteApp.NostrApp(entry.coordinate, entry.title, 0L, entry.iconUrl), + tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(44.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - MaterialSymbols.Apps, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(24.dp), - ) - } - } + ) Column(modifier = Modifier.weight(1f)) { Text( @@ -242,21 +234,23 @@ private suspend fun loadConnectedApps( .map { coordinate -> val author = coordinate.substringBefore(':') val identifier = coordinate.substringAfter(':', "") + val (title, iconUrl) = resolveAppMeta(author, identifier, untitled) ConnectedAppEntry( coordinate = coordinate, - title = resolveAppTitle(author, identifier, untitled), + title = title, domain = identifier.ifBlank { author.take(12) + "…" }, + iconUrl = iconUrl, signerPolicy = signerPolicies[coordinate], capabilityCount = capGrants[coordinate]?.size ?: 0, ) }.sortedBy { it.title.lowercase() } } -private fun resolveAppTitle( +private fun resolveAppMeta( author: String, identifier: String, untitled: String, -): String { +): Pair { val events = Amethyst.instance.cache .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) @@ -268,7 +262,8 @@ private fun resolveAppTitle( is RootNappletEvent -> identifier.isEmpty() else -> false } - } - return (match as? NappletManifest)?.title()?.ifBlank { null } - ?: identifier.ifBlank { untitled } + } as? NappletManifest + val title = match?.title()?.ifBlank { null } ?: identifier.ifBlank { untitled } + val iconUrl = match?.icon()?.ifBlank { null } + return title to iconUrl } From 162ee519556a944f6dc67fc14c607a2bc43b3d71 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 21:47:26 +0000 Subject: [PATCH 09/33] feat(ui): modernize napplet and relay auth permission screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConnectedAppDetailScreen: replace emoji policy icons (ā¤šŸ‘šŸ•¶) with themed MaterialSymbols (Favorite/Shield/Lock); show domain instead of raw coordinate in AppIdentityHeader - ConnectedAppsScreen: remove redundant Column wrapper around SuggestionChip - NappletSignerConsentActivity: add centered FavoriteAppIcon header with title + description; promote "Allow once" to FilledTonalButton; collapse time-based/granular grants behind a "More options" toggle; resolve app icon from cache using coordinate - RelayAuthSettingsScreen: replace plain radio buttons with styled PolicyCard composable (bordered card, icon, check mark); wrap per-relay overrides in surfaceVariant Surface with dividers; fix duplicate if/if → if/else; add TextOverflow.Ellipsis to URL text Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../napplet/NappletSignerConsentActivity.kt | 147 ++++++++++---- .../napplets/ConnectedAppDetailScreen.kt | 20 +- .../loggedIn/napplets/ConnectedAppsScreen.kt | 15 +- .../relayauth/RelayAuthSettingsScreen.kt | 185 +++++++++++------- amethyst/src/main/res/values/strings.xml | 2 + 5 files changed, 251 insertions(+), 118 deletions(-) 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 28323c2443..80e786fe4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -23,16 +23,20 @@ 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.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.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -43,6 +47,7 @@ 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.graphics.Color import androidx.compose.ui.platform.LocalConfiguration @@ -52,9 +57,18 @@ 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.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant import com.vitorpamplona.amethyst.ui.theme.AmethystTheme +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import com.vitorpamplona.quartz.utils.TimeUtils class NappletSignerConsentActivity : ComponentActivity() { @@ -96,6 +110,24 @@ class NappletSignerConsentActivity : ComponentActivity() { } } +private fun resolveIconUrl(coordinate: String): String? { + val author = coordinate.substringBefore(':') + val identifier = coordinate.substringAfter(':', "") + val events = + Amethyst.instance.cache + .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) + .mapNotNull { it.event } + val match = + events.firstOrNull { ev -> + when (ev) { + is NamedNappletEvent -> ev.identifier() == identifier + is RootNappletEvent -> identifier.isEmpty() + else -> false + } + } as? NappletManifest + return match?.icon()?.ifBlank { null } +} + @Composable private fun NappletSignerConsentDialog( info: NappletSignerConsentInfo, @@ -103,8 +135,10 @@ private fun NappletSignerConsentDialog( onDismiss: () -> Unit, ) { var showRawData by remember { mutableStateOf(false) } + var showMoreOptions by remember { mutableStateOf(false) } val scrollState = rememberScrollState() val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f + val iconUrl = remember(info.coordinate) { resolveIconUrl(info.coordinate) } Dialog( onDismissRequest = onDismiss, @@ -126,16 +160,27 @@ private fun NappletSignerConsentDialog( .verticalScroll(scrollState) .padding(vertical = 24.dp), ) { - Column(modifier = Modifier.padding(horizontal = 24.dp)) { + // Centered header: icon + title + description + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FavoriteAppIcon( + app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, iconUrl), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp), + ) Text( info.appletTitle, style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, ) - Spacer(Modifier.height(4.dp)) Text( stringResource(R.string.napplet_consent_wants_to, info.operationSummary), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) } @@ -191,46 +236,80 @@ private fun NappletSignerConsentDialog( Spacer(Modifier.height(8.dp)) Text( - info.coordinate, - modifier = Modifier.padding(horizontal = 24.dp), + info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(16.dp)) HorizontalDivider() - ConsentActionButton( - text = stringResource(R.string.napplet_signer_allow_once), - color = MaterialTheme.colorScheme.primary, + // Primary action + Spacer(Modifier.height(8.dp)) + FilledTonalButton( onClick = { onGrant(SignerOpGrant.AllowOnce) }, - ) - ConsentActionButton( - text = stringResource(R.string.napplet_signer_allow_session), - color = MaterialTheme.colorScheme.primary, - onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) }, - ) - ConsentActionButton( - text = stringResource(R.string.napplet_signer_allow_24h), - color = MaterialTheme.colorScheme.primary, - onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) }, - ) - ConsentActionButton( - text = stringResource(R.string.napplet_signer_allow_30d), - color = MaterialTheme.colorScheme.primary, - onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) }, - ) - ConsentActionButton( - text = stringResource(R.string.napplet_signer_allow_op, info.operationSummary), - color = MaterialTheme.colorScheme.primary, - onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, - ) - ConsentActionButton( - text = stringResource(R.string.napplet_signer_allow_all), - color = MaterialTheme.colorScheme.primary, - onClick = { onGrant(SignerOpGrant.AllowAll) }, - ) + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_once)) + } + // "More options" toggle + TextButton( + onClick = { showMoreOptions = !showMoreOptions }, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + if (showMoreOptions) { + stringResource(R.string.napplet_consent_fewer_options) + } else { + stringResource(R.string.napplet_consent_more_options) + }, + style = MaterialTheme.typography.bodyMedium, + ) + Icon( + if (showMoreOptions) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + + if (showMoreOptions) { + ConsentActionButton( + text = stringResource(R.string.napplet_signer_allow_session), + color = MaterialTheme.colorScheme.primary, + onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) }, + ) + ConsentActionButton( + text = stringResource(R.string.napplet_signer_allow_24h), + color = MaterialTheme.colorScheme.primary, + onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) }, + ) + ConsentActionButton( + text = stringResource(R.string.napplet_signer_allow_30d), + color = MaterialTheme.colorScheme.primary, + onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) }, + ) + ConsentActionButton( + text = stringResource(R.string.napplet_signer_allow_op, info.operationSummary), + color = MaterialTheme.colorScheme.primary, + onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, + ) + ConsentActionButton( + text = stringResource(R.string.napplet_signer_allow_all), + color = MaterialTheme.colorScheme.primary, + onClick = { onGrant(SignerOpGrant.AllowAll) }, + ) + } + + Spacer(Modifier.height(4.dp)) HorizontalDivider() ConsentActionButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index e15bc2ca7c..3945cbc94d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -259,12 +259,13 @@ private fun AppIdentityHeader(state: ConnectedAppDetailState) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) + val domain = state.coordinate.substringAfter(':', "").ifBlank { state.coordinate.substringBefore(':').take(12) + "…" } Text( - state.coordinate, + domain, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, - maxLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis, ) } @@ -289,21 +290,21 @@ private fun PolicyPicker( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { PolicyOption( selected = selected == AppSignerPolicy.FULL_TRUST, - icon = "ā¤", + symbol = MaterialSymbols.Favorite, label = stringResource(R.string.napplet_policy_full_trust), description = stringResource(R.string.napplet_policy_full_trust_desc), onClick = { onSelect(AppSignerPolicy.FULL_TRUST) }, ) PolicyOption( selected = selected == AppSignerPolicy.REASONABLE, - icon = "šŸ‘", + symbol = MaterialSymbols.Shield, label = stringResource(R.string.napplet_policy_reasonable), description = stringResource(R.string.napplet_policy_reasonable_desc), onClick = { onSelect(AppSignerPolicy.REASONABLE) }, ) PolicyOption( selected = selected == AppSignerPolicy.PARANOID, - icon = "šŸ•¶", + symbol = MaterialSymbols.Lock, label = stringResource(R.string.napplet_policy_paranoid), description = stringResource(R.string.napplet_policy_paranoid_desc), onClick = { onSelect(AppSignerPolicy.PARANOID) }, @@ -314,7 +315,7 @@ private fun PolicyPicker( @Composable private fun PolicyOption( selected: Boolean, - icon: String, + symbol: MaterialSymbol, label: String, description: String, onClick: () -> Unit, @@ -336,7 +337,12 @@ private fun PolicyOption( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text(icon, style = MaterialTheme.typography.headlineSmall) + Icon( + symbol = symbol, + contentDescription = null, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + ) Column(modifier = Modifier.weight(1f)) { Text(label, style = MaterialTheme.typography.titleSmall) Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 4c6755be48..3be6479b24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -190,16 +190,11 @@ private fun ConnectedAppCard( ) } - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (entry.signerPolicy != null) { - SuggestionChip( - onClick = {}, - label = { Text(entry.signerPolicy.shortLabel(), style = MaterialTheme.typography.labelSmall) }, - ) - } + if (entry.signerPolicy != null) { + SuggestionChip( + onClick = {}, + label = { Text(entry.signerPolicy.shortLabel(), style = MaterialTheme.typography.labelSmall) }, + ) } Icon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt index 005cbcae76..88272e4418 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,17 +31,17 @@ import androidx.compose.foundation.layout.fillMaxSize 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.selection.selectable -import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -53,11 +55,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy @@ -107,83 +110,84 @@ fun RelayAuthSettingsScreen( ) Spacer(Modifier.height(4.dp)) - Column(Modifier.selectableGroup()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { RelayAuthPolicy.entries.forEach { policy -> - val (titleRes, descRes) = + val (titleRes, descRes, symbol) = when (policy) { - RelayAuthPolicy.ALWAYS -> R.string.relay_auth_policy_always to R.string.relay_auth_policy_always_desc - RelayAuthPolicy.NEVER -> R.string.relay_auth_policy_never to R.string.relay_auth_policy_never_desc - RelayAuthPolicy.IF_IN_MY_LIST -> R.string.relay_auth_policy_if_in_my_list to R.string.relay_auth_policy_if_in_my_list_desc + RelayAuthPolicy.ALWAYS -> + Triple( + R.string.relay_auth_policy_always, + R.string.relay_auth_policy_always_desc, + MaterialSymbols.LockOpen, + ) + RelayAuthPolicy.NEVER -> + Triple( + R.string.relay_auth_policy_never, + R.string.relay_auth_policy_never_desc, + MaterialSymbols.Lock, + ) + RelayAuthPolicy.IF_IN_MY_LIST -> + Triple( + R.string.relay_auth_policy_if_in_my_list, + R.string.relay_auth_policy_if_in_my_list_desc, + MaterialSymbols.PrivacyTip, + ) } - Row( - modifier = - Modifier - .fillMaxWidth() - .selectable( - selected = globalPolicy == policy, - onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) }, - role = Role.RadioButton, - ).padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - RadioButton( - selected = globalPolicy == policy, - onClick = null, - ) - Column { - Text(text = stringResource(titleRes), style = MaterialTheme.typography.bodyLarge) - Text( - text = stringResource(descRes), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + PolicyCard( + selected = globalPolicy == policy, + symbol = symbol, + title = stringResource(titleRes), + description = stringResource(descRes), + onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) }, + ) } } - if (perRelayOverrides.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + if (perRelayOverrides.isNotEmpty()) { Text( text = stringResource(R.string.relay_auth_per_relay_overrides), style = MaterialTheme.typography.titleMedium, ) Spacer(Modifier.height(4.dp)) - perRelayOverrides.entries.sortedBy { it.key }.forEach { (url, decision) -> - PerRelayOverrideRow( - url = url, - decision = decision, - onRemove = { - scope.launch { - ledger.clearDecision(url) - reloadKey++ - } - }, - onToggle = { - scope.launch { - val next = - if (decision == RelayAuthDecision.ALLOW) { - RelayAuthDecision.DENY - } else { - RelayAuthDecision.ALLOW + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(4.dp)) { + perRelayOverrides.entries.sortedBy { it.key }.forEachIndexed { index, (url, decision) -> + if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + PerRelayOverrideRow( + url = url, + decision = decision, + onRemove = { + scope.launch { + ledger.clearDecision(url) + reloadKey++ } - ledger.setDecision(url, next) - reloadKey++ - } - }, - ) + }, + onToggle = { + scope.launch { + val next = + if (decision == RelayAuthDecision.ALLOW) { + RelayAuthDecision.DENY + } else { + RelayAuthDecision.ALLOW + } + ledger.setDecision(url, next) + reloadKey++ + } + }, + ) + } + } } - } - - if (perRelayOverrides.isEmpty()) { - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) + } else { Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Text( text = stringResource(R.string.relay_auth_no_overrides), @@ -196,6 +200,52 @@ fun RelayAuthSettingsScreen( } } +@Composable +private fun PolicyCard( + selected: Boolean, + symbol: MaterialSymbol, + title: String, + description: String, + onClick: () -> Unit, +) { + val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface + + Surface( + modifier = + Modifier + .fillMaxWidth() + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = bgColor, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (selected) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + @Composable private fun PerRelayOverrideRow( url: String, @@ -207,7 +257,7 @@ private fun PerRelayOverrideRow( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -216,6 +266,7 @@ private fun PerRelayOverrideRow( text = url, style = MaterialTheme.typography.bodyMedium, maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } SuggestionChip( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3e33eb2334..64b6ffd0ff 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -770,6 +770,8 @@ wants to %1$s See more See less + More options + Fewer options Allow once Allow for this session Allow for 24 hours From 2786c5b68346e4eeab749e2e2b58e5f07ce24fc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 21:58:29 +0000 Subject: [PATCH 10/33] fix(relayauth): expand IF_IN_MY_LIST to all live relay lists minus blocked Replace the localRelayServers-only check with account.trustedRelays (nip65, private outbox, local, dm, search, indexer, proxy, trusted, broadcast relay lists combined) minus account.blockedRelayList. Uses normalizeRelayUrlOrNull for consistent URL comparison. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../authCommand/compose/AccountDataSourceSubscription.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt index 5f914d45ef..f17945b416 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoor import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull @Composable fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator) @@ -50,9 +51,9 @@ fun RelayAuthSubscription( store = Amethyst.instance.relayAuthPermissionStore, globalPolicy = { account.settings.defaultRelayAuthPolicy.value }, isInMyRelayList = { relayUrl -> - account.settings.localRelayServers.value.any { - it.trimEnd('/') == relayUrl.trimEnd('/') - } + val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false + normalized !in account.blockedRelayList.flow.value && + normalized in account.trustedRelays.flow.value }, ) } From 52f1b8723caadeaa2cfe8f570080ac9850a9703b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 22:29:41 +0000 Subject: [PATCH 11/33] feat(napplets): require Amethyst consent for all signer types, auto-approve encrypt/decrypt Remove the signer self-gating bypass that allowed external (Amber/NIP-55) and remote (NIP-46) signers to skip Amethyst's per-napplet consent UI. All signer types now go through Amethyst's consent dialogs first; the external signer then adds its own approval on top (double-prompting). This lets users differentiate signing requests by app inside the external signer, since Amethyst itself is the requesting app. Also expand the REASONABLE policy to auto-approve Encrypt and Decrypt operations, matching the intent that common/private-key operations that apps routinely need are pre-approved at the "reasonable" trust level. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../amethyst/commons/napplet/NappletBroker.kt | 25 ++++++------------- .../napplet/signers/AppSignerPolicy.kt | 5 ++-- .../signers/NostrSignerPermissionLedger.kt | 4 +-- .../commons/napplet/NappletBrokerTest.kt | 19 +++++++++++--- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 62ecb4321c..1e1d60d428 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -36,7 +36,6 @@ import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant import com.vitorpamplona.amethyst.commons.napplet.signers.toSignerOp import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -55,13 +54,10 @@ import kotlin.coroutines.cancellation.CancellationException * Two capability-specific policies refine step 2/3: * - **Per-use capabilities** ([NappletCapability.requiresPerUseConsent], i.e. [NappletCapability.VALUE]) * never auto-approve from a prior grant — every payment is confirmed afresh, with the amount shown. - * - **Signer self-gating**: an identity read or a sign-as-user op ([NappletRequest.signsAsUser]) - * is gated here only when the key lives in Amethyst (a [NostrSignerInternal]). Remote (NIP-46) and - * external (NIP-55) signers run their own per-request consent UI, so we defer to them rather than - * double-prompt. A standing DENY is still honored, and the applet must still have *declared* the - * capability. This is safe only because the napplet host runs foreground-only, so the signer's - * prompt appears in the clear context of the user interacting with that napplet (it can't be - * fired from the background). + * - All signer types — internal, NIP-46 remote, and NIP-55 external — are gated through Amethyst's + * consent UI before the operation reaches the signer. External signers add their own per-request + * prompt on top (double-prompting), ensuring the user can differentiate requests from different + * apps inside the external signer. * * Security invariants enforced here (never trusted from the applet): the signing identity is * always the host's signer; the napplet only ever supplies an unsigned template — the shell signs @@ -125,8 +121,8 @@ class NappletBroker( return NappletResponse.Denied(capability, "Blocked by a standing denial.") } - // For internal signers, show the first-connect dialog if the app has no signer policy yet. - if (signer is NostrSignerInternal && signerLedger != null && !signerLedger.hasPolicy(identity.coordinate)) { + // Show the first-connect dialog if the app has no signer policy yet. + if (signerLedger != null && !signerLedger.hasPolicy(identity.coordinate)) { if (!ensureConnected(identity, declared)) { return NappletResponse.Denied(capability, "Connection not authorized.") } @@ -139,8 +135,6 @@ class NappletBroker( request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true // Cosmetic/negotiation capabilities (theme) never prompt. !capability.requiresConsent -> true - // Remote/external signers run their own per-request consent UI — defer to them. - signerSelfGates(request) -> true // A standing allow short-circuits, except for per-use capabilities (e.g. payments). ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true else -> authorizeWithConsent(identity, capability, request) @@ -148,8 +142,8 @@ class NappletBroker( if (!authorized) return NappletResponse.Denied(capability, "The user declined.") - // Additional per-operation gate for internal signer signing/encryption. - if (signer is NostrSignerInternal && signerLedger != null) { + // Additional per-operation gate for signing/encryption. + if (signerLedger != null) { val op = request.toSignerOp() if (op != null && !authorizeSignerOp(identity, op, request)) { return NappletResponse.Denied(capability, "Signing operation declined.") @@ -193,9 +187,6 @@ class NappletBroker( grant.allowsExecution } - /** Identity reads and sign-as-user ops are gated by us only when we hold the key; remote/external signers gate themselves. */ - private fun signerSelfGates(request: NappletRequest): Boolean = (request.capability == NappletCapability.IDENTITY || request.signsAsUser) && signer !is NostrSignerInternal - /** Downgrades a grant to one-shot when the capability forbids persisting that scope (e.g. payments). */ private fun effectiveGrant( capability: NappletCapability, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt index bfd36e6af5..3a34dc729c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt @@ -30,8 +30,9 @@ enum class AppSignerPolicy { FULL_TRUST, /** - * Auto-approve the most common operations (kind 1 short notes, kind 6 reposts, kind 7 - * reactions); ask before anything else. A reasonable default for most apps. + * Auto-approve the most common operations: kind 1 short notes, kind 6 reposts, kind 7 + * reactions, and all encrypt/decrypt operations; ask before anything else. + * A reasonable default for most apps. */ REASONABLE, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt index c750a2614b..9ddac0d90b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt @@ -123,7 +123,7 @@ class NostrSignerPermissionLedger( 7 -> NostrOpDecision.ALLOW // Reactions / emoji else -> NostrOpDecision.ASK } - NostrSignerOp.Encrypt -> NostrOpDecision.ASK - NostrSignerOp.Decrypt -> NostrOpDecision.ASK + NostrSignerOp.Encrypt -> NostrOpDecision.ALLOW + NostrSignerOp.Decrypt -> NostrOpDecision.ALLOW } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index 5503588eda..ddc8a0304f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -405,16 +405,29 @@ class NappletBrokerTest { } @Test - fun externalSignerDefersIdentityWithoutPrompting() = + fun externalSignerIsPromptedByAmethystForIdentity() = runTest { - val prompt = ScriptedPrompt(GrantState.DENY) // would block if we asked + val prompt = ScriptedPrompt(GrantState.DENY) + val external = FakeExternalSigner("dd".repeat(32)) + + val response = + broker(prompt, signer = external).handle(applet, NappletRequest.GetPublicKey, allDeclared) + + assertIs(response) // Amethyst asked and user denied + assertEquals(1, prompt.calls) // Amethyst prompts all signer types + } + + @Test + fun externalSignerAllowedByAmethystReturnsPublicKey() = + runTest { + val prompt = ScriptedPrompt(GrantState.ALLOW_ONCE) val external = FakeExternalSigner("dd".repeat(32)) val response = broker(prompt, signer = external).handle(applet, NappletRequest.GetPublicKey, allDeclared) assertEquals(NappletResponse.PublicKey(external.pubKey), response) - assertEquals(0, prompt.calls) // deferred to the external signer; we did not prompt + assertEquals(1, prompt.calls) // Amethyst prompted before the external signer } @Test From 330379e53f7008f3e0c21a1de18640009f7e5264 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 23:33:54 +0000 Subject: [PATCH 12/33] fix(napplets): address four code-review findings before merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cancel on first-connect dialog now suppresses re-prompting for the rest of the session (sessionCancelled set under signerConsentLock) instead of showing the dialog on every subsequent request. - PARANOID signer policy no longer silently bulk-grants ALLOW_ALWAYS for all capabilities; the capability ledger is left empty so each capability prompts individually, matching user intent. - NostrSignerOp.Decrypt default changed from ALLOW → ASK in reasonableDecision(); the branch is currently unreachable (toSignerOp() never produces Decrypt) but ASK is the safer default if a decrypt request type is added in future. - TrustedRelayListState seeds its StateFlow from the synchronously available cached relay set, eliminating a startup window where IF_IN_MY_LIST incorrectly denied auth to relays in the user's list. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../trustedRelays/TrustedRelayListState.kt | 4 ++- .../amethyst/commons/napplet/NappletBroker.kt | 26 +++++++++++++++---- .../signers/NostrSignerPermissionLedger.kt | 4 +-- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt index 65a5c80c45..89df381160 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt @@ -68,7 +68,9 @@ class TrustedRelayListState( .stateIn( scope, SharingStarted.Eagerly, - emptySet(), + // Synchronously seed from the cached (pre-decrypted) backup so the relay-auth + // ledger sees a non-empty list immediately, before the IO-thread onStart emit. + settings.backupTrustedRelayList?.let { decryptionCache.cachedRelays(it) } ?: emptySet(), ) suspend fun saveRelayList(trustedRelays: List): TrustedRelayListEvent { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 1e1d60d428..9acd8b12e2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -93,6 +93,12 @@ class NappletBroker( // Only accessed under signerConsentLock. private val sessionAllows = mutableSetOf() + // Apps whose first-connect dialog the user dismissed with Cancel this session. + // Cancelling means "not now" — we suppress re-prompting within the same session so a + // napplet that fires many requests doesn't show the dialog on every one. + // Only accessed under signerConsentLock. + private val sessionCancelled = mutableSetOf() + /** * Authorizes and runs [request] on behalf of [identity]. [declared] is the capability set the * manifest's `requires` resolved to; a request outside it is refused before any prompt. @@ -351,14 +357,21 @@ class NappletBroker( // Re-check after acquiring lock: a sibling request may have set the policy while we waited. if (sl.hasPolicy(identity.coordinate)) return@withLock true + // Suppress re-prompting if the user already cancelled this session. + if (identity.coordinate in sessionCancelled) return@withLock false + val prompt = nostrConnectPrompt ?: return@withLock true when (val result = prompt.request(identity)) { is AppConnectResult.Connected -> { sl.setPolicy(identity.coordinate, result.policy) - // Bulk-grant all declared capabilities except payments so the app works immediately. - for (cap in declared) { - if (!cap.requiresPerUseConsent) { - ledger.record(identity, cap, GrantState.ALLOW_ALWAYS) + // Bulk-grant non-payment capabilities only for non-paranoid policies. + // PARANOID users chose "ask me for everything" — leave the capability ledger + // empty so each capability prompts on first use. + if (result.policy != AppSignerPolicy.PARANOID) { + for (cap in declared) { + if (!cap.requiresPerUseConsent) { + ledger.record(identity, cap, GrantState.ALLOW_ALWAYS) + } } } true @@ -370,7 +383,10 @@ class NappletBroker( } false } - AppConnectResult.Cancelled -> false + AppConnectResult.Cancelled -> { + sessionCancelled.add(identity.coordinate) + false + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt index 9ddac0d90b..69fd4c03c4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils * * 1. Per-operation overrides ([NostrSignerPermissionStore.loadOpDecision]) — these always win. * 2. The app's [AppSignerPolicy] trust level ([NostrSignerPermissionStore.loadPolicy]). - * 3. The built-in "reasonable" set (kind 1/6/7 are auto-allowed) when policy is [AppSignerPolicy.REASONABLE]. + * 3. The built-in "reasonable" set (kind 1/6/7 + encrypt are auto-allowed) when policy is [AppSignerPolicy.REASONABLE]. * * When no policy has been set (`null`), [decide] returns [NostrOpDecision.ASK], which triggers the * first-connect dialog in the broker. @@ -124,6 +124,6 @@ class NostrSignerPermissionLedger( else -> NostrOpDecision.ASK } NostrSignerOp.Encrypt -> NostrOpDecision.ALLOW - NostrSignerOp.Decrypt -> NostrOpDecision.ALLOW + NostrSignerOp.Decrypt -> NostrOpDecision.ASK } } From d05247541d7c99e568208c2955c8c25ccda4a71a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 00:01:20 +0000 Subject: [PATCH 13/33] refactor: extract shared napplet abstractions, remove what-comments - Extract resolveNappletMeta() to NappletManifestLookup.kt, replacing three private copies of the same manifest lookup across ConnectedAppsScreen, ConnectedAppDetailScreen, NappletPermissionsScreen, and NappletSignerConsentActivity. - Extract PolicyCard composable to PolicyCard.kt, shared between ConnectedAppDetailScreen and RelayAuthSettingsScreen (was duplicated). - Extract NappletCapability.symbol() to NappletCapabilityExt.kt, shared between ConnectedAppDetailScreen and NappletPermissionsScreen. - Drop what-comments on kind 1/6/7 lines in NostrSignerPermissionLedger. - Reword TrustedRelayListState stateIn comment to note private-tag absence on first boot. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../trustedRelays/TrustedRelayListState.kt | 3 +- .../amethyst/napplet/NappletManifestLookup.kt | 53 +++++++++ .../napplet/NappletSignerConsentActivity.kt | 28 +---- .../napplets/ConnectedAppDetailScreen.kt | 103 +----------------- .../loggedIn/napplets/ConnectedAppsScreen.kt | 30 +---- .../loggedIn/napplets/NappletCapabilityExt.kt | 40 +++++++ .../napplets/NappletPermissionsScreen.kt | 46 +------- .../ui/screen/loggedIn/napplets/PolicyCard.kt | 88 +++++++++++++++ .../relayauth/RelayAuthSettingsScreen.kt | 54 +-------- .../signers/NostrSignerPermissionLedger.kt | 6 +- 10 files changed, 201 insertions(+), 250 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/PolicyCard.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt index 89df381160..87d197065b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt @@ -68,8 +68,7 @@ class TrustedRelayListState( .stateIn( scope, SharingStarted.Eagerly, - // Synchronously seed from the cached (pre-decrypted) backup so the relay-auth - // ledger sees a non-empty list immediately, before the IO-thread onStart emit. + // Synchronously seed public tags from the backup; private tags may be absent on first boot. settings.backupTrustedRelayList?.let { decryptionCache.cachedRelays(it) } ?: emptySet(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt new file mode 100644 index 0000000000..f36d4f5b59 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent + +/** + * Looks up the best-effort human title and icon URL for a napplet from the local cache. + * Falls back to the d-identifier or [untitled] when no manifest is cached. + */ +fun resolveNappletMeta( + author: String, + identifier: String, + untitled: String, +): Pair { + val events = + Amethyst.instance.cache + .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) + .mapNotNull { it.event } + val match = + events.firstOrNull { ev -> + when (ev) { + is NamedNappletEvent -> ev.identifier() == identifier + is RootNappletEvent -> identifier.isEmpty() + else -> false + } + } as? NappletManifest + val title = match?.title()?.ifBlank { null } ?: identifier.ifBlank { untitled } + val iconUrl = match?.icon()?.ifBlank { null } + return title to iconUrl +} 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 80e786fe4d..a6f3a144e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -57,7 +57,6 @@ 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.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon @@ -65,10 +64,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant import com.vitorpamplona.amethyst.ui.theme.AmethystTheme -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent -import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest -import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import com.vitorpamplona.quartz.utils.TimeUtils class NappletSignerConsentActivity : ComponentActivity() { @@ -110,24 +105,6 @@ class NappletSignerConsentActivity : ComponentActivity() { } } -private fun resolveIconUrl(coordinate: String): String? { - val author = coordinate.substringBefore(':') - val identifier = coordinate.substringAfter(':', "") - val events = - Amethyst.instance.cache - .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) - .mapNotNull { it.event } - val match = - events.firstOrNull { ev -> - when (ev) { - is NamedNappletEvent -> ev.identifier() == identifier - is RootNappletEvent -> identifier.isEmpty() - else -> false - } - } as? NappletManifest - return match?.icon()?.ifBlank { null } -} - @Composable private fun NappletSignerConsentDialog( info: NappletSignerConsentInfo, @@ -138,7 +115,10 @@ private fun NappletSignerConsentDialog( var showMoreOptions by remember { mutableStateOf(false) } val scrollState = rememberScrollState() val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f - val iconUrl = remember(info.coordinate) { resolveIconUrl(info.coordinate) } + val iconUrl = + remember(info.coordinate) { + resolveNappletMeta(info.coordinate.substringBefore(':'), info.coordinate.substringAfter(':', ""), "").second + } Dialog( onDismissRequest = onDismiss, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 3945cbc94d..3268bfe0b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,7 +30,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -59,12 +56,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity @@ -78,13 +73,10 @@ import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.napplet.descriptionRes import com.vitorpamplona.amethyst.napplet.labelRes +import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent -import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest -import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -288,21 +280,21 @@ private fun PolicyPicker( onSelect: (AppSignerPolicy) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - PolicyOption( + PolicyCard( selected = selected == AppSignerPolicy.FULL_TRUST, symbol = MaterialSymbols.Favorite, label = stringResource(R.string.napplet_policy_full_trust), description = stringResource(R.string.napplet_policy_full_trust_desc), onClick = { onSelect(AppSignerPolicy.FULL_TRUST) }, ) - PolicyOption( + PolicyCard( selected = selected == AppSignerPolicy.REASONABLE, symbol = MaterialSymbols.Shield, label = stringResource(R.string.napplet_policy_reasonable), description = stringResource(R.string.napplet_policy_reasonable_desc), onClick = { onSelect(AppSignerPolicy.REASONABLE) }, ) - PolicyOption( + PolicyCard( selected = selected == AppSignerPolicy.PARANOID, symbol = MaterialSymbols.Lock, label = stringResource(R.string.napplet_policy_paranoid), @@ -312,52 +304,6 @@ private fun PolicyPicker( } } -@Composable -private fun PolicyOption( - selected: Boolean, - symbol: MaterialSymbol, - label: String, - description: String, - onClick: () -> Unit, -) { - val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface - - Surface( - modifier = - Modifier - .fillMaxWidth() - .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp)) - .clickable(onClick = onClick), - shape = RoundedCornerShape(12.dp), - color = bgColor, - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - symbol = symbol, - contentDescription = null, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(28.dp), - ) - Column(modifier = Modifier.weight(1f)) { - Text(label, style = MaterialTheme.typography.titleSmall) - Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - if (selected) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } -} - @Composable private fun OpOverrideRow( opKey: String, @@ -411,7 +357,7 @@ private fun CapabilityDetailRow( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), ) { Icon( - capability.capSymbol(), + capability.symbol(), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp), @@ -467,21 +413,6 @@ private fun NostrOpDecision.decisionLabel(): String = NostrOpDecision.DENY -> stringResource(R.string.napplet_decision_deny) } -private fun NappletCapability.capSymbol(): MaterialSymbol = - when (this) { - NappletCapability.SHELL -> MaterialSymbols.Tune - NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle - NappletCapability.KEYS -> MaterialSymbols.Key - NappletCapability.RELAY -> MaterialSymbols.Public - NappletCapability.STORAGE -> MaterialSymbols.Storage - NappletCapability.VALUE -> MaterialSymbols.Bolt - NappletCapability.RESOURCE -> MaterialSymbols.Language - NappletCapability.UPLOAD -> MaterialSymbols.Upload - NappletCapability.THEME -> MaterialSymbols.Image - NappletCapability.NOTIFY -> MaterialSymbols.Notifications - NappletCapability.INC -> MaterialSymbols.SwapHoriz - } - private suspend fun loadDetailState( coordinate: String, capabilityLedger: NappletPermissionLedger, @@ -502,7 +433,7 @@ private suspend fun loadDetailState( val signerPolicy = signerLedger.store.loadPolicy(coordinate) val opOverrides = signerLedger.store.allOpDecisions(coordinate) - val (title, iconUrl) = resolveAppMeta(author, identifier, untitled) + val (title, iconUrl) = resolveNappletMeta(author, identifier, untitled) return ConnectedAppDetailState( title = title, coordinate = coordinate, @@ -512,25 +443,3 @@ private suspend fun loadDetailState( capabilities = capGrants, ) } - -private fun resolveAppMeta( - author: String, - identifier: String, - untitled: String, -): Pair { - val events = - Amethyst.instance.cache - .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) - .mapNotNull { it.event } - val match = - events.firstOrNull { ev -> - when (ev) { - is NamedNappletEvent -> ev.identifier() == identifier - is RootNappletEvent -> identifier.isEmpty() - else -> false - } - } as? NappletManifest - val title = match?.title()?.ifBlank { null } ?: identifier.ifBlank { untitled } - val iconUrl = match?.icon()?.ifBlank { null } - return title to iconUrl -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 3be6479b24..3dc8e33fd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -52,7 +52,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon @@ -63,14 +62,11 @@ import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore +import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent -import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest -import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -229,7 +225,7 @@ private suspend fun loadConnectedApps( .map { coordinate -> val author = coordinate.substringBefore(':') val identifier = coordinate.substringAfter(':', "") - val (title, iconUrl) = resolveAppMeta(author, identifier, untitled) + val (title, iconUrl) = resolveNappletMeta(author, identifier, untitled) ConnectedAppEntry( coordinate = coordinate, title = title, @@ -240,25 +236,3 @@ private suspend fun loadConnectedApps( ) }.sortedBy { it.title.lowercase() } } - -private fun resolveAppMeta( - author: String, - identifier: String, - untitled: String, -): Pair { - val events = - Amethyst.instance.cache - .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) - .mapNotNull { it.event } - val match = - events.firstOrNull { ev -> - when (ev) { - is NamedNappletEvent -> ev.identifier() == identifier - is RootNappletEvent -> identifier.isEmpty() - else -> false - } - } as? NappletManifest - val title = match?.title()?.ifBlank { null } ?: identifier.ifBlank { untitled } - val iconUrl = match?.icon()?.ifBlank { null } - return title to iconUrl -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt new file mode 100644 index 0000000000..66bce77ba8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets + +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability + +internal fun NappletCapability.symbol(): MaterialSymbol = + when (this) { + NappletCapability.SHELL -> MaterialSymbols.Tune + NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle + NappletCapability.KEYS -> MaterialSymbols.Key + NappletCapability.RELAY -> MaterialSymbols.Public + NappletCapability.STORAGE -> MaterialSymbols.Storage + NappletCapability.VALUE -> MaterialSymbols.Bolt + NappletCapability.RESOURCE -> MaterialSymbols.Language + NappletCapability.UPLOAD -> MaterialSymbols.Upload + NappletCapability.THEME -> MaterialSymbols.Image + NappletCapability.NOTIFY -> MaterialSymbols.Notifications + NappletCapability.INC -> MaterialSymbols.SwapHoriz + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt index 7637ae5a49..b9df14ea2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt @@ -58,10 +58,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity @@ -70,13 +68,10 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore import com.vitorpamplona.amethyst.napplet.descriptionRes import com.vitorpamplona.amethyst.napplet.labelRes +import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent -import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest -import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -304,44 +299,7 @@ private suspend fun loadGrants( val identifier = coordinate.substringAfter(':', "") NappletGrantsUi( identity = NappletIdentity(authorPubKey = author, identifier = identifier), - title = resolveTitle(author, identifier, untitled), + title = resolveNappletMeta(author, identifier, untitled).first, capabilities = caps.entries.sortedBy { it.key.ordinal }.map { it.key to it.value }, ) }.sortedBy { it.title.lowercase() } - -/** Best-effort human title from a cached manifest; falls back to the d-identifier or [untitled]. */ -private fun resolveTitle( - author: String, - identifier: String, - untitled: String, -): String { - val events = - Amethyst.instance.cache - .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) - .mapNotNull { it.event } - val match = - events.firstOrNull { ev -> - when (ev) { - is NamedNappletEvent -> ev.identifier() == identifier - is RootNappletEvent -> identifier.isEmpty() - else -> false - } - } - return (match as? NappletManifest)?.title()?.ifBlank { null } - ?: identifier.ifBlank { untitled } -} - -private fun NappletCapability.symbol(): MaterialSymbol = - when (this) { - NappletCapability.SHELL -> MaterialSymbols.Tune - NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle - NappletCapability.KEYS -> MaterialSymbols.Key - NappletCapability.RELAY -> MaterialSymbols.Public - NappletCapability.STORAGE -> MaterialSymbols.Storage - NappletCapability.VALUE -> MaterialSymbols.Bolt - NappletCapability.RESOURCE -> MaterialSymbols.Language - NappletCapability.UPLOAD -> MaterialSymbols.Upload - NappletCapability.THEME -> MaterialSymbols.Image - NappletCapability.NOTIFY -> MaterialSymbols.Notifications - NappletCapability.INC -> MaterialSymbols.SwapHoriz - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/PolicyCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/PolicyCard.kt new file mode 100644 index 0000000000..1fa058d801 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/PolicyCard.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols + +/** Bordered selection card used in policy-picker UIs (napplet trust level, relay auth). */ +@Composable +fun PolicyCard( + selected: Boolean, + symbol: MaterialSymbol, + label: String, + description: String, + onClick: () -> Unit, +) { + val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface + + Surface( + modifier = + Modifier + .fillMaxWidth() + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = bgColor, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.titleSmall) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (selected) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt index 88272e4418..b228c4812e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,9 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize 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.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton @@ -60,7 +56,6 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy @@ -69,6 +64,7 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAut import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.PolicyCard import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -136,7 +132,7 @@ fun RelayAuthSettingsScreen( PolicyCard( selected = globalPolicy == policy, symbol = symbol, - title = stringResource(titleRes), + label = stringResource(titleRes), description = stringResource(descRes), onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) }, ) @@ -200,52 +196,6 @@ fun RelayAuthSettingsScreen( } } -@Composable -private fun PolicyCard( - selected: Boolean, - symbol: MaterialSymbol, - title: String, - description: String, - onClick: () -> Unit, -) { - val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface - - Surface( - modifier = - Modifier - .fillMaxWidth() - .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp)) - .clickable(onClick = onClick), - shape = RoundedCornerShape(12.dp), - color = bgColor, - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - symbol = symbol, - contentDescription = null, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(28.dp), - ) - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleSmall) - Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - if (selected) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } -} - @Composable private fun PerRelayOverrideRow( url: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt index 69fd4c03c4..2b3a40c27b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt @@ -118,9 +118,9 @@ class NostrSignerPermissionLedger( when (op) { is NostrSignerOp.SignKind -> when (op.kind) { - 1 -> NostrOpDecision.ALLOW // Short text notes: common, low-risk - 6 -> NostrOpDecision.ALLOW // Reposts - 7 -> NostrOpDecision.ALLOW // Reactions / emoji + 1 -> NostrOpDecision.ALLOW + 6 -> NostrOpDecision.ALLOW + 7 -> NostrOpDecision.ALLOW else -> NostrOpDecision.ASK } NostrSignerOp.Encrypt -> NostrOpDecision.ALLOW From 343263cb2cd712091cbbcc38816f74a3f9fc6a14 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 19:09:11 +0000 Subject: [PATCH 14/33] fix: use CommonsR for napplet_untitled after resource move to commons The string moved to commons/src/androidMain/res/values/strings.xml in the upstream merge; update the two remaining call sites. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt | 3 ++- .../ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 3268bfe0b6..979d341f52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import com.vitorpamplona.amethyst.commons.R as CommonsR private data class ConnectedAppDetailState( val title: String, @@ -99,7 +100,7 @@ fun ConnectedAppDetailScreen( val context = LocalContext.current val capabilityLedger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) } val signerLedger = remember { NostrSignerPermissionLedger(DataStoreNostrSignerPermissionStore(context)) } - val untitled = stringResource(R.string.napplet_untitled) + val untitled = stringResource(CommonsR.string.napplet_untitled) var state by remember { mutableStateOf(null) } var reload by remember { mutableIntStateOf(0) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 3dc8e33fd0..d3fc73c9b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import com.vitorpamplona.amethyst.commons.R as CommonsR private data class ConnectedAppEntry( val coordinate: String, @@ -87,7 +88,7 @@ fun ConnectedAppsScreen( val context = LocalContext.current val capabilityLedger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) } val signerLedger = remember { NostrSignerPermissionLedger(DataStoreNostrSignerPermissionStore(context)) } - val untitled = stringResource(R.string.napplet_untitled) + val untitled = stringResource(CommonsR.string.napplet_untitled) var items by remember { mutableStateOf?>(null) } var reload by remember { mutableIntStateOf(0) } From c7566f26858a2fddd6f09a81b1ac1e127a19905f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 00:45:36 +0000 Subject: [PATCH 15/33] fix(napplet): center connect dialog, fix label color and domain display - Wrap content in a scrollable Box with Alignment.Center so the dialog is vertically centered instead of stuck at the top - Explicitly set onSurface color on the PolicyOption label so it stays visible regardless of Surface background tint - Fix buildConnectInfo to show the napplet identifier (e.g. "browser") in the block button instead of the raw coordinate prefix (pubkey) --- .../napplet/NappletConnectActivity.kt | 168 +++++++++--------- .../amethyst/napplet/NostrSignerOpLabels.kt | 2 +- 2 files changed, 87 insertions(+), 83 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 5ac982732a..875abc8f2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt @@ -26,6 +26,7 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -119,94 +120,97 @@ private fun NappletConnectScreen( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background.copy(alpha = 0.85f), ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Box( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + contentAlignment = Alignment.Center, ) { - Spacer(Modifier.height(32.dp)) - Text( - stringResource(R.string.napplet_connect_title), - style = MaterialTheme.typography.headlineSmall, - ) - Spacer(Modifier.height(16.dp)) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringResource(R.string.napplet_connect_title), + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(16.dp)) - // App card - Card(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - modifier = Modifier.size(40.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - ) {} - Column { - Text(info.appletTitle, style = MaterialTheme.typography.titleMedium) - Text(info.domain, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + // App card + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + modifier = Modifier.size(40.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + ) {} + Column { + Text(info.appletTitle, style = MaterialTheme.typography.titleMedium) + Text(info.domain, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } - } - Spacer(Modifier.height(24.dp)) - Text( - stringResource(R.string.napplet_connect_how_handle), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - - // Trust level options - PolicyOption( - selected = selected == AppSignerPolicy.FULL_TRUST, - icon = "ā¤", - label = stringResource(R.string.napplet_policy_full_trust), - description = stringResource(R.string.napplet_policy_full_trust_desc), - onClick = { selected = AppSignerPolicy.FULL_TRUST }, - ) - Spacer(Modifier.height(8.dp)) - PolicyOption( - selected = selected == AppSignerPolicy.REASONABLE, - icon = "šŸ‘", - label = stringResource(R.string.napplet_policy_reasonable), - description = stringResource(R.string.napplet_policy_reasonable_desc), - onClick = { selected = AppSignerPolicy.REASONABLE }, - ) - Spacer(Modifier.height(8.dp)) - PolicyOption( - selected = selected == AppSignerPolicy.PARANOID, - icon = "šŸ•¶", - label = stringResource(R.string.napplet_policy_paranoid), - description = stringResource(R.string.napplet_policy_paranoid_desc), - onClick = { selected = AppSignerPolicy.PARANOID }, - ) - - Spacer(Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.cancel)) - } - Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.napplet_connect_button)) - } - } - - Spacer(Modifier.height(16.dp)) - TextButton(onClick = onBlock) { + Spacer(Modifier.height(24.dp)) Text( - stringResource(R.string.napplet_connect_block, info.domain), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, + stringResource(R.string.napplet_connect_how_handle), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(), ) + Spacer(Modifier.height(8.dp)) + + // Trust level options + PolicyOption( + selected = selected == AppSignerPolicy.FULL_TRUST, + icon = "ā¤", + label = stringResource(R.string.napplet_policy_full_trust), + description = stringResource(R.string.napplet_policy_full_trust_desc), + onClick = { selected = AppSignerPolicy.FULL_TRUST }, + ) + Spacer(Modifier.height(8.dp)) + PolicyOption( + selected = selected == AppSignerPolicy.REASONABLE, + icon = "šŸ‘", + label = stringResource(R.string.napplet_policy_reasonable), + description = stringResource(R.string.napplet_policy_reasonable_desc), + onClick = { selected = AppSignerPolicy.REASONABLE }, + ) + Spacer(Modifier.height(8.dp)) + PolicyOption( + selected = selected == AppSignerPolicy.PARANOID, + icon = "šŸ•¶", + label = stringResource(R.string.napplet_policy_paranoid), + description = stringResource(R.string.napplet_policy_paranoid_desc), + onClick = { selected = AppSignerPolicy.PARANOID }, + ) + + Spacer(Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.cancel)) + } + Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.napplet_connect_button)) + } + } + + Spacer(Modifier.height(16.dp)) + TextButton(onClick = onBlock) { + Text( + stringResource(R.string.napplet_connect_block, info.domain), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } } } } @@ -239,7 +243,7 @@ private fun PolicyOption( ) { Text(icon, style = MaterialTheme.typography.headlineSmall) Column(modifier = Modifier.weight(1f)) { - Text(label, style = MaterialTheme.typography.titleSmall) + Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } if (selected) { 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 209cfd225a..91213d9310 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -106,6 +106,6 @@ fun buildConnectInfo( identity: NappletIdentity, ): NappletConnectInfo { val title = identity.identifier.ifBlank { context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) } - val domain = identity.coordinate.substringBefore(":") + val domain = identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" } return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain) } From af2fcc46b6b6be96a787649b15fb14ec352923b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 18:43:24 +0000 Subject: [PATCH 16/33] feat(napplet): promote Always Allow to primary action in signer consent Move AllowForOp to the primary Button (filled) since always allowing the operation is the preferred choice, with AllowOnce as the secondary FilledTonalButton. The time-bound and allow-all options stay in "More options", keeping the nuclear allow-all less discoverable. --- .../napplet/NappletSignerConsentActivity.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 a6f3a144e5..3569cb700a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -226,8 +227,16 @@ private fun NappletSignerConsentDialog( Spacer(Modifier.height(16.dp)) HorizontalDivider() - // Primary action + // Primary action: always allow this operation Spacer(Modifier.height(8.dp)) + Button( + onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_op, info.operationSummary)) + } + + // Secondary action: allow just this once FilledTonalButton( onClick = { onGrant(SignerOpGrant.AllowOnce) }, modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), @@ -235,7 +244,7 @@ private fun NappletSignerConsentDialog( Text(stringResource(R.string.napplet_signer_allow_once)) } - // "More options" toggle + // "More options" toggle: session, time-bound, and nuclear allow-all TextButton( onClick = { showMoreOptions = !showMoreOptions }, modifier = Modifier.fillMaxWidth(), @@ -277,11 +286,6 @@ private fun NappletSignerConsentDialog( color = MaterialTheme.colorScheme.primary, onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) }, ) - ConsentActionButton( - text = stringResource(R.string.napplet_signer_allow_op, info.operationSummary), - color = MaterialTheme.colorScheme.primary, - onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, - ) ConsentActionButton( text = stringResource(R.string.napplet_signer_allow_all), color = MaterialTheme.colorScheme.primary, From 9dcfc3a0edfc59101b407a0a336560dc58ad0f80 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 18:46:45 +0000 Subject: [PATCH 17/33] fix(napplet): convert connect screen to floating dialog, match consent style Replace the full-screen semi-transparent overlay with a floating card Dialog (same Surface/shape/elevation as the signer consent dialog) so both permission prompts look consistent. Header now shows the app icon, name, and "wants to connect to your Nostr account" subtitle instead of the generic "Connect to Nostr" headline. Block button style matches the deny row in the signer consent. --- .../napplet/NappletConnectActivity.kt | 169 +++++++++++------- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 103 insertions(+), 67 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 875abc8f2b..5a6705e3b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt @@ -26,21 +26,20 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize 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.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.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -53,10 +52,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource 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.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult @@ -115,83 +119,110 @@ private fun NappletConnectScreen( onCancel: () -> Unit, ) { var selected by remember { mutableStateOf(AppSignerPolicy.REASONABLE) } + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.9f + val iconUrl = + remember(info.coordinate) { + resolveNappletMeta(info.coordinate.substringBefore(':'), info.coordinate.substringAfter(':', ""), "").second + } - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background.copy(alpha = 0.85f), + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), ) { - Box( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - contentAlignment = Alignment.Center, + 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 - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .verticalScroll(rememberScrollState()) + .padding(vertical = 24.dp), ) { - Text( - stringResource(R.string.napplet_connect_title), - style = MaterialTheme.typography.headlineSmall, - ) - Spacer(Modifier.height(16.dp)) - - // App card - Card(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - modifier = Modifier.size(40.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - ) {} - Column { - Text(info.appletTitle, style = MaterialTheme.typography.titleMedium) - Text(info.domain, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } + // Centered header: icon + app name + connect subtitle + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FavoriteAppIcon( + app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, iconUrl), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp), + ) + Text( + info.appletTitle, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.napplet_connect_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(8.dp)) + Text( + info.domain, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(12.dp)) + Text( stringResource(R.string.napplet_connect_how_handle), style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), ) Spacer(Modifier.height(8.dp)) // Trust level options - PolicyOption( - selected = selected == AppSignerPolicy.FULL_TRUST, - icon = "ā¤", - label = stringResource(R.string.napplet_policy_full_trust), - description = stringResource(R.string.napplet_policy_full_trust_desc), - onClick = { selected = AppSignerPolicy.FULL_TRUST }, - ) - Spacer(Modifier.height(8.dp)) - PolicyOption( - selected = selected == AppSignerPolicy.REASONABLE, - icon = "šŸ‘", - label = stringResource(R.string.napplet_policy_reasonable), - description = stringResource(R.string.napplet_policy_reasonable_desc), - onClick = { selected = AppSignerPolicy.REASONABLE }, - ) - Spacer(Modifier.height(8.dp)) - PolicyOption( - selected = selected == AppSignerPolicy.PARANOID, - icon = "šŸ•¶", - label = stringResource(R.string.napplet_policy_paranoid), - description = stringResource(R.string.napplet_policy_paranoid_desc), - onClick = { selected = AppSignerPolicy.PARANOID }, - ) + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + PolicyOption( + selected = selected == AppSignerPolicy.FULL_TRUST, + icon = "ā¤", + label = stringResource(R.string.napplet_policy_full_trust), + description = stringResource(R.string.napplet_policy_full_trust_desc), + onClick = { selected = AppSignerPolicy.FULL_TRUST }, + ) + PolicyOption( + selected = selected == AppSignerPolicy.REASONABLE, + icon = "šŸ‘", + label = stringResource(R.string.napplet_policy_reasonable), + description = stringResource(R.string.napplet_policy_reasonable_desc), + onClick = { selected = AppSignerPolicy.REASONABLE }, + ) + PolicyOption( + selected = selected == AppSignerPolicy.PARANOID, + icon = "šŸ•¶", + label = stringResource(R.string.napplet_policy_paranoid), + description = stringResource(R.string.napplet_policy_paranoid_desc), + onClick = { selected = AppSignerPolicy.PARANOID }, + ) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) - Spacer(Modifier.height(24.dp)) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { @@ -202,13 +233,17 @@ private fun NappletConnectScreen( } } - Spacer(Modifier.height(16.dp)) - TextButton(onClick = onBlock) { + TextButton( + onClick = onBlock, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp), + ) { Text( stringResource(R.string.napplet_connect_block, info.domain), - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Start, ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 64b6ffd0ff..a15523eec6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -754,6 +754,7 @@ Connect to Nostr + wants to connect to your Nostr account How should this app\'s requests be handled? Connect Block and ignore %1$s From 840237fa723e46aa896dd750fc81592cd2084e77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:10:59 +0000 Subject: [PATCH 18/33] fix(napplet): convert capability consent to floating dialog, match consent style Replace the AlertDialog with the same Surface(extraLarge)/Dialog pattern used by the signer consent and connect dialogs. Shows app icon + name + capability category in the centered header, operation detail (with any content preview) in a selectable surfaceVariant box, and the same button hierarchy: Always allow (Button, primary) / Allow once (FilledTonalButton) then Never allow / Not now as left-aligned TextButtons below a divider. When the capability is per-use only (payments), Allow once is promoted to the primary Button. --- .../napplet/NappletConsentActivity.kt | 174 ++++++++++++++---- 1 file changed, 141 insertions(+), 33 deletions(-) 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 04ed6b60fb..c6123a96ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt @@ -25,17 +25,36 @@ 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.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +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.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource +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.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState import com.vitorpamplona.amethyst.ui.theme.AmethystTheme @@ -93,49 +112,138 @@ private fun NappletConsentDialog( onDecision: (GrantState) -> Unit, onDismiss: () -> Unit, ) { - AlertDialog( + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f + val iconUrl = + remember(info.coordinate) { + resolveNappletMeta(info.coordinate.substringBefore(':'), info.coordinate.substringAfter(':', ""), "").second + } + + Dialog( onDismissRequest = onDismiss, - title = { Text(info.appletTitle) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(info.operationSummary) + 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(rememberScrollState()) + .padding(vertical = 24.dp), + ) { + // Centered header: icon + app name + capability category + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FavoriteAppIcon( + app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, iconUrl), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp), + ) + Text( + info.appletTitle, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + Text( + info.capabilityLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + // Operation detail box (may include content preview) + if (info.operationSummary.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, + ) + } + } + } + + Spacer(Modifier.height(8.dp)) Text( - stringResource(R.string.napplet_consent_capability, info.capabilityLabel), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - info.coordinate, + info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) - } - }, - confirmButton = { - Column(modifier = Modifier.fillMaxWidth()) { + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + if (info.allowAlways) { - TextButton( + Button( onClick = { onDecision(GrantState.ALLOW_ALWAYS) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_consent_allow_always)) } + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_always)) + } + FilledTonalButton( + onClick = { onDecision(GrantState.ALLOW_ONCE) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_once)) + } + } else { + Button( + onClick = { onDecision(GrantState.ALLOW_ONCE) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_once)) + } } - TextButton( - onClick = { onDecision(GrantState.ALLOW_ONCE) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_consent_allow_once)) } - } - }, - dismissButton = { - Column(modifier = Modifier.fillMaxWidth()) { + + Spacer(Modifier.height(4.dp)) + HorizontalDivider() + TextButton( onClick = { onDecision(GrantState.DENY) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_consent_deny_always)) } + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp), + ) { + Text( + stringResource(R.string.napplet_consent_deny_always), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + ) + } TextButton( onClick = { onDecision(GrantState.ASK) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_consent_not_now)) } + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp), + ) { + Text( + stringResource(R.string.napplet_consent_not_now), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + ) + } } - }, - ) + } + } } From 94ac370379fe0219c1220f1d4b4d19a43202b1ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 20:34:37 +0000 Subject: [PATCH 19/33] fix(napplet): consistent button styling, fix DataStore crash, icon loading, dead-code cleanup - Make all four consent actions (Always Allow / Allow Once / Deny Once / Always Deny) full-width OutlinedButton/Button so they are visually consistent; remove TextButton with left-aligned text that clashed with the centered primary buttons - Move domain/coordinate label into the header Column directly below the subtitle so the URL is contextually grouped with the app identity rather than floating near buttons - Fix DataStore multiple-instances crash: add nappletPermissionStore and signerPermissionStore as lazy singletons in AppModules; warm them up on IO thread to avoid StrictMode DiskReadViolation; update NappletBrokerService and connected-apps screens to use the shared singletons instead of creating independent instances - Propagate iconUrl through NappletConsentInfo / NappletSignerConsentInfo / NappletConnectInfo data classes; resolve napplet metadata (title + icon) in the broker-side builders (NappletConsentSummary, NostrSignerOpLabels) so dialogs receive it ready to display - Replace manual buildEventJson (org.json) with JacksonMapper.toJsonPretty(EventTemplate) in NostrSignerOpLabels; add toJsonPretty(EventTemplate<*>) overload to JacksonMapper - Delete dead NappletPermissionsScreen.kt and NappletSignerPermissionsScreen.kt (never navigated to); remove Route.NappletPermissions and its composable registration Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../com/vitorpamplona/amethyst/AppModules.kt | 13 + .../amethyst/napplet/NappletBrokerService.kt | 4 +- .../napplet/NappletConnectActivity.kt | 31 +- .../napplet/NappletConnectCoordinator.kt | 1 + .../napplet/NappletConsentActivity.kt | 52 ++- .../napplet/NappletConsentCoordinator.kt | 1 + .../amethyst/napplet/NappletConsentSummary.kt | 4 +- .../napplet/NappletSignerConsentActivity.kt | 123 ++++--- .../NappletSignerConsentCoordinator.kt | 1 + .../amethyst/napplet/NostrSignerOpLabels.kt | 65 ++-- .../amethyst/ui/navigation/AppNavigation.kt | 2 - .../amethyst/ui/navigation/routes/Routes.kt | 2 - .../napplets/ConnectedAppDetailScreen.kt | 9 +- .../loggedIn/napplets/ConnectedAppsScreen.kt | 9 +- .../napplets/NappletPermissionsScreen.kt | 305 ------------------ .../NappletSignerPermissionsScreen.kt | 239 -------------- .../quartz/nip01Core/jackson/JacksonMapper.kt | 2 + 17 files changed, 143 insertions(+), 720 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 57e109a3a3..2a70f2084f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -44,6 +44,8 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector import com.vitorpamplona.amethyst.model.torState.TorRelayState +import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore +import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager @@ -535,6 +537,10 @@ class AppModules( DataStoreRelayAuthPermissionStore(appContext) } + // Singleton stores for napplet permissions — DataStore v1 enforces one instance per file. + val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) } + val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) } + // Authenticates with relays. val authCoordinator = AuthCoordinator(client, applicationIOScope) @@ -800,6 +806,13 @@ class AppModules( resourceCacheInit() } + // Initialize napplet permission stores on an IO thread to avoid StrictMode violations + // when ConnectedAppsScreen first accesses them on the main thread. + applicationIOScope.launch { + nappletPermissionStore + signerPermissionStore + } + // registers to receive events pokeyReceiver.register(appContext) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index eeaa67157f..fc282ec8e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -76,11 +76,11 @@ class NappletBrokerService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM. - private val ledger by lazy { NappletPermissionLedger(DataStoreNappletPermissionStore(applicationContext)) } + private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } // Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only // instantiated in the main process where the signer lives; never touched from :napplet. - private val signerLedger by lazy { NostrSignerPermissionLedger(DataStoreNostrSignerPermissionStore(applicationContext)) } + private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } // Per-applet sandboxed key-value store (namespaced by coordinate inside the impl). private val storage by lazy { DataStoreNappletStorage(applicationContext) } 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 5a6705e3b3..e8f7aebba4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -39,12 +38,12 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider 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 @@ -120,10 +119,6 @@ private fun NappletConnectScreen( ) { var selected by remember { mutableStateOf(AppSignerPolicy.REASONABLE) } val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.9f - val iconUrl = - remember(info.coordinate) { - resolveNappletMeta(info.coordinate.substringBefore(':'), info.coordinate.substringAfter(':', ""), "").second - } Dialog( onDismissRequest = onCancel, @@ -152,7 +147,7 @@ private fun NappletConnectScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { FavoriteAppIcon( - app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, iconUrl), + app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl), tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(56.dp), ) @@ -167,17 +162,14 @@ private fun NappletConnectScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) + Text( + info.domain, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } - Spacer(Modifier.height(8.dp)) - Text( - info.domain, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(16.dp)) HorizontalDivider() Spacer(Modifier.height(12.dp)) @@ -233,15 +225,14 @@ private fun NappletConnectScreen( } } - TextButton( + OutlinedButton( onClick = onBlock, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), ) { Text( stringResource(R.string.napplet_connect_block, info.domain), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Start, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt index 0f8f244526..0467578f2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt @@ -32,6 +32,7 @@ data class NappletConnectInfo( val appletTitle: String, val coordinate: String, val domain: String, + val iconUrl: String? = null, ) /** 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 c6123a96ce..caff831d72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt @@ -25,7 +25,6 @@ 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 @@ -36,14 +35,13 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider 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.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration @@ -113,10 +111,6 @@ private fun NappletConsentDialog( onDismiss: () -> Unit, ) { val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f - val iconUrl = - remember(info.coordinate) { - resolveNappletMeta(info.coordinate.substringBefore(':'), info.coordinate.substringAfter(':', ""), "").second - } Dialog( onDismissRequest = onDismiss, @@ -138,14 +132,14 @@ private fun NappletConsentDialog( .verticalScroll(rememberScrollState()) .padding(vertical = 24.dp), ) { - // Centered header: icon + app name + capability category + // Centered header: icon + app name + capability category + coordinate Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { FavoriteAppIcon( - app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, iconUrl), + app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl), tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(56.dp), ) @@ -160,6 +154,12 @@ private fun NappletConsentDialog( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) + Text( + info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } // Operation detail box (may include content preview) @@ -180,15 +180,6 @@ private fun NappletConsentDialog( } } - Spacer(Modifier.height(8.dp)) - Text( - info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(16.dp)) HorizontalDivider() Spacer(Modifier.height(8.dp)) @@ -200,7 +191,7 @@ private fun NappletConsentDialog( ) { Text(stringResource(R.string.napplet_consent_allow_always)) } - FilledTonalButton( + OutlinedButton( onClick = { onDecision(GrantState.ALLOW_ONCE) }, modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), ) { @@ -217,27 +208,26 @@ private fun NappletConsentDialog( Spacer(Modifier.height(4.dp)) HorizontalDivider() + Spacer(Modifier.height(8.dp)) - TextButton( - onClick = { onDecision(GrantState.DENY) }, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp), + OutlinedButton( + onClick = { onDecision(GrantState.ASK) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), ) { Text( - stringResource(R.string.napplet_consent_deny_always), - color = MaterialTheme.colorScheme.error, + stringResource(R.string.napplet_consent_not_now), modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Start, ) } - TextButton( - onClick = { onDecision(GrantState.ASK) }, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp), + OutlinedButton( + onClick = { onDecision(GrantState.DENY) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), ) { Text( - stringResource(R.string.napplet_consent_not_now), + stringResource(R.string.napplet_consent_deny_always), modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Start, 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 a069f1f0c1..e2a0daa355 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt @@ -35,6 +35,7 @@ data class NappletConsentInfo( val operationSummary: String, /** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */ val allowAlways: Boolean, + val iconUrl: String? = null, ) /** 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 c7ff90ac5f..888557adf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -41,13 +41,15 @@ class NappletConsentSummary( capability: NappletCapability, request: NappletRequest, ): NappletConsentInfo { - val title = identity.identifier.ifBlank { context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) } + val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) + val (title, iconUrl) = resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) return NappletConsentInfo( appletTitle = title, coordinate = identity.coordinate, capabilityLabel = context.getString(capability.labelRes()), operationSummary = summaryFor(request), allowAlways = capability.canGrantAlways, + iconUrl = iconUrl, ) } 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 3569cb700a..c33916a849 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -37,9 +37,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider 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 @@ -50,7 +51,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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 @@ -116,10 +116,6 @@ private fun NappletSignerConsentDialog( var showMoreOptions by remember { mutableStateOf(false) } val scrollState = rememberScrollState() val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f - val iconUrl = - remember(info.coordinate) { - resolveNappletMeta(info.coordinate.substringBefore(':'), info.coordinate.substringAfter(':', ""), "").second - } Dialog( onDismissRequest = onDismiss, @@ -148,7 +144,7 @@ private fun NappletSignerConsentDialog( verticalArrangement = Arrangement.spacedBy(8.dp), ) { FavoriteAppIcon( - app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, iconUrl), + app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl), tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(56.dp), ) @@ -163,6 +159,12 @@ private fun NappletSignerConsentDialog( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) + Text( + info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank() @@ -215,36 +217,27 @@ private fun NappletSignerConsentDialog( } } - Spacer(Modifier.height(8.dp)) - Text( - info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(16.dp)) HorizontalDivider() - - // Primary action: always allow this operation Spacer(Modifier.height(8.dp)) + + // Primary: always allow this op Button( onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), ) { - Text(stringResource(R.string.napplet_signer_allow_op, info.operationSummary)) + Text(stringResource(R.string.napplet_consent_allow_always)) } - // Secondary action: allow just this once - FilledTonalButton( + // Secondary: allow just once + OutlinedButton( onClick = { onGrant(SignerOpGrant.AllowOnce) }, modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), ) { Text(stringResource(R.string.napplet_signer_allow_once)) } - // "More options" toggle: session, time-bound, and nuclear allow-all + // "More options" toggle: session and time-bound grants TextButton( onClick = { showMoreOptions = !showMoreOptions }, modifier = Modifier.fillMaxWidth(), @@ -271,63 +264,59 @@ private fun NappletSignerConsentDialog( } if (showMoreOptions) { - ConsentActionButton( - text = stringResource(R.string.napplet_signer_allow_session), - color = MaterialTheme.colorScheme.primary, + OutlinedButton( onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) }, - ) - ConsentActionButton( - text = stringResource(R.string.napplet_signer_allow_24h), - color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_session)) + } + OutlinedButton( onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) }, - ) - ConsentActionButton( - text = stringResource(R.string.napplet_signer_allow_30d), - color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_24h)) + } + OutlinedButton( onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) }, - ) - ConsentActionButton( - text = stringResource(R.string.napplet_signer_allow_all), - color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_30d)) + } + OutlinedButton( onClick = { onGrant(SignerOpGrant.AllowAll) }, - ) + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_all)) + } } Spacer(Modifier.height(4.dp)) HorizontalDivider() + Spacer(Modifier.height(8.dp)) - ConsentActionButton( - text = stringResource(R.string.napplet_signer_deny_once), - color = MaterialTheme.colorScheme.error, + OutlinedButton( onClick = { onGrant(SignerOpGrant.DenyOnce) }, - ) - ConsentActionButton( - text = stringResource(R.string.napplet_signer_deny_op, info.operationSummary), - color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text( + stringResource(R.string.napplet_signer_deny_once), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Start, + ) + } + OutlinedButton( onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) }, - ) + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text( + stringResource(R.string.napplet_signer_deny_op, info.operationSummary), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Start, + ) + } } } } } - -@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 f9658576e6..9febb3b0f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt @@ -41,6 +41,7 @@ data class NappletSignerConsentInfo( * decrypted plaintext for decrypt (Amethyst decrypts first, then asks permission to expose). */ val rawData: String = "", + val iconUrl: String? = null, ) /** 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 91213d9310..07912b0cc6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -25,8 +25,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp -import org.json.JSONArray -import org.json.JSONObject +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils /** Human-readable label for a [NostrSignerOp]. */ fun NostrSignerOp.label(context: Context): String = @@ -43,7 +44,8 @@ fun buildSignerConsentInfo( op: NostrSignerOp, request: NappletRequest, ): NappletSignerConsentInfo { - val title = identity.identifier.ifBlank { context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) } + val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) + val (title, iconUrl) = resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) val summary = op.label(context) val preview = when (request) { @@ -54,16 +56,23 @@ fun buildSignerConsentInfo( } 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, - ) + is NappletRequest.Publish -> + JacksonMapper.toJsonPretty(EventTemplate(TimeUtils.now(), request.kind, request.tags, request.content)) + is NappletRequest.SignEvent -> + JacksonMapper.toJsonPretty(EventTemplate(request.createdAt, request.kind, request.tags, request.content)) + is NappletRequest.PublishEncrypted -> { + val node = JacksonMapper.mapper.createObjectNode() + node.put("kind", request.kind) + node.put("recipient", request.recipient) + node.put("encryption", request.encryption) + val tagsNode = node.putArray("tags") + for (tag in request.tags) { + val tagNode = tagsNode.addArray() + for (item in tag) tagNode.add(item) + } + node.put("content", request.content) + JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node) + } else -> "" } return NappletSignerConsentInfo( @@ -73,39 +82,17 @@ fun buildSignerConsentInfo( operationSummary = summary, contentPreview = preview, rawData = rawData, + iconUrl = iconUrl, ) } -private fun buildEventJson( - kind: Int, - tags: Array>, - content: String, - createdAt: Long? = null, - recipient: String? = null, - encryption: String? = null, -): String { - val obj = JSONObject() - obj.put("kind", kind) - if (createdAt != null) obj.put("created_at", createdAt) - if (recipient != null) obj.put("recipient", recipient) - if (encryption != null) obj.put("encryption", encryption) - val tagsArray = JSONArray() - for (tag in tags) { - val tagArray = JSONArray() - for (item in tag) tagArray.put(item) - tagsArray.put(tagArray) - } - obj.put("tags", tagsArray) - obj.put("content", content) - return obj.toString(2) -} - /** Creates a [NappletConnectInfo] for the first-connect dialog. */ fun buildConnectInfo( context: Context, identity: NappletIdentity, ): NappletConnectInfo { - val title = identity.identifier.ifBlank { context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) } + val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) + val (title, iconUrl) = resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) val domain = identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" } - return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain) + return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 809b7703f1..47c6b4875a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -161,7 +161,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicPlaylistScree import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicTrackScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppDetailScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppsScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletPermissionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen @@ -338,7 +337,6 @@ fun BuildNavigation( composableFromEnd { FavoriteAppsScreen(accountViewModel, nav) } composableFromEndArgs { WebAppScreen(it.url, accountViewModel, nav) } composableFromEndArgs { NostrAppScreen(it.coordinate, accountViewModel, nav) } - composableFromEnd { NappletPermissionsScreen(accountViewModel, nav) } composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } composableFromEnd { RelayAuthSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 664ce24c84..496f63eca7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -105,8 +105,6 @@ sealed class Route { val coordinate: String, ) : Route() - @Serializable object NappletPermissions : Route() - @Serializable object ConnectedApps : Route() @Serializable object RelayAuthSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 979d341f52..37d5ca5f91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -51,11 +51,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon @@ -69,8 +69,6 @@ import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger -import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore -import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.napplet.descriptionRes import com.vitorpamplona.amethyst.napplet.labelRes import com.vitorpamplona.amethyst.napplet.resolveNappletMeta @@ -97,9 +95,8 @@ fun ConnectedAppDetailScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val context = LocalContext.current - val capabilityLedger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) } - val signerLedger = remember { NostrSignerPermissionLedger(DataStoreNostrSignerPermissionStore(context)) } + val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } + val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } val untitled = stringResource(CommonsR.string.napplet_untitled) var state by remember { mutableStateOf(null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index d3fc73c9b2..0877e9fc1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -47,11 +47,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon @@ -60,8 +60,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger -import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore -import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -85,9 +83,8 @@ fun ConnectedAppsScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val context = LocalContext.current - val capabilityLedger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) } - val signerLedger = remember { NostrSignerPermissionLedger(DataStoreNostrSignerPermissionStore(context)) } + val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } + val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } val untitled = stringResource(CommonsR.string.napplet_untitled) var items by remember { mutableStateOf?>(null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt deleted file mode 100644 index b9df14ea2d..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt +++ /dev/null @@ -1,305 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.foundation.shape.CircleShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.napplet.NappletCapability -import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity -import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState -import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger -import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore -import com.vitorpamplona.amethyst.napplet.descriptionRes -import com.vitorpamplona.amethyst.napplet.labelRes -import com.vitorpamplona.amethyst.napplet.resolveNappletMeta -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import com.vitorpamplona.amethyst.commons.R as CommonsR - -/** One napplet's persisted permission grants, ready to render. */ -private data class NappletGrantsUi( - val identity: NappletIdentity, - val title: String, - val capabilities: List>, -) - -@Composable -fun NappletPermissionsScreen( - accountViewModel: AccountViewModel, - nav: INav, -) { - val context = LocalContext.current - val ledger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) } - val untitled = stringResource(CommonsR.string.napplet_untitled) - - var items by remember { mutableStateOf?>(null) } - var reload by remember { mutableIntStateOf(0) } - - LaunchedEffect(reload) { - items = withContext(Dispatchers.Default) { loadGrants(ledger, untitled) } - } - - val scope = rememberCoroutineScope() - - fun mutate(block: suspend () -> Unit) { - scope.launch { - block() - reload++ - } - } - - Scaffold( - topBar = { TopBarWithBackButton(stringResource(R.string.napplet_permissions), nav) }, - ) { padding -> - val current = items - when { - current == null -> - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - - current.isEmpty() -> EmptyState(Modifier.fillMaxSize().padding(padding)) - - else -> - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(current, key = { it.identity.coordinate }) { napplet -> - NappletPermissionCard( - napplet = napplet, - onSetAllowed = { cap, allowed -> - mutate { ledger.record(napplet.identity, cap, if (allowed) GrantState.ALLOW_ALWAYS else GrantState.DENY) } - }, - onRevoke = { cap -> mutate { ledger.revoke(napplet.identity, cap) } }, - onForget = { mutate { ledger.revokeAll(napplet.identity) } }, - ) - } - } - } - } -} - -@Composable -private fun EmptyState(modifier: Modifier) { - Box(modifier, contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(32.dp), - ) { - Icon( - MaterialSymbols.Shield, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(56.dp), - ) - Text( - stringResource(R.string.napplet_permissions_empty), - style = MaterialTheme.typography.titleMedium, - ) - Text( - stringResource(R.string.napplet_permissions_empty_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun NappletPermissionCard( - napplet: NappletGrantsUi, - onSetAllowed: (NappletCapability, Boolean) -> Unit, - onRevoke: (NappletCapability) -> Unit, - onForget: () -> Unit, -) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(44.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - MaterialSymbols.Apps, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(24.dp), - ) - } - } - Spacer(Modifier.size(12.dp)) - Column(Modifier.weight(1f)) { - Text( - napplet.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - napplet.identity.authorPubKey.take(12) + "…", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - - napplet.capabilities.forEach { (cap, grant) -> - CapabilityRow( - capability = cap, - grant = grant, - onSetAllowed = { onSetAllowed(cap, it) }, - onRevoke = { onRevoke(cap) }, - ) - } - - TextButton( - onClick = onForget, - modifier = Modifier.align(Alignment.End), - ) { - Icon(MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(6.dp)) - Text(stringResource(R.string.napplet_permissions_forget)) - } - } - } -} - -@Composable -private fun CapabilityRow( - capability: NappletCapability, - grant: GrantState, - onSetAllowed: (Boolean) -> Unit, - onRevoke: () -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), - ) { - Icon( - capability.symbol(), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) - Spacer(Modifier.size(12.dp)) - Column(Modifier.weight(1f)) { - Text(stringResource(capability.labelRes()), style = MaterialTheme.typography.bodyLarge) - Text( - stringResource(capability.descriptionRes()), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (capability.requiresPerUseConsent) { - // Payments only ever persist a DENY; the user can clear it to allow per-payment prompts again. - Text( - stringResource(R.string.napplet_permissions_blocked), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.error, - ) - } else { - Switch( - checked = grant == GrantState.ALLOW_ALWAYS, - onCheckedChange = onSetAllowed, - ) - } - - Spacer(Modifier.size(4.dp)) - IconButton(onClick = onRevoke) { - Icon( - MaterialSymbols.Block, - contentDescription = stringResource(R.string.napplet_permissions_revoke), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - } -} - -private suspend fun loadGrants( - ledger: NappletPermissionLedger, - untitled: String, -): List = - ledger - .allPersistedGrants() - .map { (coordinate, caps) -> - val author = coordinate.substringBefore(':') - val identifier = coordinate.substringAfter(':', "") - NappletGrantsUi( - identity = NappletIdentity(authorPubKey = author, identifier = identifier), - title = resolveNappletMeta(author, identifier, untitled).first, - capabilities = caps.entries.sortedBy { it.key.ordinal }.map { it.key to it.value }, - ) - }.sortedBy { it.title.lowercase() } 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 deleted file mode 100644 index 02e185f4ed..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletSignerPermissionsScreen.kt +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SuggestionChip -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -data class AppSignerPermissionEntry( - val coordinate: String, - val policy: AppSignerPolicy, - val opOverrides: Map, -) - -class NappletSignerPermissionsViewModel( - private val ledger: NostrSignerPermissionLedger, -) : ViewModel() { - private val _entries = MutableStateFlow>(emptyList()) - val entries: StateFlow> = _entries.asStateFlow() - - init { - reload() - } - - fun reload() { - viewModelScope.launch { - val policies = ledger.store.allPolicies() - _entries.value = - policies.map { (coord, policy) -> - AppSignerPermissionEntry( - coordinate = coord, - policy = policy, - opOverrides = ledger.store.allOpDecisions(coord), - ) - } - } - } - - fun revokeAll(coordinate: String) { - viewModelScope.launch { - ledger.revokeAll(coordinate) - reload() - } - } - - fun revokeOp( - coordinate: String, - opKey: String, - ) { - viewModelScope.launch { - NostrSignerOp.fromKey(opKey)?.let { ledger.revokeOpDecision(coordinate, it) } - reload() - } - } -} - -@Composable -fun NappletSignerPermissionsScreen(viewModel: NappletSignerPermissionsViewModel) { - val entries by viewModel.entries.collectAsState() - - if (entries.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - stringResource(R.string.napplet_signer_permissions_empty), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(entries, key = { it.coordinate }) { entry -> - AppSignerPermissionCard( - entry = entry, - onRevokeAll = { viewModel.revokeAll(entry.coordinate) }, - onRevokeOp = { opKey -> viewModel.revokeOp(entry.coordinate, opKey) }, - ) - } - } - } -} - -@Composable -private fun AppSignerPermissionCard( - entry: AppSignerPermissionEntry, - onRevokeAll: () -> Unit, - onRevokeOp: (String) -> Unit, -) { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - entry.coordinate.substringAfter(":").ifBlank { entry.coordinate }, - style = MaterialTheme.typography.titleSmall, - ) - Text( - entry.coordinate, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - IconButton(onClick = onRevokeAll) { - Icon( - symbol = MaterialSymbols.Delete, - contentDescription = stringResource(R.string.napplet_signer_permissions_revoke_all), - ) - } - } - - SuggestionChip( - onClick = {}, - label = { Text(entry.policy.label()) }, - ) - - if (entry.opOverrides.isNotEmpty()) { - HorizontalDivider() - Text( - stringResource(R.string.napplet_permissions_overrides), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - for ((opKey, decision) in entry.opOverrides) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - NostrSignerOp.fromKey(opKey)?.label() ?: opKey, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - ) - 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, - ) - } - } - } - } - } - } -} - -@Composable -private fun AppSignerPolicy.label(): String = - when (this) { - AppSignerPolicy.FULL_TRUST -> stringResource(R.string.napplet_policy_full_trust) - 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/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index b73047c0e5..0eccbaedaa 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -168,6 +168,8 @@ class JacksonMapper { ), ) + fun toJsonPretty(template: EventTemplate<*>): String = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(template) + fun toJson(event: ArrayNode): String = mapper.writeValueAsString(event) fun toJson(event: ObjectNode?): String = mapper.writeValueAsString(event) From 1086f4cf5eaad600e438cdb929d629a69dee73ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 21:20:12 +0000 Subject: [PATCH 20/33] fix(napplet): unify DataStore cache key to file path, fixing connected-apps crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allPolicies() keyed the LargeCache on the filename ("nsp_HASH") while storeFor() keyed it on the coordinate string. Both point at the same .preferences_pb file, so getOrCreate created two live DataStore instances for one file — DataStore's own singleton guard then threw IllegalStateException. Fix: use file.absolutePath as the cache key in both code paths so the second call always returns the already-open DataStore instance instead of creating a new one. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../DataStoreNostrSignerPermissionStore.kt | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt index 0812466778..fa64ed6378 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt @@ -50,12 +50,12 @@ class DataStoreNostrSignerPermissionStore( private val cache = LargeCache>() - private fun storeFor(coordinate: String): DataStore = - cache.getOrCreate(coordinate) { - PreferenceDataStoreFactory.create( - produceFile = { File(filesDir, "datastore/nsp_${hash(coordinate)}.preferences_pb") }, - ) + private fun storeFor(coordinate: String): DataStore { + val file = File(filesDir, "datastore/nsp_${hash(coordinate)}.preferences_pb") + return cache.getOrCreate(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) } + } override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? { val raw = storeFor(coordinate).data.first()[KEY_POLICY] ?: return null @@ -107,15 +107,11 @@ class DataStoreNostrSignerPermissionStore( if (!dir.exists()) return emptyMap() val result = mutableMapOf() for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) { - val coordinate = - file.nameWithoutExtension.let { name -> - // Derive the DataStore by re-opening the file; we stored the coordinate inside. - val ds = - cache.getOrCreate(name) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } - ds.data.first()[KEY_COORDINATE] - } ?: continue + val ds = + cache.getOrCreate(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue val policy = loadPolicy(coordinate) ?: continue result[coordinate] = policy } From debe3c3a48222d4caed1bd9a8f665ac47e5f51a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 21:41:38 +0000 Subject: [PATCH 21/33] feat(napplet): rich app cards with dynamic manifest loading in Connected Apps list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add authorPubKey (npub) to ConnectedAppEntry and show it in each card below the domain, so users can verify who published the napplet/nsite - Increase icon to 48dp and use surfaceVariant card background to match the detail screen's AppIdentityHeader style - After the initial cache-based load, subscribe to LocalCache.live.newEventBundles and re-resolve metadata (title + icon) for all entries whenever a manifest event (kind 15129 / 35129) arrives — handles manifests delivered by any relay subscription - For apps whose icon is not yet cached, issue a one-shot relay fetch (kinds 15129/35129 for the relevant author set) via account.client.fetchAll; inject results into LocalCache.justConsume so the newEventBundles observer picks them up and updates the list without a full reload Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../loggedIn/napplets/ConnectedAppsScreen.kt | 118 +++++++++++++++--- 1 file changed, 99 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 0877e9fc1e..d837f092e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -49,6 +50,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst @@ -60,12 +62,19 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import com.vitorpamplona.amethyst.commons.R as CommonsR @@ -73,6 +82,7 @@ private data class ConnectedAppEntry( val coordinate: String, val title: String, val domain: String, + val authorPubKey: String, val iconUrl: String?, val signerPolicy: AppSignerPolicy?, val capabilityCount: Int, @@ -90,6 +100,7 @@ fun ConnectedAppsScreen( var items by remember { mutableStateOf?>(null) } var reload by remember { mutableIntStateOf(0) } + // Initial load from permission stores LaunchedEffect(reload) { items = withContext(Dispatchers.Default) { @@ -97,6 +108,55 @@ fun ConnectedAppsScreen( } } + // Fetch missing manifests from relays, then watch cache for arrivals + LaunchedEffect(Unit) { + // Watch for napplet manifest events arriving in LocalCache from any source + launch(Dispatchers.Default) { + val manifestKinds = setOf(RootNappletEvent.KIND, NamedNappletEvent.KIND) + LocalCache.live.newEventBundles.collect { bundle -> + if (bundle.any { it.event?.kind in manifestKinds }) { + val current = items ?: return@collect + items = + current.map { entry -> + val author = entry.coordinate.substringBefore(':') + val identifier = entry.coordinate.substringAfter(':', "") + val (title, iconUrl) = resolveNappletMeta(author, identifier, untitled) + entry.copy(title = title, iconUrl = iconUrl) + } + } + } + } + + // Fetch manifests for apps whose metadata isn't in cache yet + launch(Dispatchers.IO) { + val current = items ?: return@launch + val authorsToFetch = + current + .filter { it.iconUrl == null } + .map { it.coordinate.substringBefore(':') } + .toSet() + if (authorsToFetch.isEmpty()) return@launch + + val filter = + Filter( + kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), + authors = authorsToFetch.toList(), + ) + val relays = accountViewModel.account.homeRelays.flow.value + if (relays.isEmpty()) return@launch + + runCatching { + val events = + accountViewModel.account.client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = 15_000L, + ) + // Inject into LocalCache so the newEventBundles observer above can re-resolve + events.forEach { LocalCache.justConsume(it, null, false) } + } + } + } + Scaffold( topBar = { TopBarWithBackButton(stringResource(R.string.napplet_permissions_title), nav) }, ) { padding -> @@ -123,7 +183,7 @@ fun ConnectedAppsScreen( stringResource(R.string.napplet_connected_app_empty), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, + textAlign = TextAlign.Center, ) } } @@ -151,10 +211,8 @@ private fun ConnectedAppCard( onClick: () -> Unit, ) { Card( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick), + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Row( modifier = Modifier.padding(16.dp), @@ -164,10 +222,13 @@ private fun ConnectedAppCard( FavoriteAppIcon( app = FavoriteApp.NostrApp(entry.coordinate, entry.title, 0L, entry.iconUrl), tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(44.dp), + modifier = Modifier.size(48.dp), ) - Column(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { Text( entry.title, style = MaterialTheme.typography.titleSmall, @@ -182,21 +243,38 @@ private fun ConnectedAppCard( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - } - - if (entry.signerPolicy != null) { - SuggestionChip( - onClick = {}, - label = { Text(entry.signerPolicy.shortLabel(), style = MaterialTheme.typography.labelSmall) }, + Text( + entry.authorPubKey, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } - Icon( - MaterialSymbols.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (entry.signerPolicy != null) { + SuggestionChip( + onClick = {}, + label = { + Text( + entry.signerPolicy.shortLabel(), + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } } } } @@ -224,10 +302,12 @@ private suspend fun loadConnectedApps( val author = coordinate.substringBefore(':') val identifier = coordinate.substringAfter(':', "") val (title, iconUrl) = resolveNappletMeta(author, identifier, untitled) + val npub = runCatching { NPub.create(author) }.getOrDefault(author.take(12) + "…") ConnectedAppEntry( coordinate = coordinate, title = title, domain = identifier.ifBlank { author.take(12) + "…" }, + authorPubKey = npub, iconUrl = iconUrl, signerPolicy = signerPolicies[coordinate], capabilityCount = capGrants[coordinate]?.size ?: 0, From 2e81a71f1987e768590dda860e681e333dcbc9df Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 22:32:44 +0000 Subject: [PATCH 22/33] refactor(napplet): replace newEventBundles with LocalCache.observeEvents for manifest updates LocalCache.observeEvents(filter) handles both the initial snapshot and subsequent insertions via the observables registry, replacing the manual bundle-scan over newEventBundles. Fewer moving parts and the filter is scoped to manifest kinds rather than scanning every arriving event bundle. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../loggedIn/napplets/ConnectedAppsScreen.kt | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index d837f092e0..783dcc88fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip19Bech32.entities.NPub @@ -108,22 +109,22 @@ fun ConnectedAppsScreen( } } - // Fetch missing manifests from relays, then watch cache for arrivals + // Watch manifest events in LocalCache and fetch missing ones from relays LaunchedEffect(Unit) { - // Watch for napplet manifest events arriving in LocalCache from any source + val manifestFilter = + Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND)) + + // observeEvents emits immediately with cached events, then re-emits on each insertion launch(Dispatchers.Default) { - val manifestKinds = setOf(RootNappletEvent.KIND, NamedNappletEvent.KIND) - LocalCache.live.newEventBundles.collect { bundle -> - if (bundle.any { it.event?.kind in manifestKinds }) { - val current = items ?: return@collect - items = - current.map { entry -> - val author = entry.coordinate.substringBefore(':') - val identifier = entry.coordinate.substringAfter(':', "") - val (title, iconUrl) = resolveNappletMeta(author, identifier, untitled) - entry.copy(title = title, iconUrl = iconUrl) - } - } + LocalCache.observeEvents(manifestFilter).collect { + val current = items ?: return@collect + items = + current.map { entry -> + val author = entry.coordinate.substringBefore(':') + val identifier = entry.coordinate.substringAfter(':', "") + val (title, iconUrl) = resolveNappletMeta(author, identifier, untitled) + entry.copy(title = title, iconUrl = iconUrl) + } } } @@ -151,7 +152,7 @@ fun ConnectedAppsScreen( filters = relays.associateWith { listOf(filter) }, timeoutMs = 15_000L, ) - // Inject into LocalCache so the newEventBundles observer above can re-resolve + // Inject into LocalCache; the observeEvents collector above re-resolves metadata events.forEach { LocalCache.justConsume(it, null, false) } } } From 7fd0741778acd3661073f888d4cac4f75ad16c97 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 23:09:31 +0000 Subject: [PATCH 23/33] fix: center-align text in deny/block OutlinedButtons Remove the Modifier.fillMaxWidth() + TextAlign.Start that was forcing button labels to the left edge; buttons now use the default centered layout. --- .../amethyst/napplet/NappletConnectActivity.kt | 2 -- .../amethyst/napplet/NappletConsentActivity.kt | 4 ---- .../amethyst/napplet/NappletSignerConsentActivity.kt | 12 ++---------- 3 files changed, 2 insertions(+), 16 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 e8f7aebba4..b68aa5eaea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt @@ -233,8 +233,6 @@ private fun NappletConnectScreen( Text( stringResource(R.string.napplet_connect_block, info.domain), style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Start, ) } } 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 caff831d72..ff4a11915a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt @@ -216,9 +216,7 @@ private fun NappletConsentDialog( ) { Text( stringResource(R.string.napplet_consent_not_now), - modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Start, ) } OutlinedButton( @@ -228,9 +226,7 @@ private fun NappletConsentDialog( ) { Text( stringResource(R.string.napplet_consent_deny_always), - modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Start, ) } } 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 c33916a849..74318dd06e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -299,22 +299,14 @@ private fun NappletSignerConsentDialog( modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), ) { - Text( - stringResource(R.string.napplet_signer_deny_once), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Start, - ) + Text(stringResource(R.string.napplet_signer_deny_once)) } OutlinedButton( onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) }, modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), ) { - Text( - stringResource(R.string.napplet_signer_deny_op, info.operationSummary), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Start, - ) + Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary)) } } } From f2fbe260d56acc7bd93d6c9d29e83229a5fb0988 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 23:17:56 +0000 Subject: [PATCH 24/33] feat: show kind name in signer consent label; fix icon race in ConnectedAppsScreen - NostrSignerOp.SignKind label now renders "sign for (kind: N)" using KindNames.nameFor(), falling back to "sign kind N event" for unknowns - Fix ConnectedAppsScreen race: merge two LaunchedEffects into one so items is set before observeEvents fires and before the relay fetch runs; previously both could find items==null and return early, leaving icons blank --- .../amethyst/napplet/NostrSignerOpLabels.kt | 10 ++++++- .../loggedIn/napplets/ConnectedAppsScreen.kt | 27 +++++++------------ amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 20 insertions(+), 18 deletions(-) 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 07912b0cc6..3c3bd11547 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.quartz.kinds.KindNames import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @@ -32,7 +33,14 @@ import com.vitorpamplona.quartz.utils.TimeUtils /** Human-readable label for a [NostrSignerOp]. */ fun NostrSignerOp.label(context: Context): String = when (this) { - is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind, kind) + is NostrSignerOp.SignKind -> { + val name = KindNames.nameFor(kind) + if (name != null) { + context.getString(R.string.napplet_op_sign_kind_named, name, kind) + } else { + context.getString(R.string.napplet_op_sign_kind, kind) + } + } NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt) NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 783dcc88fa..727b3ec71a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -99,27 +98,23 @@ fun ConnectedAppsScreen( val untitled = stringResource(CommonsR.string.napplet_untitled) var items by remember { mutableStateOf?>(null) } - var reload by remember { mutableIntStateOf(0) } - // Initial load from permission stores - LaunchedEffect(reload) { - items = + LaunchedEffect(Unit) { + // Load initial list; items is set before child coroutines start so the + // observeEvents first-emission and the relay fetch both see a non-null list. + val initial = withContext(Dispatchers.Default) { loadConnectedApps(capabilityLedger, signerLedger, untitled) } - } + items = initial - // Watch manifest events in LocalCache and fetch missing ones from relays - LaunchedEffect(Unit) { - val manifestFilter = - Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND)) + val manifestFilter = Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND)) - // observeEvents emits immediately with cached events, then re-emits on each insertion + // Re-resolve metadata whenever a napplet manifest arrives in LocalCache. launch(Dispatchers.Default) { LocalCache.observeEvents(manifestFilter).collect { - val current = items ?: return@collect items = - current.map { entry -> + items?.map { entry -> val author = entry.coordinate.substringBefore(':') val identifier = entry.coordinate.substringAfter(':', "") val (title, iconUrl) = resolveNappletMeta(author, identifier, untitled) @@ -128,11 +123,10 @@ fun ConnectedAppsScreen( } } - // Fetch manifests for apps whose metadata isn't in cache yet + // Fetch manifests for apps whose metadata isn't in cache yet. launch(Dispatchers.IO) { - val current = items ?: return@launch val authorsToFetch = - current + initial .filter { it.iconUrl == null } .map { it.coordinate.substringBefore(':') } .toSet() @@ -152,7 +146,6 @@ fun ConnectedAppsScreen( filters = relays.associateWith { listOf(filter) }, timeoutMs = 15_000L, ) - // Inject into LocalCache; the observeEvents collector above re-resolves metadata events.forEach { LocalCache.justConsume(it, null, false) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a15523eec6..550b192ae1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -786,6 +786,7 @@ sign kind %1$d event + sign for %1$s (kind: %2$d) encrypt a message read your private messages From e0a6ed5ffca324af20cf417bcb9f2f2806102cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 00:01:02 +0000 Subject: [PATCH 25/33] fix: i18n kind names in signer consent; live icons in connected apps list - Extract kindDisplayName() to KindDisplayName.kt and add kindNameFor() helper that picks the translated string resource when available, falls back to KindNames English map, then "k" - NostrSignerOpLabels: use kindNameFor() so "sign for Notes (kind: 1)" is translated instead of always English - ConnectedAppsScreen: wire rememberNappletIconModel so the card icons load from blossom just like FavoriteAppsScreen does - Remove the now-duplicate kindDisplayName() definition from RelayInformationScreen.kt Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../amethyst/napplet/NostrSignerOpLabels.kt | 11 +- .../loggedIn/napplets/ConnectedAppsScreen.kt | 3 + .../screen/loggedIn/relays/KindDisplayName.kt | 336 ++++++++++++++++++ .../loggedIn/relays/RelayInformationScreen.kt | 289 --------------- 4 files changed, 341 insertions(+), 298 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt 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 3c3bd11547..9b1714404f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp -import com.vitorpamplona.quartz.kinds.KindNames +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @@ -33,14 +33,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils /** Human-readable label for a [NostrSignerOp]. */ fun NostrSignerOp.label(context: Context): String = when (this) { - is NostrSignerOp.SignKind -> { - val name = KindNames.nameFor(kind) - if (name != null) { - context.getString(R.string.napplet_op_sign_kind_named, name, kind) - } else { - context.getString(R.string.napplet_op_sign_kind, kind) - } - } + is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind) NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt) NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 727b3ec71a..2b3d833b36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -204,6 +205,7 @@ private fun ConnectedAppCard( entry: ConnectedAppEntry, onClick: () -> Unit, ) { + val iconModel = rememberNappletIconModel(entry.coordinate) Card( modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), @@ -215,6 +217,7 @@ private fun ConnectedAppCard( ) { FavoriteAppIcon( app = FavoriteApp.NostrApp(entry.coordinate, entry.title, 0L, entry.iconUrl), + iconModel = iconModel, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt new file mode 100644 index 0000000000..cdd630e3c0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays + +import android.content.Context +import androidx.annotation.StringRes +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.nns.NNSEvent +import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent +import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.kinds.KindNames +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent +import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +/** Returns the `@StringRes` id for the translated kind name, or -1 if unknown. */ +@Suppress("DEPRECATION") +@StringRes +fun kindDisplayName(kind: Int): Int = + when (kind) { + AcceptedBadgeSetEvent.KIND -> R.string.kind_accepted_badge_set + AdvertisedRelayListEvent.KIND -> R.string.kind_outbox_relays + AppDefinitionEvent.KIND -> R.string.kind_apps + AppRecommendationEvent.KIND -> R.string.kind_app_recommendations + AppSpecificDataEvent.KIND -> R.string.kind_user_settings + AudioHeaderEvent.KIND -> R.string.kind_audio_header + AudioTrackEvent.KIND -> R.string.kind_audio_track + MusicTrackEvent.KIND -> R.string.kind_music_track + MusicPlaylistEvent.KIND -> R.string.kind_music_playlist + PodcastEpisodeEvent.KIND -> R.string.kind_podcast_episode + PodcastMetadataEvent.KIND -> R.string.kind_podcast_metadata + AuthoredPodcastsEvent.KIND -> R.string.kind_authored_podcasts + FavoritePodcastsListEvent.KIND -> R.string.kind_favorite_podcasts + AttestationEvent.KIND -> R.string.attestation + AttestationRequestEvent.KIND -> R.string.attestation_request + AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation + AttestorProficiencyEvent.KIND -> R.string.attestor_proficiency + BadgeAwardEvent.KIND -> R.string.kind_badge_awards + BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions + BlockedRelayListEvent.KIND -> R.string.kind_blocked_relays + BlossomServersEvent.KIND -> R.string.kind_blossom_servers + NestsServersEvent.KIND -> R.string.kind_nests_servers + BlossomAuthorizationEvent.KIND -> R.string.kind_blossom_auth + BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays + BookmarkListEvent.KIND -> R.string.kind_bookmark_list + OldBookmarkListEvent.KIND -> R.string.kind_old_bookmark_list + CalendarDateSlotEvent.KIND -> R.string.kind_day_appointment + CalendarEvent.KIND -> R.string.kind_calendar + CalendarTimeSlotEvent.KIND -> R.string.kind_appointment + CalendarRSVPEvent.KIND -> R.string.kind_appt_rsvp + ChessGameEvent.KIND -> R.string.kind_chess_games + JesterEvent.KIND -> R.string.kind_chess_auth + RelayFeedsListEvent.KIND -> R.string.kind_favorite_relays + LiveChessGameChallengeEvent.KIND -> R.string.kind_chess_challenges + LiveChessGameAcceptEvent.KIND -> R.string.kind_chess_game_accept + LiveChessMoveEvent.KIND -> R.string.kind_chess_move + LiveChessGameEndEvent.KIND -> R.string.kind_chess_game_end + LiveChessDrawOfferEvent.KIND -> R.string.kind_chess_draw_offer + ChannelCreateEvent.KIND -> R.string.kind_channel_definition + ChannelHideMessageEvent.KIND -> R.string.kind_channel_hide_msg + ChannelListEvent.KIND -> R.string.kind_channel_list + ChannelMessageEvent.KIND -> R.string.kind_channel_message + ChannelMetadataEvent.KIND -> R.string.kind_channel_metadata + ChannelMuteUserEvent.KIND -> R.string.kind_channel_mute_user + ChatMessageEncryptedFileHeaderEvent.KIND -> R.string.kind_dm_file + ChatMessageEvent.KIND -> R.string.kind_dm_message + ChatMessageRelayListEvent.KIND -> R.string.kind_dm_relays + ClassifiedsEvent.KIND -> R.string.kind_classifieds + CommentEvent.KIND -> R.string.kind_comments + CommunityDefinitionEvent.KIND -> R.string.kind_community_def + CommunityListEvent.KIND -> R.string.kind_community_list + CommunityPostApprovalEvent.KIND -> R.string.kind_community_post + ContactListEvent.KIND -> R.string.kind_follow_list + DeletionEvent.KIND -> R.string.kind_deletions + DraftWrapEvent.KIND -> R.string.kind_drafts + EmojiPackEvent.KIND -> R.string.kind_emoji_packs + EmojiPackSelectionEvent.KIND -> R.string.kind_emoji_pack_list + EphemeralChatEvent.KIND -> R.string.kind_ephemeral_chat + EphemeralChatListEvent.KIND -> R.string.kind_ephemeral_chatrooms + FileHeaderEvent.KIND -> R.string.kind_file_headers + ProfileGalleryEntryEvent.KIND -> R.string.kind_profile_gallery + FileServersEvent.KIND -> R.string.kind_file_servers + FileStorageEvent.KIND -> R.string.kind_blob_data + FileStorageHeaderEvent.KIND -> R.string.kind_blob_headers + FhirResourceEvent.KIND -> R.string.kind_medical_data + FollowListEvent.KIND -> R.string.kind_follow_packs + GenericRepostEvent.KIND -> R.string.kind_reposts_16 + GeohashListEvent.KIND -> R.string.kind_geohash_follows + GiftWrapEvent.KIND -> R.string.kind_gift_wraps + EphemeralGiftWrapEvent.KIND -> R.string.kind_gift_wraps + GitIssueEvent.KIND -> R.string.kind_git_issue + GitPatchEvent.KIND -> R.string.kind_git_patch + GitRepositoryEvent.KIND -> R.string.kind_git_repo + GitReplyEvent.KIND -> R.string.kind_git_reply + GoalEvent.KIND -> R.string.kind_zap_goals + HashtagListEvent.KIND -> R.string.kind_hashtag_follows + HighlightEvent.KIND -> R.string.kind_highlights + HTTPAuthorizationEvent.KIND -> R.string.kind_http_auth + IndexerRelayListEvent.KIND -> R.string.kind_index_relay_list + InteractiveStoryPrologueEvent.KIND -> R.string.kind_adventure_prologue + InteractiveStorySceneEvent.KIND -> R.string.kind_adventure_scene + InteractiveStoryReadingStateEvent.KIND -> R.string.kind_adventure_reading + LabeledBookmarkListEvent.KIND -> R.string.kind_named_bookmarks + LiveActivitiesChatMessageEvent.KIND -> R.string.kind_live_chats + LiveActivitiesEvent.KIND -> R.string.kind_live_streams + LnZapEvent.KIND -> R.string.kind_zaps + LnZapPaymentRequestEvent.KIND -> R.string.kind_nwc_request + LnZapPaymentResponseEvent.KIND -> R.string.kind_nwc_response + LnZapPrivateEvent.KIND -> R.string.kind_private_zaps + LnZapRequestEvent.KIND -> R.string.kind_zap_req + LongTextNoteEvent.KIND -> R.string.kind_blogs + MeetingRoomEvent.KIND -> R.string.kind_meeting_room + MeetingRoomPresenceEvent.KIND -> R.string.kind_room_presence + MeetingSpaceEvent.KIND -> R.string.kind_meeting_space + MetadataEvent.KIND -> R.string.kind_profile + MuteListEvent.KIND -> R.string.kind_mute_list + NNSEvent.KIND -> R.string.kind_nns + NipTextEvent.KIND -> R.string.kind_nip + NostrConnectEvent.KIND -> R.string.kind_nostr_connect + NIP90StatusEvent.KIND -> R.string.kind_dvm_status + NIP90ContentDiscoveryRequestEvent.KIND -> R.string.kind_dvm_content_req + NIP90ContentDiscoveryResponseEvent.KIND -> R.string.kind_dvm_content_resp + NIP90UserDiscoveryRequestEvent.KIND -> R.string.kind_dvm_user_req + NIP90UserDiscoveryResponseEvent.KIND -> R.string.kind_dvm_user_resp + OtsEvent.KIND -> R.string.kind_ots + PaymentTargetsEvent.KIND -> R.string.kind_pay_to + PeopleListEvent.KIND -> R.string.kind_people_lists + ProfileBadgesEvent.KIND -> R.string.kind_profile_badges + PictureEvent.KIND -> R.string.kind_pictures + WorkoutRecordEvent.KIND -> R.string.kind_workouts + PinListEvent.KIND -> R.string.kind_pins + ZapPollEvent.KIND -> R.string.kind_zap_poll + PollEvent.KIND -> R.string.kind_poll + PollResponseEvent.KIND -> R.string.kind_poll_response + PrivateDmEvent.KIND -> R.string.kind_nip04_dms + PrivateOutboxRelayListEvent.KIND -> R.string.kind_private_relays + ProxyRelayListEvent.KIND -> R.string.kind_proxy_relays + PublicMessageEvent.KIND -> R.string.kind_public_message + ReactionEvent.KIND -> R.string.kind_reactions + ContactCardEvent.KIND -> R.string.kind_contact_card + RelayAuthEvent.KIND -> R.string.kind_relay_auth + RelayDiscoveryEvent.KIND -> R.string.kind_relay_discovery + RelayMonitorEvent.KIND -> R.string.kind_relay_monitor + RelaySetEvent.KIND -> R.string.kind_relay_set + ReportEvent.KIND -> R.string.kind_reports + RepostEvent.KIND -> R.string.kind_reposts + RequestToVanishEvent.KIND -> R.string.kind_user_delete + SealedRumorEvent.KIND -> R.string.kind_seals + SearchRelayListEvent.KIND -> R.string.kind_search_relays + StatusEvent.KIND -> R.string.kind_user_status + TextNoteEvent.KIND -> R.string.kind_notes + TextNoteModificationEvent.KIND -> R.string.kind_edits + TorrentEvent.KIND -> R.string.kind_torrents + TorrentCommentEvent.KIND -> R.string.kind_torrent_comments + TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays + TrustProviderListEvent.KIND -> R.string.kind_trusted_providers + VideoHorizontalEvent.KIND -> R.string.kind_video_repl + VideoVerticalEvent.KIND -> R.string.kind_shorts_repl + VideoNormalEvent.KIND -> R.string.kind_video + VideoShortEvent.KIND -> R.string.kind_shorts + VoiceEvent.KIND -> R.string.kind_voice_msg + VoiceReplyEvent.KIND -> R.string.kind_voice_reply + WakeUpEvent.KIND -> R.string.kind_wake + WebBookmarkEvent.KIND -> R.string.kind_web_bookmark + WikiNoteEvent.KIND -> R.string.kind_wiki + else -> -1 + } + +/** + * Returns the translated display name for [kind] using Android string resources when available, + * falling back to the English name from [KindNames], then to "k". + */ +fun kindNameFor( + context: Context, + kind: Int, +): String { + val resId = kindDisplayName(kind) + return if (resId != -1) context.getString(resId) else (KindNames.nameFor(kind) ?: "k$kind") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index c6b530c89f..fe5a328677 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -109,33 +109,8 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.redColorOnSecondSurface -import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent -import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent -import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent -import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent -import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent -import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent -import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent -import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent -import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent -import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent -import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent -import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent -import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.nns.NNSEvent -import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent -import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.kinds.KindNames import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessageText @@ -146,128 +121,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent -import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent -import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent -import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent -import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent -import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent -import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent -import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent -import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent -import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent -import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent -import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent -import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent -import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent -import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent -import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent -import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent -import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent -import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent -import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent -import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent -import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client -import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent -import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent -import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent -import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent -import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent -import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent -import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent -import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent -import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent -import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent -import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -535,157 +397,6 @@ fun RelayInformationBody( // Active subscriptions + outbox display // --------------------------------------------------------------------------- -@Suppress("DEPRECATION") -fun kindDisplayName(kind: Int): Int = - when (kind) { - AcceptedBadgeSetEvent.KIND -> R.string.kind_accepted_badge_set - AdvertisedRelayListEvent.KIND -> R.string.kind_outbox_relays - AppDefinitionEvent.KIND -> R.string.kind_apps - AppRecommendationEvent.KIND -> R.string.kind_app_recommendations - AppSpecificDataEvent.KIND -> R.string.kind_user_settings - AudioHeaderEvent.KIND -> R.string.kind_audio_header - AudioTrackEvent.KIND -> R.string.kind_audio_track - MusicTrackEvent.KIND -> R.string.kind_music_track - MusicPlaylistEvent.KIND -> R.string.kind_music_playlist - PodcastEpisodeEvent.KIND -> R.string.kind_podcast_episode - PodcastMetadataEvent.KIND -> R.string.kind_podcast_metadata - AuthoredPodcastsEvent.KIND -> R.string.kind_authored_podcasts - FavoritePodcastsListEvent.KIND -> R.string.kind_favorite_podcasts - AttestationEvent.KIND -> R.string.attestation - AttestationRequestEvent.KIND -> R.string.attestation_request - AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation - AttestorProficiencyEvent.KIND -> R.string.attestor_proficiency - BadgeAwardEvent.KIND -> R.string.kind_badge_awards - BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions - BlockedRelayListEvent.KIND -> R.string.kind_blocked_relays - BlossomServersEvent.KIND -> R.string.kind_blossom_servers - NestsServersEvent.KIND -> R.string.kind_nests_servers - BlossomAuthorizationEvent.KIND -> R.string.kind_blossom_auth - BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays - BookmarkListEvent.KIND -> R.string.kind_bookmark_list - OldBookmarkListEvent.KIND -> R.string.kind_old_bookmark_list - CalendarDateSlotEvent.KIND -> R.string.kind_day_appointment - CalendarEvent.KIND -> R.string.kind_calendar - CalendarTimeSlotEvent.KIND -> R.string.kind_appointment - CalendarRSVPEvent.KIND -> R.string.kind_appt_rsvp - ChessGameEvent.KIND -> R.string.kind_chess_games - JesterEvent.KIND -> R.string.kind_chess_auth - RelayFeedsListEvent.KIND -> R.string.kind_favorite_relays - LiveChessGameChallengeEvent.KIND -> R.string.kind_chess_challenges - LiveChessGameAcceptEvent.KIND -> R.string.kind_chess_game_accept - LiveChessMoveEvent.KIND -> R.string.kind_chess_move - LiveChessGameEndEvent.KIND -> R.string.kind_chess_game_end - LiveChessDrawOfferEvent.KIND -> R.string.kind_chess_draw_offer - ChannelCreateEvent.KIND -> R.string.kind_channel_definition - ChannelHideMessageEvent.KIND -> R.string.kind_channel_hide_msg - ChannelListEvent.KIND -> R.string.kind_channel_list - ChannelMessageEvent.KIND -> R.string.kind_channel_message - ChannelMetadataEvent.KIND -> R.string.kind_channel_metadata - ChannelMuteUserEvent.KIND -> R.string.kind_channel_mute_user - ChatMessageEncryptedFileHeaderEvent.KIND -> R.string.kind_dm_file - ChatMessageEvent.KIND -> R.string.kind_dm_message - ChatMessageRelayListEvent.KIND -> R.string.kind_dm_relays - ClassifiedsEvent.KIND -> R.string.kind_classifieds - CommentEvent.KIND -> R.string.kind_comments - CommunityDefinitionEvent.KIND -> R.string.kind_community_def - CommunityListEvent.KIND -> R.string.kind_community_list - CommunityPostApprovalEvent.KIND -> R.string.kind_community_post - ContactListEvent.KIND -> R.string.kind_follow_list - DeletionEvent.KIND -> R.string.kind_deletions - DraftWrapEvent.KIND -> R.string.kind_drafts - EmojiPackEvent.KIND -> R.string.kind_emoji_packs - EmojiPackSelectionEvent.KIND -> R.string.kind_emoji_pack_list - EphemeralChatEvent.KIND -> R.string.kind_ephemeral_chat - EphemeralChatListEvent.KIND -> R.string.kind_ephemeral_chatrooms - FileHeaderEvent.KIND -> R.string.kind_file_headers - ProfileGalleryEntryEvent.KIND -> R.string.kind_profile_gallery - FileServersEvent.KIND -> R.string.kind_file_servers - FileStorageEvent.KIND -> R.string.kind_blob_data - FileStorageHeaderEvent.KIND -> R.string.kind_blob_headers - FhirResourceEvent.KIND -> R.string.kind_medical_data - FollowListEvent.KIND -> R.string.kind_follow_packs - GenericRepostEvent.KIND -> R.string.kind_reposts_16 - GeohashListEvent.KIND -> R.string.kind_geohash_follows - GiftWrapEvent.KIND -> R.string.kind_gift_wraps - EphemeralGiftWrapEvent.KIND -> R.string.kind_gift_wraps - GitIssueEvent.KIND -> R.string.kind_git_issue - GitPatchEvent.KIND -> R.string.kind_git_patch - GitRepositoryEvent.KIND -> R.string.kind_git_repo - GitReplyEvent.KIND -> R.string.kind_git_reply - GoalEvent.KIND -> R.string.kind_zap_goals - HashtagListEvent.KIND -> R.string.kind_hashtag_follows - HighlightEvent.KIND -> R.string.kind_highlights - HTTPAuthorizationEvent.KIND -> R.string.kind_http_auth - IndexerRelayListEvent.KIND -> R.string.kind_index_relay_list - InteractiveStoryPrologueEvent.KIND -> R.string.kind_adventure_prologue - InteractiveStorySceneEvent.KIND -> R.string.kind_adventure_scene - InteractiveStoryReadingStateEvent.KIND -> R.string.kind_adventure_reading - LabeledBookmarkListEvent.KIND -> R.string.kind_named_bookmarks - LiveActivitiesChatMessageEvent.KIND -> R.string.kind_live_chats - LiveActivitiesEvent.KIND -> R.string.kind_live_streams - LnZapEvent.KIND -> R.string.kind_zaps - LnZapPaymentRequestEvent.KIND -> R.string.kind_nwc_request - LnZapPaymentResponseEvent.KIND -> R.string.kind_nwc_response - LnZapPrivateEvent.KIND -> R.string.kind_private_zaps - LnZapRequestEvent.KIND -> R.string.kind_zap_req - LongTextNoteEvent.KIND -> R.string.kind_blogs - MeetingRoomEvent.KIND -> R.string.kind_meeting_room - MeetingRoomPresenceEvent.KIND -> R.string.kind_room_presence - MeetingSpaceEvent.KIND -> R.string.kind_meeting_space - MetadataEvent.KIND -> R.string.kind_profile - MuteListEvent.KIND -> R.string.kind_mute_list - NNSEvent.KIND -> R.string.kind_nns - NipTextEvent.KIND -> R.string.kind_nip - NostrConnectEvent.KIND -> R.string.kind_nostr_connect - NIP90StatusEvent.KIND -> R.string.kind_dvm_status - NIP90ContentDiscoveryRequestEvent.KIND -> R.string.kind_dvm_content_req - NIP90ContentDiscoveryResponseEvent.KIND -> R.string.kind_dvm_content_resp - NIP90UserDiscoveryRequestEvent.KIND -> R.string.kind_dvm_user_req - NIP90UserDiscoveryResponseEvent.KIND -> R.string.kind_dvm_user_resp - OtsEvent.KIND -> R.string.kind_ots - PaymentTargetsEvent.KIND -> R.string.kind_pay_to - PeopleListEvent.KIND -> R.string.kind_people_lists - ProfileBadgesEvent.KIND -> R.string.kind_profile_badges - PictureEvent.KIND -> R.string.kind_pictures - WorkoutRecordEvent.KIND -> R.string.kind_workouts - PinListEvent.KIND -> R.string.kind_pins - ZapPollEvent.KIND -> R.string.kind_zap_poll - PollEvent.KIND -> R.string.kind_poll - PollResponseEvent.KIND -> R.string.kind_poll_response - PrivateDmEvent.KIND -> R.string.kind_nip04_dms - PrivateOutboxRelayListEvent.KIND -> R.string.kind_private_relays - ProxyRelayListEvent.KIND -> R.string.kind_proxy_relays - PublicMessageEvent.KIND -> R.string.kind_public_message - ReactionEvent.KIND -> R.string.kind_reactions - ContactCardEvent.KIND -> R.string.kind_contact_card - RelayAuthEvent.KIND -> R.string.kind_relay_auth - RelayDiscoveryEvent.KIND -> R.string.kind_relay_discovery - RelayMonitorEvent.KIND -> R.string.kind_relay_monitor - RelaySetEvent.KIND -> R.string.kind_relay_set - ReportEvent.KIND -> R.string.kind_reports - RepostEvent.KIND -> R.string.kind_reposts - RequestToVanishEvent.KIND -> R.string.kind_user_delete - SealedRumorEvent.KIND -> R.string.kind_seals - SearchRelayListEvent.KIND -> R.string.kind_search_relays - StatusEvent.KIND -> R.string.kind_user_status - TextNoteEvent.KIND -> R.string.kind_notes - TextNoteModificationEvent.KIND -> R.string.kind_edits - TorrentEvent.KIND -> R.string.kind_torrents - TorrentCommentEvent.KIND -> R.string.kind_torrent_comments - TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays - TrustProviderListEvent.KIND -> R.string.kind_trusted_providers - VideoHorizontalEvent.KIND -> R.string.kind_video_repl - VideoVerticalEvent.KIND -> R.string.kind_shorts_repl - VideoNormalEvent.KIND -> R.string.kind_video - VideoShortEvent.KIND -> R.string.kind_shorts - VoiceEvent.KIND -> R.string.kind_voice_msg - VoiceReplyEvent.KIND -> R.string.kind_voice_reply - WakeUpEvent.KIND -> R.string.kind_wake - WebBookmarkEvent.KIND -> R.string.kind_web_bookmark - WikiNoteEvent.KIND -> R.string.kind_wiki - else -> -1 - } - val posts = setOf(0, 1, 6, 7, 16, 30023) val settings = setOf(3, 10002, 10000, 10001, 10003, 10004, 30000) val dms = setOf(4, GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, 10050) From 05b3e3e25efc9f244327cc996f980fe36623f954 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 01:09:00 +0000 Subject: [PATCH 26/33] fix: make ConnectedAppsScreen fully reactive for title and icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach baked title/iconUrl into ConnectedAppEntry at load time (a snapshot) and passed pubkey:identifier to rememberNappletIconModel which expects kind:pubkey:dtag — so both title and icon never updated from LocalCache. Now: - ConnectedAppEntry only holds coordinate + signerPolicy - Each ConnectedAppCard builds the full kind:pubkey:dtag coordinate (kind 15129 for root napplets with empty identifier, 35129 for named) - rememberNappletManifest observes the live AddressableNote in LocalCache so title/iconUrl update reactively as manifests arrive - rememberNappletIconModel receives the correct full coordinate so blossom blob icons load exactly like FavoriteAppsScreen - The relay fetch now covers all connected apps (not just those with missing iconUrl) so manifests arrive promptly Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../loggedIn/napplets/ConnectedAppsScreen.kt | 102 ++++++++---------- 1 file changed, 47 insertions(+), 55 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 2b3d833b36..494c42b444 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp @@ -63,16 +64,15 @@ import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -81,12 +81,7 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR private data class ConnectedAppEntry( val coordinate: String, - val title: String, - val domain: String, - val authorPubKey: String, - val iconUrl: String?, val signerPolicy: AppSignerPolicy?, - val capabilityCount: Int, ) @Composable @@ -96,51 +91,27 @@ fun ConnectedAppsScreen( ) { val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } - val untitled = stringResource(CommonsR.string.napplet_untitled) var items by remember { mutableStateOf?>(null) } LaunchedEffect(Unit) { - // Load initial list; items is set before child coroutines start so the - // observeEvents first-emission and the relay fetch both see a non-null list. val initial = withContext(Dispatchers.Default) { - loadConnectedApps(capabilityLedger, signerLedger, untitled) + loadConnectedApps(capabilityLedger, signerLedger) } items = initial - val manifestFilter = Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND)) - - // Re-resolve metadata whenever a napplet manifest arrives in LocalCache. - launch(Dispatchers.Default) { - LocalCache.observeEvents(manifestFilter).collect { - items = - items?.map { entry -> - val author = entry.coordinate.substringBefore(':') - val identifier = entry.coordinate.substringAfter(':', "") - val (title, iconUrl) = resolveNappletMeta(author, identifier, untitled) - entry.copy(title = title, iconUrl = iconUrl) - } - } - } - - // Fetch manifests for apps whose metadata isn't in cache yet. + // Fetch manifests for all entries so the reactive cards can display title + icon. launch(Dispatchers.IO) { - val authorsToFetch = - initial - .filter { it.iconUrl == null } - .map { it.coordinate.substringBefore(':') } - .toSet() - if (authorsToFetch.isEmpty()) return@launch - + if (initial.isEmpty()) return@launch + val authors = initial.map { it.coordinate.substringBefore(':') }.toSet() val filter = Filter( kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), - authors = authorsToFetch.toList(), + authors = authors.toList(), ) val relays = accountViewModel.account.homeRelays.flow.value if (relays.isEmpty()) return@launch - runCatching { val events = accountViewModel.account.client.fetchAll( @@ -183,7 +154,8 @@ fun ConnectedAppsScreen( } } - else -> + else -> { + val untitled = stringResource(CommonsR.string.napplet_untitled) LazyColumn( modifier = Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(16.dp), @@ -192,20 +164,52 @@ fun ConnectedAppsScreen( items(current, key = { it.coordinate }) { entry -> ConnectedAppCard( entry = entry, + untitled = untitled, onClick = { nav.nav(Route.ConnectedAppDetail(entry.coordinate)) }, ) } } + } } } } +@Composable +private fun rememberNappletManifest(fullCoordinate: String): NappletManifest? { + val note = + remember(fullCoordinate) { LocalCache.checkGetOrCreateAddressableNote(fullCoordinate) } + ?: return null + val noteState by note + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + return noteState.note.event as? NappletManifest +} + @Composable private fun ConnectedAppCard( entry: ConnectedAppEntry, + untitled: String, onClick: () -> Unit, ) { - val iconModel = rememberNappletIconModel(entry.coordinate) + val author = remember(entry.coordinate) { entry.coordinate.substringBefore(':') } + val identifier = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") } + + // Build the full kind:pubkey:dtag coordinate that LocalCache and rememberNappletIconModel expect. + val kind = if (identifier.isEmpty()) RootNappletEvent.KIND else NamedNappletEvent.KIND + val fullCoordinate = remember(entry.coordinate) { "$kind:$author:$identifier" } + + // Reactive icon blob (downloads from blossom as needed). + val iconModel = rememberNappletIconModel(fullCoordinate) + + // Reactively resolve title and iconUrl from the live addressable note. + val manifest = rememberNappletManifest(fullCoordinate) + val title = manifest?.title()?.ifBlank { null } ?: identifier.ifBlank { untitled } + val iconUrl = manifest?.icon()?.ifBlank { null } + + val npub = remember(author) { runCatching { NPub.create(author) }.getOrDefault(author.take(12) + "…") } + val domain = identifier.ifBlank { author.take(12) + "…" } + Card( modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), @@ -216,7 +220,7 @@ private fun ConnectedAppCard( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { FavoriteAppIcon( - app = FavoriteApp.NostrApp(entry.coordinate, entry.title, 0L, entry.iconUrl), + app = FavoriteApp.NostrApp(fullCoordinate, title, 0L, iconUrl), iconModel = iconModel, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp), @@ -227,13 +231,13 @@ private fun ConnectedAppCard( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - entry.title, + title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( - entry.domain, + domain, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, @@ -241,7 +245,7 @@ private fun ConnectedAppCard( overflow = TextOverflow.Ellipsis, ) Text( - entry.authorPubKey, + npub, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, @@ -287,27 +291,15 @@ private fun AppSignerPolicy.shortLabel(): String = private suspend fun loadConnectedApps( capabilityLedger: NappletPermissionLedger, signerLedger: NostrSignerPermissionLedger, - untitled: String, ): List { val capGrants = capabilityLedger.allPersistedGrants() val signerPolicies = signerLedger.store.allPolicies() - val allCoordinates = (capGrants.keys + signerPolicies.keys).toSet() - return allCoordinates .map { coordinate -> - val author = coordinate.substringBefore(':') - val identifier = coordinate.substringAfter(':', "") - val (title, iconUrl) = resolveNappletMeta(author, identifier, untitled) - val npub = runCatching { NPub.create(author) }.getOrDefault(author.take(12) + "…") ConnectedAppEntry( coordinate = coordinate, - title = title, - domain = identifier.ifBlank { author.take(12) + "…" }, - authorPubKey = npub, - iconUrl = iconUrl, signerPolicy = signerPolicies[coordinate], - capabilityCount = capGrants[coordinate]?.size ?: 0, ) - }.sortedBy { it.title.lowercase() } + }.sortedBy { it.coordinate } } From 0d51dff41aeb5cb767b270cbea68ad43a5223b73 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 14:03:17 +0000 Subject: [PATCH 27/33] feat: live relay subscription for connected-apps manifest screen Replaces the one-shot fetchAll with a ComposeSubscriptionManager that holds an open relay subscription to NIP-5D manifests (kinds 15129/35129) for exactly the set of authors stored in the permission ledger, while ConnectedAppsScreen is in composition. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../RelaySubscriptionsCoordinator.kt | 2 + .../loggedIn/napplets/ConnectedAppsScreen.kt | 30 ++-------- .../ConnectedAppsFilterAssembler.kt | 59 +++++++++++++++++++ ...onnectedAppsFilterAssemblerSubscription.kt | 40 +++++++++++++ .../datasource/ConnectedAppsSubAssembler.kt | 52 ++++++++++++++++ 5 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsSubAssembler.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 9cbfccfa28..87084e060f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.Live import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicPlaylistsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.ConnectedAppsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler @@ -146,6 +147,7 @@ class RelaySubscriptionsCoordinator( val onePodcast = OnePodcastFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) val napplets = NappletsFilterAssembler(client) + val connectedApps = ConnectedAppsFilterAssembler(client) val nsites = NsitesFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 494c42b444..15c07bb090 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -68,14 +68,13 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.ConnectedAppsFilterAssemblerSubscription +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import com.vitorpamplona.amethyst.commons.R as CommonsR @@ -93,6 +92,7 @@ fun ConnectedAppsScreen( val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } var items by remember { mutableStateOf?>(null) } + var authors by remember { mutableStateOf>(emptySet()) } LaunchedEffect(Unit) { val initial = @@ -100,29 +100,11 @@ fun ConnectedAppsScreen( loadConnectedApps(capabilityLedger, signerLedger) } items = initial - - // Fetch manifests for all entries so the reactive cards can display title + icon. - launch(Dispatchers.IO) { - if (initial.isEmpty()) return@launch - val authors = initial.map { it.coordinate.substringBefore(':') }.toSet() - val filter = - Filter( - kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), - authors = authors.toList(), - ) - val relays = accountViewModel.account.homeRelays.flow.value - if (relays.isEmpty()) return@launch - runCatching { - val events = - accountViewModel.account.client.fetchAll( - filters = relays.associateWith { listOf(filter) }, - timeoutMs = 15_000L, - ) - events.forEach { LocalCache.justConsume(it, null, false) } - } - } + authors = initial.map { it.coordinate.substringBefore(':') }.toSet() } + ConnectedAppsFilterAssemblerSubscription(accountViewModel, authors) + Scaffold( topBar = { TopBarWithBackButton(stringResource(R.string.napplet_permissions_title), nav) }, ) { padding -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssembler.kt new file mode 100644 index 0000000000..331a494a85 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssembler.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +/** + * Keyspace for the connected-apps manifest subscription. Carries the account (for relay selection) + * and the specific authors whose napplet manifests should be fetched — the set of pubkeys that have + * been granted permissions in the user's ledger. + */ +class ConnectedAppsQueryState( + val account: Account, + val authors: Set, +) + +/** + * Live subscription for NIP-5D napplet manifests (kinds 15129/35129) while + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppsScreen] is open. + * Unlike [NappletsFilterAssembler] (which follows the global follow list), this assembler + * only fetches manifests for the specific authors that have entries in the permission ledger. + */ +@Stable +class ConnectedAppsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + ConnectedAppsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..b970fad2f7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssemblerSubscription.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Composable +fun ConnectedAppsFilterAssemblerSubscription( + accountViewModel: AccountViewModel, + authors: Set, +) { + val state = + remember(accountViewModel.account, authors) { + ConnectedAppsQueryState(accountViewModel.account, authors) + } + + LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().connectedApps) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsSubAssembler.kt new file mode 100644 index 0000000000..88241ebde1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsSubAssembler.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies.filterNappletsByAuthors +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +/** + * Builds relay REQs for napplet manifests limited to the authors stored in + * [ConnectedAppsQueryState.authors], queried against the account's home relays. + * The filter is purposely narrow — we only want manifests for apps the user has already + * connected to, not the full follow-list. + */ +class ConnectedAppsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: ConnectedAppsQueryState) = key.account.userProfile() + + override fun updateFilter( + key: ConnectedAppsQueryState, + since: SincePerRelayMap?, + ): List { + if (key.authors.isEmpty()) return emptyList() + val relays = key.account.homeRelays.flow.value + if (relays.isEmpty()) return emptyList() + return relays.flatMap { relay -> + filterNappletsByAuthors(relay, key.authors, since?.get(relay)?.time) + } + } +} From 6f9bf33595145e207fcf544a9a114c36348909ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 15:17:04 +0000 Subject: [PATCH 28/33] fix: show globe icon and domain for browser-connected web apps The permission ledger stores browser-visited origins as `browser:` where "browser" is a sentinel non-pubkey. All previous code treated every entry as a napplet, causing NPub.create("browser") to fail and LocalCache.checkGetOrCreateAddressableNote to return null for every entry -- so icons and titles never resolved. Now ConnectedAppCard splits on author == "browser": web-app entries get a globe icon (FavoriteApp.WebApp), the domain extracted from the URL as title, and no npub row. Napplet entries (real hex pubkeys) continue using the reactive manifest lookup. The relay subscription now also filters out "browser" from the authors set so no invalid pubkey is sent to relays. ConnectedAppDetailScreen receives the same fix in AppIdentityHeader, using FavoriteApp.WebApp for browser entries so the globe icon appears there too. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../napplets/ConnectedAppDetailScreen.kt | 22 +++- .../loggedIn/napplets/ConnectedAppsScreen.kt | 109 ++++++++++++++---- 2 files changed, 109 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 37d5ca5f91..7e52e03f9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -237,8 +237,15 @@ private fun AppIdentityHeader(state: ConnectedAppDetailState) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + val isBrowserEntry = state.coordinate.startsWith("browser:") + val appForIcon = + if (isBrowserEntry) { + FavoriteApp.WebApp(state.coordinate.substringAfter(':'), state.title, 0L) + } else { + FavoriteApp.NostrApp(state.coordinate, state.title, 0L, state.iconUrl) + } FavoriteAppIcon( - app = FavoriteApp.NostrApp(state.coordinate, state.title, 0L, state.iconUrl), + app = appForIcon, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp), ) @@ -249,7 +256,18 @@ private fun AppIdentityHeader(state: ConnectedAppDetailState) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) - val domain = state.coordinate.substringAfter(':', "").ifBlank { state.coordinate.substringBefore(':').take(12) + "…" } + val author = state.coordinate.substringBefore(':') + val identifier = state.coordinate.substringAfter(':', "") + val domain = + if (author == "browser") { + identifier + .removePrefix("https://") + .removePrefix("http://") + .substringBefore('/') + .ifBlank { identifier } + } else { + identifier.ifBlank { author.take(12) + "…" } + } Text( domain, style = MaterialTheme.typography.labelSmall, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 15c07bb090..8e7f41ca9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -78,6 +78,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import com.vitorpamplona.amethyst.commons.R as CommonsR +/** Author placeholder used by the browser permission path — not a real pubkey. */ +private const val BROWSER_AUTHOR = "browser" + private data class ConnectedAppEntry( val coordinate: String, val signerPolicy: AppSignerPolicy?, @@ -92,7 +95,7 @@ fun ConnectedAppsScreen( val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } var items by remember { mutableStateOf?>(null) } - var authors by remember { mutableStateOf>(emptySet()) } + var nappletAuthors by remember { mutableStateOf>(emptySet()) } LaunchedEffect(Unit) { val initial = @@ -100,10 +103,15 @@ fun ConnectedAppsScreen( loadConnectedApps(capabilityLedger, signerLedger) } items = initial - authors = initial.map { it.coordinate.substringBefore(':') }.toSet() + // Only include real pubkeys (not the "browser" sentinel) in the relay subscription. + nappletAuthors = + initial + .map { it.coordinate.substringBefore(':') } + .filter { it != BROWSER_AUTHOR } + .toSet() } - ConnectedAppsFilterAssemblerSubscription(accountViewModel, authors) + ConnectedAppsFilterAssemblerSubscription(accountViewModel, nappletAuthors) Scaffold( topBar = { TopBarWithBackButton(stringResource(R.string.napplet_permissions_title), nav) }, @@ -175,16 +183,54 @@ private fun ConnectedAppCard( onClick: () -> Unit, ) { val author = remember(entry.coordinate) { entry.coordinate.substringBefore(':') } - val identifier = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") } + if (author == BROWSER_AUTHOR) { + val url = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") } + BrowserAppCard(url = url, entry = entry, onClick = onClick) + } else { + NappletAppCard(author = author, entry = entry, untitled = untitled, onClick = onClick) + } +} - // Build the full kind:pubkey:dtag coordinate that LocalCache and rememberNappletIconModel expect. +/** Card for a web app permission entry — the user visited this origin in the sandboxed browser. */ +@Composable +private fun BrowserAppCard( + url: String, + entry: ConnectedAppEntry, + onClick: () -> Unit, +) { + val domain = + remember(url) { + url + .removePrefix("https://") + .removePrefix("http://") + .substringBefore('/') + .ifBlank { url } + } + + ConnectedAppCardLayout( + app = FavoriteApp.WebApp(url, domain, 0L), + iconModel = null, + title = domain, + subtitle = url, + npub = null, + signerPolicy = entry.signerPolicy, + onClick = onClick, + ) +} + +/** Card for a napplet / nsite permission entry — resolves title and icon from the live manifest. */ +@Composable +private fun NappletAppCard( + author: String, + entry: ConnectedAppEntry, + untitled: String, + onClick: () -> Unit, +) { + val identifier = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") } val kind = if (identifier.isEmpty()) RootNappletEvent.KIND else NamedNappletEvent.KIND val fullCoordinate = remember(entry.coordinate) { "$kind:$author:$identifier" } - // Reactive icon blob (downloads from blossom as needed). val iconModel = rememberNappletIconModel(fullCoordinate) - - // Reactively resolve title and iconUrl from the live addressable note. val manifest = rememberNappletManifest(fullCoordinate) val title = manifest?.title()?.ifBlank { null } ?: identifier.ifBlank { untitled } val iconUrl = manifest?.icon()?.ifBlank { null } @@ -192,6 +238,27 @@ private fun ConnectedAppCard( val npub = remember(author) { runCatching { NPub.create(author) }.getOrDefault(author.take(12) + "…") } val domain = identifier.ifBlank { author.take(12) + "…" } + ConnectedAppCardLayout( + app = FavoriteApp.NostrApp(fullCoordinate, title, 0L, iconUrl), + iconModel = iconModel, + title = title, + subtitle = domain, + npub = npub, + signerPolicy = entry.signerPolicy, + onClick = onClick, + ) +} + +@Composable +private fun ConnectedAppCardLayout( + app: FavoriteApp, + iconModel: Any?, + title: String, + subtitle: String, + npub: String?, + signerPolicy: AppSignerPolicy?, + onClick: () -> Unit, +) { Card( modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), @@ -202,7 +269,7 @@ private fun ConnectedAppCard( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { FavoriteAppIcon( - app = FavoriteApp.NostrApp(fullCoordinate, title, 0L, iconUrl), + app = app, iconModel = iconModel, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp), @@ -219,33 +286,35 @@ private fun ConnectedAppCard( overflow = TextOverflow.Ellipsis, ) Text( - domain, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - npub, + subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (npub != null) { + Text( + npub, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } Column( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - if (entry.signerPolicy != null) { + if (signerPolicy != null) { SuggestionChip( onClick = {}, label = { Text( - entry.signerPolicy.shortLabel(), + signerPolicy.shortLabel(), style = MaterialTheme.typography.labelSmall, ) }, From 3cc0ce09d4a0c30bcb7a3616bfa2e29dcff56134 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 15:41:27 +0000 Subject: [PATCH 29/33] feat: show website favicon and domain in all browser-connected app views Web app entries (browser:https://...) now display: - The captured favicon from BrowserIconRegistry (same source as the bottom-nav favourite website icon) in ConnectedAppsScreen, ConnectedAppDetailScreen, and all three permission/consent dialogs - The domain name (host) as the title instead of the full URL, via OmniboxInput.hostOf() in loadDetailState, NappletConsentSummary, buildSignerConsentInfo, and buildConnectInfo - A globe icon (FavoriteApp.WebApp) instead of the grid icon (FavoriteApp.NostrApp) in all consent dialog headers Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../amethyst/favorites/NappletFavoriteIcon.kt | 17 +++++++++++++ .../napplet/NappletConnectActivity.kt | 8 +++++- .../napplet/NappletConsentActivity.kt | 8 +++++- .../amethyst/napplet/NappletConsentSummary.kt | 10 +++++++- .../napplet/NappletSignerConsentActivity.kt | 8 +++++- .../amethyst/napplet/NostrSignerOpLabels.kt | 25 ++++++++++++++++--- .../napplets/ConnectedAppDetailScreen.kt | 16 ++++++++++-- .../loggedIn/napplets/ConnectedAppsScreen.kt | 5 +++- 8 files changed, 87 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt index d0937b37b2..497a7a143e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.napplethost.NappletBlobCache import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher @@ -107,3 +108,19 @@ private fun resolveIconBlob(event: Event?): IconBlob? = is NamedSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) } else -> null } + +/** + * A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon + * has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the + * WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache. + * + * Early-returns null when [url] is blank or has no parseable host — this early return is stable + * for a given [url] (the host either always parses or never does), so composition structure is + * preserved across recompositions. + */ +@Composable +fun rememberWebAppIconModel(url: String): String? { + val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) } +} 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 b68aa5eaea..a5c2f15811 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt @@ -146,8 +146,14 @@ private fun NappletConnectScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { + val isBrowser = info.coordinate.startsWith("browser:") FavoriteAppIcon( - app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl), + app = + if (isBrowser) { + FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl) + } else { + FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl) + }, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(56.dp), ) 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 ff4a11915a..94875be170 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt @@ -138,8 +138,14 @@ private fun NappletConsentDialog( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { + val isBrowser = info.coordinate.startsWith("browser:") FavoriteAppIcon( - app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl), + app = + if (isBrowser) { + FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl) + } else { + FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl) + }, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(56.dp), ) 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 888557adf2..a65238c703 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context 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.ui.pluralStringRes import com.vitorpamplona.quartz.lightning.LnInvoiceUtil @@ -42,7 +44,13 @@ class NappletConsentSummary( request: NappletRequest, ): NappletConsentInfo { val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) - val (title, iconUrl) = resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) + val (title, iconUrl) = + if (identity.authorPubKey == "browser") { + val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + host to BrowserIconRegistry.iconModelFor(host) + } else { + resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) + } return NappletConsentInfo( appletTitle = title, coordinate = identity.coordinate, 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 74318dd06e..8dd8ed0e88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -143,8 +143,14 @@ private fun NappletSignerConsentDialog( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { + val isBrowser = info.coordinate.startsWith("browser:") FavoriteAppIcon( - app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl), + app = + if (isBrowser) { + FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl) + } else { + FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl) + }, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(56.dp), ) 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 9b1714404f..9c7bb146d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate @@ -46,7 +48,13 @@ fun buildSignerConsentInfo( request: NappletRequest, ): NappletSignerConsentInfo { val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) - val (title, iconUrl) = resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) + val (title, iconUrl) = + if (identity.authorPubKey == "browser") { + val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + host to BrowserIconRegistry.iconModelFor(host) + } else { + resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) + } val summary = op.label(context) val preview = when (request) { @@ -93,7 +101,18 @@ fun buildConnectInfo( identity: NappletIdentity, ): NappletConnectInfo { val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) - val (title, iconUrl) = resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) - val domain = identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" } + val (title, iconUrl) = + if (identity.authorPubKey == "browser") { + val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + host to BrowserIconRegistry.iconModelFor(host) + } else { + resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) + } + val domain = + if (identity.authorPubKey == "browser") { + OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + } else { + identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" } + } return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 7e52e03f9d..a12e7b3638 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -69,6 +70,8 @@ import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel import com.vitorpamplona.amethyst.napplet.descriptionRes import com.vitorpamplona.amethyst.napplet.labelRes import com.vitorpamplona.amethyst.napplet.resolveNappletMeta @@ -238,14 +241,17 @@ private fun AppIdentityHeader(state: ConnectedAppDetailState) { horizontalArrangement = Arrangement.spacedBy(12.dp), ) { val isBrowserEntry = state.coordinate.startsWith("browser:") + val browserUrl = if (isBrowserEntry) state.coordinate.substringAfter(':') else null + val iconModel = if (browserUrl != null) rememberWebAppIconModel(browserUrl) else null val appForIcon = if (isBrowserEntry) { - FavoriteApp.WebApp(state.coordinate.substringAfter(':'), state.title, 0L) + FavoriteApp.WebApp(browserUrl ?: "", state.title, 0L) } else { FavoriteApp.NostrApp(state.coordinate, state.title, 0L, state.iconUrl) } FavoriteAppIcon( app = appForIcon, + iconModel = iconModel, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp), ) @@ -449,7 +455,13 @@ private suspend fun loadDetailState( val signerPolicy = signerLedger.store.loadPolicy(coordinate) val opOverrides = signerLedger.store.allOpDecisions(coordinate) - val (title, iconUrl) = resolveNappletMeta(author, identifier, untitled) + val (title, iconUrl) = + if (author == "browser") { + val host = OmniboxInput.hostOf(identifier) ?: identifier + host to BrowserIconRegistry.iconModelFor(host) + } else { + resolveNappletMeta(author, identifier, untitled) + } return ConnectedAppDetailState( title = title, coordinate = coordinate, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 8e7f41ca9b..c33f032923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -207,9 +208,11 @@ private fun BrowserAppCard( .ifBlank { url } } + val iconModel = rememberWebAppIconModel(url) + ConnectedAppCardLayout( app = FavoriteApp.WebApp(url, domain, 0L), - iconModel = null, + iconModel = iconModel, title = domain, subtitle = url, npub = null, From f502b09b55a3e952eaaff9145a1028c3d8ca8719 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 15:57:52 +0000 Subject: [PATCH 30/33] feat: rename See more/less to Show/Hide Event and make JSON scrollable The raw event JSON toggle in the signer consent dialog is now labeled "Show Event" / "Hide Event" instead of the generic "See more" / "See less". The expanded JSON block also gains horizontal scroll (softWrap=false + horizontalScroll) so wide event JSON doesn't get clipped on narrow screens. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../napplet/NappletSignerConsentActivity.kt | 27 +++++++++++-------- amethyst/src/main/res/values/strings.xml | 4 +-- 2 files changed, 18 insertions(+), 13 deletions(-) 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 8dd8ed0e88..6fa8bad403 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -23,7 +23,9 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -194,15 +196,18 @@ private fun NappletSignerConsentDialog( 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, - ) + Box(modifier = Modifier.horizontalScroll(rememberScrollState())) { + SelectionContainer { + Text( + info.rawData, + style = + MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + softWrap = false, + ) + } } } TextButton( @@ -211,9 +216,9 @@ private fun NappletSignerConsentDialog( ) { Text( if (showRawData) { - stringResource(R.string.napplet_consent_see_less) + stringResource(R.string.napplet_consent_hide_event) } else { - stringResource(R.string.napplet_consent_see_more) + stringResource(R.string.napplet_consent_show_event) }, style = MaterialTheme.typography.labelSmall, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 550b192ae1..b562d09f2e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -769,8 +769,8 @@ wants to %1$s - See more - See less + Show Event + Hide Event More options Fewer options Allow once From bee418fd17c3a406da1e0875bf55fa0248eb67fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 16:06:36 +0000 Subject: [PATCH 31/33] fix: clarify capability toggle and revoke button semantics in ConnectedAppDetail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toggle ON/OFF state was ambiguous (users couldn't tell if OFF meant "ask me" or "permanently deny"). The revoke/Block icon looked like a deny action but actually resets to "ask me each time". - Add "Allow always" (primary) / "Never allow" (error) label above the Switch so the toggle's two states are explicit - Swap MaterialSymbols.Block for MaterialSymbols.Refresh on the reset button — Refresh reads as "start over / go back to asking" - Update its content description to "Ask me each time" - Rename "Blocked" to "Requires per-use approval" for per-use-consent capabilities to explain why there's no toggle Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../napplets/ConnectedAppDetailScreen.kt | 35 +++++++++++++++---- amethyst/src/main/res/values/strings.xml | 3 +- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index a12e7b3638..7daeaec015 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -398,20 +398,41 @@ private fun CapabilityDetailRow( Text( stringResource(R.string.napplet_permissions_blocked), style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.error, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - Switch( - checked = grant == GrantState.ALLOW_ALWAYS, - onCheckedChange = onSetAllowed, - ) + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + stringResource( + if (grant == GrantState.ALLOW_ALWAYS) { + R.string.napplet_consent_allow_always + } else { + R.string.napplet_consent_deny_always + }, + ), + style = MaterialTheme.typography.labelSmall, + color = + if (grant == GrantState.ALLOW_ALWAYS) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + ) + Switch( + checked = grant == GrantState.ALLOW_ALWAYS, + onCheckedChange = onSetAllowed, + ) + } } Spacer(Modifier.size(4.dp)) IconButton(onClick = onRevoke) { Icon( - MaterialSymbols.Block, - contentDescription = stringResource(R.string.napplet_permissions_revoke), + MaterialSymbols.Refresh, + contentDescription = stringResource(R.string.napplet_permissions_ask_each_time), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b562d09f2e..7f16ed4642 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -701,8 +701,9 @@ No nApplet permissions yet Permissions you grant to nApplets will appear here. Forget this nApplet - Blocked + Requires per-use approval Revoke + Ask me each time No nApplets found yet. nApplet %1$s… From c464c7cdbaf1de7daf8f1dbcbe989a411f6900ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 16:22:01 +0000 Subject: [PATCH 32/33] feat: replace capability toggle with dialog and fix napplet icon loading - Capability rows now open a dialog with radio options (Ask each time / Always allow / Never allow) instead of the confusing Switch+revoke button combo; requiresPerUseConsent capabilities omit "Always allow" - AppIdentityHeader now reactively loads napplet/nsite icons via rememberNappletIconModel using the full kind:pubkey:identifier coord Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../napplets/ConnectedAppDetailScreen.kt | 188 ++++++++++++------ 1 file changed, 132 insertions(+), 56 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 7daeaec015..36757c94d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,16 +32,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -71,6 +74,7 @@ import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel import com.vitorpamplona.amethyst.napplet.descriptionRes import com.vitorpamplona.amethyst.napplet.labelRes @@ -78,6 +82,8 @@ import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -197,10 +203,15 @@ fun ConnectedAppDetailScreen( CapabilityDetailRow( capability = cap, grant = grant, - onSetAllowed = { allowed -> - mutate { capabilityLedger.record(identity, cap, if (allowed) GrantState.ALLOW_ALWAYS else GrantState.DENY) } + onSetGrant = { newGrant -> + mutate { + if (newGrant == null) { + capabilityLedger.revoke(identity, cap) + } else { + capabilityLedger.record(identity, cap, newGrant) + } + } }, - onRevoke = { mutate { capabilityLedger.revoke(identity, cap) } }, ) } } @@ -241,14 +252,18 @@ private fun AppIdentityHeader(state: ConnectedAppDetailState) { horizontalArrangement = Arrangement.spacedBy(12.dp), ) { val isBrowserEntry = state.coordinate.startsWith("browser:") - val browserUrl = if (isBrowserEntry) state.coordinate.substringAfter(':') else null - val iconModel = if (browserUrl != null) rememberWebAppIconModel(browserUrl) else null - val appForIcon = - if (isBrowserEntry) { - FavoriteApp.WebApp(browserUrl ?: "", state.title, 0L) - } else { - FavoriteApp.NostrApp(state.coordinate, state.title, 0L, state.iconUrl) - } + val author = state.coordinate.substringBefore(':') + val identifier = state.coordinate.substringAfter(':', "") + val iconModel: String? + val appForIcon: FavoriteApp + if (isBrowserEntry) { + iconModel = rememberWebAppIconModel(identifier) + appForIcon = FavoriteApp.WebApp(identifier, state.title, 0L) + } else { + val kind = if (identifier.isEmpty()) RootNappletEvent.KIND else NamedNappletEvent.KIND + iconModel = rememberNappletIconModel("$kind:$author:$identifier") + appForIcon = FavoriteApp.NostrApp(state.coordinate, state.title, 0L, state.iconUrl) + } FavoriteAppIcon( app = appForIcon, iconModel = iconModel, @@ -262,8 +277,6 @@ private fun AppIdentityHeader(state: ConnectedAppDetailState) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) - val author = state.coordinate.substringBefore(':') - val identifier = state.coordinate.substringAfter(':', "") val domain = if (author == "browser") { identifier @@ -371,12 +384,29 @@ private fun OpOverrideRow( private fun CapabilityDetailRow( capability: NappletCapability, grant: GrantState, - onSetAllowed: (Boolean) -> Unit, - onRevoke: () -> Unit, + onSetGrant: (GrantState?) -> Unit, ) { + var showDialog by remember { mutableStateOf(false) } + + if (showDialog) { + CapabilityPermissionDialog( + capability = capability, + current = grant, + onSetGrant = { newGrant -> + showDialog = false + onSetGrant(newGrant) + }, + onDismiss = { showDialog = false }, + ) + } + Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { showDialog = true } + .padding(horizontal = 12.dp, vertical = 12.dp), ) { Icon( capability.symbol(), @@ -393,50 +423,96 @@ private fun CapabilityDetailRow( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + Spacer(Modifier.size(8.dp)) + Text( + when (grant) { + GrantState.ALLOW_ALWAYS -> stringResource(R.string.napplet_consent_allow_always) + GrantState.DENY -> stringResource(R.string.napplet_consent_deny_always) + else -> stringResource(R.string.napplet_permissions_ask_each_time) + }, + style = MaterialTheme.typography.labelSmall, + color = + when (grant) { + GrantState.ALLOW_ALWAYS -> MaterialTheme.colorScheme.primary + GrantState.DENY -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } +} - if (capability.requiresPerUseConsent) { - Text( - stringResource(R.string.napplet_permissions_blocked), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - stringResource( - if (grant == GrantState.ALLOW_ALWAYS) { - R.string.napplet_consent_allow_always - } else { - R.string.napplet_consent_deny_always - }, - ), - style = MaterialTheme.typography.labelSmall, - color = - if (grant == GrantState.ALLOW_ALWAYS) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - }, +@Composable +private fun CapabilityPermissionDialog( + capability: NappletCapability, + current: GrantState, + onSetGrant: (GrantState?) -> Unit, + onDismiss: () -> Unit, +) { + val initial = + when (current) { + GrantState.ALLOW_ALWAYS -> GrantState.ALLOW_ALWAYS + GrantState.DENY -> GrantState.DENY + else -> GrantState.ASK + } + var selected by remember { mutableStateOf(initial) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(capability.labelRes())) }, + text = { + Column { + GrantOption( + label = stringResource(R.string.napplet_permissions_ask_each_time), + selected = selected == GrantState.ASK, + onClick = { selected = GrantState.ASK }, ) - Switch( - checked = grant == GrantState.ALLOW_ALWAYS, - onCheckedChange = onSetAllowed, + if (!capability.requiresPerUseConsent) { + GrantOption( + label = stringResource(R.string.napplet_consent_allow_always), + selected = selected == GrantState.ALLOW_ALWAYS, + onClick = { selected = GrantState.ALLOW_ALWAYS }, + ) + } + GrantOption( + label = stringResource(R.string.napplet_consent_deny_always), + selected = selected == GrantState.DENY, + onClick = { selected = GrantState.DENY }, ) } - } + }, + confirmButton = { + TextButton( + onClick = { onSetGrant(if (selected == GrantState.ASK) null else selected) }, + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} - Spacer(Modifier.size(4.dp)) - IconButton(onClick = onRevoke) { - Icon( - MaterialSymbols.Refresh, - contentDescription = stringResource(R.string.napplet_permissions_ask_each_time), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } +@Composable +private fun GrantOption( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + RadioButton(selected = selected, onClick = onClick) + Text(label, style = MaterialTheme.typography.bodyMedium) } } From 62f8cbe48a214bbe5999929326e0e8b4cf8ba4cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 20:07:11 +0000 Subject: [PATCH 33/33] fix: resolve nsite icons and titles by trying both napplet and nsite manifest kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSite manifests (kinds 15128/35128) were never found because all coordinate construction hardcoded napplet kinds (15129/35129). The connected apps list and detail screen therefore showed no icon or title for nsite entries. - Add rememberManifestIconModel(author, identifier) in NappletFavoriteIcon.kt: tries napplet coord first, falls back to nsite coord. resolveIconBlob already handled all four event types — just needed the right coordinate. - Replace rememberNappletManifest with rememberManifestEvent in ConnectedAppsScreen.kt: watches both napplet and nsite notes, returns whichever carries an event. NappletAppCard dispatches on the event type to extract title/icon from NappletManifest, RootSiteEvent, or NamedSiteEvent. - ConnectedAppDetailScreen.AppIdentityHeader: swap hardcoded kind+ rememberNappletIconModel call for rememberManifestIconModel(author, identifier). - resolveNappletMeta in NappletManifestLookup.kt: widen the cache filter to include nsite kinds and dispatch title/icon extraction across all four types. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT --- .../amethyst/favorites/NappletFavoriteIcon.kt | 21 ++++++++ .../amethyst/napplet/NappletManifestLookup.kt | 32 +++++++++--- .../napplets/ConnectedAppDetailScreen.kt | 7 +-- .../loggedIn/napplets/ConnectedAppsScreen.kt | 51 +++++++++++++++---- 4 files changed, 89 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt index 497a7a143e..e03d31a737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt @@ -124,3 +124,24 @@ fun rememberWebAppIconModel(url: String): String? { val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) } } + +/** + * A Coil model for the bundled icon of an napplet or nsite identified by [author] and + * [identifier]. Tries the napplet manifest (kinds 15129 / 35129) first, then falls back to the + * nsite manifest (kinds 15128 / 35128). Returns null until the blob lands on disk. + */ +@Composable +fun rememberManifestIconModel( + author: String, + identifier: String, +): String? { + val nappletCoord = + remember(author, identifier) { + if (identifier.isEmpty()) "${RootNappletEvent.KIND}:$author:" else "${NamedNappletEvent.KIND}:$author:$identifier" + } + val nsiteCoord = + remember(author, identifier) { + if (identifier.isEmpty()) "${RootSiteEvent.KIND}:$author:" else "${NamedSiteEvent.KIND}:$author:$identifier" + } + return rememberNappletIconModel(nappletCoord) ?: rememberNappletIconModel(nsiteCoord) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt index f36d4f5b59..067661e93e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt @@ -22,12 +22,14 @@ package com.vitorpamplona.amethyst.napplet import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent /** - * Looks up the best-effort human title and icon URL for a napplet from the local cache. + * Looks up the best-effort human title and icon URL for a napplet or nsite from the local cache. * Falls back to the d-identifier or [untitled] when no manifest is cached. */ fun resolveNappletMeta( @@ -37,17 +39,35 @@ fun resolveNappletMeta( ): Pair { val events = Amethyst.instance.cache - .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) - .mapNotNull { it.event } + .filter( + Filter( + kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND, RootSiteEvent.KIND, NamedSiteEvent.KIND), + authors = listOf(author), + ), + ).mapNotNull { it.event } val match = events.firstOrNull { ev -> when (ev) { is NamedNappletEvent -> ev.identifier() == identifier is RootNappletEvent -> identifier.isEmpty() + is NamedSiteEvent -> ev.identifier() == identifier + is RootSiteEvent -> identifier.isEmpty() else -> false } - } as? NappletManifest - val title = match?.title()?.ifBlank { null } ?: identifier.ifBlank { untitled } - val iconUrl = match?.icon()?.ifBlank { null } + } + val title = + when (match) { + is NappletManifest -> match.title() + is RootSiteEvent -> match.title() + is NamedSiteEvent -> match.title() + else -> null + }?.ifBlank { null } ?: identifier.ifBlank { untitled } + val iconUrl = + when (match) { + is NappletManifest -> match.icon() + is RootSiteEvent -> match.icon() + is NamedSiteEvent -> match.icon() + else -> null + }?.ifBlank { null } return title to iconUrl } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 36757c94d6..b74352c169 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -74,7 +74,7 @@ import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry -import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel import com.vitorpamplona.amethyst.napplet.descriptionRes import com.vitorpamplona.amethyst.napplet.labelRes @@ -82,8 +82,6 @@ import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent -import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -260,8 +258,7 @@ private fun AppIdentityHeader(state: ConnectedAppDetailState) { iconModel = rememberWebAppIconModel(identifier) appForIcon = FavoriteApp.WebApp(identifier, state.title, 0L) } else { - val kind = if (identifier.isEmpty()) RootNappletEvent.KIND else NamedNappletEvent.KIND - iconModel = rememberNappletIconModel("$kind:$author:$identifier") + iconModel = rememberManifestIconModel(author, identifier) appForIcon = FavoriteApp.NostrApp(state.coordinate, state.title, 0L, state.iconUrl) } FavoriteAppIcon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index c33f032923..e32255cd30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger -import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -70,8 +70,11 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.ConnectedAppsFilterAssemblerSubscription +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent @@ -166,15 +169,29 @@ fun ConnectedAppsScreen( } @Composable -private fun rememberNappletManifest(fullCoordinate: String): NappletManifest? { - val note = - remember(fullCoordinate) { LocalCache.checkGetOrCreateAddressableNote(fullCoordinate) } - ?: return null - val noteState by note +private fun rememberManifestEvent( + author: String, + identifier: String, +): Event? { + val nappletCoord = + remember(author, identifier) { + if (identifier.isEmpty()) "${RootNappletEvent.KIND}:$author:" else "${NamedNappletEvent.KIND}:$author:$identifier" + } + val nsiteCoord = + remember(author, identifier) { + if (identifier.isEmpty()) "${RootSiteEvent.KIND}:$author:" else "${NamedSiteEvent.KIND}:$author:$identifier" + } + val nappletNote = remember(nappletCoord) { LocalCache.checkGetOrCreateAddressableNote(nappletCoord) } ?: return null + val nsiteNote = remember(nsiteCoord) { LocalCache.checkGetOrCreateAddressableNote(nsiteCoord) } ?: return null + val nappletState by nappletNote .flow() .metadata.stateFlow .collectAsStateWithLifecycle() - return noteState.note.event as? NappletManifest + val nsiteState by nsiteNote + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + return nappletState.note.event ?: nsiteState.note.event } @Composable @@ -233,10 +250,22 @@ private fun NappletAppCard( val kind = if (identifier.isEmpty()) RootNappletEvent.KIND else NamedNappletEvent.KIND val fullCoordinate = remember(entry.coordinate) { "$kind:$author:$identifier" } - val iconModel = rememberNappletIconModel(fullCoordinate) - val manifest = rememberNappletManifest(fullCoordinate) - val title = manifest?.title()?.ifBlank { null } ?: identifier.ifBlank { untitled } - val iconUrl = manifest?.icon()?.ifBlank { null } + val iconModel = rememberManifestIconModel(author, identifier) + val event = rememberManifestEvent(author, identifier) + val title = + when (event) { + is NappletManifest -> event.title() + is RootSiteEvent -> event.title() + is NamedSiteEvent -> event.title() + else -> null + }?.ifBlank { null } ?: identifier.ifBlank { untitled } + val iconUrl = + when (event) { + is NappletManifest -> event.icon() + is RootSiteEvent -> event.icon() + is NamedSiteEvent -> event.icon() + else -> null + }?.ifBlank { null } val npub = remember(author) { runCatching { NPub.create(author) }.getOrDefault(author.take(12) + "…") } val domain = identifier.ifBlank { author.take(12) + "…" }