feat(napplet): permissions management screen

A modern Material3 screen to review and revoke the permissions napplets hold.

Data layer (commons, tested): NappletPermissionStore gains all() (enumerate
persisted grants by coordinate) and remove(coordinate, capability); the ledger
gains allPersistedGrants() and revoke(identity, capability). DataStore actual
implements both (capability is the final space-delimited token of each key).

UI (amethyst): NappletPermissionsScreen renders one ElevatedCard per napplet —
resolved title + author, and a row per capability with an icon, label, and a
control: a Switch (Allowed/Blocked) for normal capabilities, or a "Blocked"
indicator for per-use ones (payments only ever persist a DENY). Each row has a
revoke action; each card a "Forget this napplet" (revokeAll). Empty state with a
shield. Reads/writes the same DataStore the broker uses, so changes take effect
immediately. Reached via a "Manage permissions" action on the Napplets top bar
(Route.NappletPermissions).

commons ledger tests added for allPersistedGrants + single-capability revoke.
:commons:jvmTest and :amethyst:compileFdroidDebugKotlin pass; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-20 15:28:28 +00:00
parent 916a624ddb
commit c999d2ac73
9 changed files with 440 additions and 2 deletions
@@ -144,6 +144,7 @@ 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.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
@@ -290,6 +291,7 @@ fun BuildNavigation(
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.Napplets> { NappletsScreen(accountViewModel, nav) }
composableFromEnd<Route.NappletPermissions> { NappletPermissionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
@@ -89,6 +89,8 @@ sealed class Route {
@Serializable object Napplets : Route()
@Serializable object NappletPermissions : Route()
@Serializable data class SoftwareAppDetail(
val kind: Int,
val pubKeyHex: HexKey,
@@ -0,0 +1,351 @@
/*
* 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.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.napplet.DataStoreNappletPermissionStore
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
/** One napplet's persisted permission grants, ready to render. */
private data class NappletGrantsUi(
val identity: NappletIdentity,
val title: String,
val capabilities: List<Pair<NappletCapability, GrantState>>,
)
@Composable
fun NappletPermissionsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val context = LocalContext.current
val ledger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) }
var items by remember { mutableStateOf<List<NappletGrantsUi>?>(null) }
var reload by remember { mutableIntStateOf(0) }
LaunchedEffect(reload) {
items = withContext(Dispatchers.Default) { loadGrants(ledger) }
}
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(capability.label(), style = MaterialTheme.typography.bodyLarge)
Text(
capability.description(),
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): List<NappletGrantsUi> =
ledger
.allPersistedGrants()
.map { (coordinate, caps) ->
val author = coordinate.substringBefore(':')
val identifier = coordinate.substringAfter(':', "")
NappletGrantsUi(
identity = NappletIdentity(authorPubKey = author, identifier = identifier),
title = resolveTitle(author, identifier),
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. */
private fun resolveTitle(
author: String,
identifier: 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 { "Napplet" }
}
private fun NappletCapability.label(): String =
when (this) {
NappletCapability.IDENTITY -> "Identity"
NappletCapability.RELAY -> "Relays"
NappletCapability.WALLET -> "Wallet"
NappletCapability.STORAGE -> "Storage"
NappletCapability.NET -> "Network"
}
private fun NappletCapability.description(): String =
when (this) {
NappletCapability.IDENTITY -> "Sign and encrypt as you"
NappletCapability.RELAY -> "Read and publish your events"
NappletCapability.WALLET -> "Pay Lightning invoices"
NappletCapability.STORAGE -> "Its own private storage"
NappletCapability.NET -> "Direct network access"
}
private fun NappletCapability.symbol(): MaterialSymbol =
when (this) {
NappletCapability.IDENTITY -> MaterialSymbols.Key
NappletCapability.RELAY -> MaterialSymbols.Public
NappletCapability.WALLET -> MaterialSymbols.Bolt
NappletCapability.STORAGE -> MaterialSymbols.Storage
NappletCapability.NET -> MaterialSymbols.Language
}
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -45,9 +46,13 @@ 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.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -78,7 +83,17 @@ fun NappletsScreen(
}.collectAsStateWithLifecycle(emptyList())
Scaffold(
topBar = { TopBarWithBackButton(stringResource(R.string.napplets), nav) },
topBar = {
MyExtensibleTopAppBar(
title = { Text(stringResource(R.string.napplets)) },
navigationIcon = { IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() } },
actions = {
IconButton(onClick = { nav.nav(Route.NappletPermissions) }) {
Icon(MaterialSymbols.Tune, contentDescription = stringResource(R.string.napplet_manage_permissions))
}
},
)
},
) { padding ->
if (napplets.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
+7
View File
@@ -734,6 +734,13 @@
<string name="software_apps">Apps</string>
<string name="route_software_apps">Apps</string>
<string name="napplets">Napplets</string>
<string name="napplet_permissions">Napplet permissions</string>
<string name="napplet_manage_permissions">Manage permissions</string>
<string name="napplet_permissions_empty">No napplet permissions yet</string>
<string name="napplet_permissions_empty_subtitle">Permissions you grant to napplets will appear here.</string>
<string name="napplet_permissions_forget">Forget this napplet</string>
<string name="napplet_permissions_blocked">Blocked</string>
<string name="napplet_permissions_revoke">Revoke</string>
<string name="nip82_repository_label">Source: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Download</string>
@@ -88,6 +88,18 @@ class NappletPermissionLedger(
}
}
/** All persisted standing grants, keyed by coordinate — for the permissions-management UI. */
suspend fun allPersistedGrants(): Map<String, Map<NappletCapability, GrantState>> = store.all()
/** Forgets a single capability grant for [identity] — both persisted and in-session. */
suspend fun revoke(
identity: NappletIdentity,
capability: NappletCapability,
) {
lock.withLock { session[identity.coordinate]?.remove(capability) }
store.remove(identity.coordinate, capability)
}
/** Forgets all grants for [identity] — both the persisted and the in-session ones. */
suspend fun revokeAll(identity: NappletIdentity) {
lock.withLock { session.remove(identity.coordinate) }
@@ -41,6 +41,15 @@ interface NappletPermissionStore {
)
suspend fun clear(coordinate: String)
/** All persisted grants, keyed by coordinate — used by the permissions-management UI. */
suspend fun all(): Map<String, Map<NappletCapability, GrantState>>
/** Removes a single persisted grant (revoking one capability for one napplet). */
suspend fun remove(
coordinate: String,
capability: NappletCapability,
)
}
/** A thread-safe in-memory [NappletPermissionStore] for tests and ephemeral sessions. */
@@ -66,4 +75,14 @@ class InMemoryNappletPermissionStore : NappletPermissionStore {
data.remove(coordinate)
Unit
}
override suspend fun all(): Map<String, Map<NappletCapability, GrantState>> = lock.withLock { data.mapValues { it.value.toMap() } }
override suspend fun remove(
coordinate: String,
capability: NappletCapability,
) = lock.withLock {
data[coordinate]?.remove(capability)
Unit
}
}
@@ -108,6 +108,36 @@ class NappletPermissionLedgerTest {
assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.RELAY))
}
@Test
fun allPersistedGrantsListsPersistedDecisionsByCoordinate() =
runTest {
val ledger = ledger()
ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_ALWAYS)
ledger.record(applet, NappletCapability.RELAY, GrantState.DENY)
ledger.record(applet, NappletCapability.STORAGE, GrantState.ALLOW_SESSION) // not persisted
ledger.record(other, NappletCapability.WALLET, GrantState.DENY)
val all = ledger.allPersistedGrants()
assertEquals(
mapOf(NappletCapability.IDENTITY to GrantState.ALLOW_ALWAYS, NappletCapability.RELAY to GrantState.DENY),
all[applet.coordinate],
)
assertEquals(mapOf(NappletCapability.WALLET to GrantState.DENY), all[other.coordinate])
}
@Test
fun revokeForgetsOnlyThatCapability() =
runTest {
val ledger = ledger()
ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_ALWAYS)
ledger.record(applet, NappletCapability.RELAY, GrantState.ALLOW_ALWAYS)
ledger.revoke(applet, NappletCapability.IDENTITY)
assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.IDENTITY))
assertEquals(PermissionDecision.ALLOW, ledger.decide(applet, NappletCapability.RELAY))
}
@Test
fun aggregateHashDoesNotAffectTheLedgerKey() =
runTest {