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" />
+
+
+
+
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) }
+}
+
+/**
+ * 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/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/model/nip51Lists/trustedRelays/TrustedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt
index 65a5c80c45..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,7 +68,8 @@ class TrustedRelayListState(
.stateIn(
scope,
SharingStarted.Eagerly,
- emptySet(),
+ // Synchronously seed public tags from the backup; private tags may be absent on first boot.
+ settings.backupTrustedRelayList?.let { decryptionCache.cachedRelays(it) } ?: emptySet(),
)
suspend fun saveRelayList(trustedRelays: List): TrustedRelayListEvent {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt
new file mode 100644
index 0000000000..fa64ed6378
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt
@@ -0,0 +1,191 @@
+/*
+ * 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 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.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.NostrSignerPermissionStore
+import com.vitorpamplona.quartz.utils.cache.LargeCache
+import kotlinx.coroutines.flow.first
+import java.io.File
+import java.security.MessageDigest
+
+/**
+ * Per-coordinate DataStore-backed [NostrSignerPermissionStore]. One small `.preferences_pb`
+ * file per app (keyed by a SHA-256 prefix of the coordinate) so loading or saving one app's
+ * permissions never touches another app's data — essential at scale with 1000s of apps.
+ *
+ * The coordinate is stored inside each file under [KEY_COORDINATE] so [allPolicies] can
+ * reverse-map file → coordinate without scanning the filesystem.
+ */
+class DataStoreNostrSignerPermissionStore(
+ private val filesDir: File,
+) : NostrSignerPermissionStore {
+ constructor(context: Context) : this(context.applicationContext.filesDir)
+
+ private val cache = LargeCache>()
+
+ 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
+ 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 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
+ }
+ 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
+ // 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
+ }
+ 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())
+ 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..fc282ec8e8 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
@@ -75,7 +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(Amethyst.instance.signerPermissionStore) }
// 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..a5c2f15811
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt
@@ -0,0 +1,288 @@
+/*
+ * 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.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.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.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.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
+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) }
+ val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.9f
+
+ Dialog(
+ onDismissRequest = onCancel,
+ 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 + connect subtitle
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val isBrowser = info.coordinate.startsWith("browser:")
+ FavoriteAppIcon(
+ 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),
+ )
+ 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,
+ )
+ Text(
+ info.domain,
+ 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().padding(horizontal = 24.dp),
+ )
+ Spacer(Modifier.height(8.dp))
+
+ // Trust level options
+ 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))
+
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ 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))
+ }
+ }
+
+ OutlinedButton(
+ onClick = onBlock,
+ 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,
+ )
+ }
+ }
+ }
+ }
+}
+
+@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, color = MaterialTheme.colorScheme.onSurface)
+ 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..0467578f2e
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt
@@ -0,0 +1,86 @@
+/*
+ * 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,
+ val iconUrl: String? = null,
+)
+
+/**
+ * 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/NappletConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt
index 04ed6b60fb..94875be170 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,34 @@ 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.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.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.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 +110,132 @@ private fun NappletConsentDialog(
onDecision: (GrantState) -> Unit,
onDismiss: () -> Unit,
) {
- AlertDialog(
+ 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)
- Text(
- stringResource(R.string.napplet_consent_capability, info.capabilityLabel),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Text(
- info.coordinate,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- },
- confirmButton = {
- Column(modifier = Modifier.fillMaxWidth()) {
- if (info.allowAlways) {
- TextButton(
- onClick = { onDecision(GrantState.ALLOW_ALWAYS) },
- modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
- ) { Text(stringResource(R.string.napplet_consent_allow_always)) }
+ 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 + coordinate
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val isBrowser = info.coordinate.startsWith("browser:")
+ FavoriteAppIcon(
+ 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),
+ )
+ Text(
+ info.appletTitle,
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ info.capabilityLabel,
+ style = MaterialTheme.typography.bodyMedium,
+ 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,
+ )
}
- 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()) {
- TextButton(
- onClick = { onDecision(GrantState.DENY) },
- modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
- ) { Text(stringResource(R.string.napplet_consent_deny_always)) }
- TextButton(
+
+ // 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(16.dp))
+ HorizontalDivider()
+ Spacer(Modifier.height(8.dp))
+
+ if (info.allowAlways) {
+ Button(
+ onClick = { onDecision(GrantState.ALLOW_ALWAYS) },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ ) {
+ Text(stringResource(R.string.napplet_consent_allow_always))
+ }
+ OutlinedButton(
+ 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))
+ }
+ }
+
+ Spacer(Modifier.height(4.dp))
+ HorizontalDivider()
+ Spacer(Modifier.height(8.dp))
+
+ OutlinedButton(
onClick = { onDecision(GrantState.ASK) },
- modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
- ) { Text(stringResource(R.string.napplet_consent_not_now)) }
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ ) {
+ Text(
+ stringResource(R.string.napplet_consent_not_now),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ 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_deny_always),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
}
- },
- )
+ }
+ }
}
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..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
@@ -41,13 +43,21 @@ 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) =
+ 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,
capabilityLabel = context.getString(capability.labelRes()),
operationSummary = summaryFor(request),
allowAlways = capability.canGrantAlways,
+ iconUrl = iconUrl,
)
}
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..067661e93e
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.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 or nsite 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, 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
+ }
+ }
+ 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/napplet/NappletSignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt
new file mode 100644
index 0000000000..6fa8bad403
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt
@@ -0,0 +1,325 @@
+/*
+ * 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.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
+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.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
+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.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.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.utils.TimeUtils
+
+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,
+) {
+ var showRawData by remember { mutableStateOf(false) }
+ var showMoreOptions by remember { mutableStateOf(false) }
+ val scrollState = rememberScrollState()
+ val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
+
+ Dialog(
+ onDismissRequest = onDismiss,
+ 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),
+ ) {
+ // Centered header: icon + title + description
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val isBrowser = info.coordinate.startsWith("browser:")
+ FavoriteAppIcon(
+ 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),
+ )
+ Text(
+ info.appletTitle,
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ stringResource(R.string.napplet_consent_wants_to, info.operationSummary),
+ style = MaterialTheme.typography.bodyMedium,
+ 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()
+ 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))
+ Box(modifier = Modifier.horizontalScroll(rememberScrollState())) {
+ SelectionContainer {
+ Text(
+ info.rawData,
+ style =
+ MaterialTheme.typography.labelSmall.copy(
+ fontFamily = FontFamily.Monospace,
+ ),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ softWrap = false,
+ )
+ }
+ }
+ }
+ TextButton(
+ onClick = { showRawData = !showRawData },
+ contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
+ ) {
+ Text(
+ if (showRawData) {
+ stringResource(R.string.napplet_consent_hide_event)
+ } else {
+ stringResource(R.string.napplet_consent_show_event)
+ },
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+ HorizontalDivider()
+ 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_consent_allow_always))
+ }
+
+ // 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 and time-bound grants
+ 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) {
+ OutlinedButton(
+ onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) },
+ 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)) },
+ 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)) },
+ 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))
+
+ OutlinedButton(
+ onClick = { onGrant(SignerOpGrant.DenyOnce) },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) {
+ 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))
+ }
+ }
+ }
+ }
+}
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..9febb3b0f2
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt
@@ -0,0 +1,94 @@
+/*
+ * 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,
+ /** 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 = "",
+ val iconUrl: String? = null,
+)
+
+/**
+ * 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..9c7bb146d9
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt
@@ -0,0 +1,118 @@
+/*
+ * 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.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
+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_named, kindNameFor(context, 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 untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
+ 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) {
+ 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 ->
+ 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(
+ appletTitle = title,
+ coordinate = identity.coordinate,
+ op = op,
+ operationSummary = summary,
+ contentPreview = preview,
+ rawData = rawData,
+ iconUrl = iconUrl,
+ )
+}
+
+/** Creates a [NappletConnectInfo] for the first-connect dialog. */
+fun buildConnectInfo(
+ context: Context,
+ identity: NappletIdentity,
+): NappletConnectInfo {
+ val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
+ 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/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/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt
index 9c5c15f1cc..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
@@ -25,8 +25,10 @@ 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
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
@Composable
fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator)
@@ -36,17 +38,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 ->
+ val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false
+ normalized !in account.blockedRelayList.flow.value &&
+ normalized in account.trustedRelays.flow.value
+ },
+ )
+ }
+
+ 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/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/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
index 862aa1d516..8a977675fe 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,7 +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.NappletPermissionsScreen
+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.NappletsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen
@@ -183,6 +184,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
@@ -337,7 +339,9 @@ 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) }
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 e4817e0995..6bad216548 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,7 +105,13 @@ sealed class Route {
val coordinate: String,
) : Route()
- @Serializable object NappletPermissions : Route()
+ @Serializable object ConnectedApps : Route()
+
+ @Serializable object RelayAuthSettings : Route()
+
+ @Serializable data class ConnectedAppDetail(
+ val coordinate: String,
+ ) : Route()
@Serializable data class SoftwareAppDetail(
val kind: Int,
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 f8bef8becd..a7674daec0 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
@@ -311,7 +311,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/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt
new file mode 100644
index 0000000000..b74352c169
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt
@@ -0,0 +1,567 @@
+/*
+ * 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.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.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.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.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.browser.OmniboxInput
+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.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.favorites.BrowserIconRegistry
+import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel
+import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel
+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
+
+private data class ConnectedAppDetailState(
+ val title: String,
+ val coordinate: String,
+ val iconUrl: String?,
+ val signerPolicy: AppSignerPolicy?,
+ val opOverrides: Map,
+ val capabilities: List>,
+)
+
+@Composable
+fun ConnectedAppDetailScreen(
+ coordinate: String,
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ 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) }
+ 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,
+ onSetGrant = { newGrant ->
+ mutate {
+ if (newGrant == null) {
+ capabilityLedger.revoke(identity, cap)
+ } else {
+ capabilityLedger.record(identity, cap, newGrant)
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+
+ // 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),
+ ) {
+ val isBrowserEntry = state.coordinate.startsWith("browser:")
+ 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 {
+ iconModel = rememberManifestIconModel(author, identifier)
+ appForIcon = FavoriteApp.NostrApp(state.coordinate, state.title, 0L, state.iconUrl)
+ }
+ FavoriteAppIcon(
+ app = appForIcon,
+ iconModel = iconModel,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(48.dp),
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ state.title,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ 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,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ 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)) {
+ 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) },
+ )
+ 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) },
+ )
+ PolicyCard(
+ selected = selected == AppSignerPolicy.PARANOID,
+ symbol = MaterialSymbols.Lock,
+ label = stringResource(R.string.napplet_policy_paranoid),
+ description = stringResource(R.string.napplet_policy_paranoid_desc),
+ onClick = { onSelect(AppSignerPolicy.PARANOID) },
+ )
+ }
+}
+
+@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,
+ 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()
+ .clickable { showDialog = true }
+ .padding(horizontal = 12.dp, vertical = 12.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.bodyMedium)
+ Text(
+ stringResource(capability.descriptionRes()),
+ style = MaterialTheme.typography.bodySmall,
+ 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),
+ )
+ }
+}
+
+@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 },
+ )
+ 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))
+ }
+ },
+ )
+}
+
+@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)
+ }
+}
+
+@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 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)
+
+ 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,
+ iconUrl = iconUrl,
+ signerPolicy = signerPolicy,
+ opOverrides = opOverrides,
+ capabilities = capGrants,
+ )
+}
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..e32255cd30
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt
@@ -0,0 +1,388 @@
+/*
+ * 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.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SuggestionChip
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.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
+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
+import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
+import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
+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
+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
+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?,
+)
+
+@Composable
+fun ConnectedAppsScreen(
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
+ val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
+
+ var items by remember { mutableStateOf?>(null) }
+ var nappletAuthors by remember { mutableStateOf>(emptySet()) }
+
+ LaunchedEffect(Unit) {
+ val initial =
+ withContext(Dispatchers.Default) {
+ loadConnectedApps(capabilityLedger, signerLedger)
+ }
+ items = initial
+ // 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, nappletAuthors)
+
+ 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 = TextAlign.Center,
+ )
+ }
+ }
+
+ else -> {
+ val untitled = stringResource(CommonsR.string.napplet_untitled)
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ items(current, key = { it.coordinate }) { entry ->
+ ConnectedAppCard(
+ entry = entry,
+ untitled = untitled,
+ onClick = { nav.nav(Route.ConnectedAppDetail(entry.coordinate)) },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+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()
+ val nsiteState by nsiteNote
+ .flow()
+ .metadata.stateFlow
+ .collectAsStateWithLifecycle()
+ return nappletState.note.event ?: nsiteState.note.event
+}
+
+@Composable
+private fun ConnectedAppCard(
+ entry: ConnectedAppEntry,
+ untitled: String,
+ onClick: () -> Unit,
+) {
+ val author = remember(entry.coordinate) { entry.coordinate.substringBefore(':') }
+ 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)
+ }
+}
+
+/** 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 }
+ }
+
+ val iconModel = rememberWebAppIconModel(url)
+
+ ConnectedAppCardLayout(
+ app = FavoriteApp.WebApp(url, domain, 0L),
+ iconModel = iconModel,
+ 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" }
+
+ 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) + "…" }
+
+ 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),
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ FavoriteAppIcon(
+ app = app,
+ iconModel = iconModel,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(48.dp),
+ )
+
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ title,
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ 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 (signerPolicy != null) {
+ SuggestionChip(
+ onClick = {},
+ label = {
+ Text(
+ 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,
+): List {
+ val capGrants = capabilityLedger.allPersistedGrants()
+ val signerPolicies = signerLedger.store.allPolicies()
+ val allCoordinates = (capGrants.keys + signerPolicies.keys).toSet()
+ return allCoordinates
+ .map { coordinate ->
+ ConnectedAppEntry(
+ coordinate = coordinate,
+ signerPolicy = signerPolicies[coordinate],
+ )
+ }.sortedBy { it.coordinate }
+}
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
deleted file mode 100644
index 7637ae5a49..0000000000
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt
+++ /dev/null
@@ -1,347 +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.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.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
-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 = resolveTitle(author, identifier, untitled),
- 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/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/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/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)
+ }
+ }
+}
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..b228c4812e
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt
@@ -0,0 +1,252 @@
+/*
+ * 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.verticalScroll
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+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
+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.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.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 com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.PolicyCard
+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