Merge remote-tracking branch 'origin/main' into claude/git-repo-readme-code-tabs-e4uf6c

This commit is contained in:
Claude
2026-06-28 22:29:16 +00:00
55 changed files with 4439 additions and 730 deletions
+14
View File
@@ -431,6 +431,20 @@
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- First-connect "Connect to Nostr" dialog. -->
<activity
android:name=".napplet.NappletConnectActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Per-operation signer consent dialog. -->
<activity
android:name=".napplet.NappletSignerConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Main-process broker: holds the signer and brokers capabilities for the sandbox. -->
<service
@@ -44,6 +44,8 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
@@ -73,6 +75,7 @@ import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
@@ -529,6 +532,15 @@ class AppModules(
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
// Persists per-relay NIP-42 ALLOW/DENY overrides across app restarts.
val relayAuthPermissionStore by lazy {
DataStoreRelayAuthPermissionStore(appContext)
}
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
@@ -794,6 +806,13 @@ class AppModules(
resourceCacheInit()
}
// Initialize napplet permission stores on an IO thread to avoid StrictMode violations
// when ConnectedAppsScreen first accesses them on the main thread.
applicationIOScope.launch {
nappletPermissionStore
signerPermissionStore
}
// registers to receive events
pokeyReceiver.register(appContext)
@@ -28,6 +28,7 @@ import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
@@ -155,6 +156,7 @@ private object PrefKeys {
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications"
@@ -506,6 +508,7 @@ object LocalPreferences {
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value)
// Any account that reaches a save has its notification filter in its
@@ -622,6 +625,10 @@ object LocalPreferences {
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
val defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.IF_IN_MY_LIST
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
@@ -825,6 +832,7 @@ object LocalPreferences {
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
backupUserMetadata = latestUserMetadataResolved,
@@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.napplethost.NappletBlobCache
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
@@ -107,3 +108,40 @@ private fun resolveIconBlob(event: Event?): IconBlob? =
is NamedSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
else -> null
}
/**
* A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon
* has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the
* WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache.
*
* Early-returns null when [url] is blank or has no parseable host — this early return is stable
* for a given [url] (the host either always parses or never does), so composition structure is
* preserved across recompositions.
*/
@Composable
fun rememberWebAppIconModel(url: String): String? {
val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) }
}
/**
* 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)
}
@@ -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<Boolean> = MutableStateFlow(true),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = 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
@@ -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<NormalizedRelayUrl>): TrustedRelayListEvent {
@@ -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<String, DataStore<Preferences>>()
private fun storeFor(coordinate: String): DataStore<Preferences> {
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<String, AppSignerPolicy> {
val dir = File(filesDir, "datastore")
if (!dir.exists()) return emptyMap()
val result = mutableMapOf<String, AppSignerPolicy>()
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<String, NostrOpDecision> {
val prefs = storeFor(coordinate).data.first()
val result = mutableMapOf<String, NostrOpDecision>()
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) }
}
}
}
@@ -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
@@ -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,
)
}
}
}
}
@@ -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<AppConnectResult>,
)
private val pending = ConcurrentHashMap<String, Pending>()
suspend fun requestConnect(
context: Context,
info: NappletConnectInfo,
): AppConnectResult {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<AppConnectResult>()
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"
}
@@ -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,
)
}
}
},
)
}
}
}
@@ -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,
)
/**
@@ -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,
)
}
@@ -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<String, String?> {
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
}
@@ -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))
}
}
}
}
}
@@ -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<SignerOpGrant>,
)
private val pending = ConcurrentHashMap<String, Pending>()
suspend fun requestConsent(
context: Context,
info: NappletSignerConsentInfo,
): SignerOpGrant {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<SignerOpGrant>()
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"
}
@@ -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<Nothing>(TimeUtils.now(), request.kind, request.tags, request.content))
is NappletRequest.SignEvent ->
JacksonMapper.toJsonPretty(EventTemplate<Nothing>(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)
}
@@ -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)
}
/**
@@ -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)
}
}
}
@@ -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<RelayAuthPermissionLedger> = 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<Account> (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()
}
},
)
@@ -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<Preferences> 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<String, RelayAuthDecision> {
val prefs = store.data.first()
val result = mutableMapOf<String, RelayAuthDecision>()
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) }
}
}
}
@@ -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<String, RelayAuthDecision> = store.allDecisions()
}
@@ -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)
@@ -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
@@ -335,7 +337,9 @@ fun BuildNavigation(
composableFromEnd<Route.FavoriteApps> { FavoriteAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WebApp> { WebAppScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.NostrApp> { NostrAppScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.NappletPermissions> { NappletPermissionsScreen(accountViewModel, nav) }
composableFromEnd<Route.ConnectedApps> { ConnectedAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ConnectedAppDetail> { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.RelayAuthSettings> { RelayAuthSettingsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
@@ -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,
@@ -308,7 +308,7 @@ class AccountViewModel(
RelayAuthenticator(
newClient,
customScope,
signWithAllLoggedInUsers = { authTemplate ->
signWithAllLoggedInUsers = { _, authTemplate ->
if (account.signer.isWriteable()) {
try {
listOf(account.signer.sign(authTemplate))
@@ -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<String, NostrOpDecision>,
val capabilities: List<Pair<NappletCapability, GrantState>>,
)
@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<ConnectedAppDetailState?>(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,
)
}
@@ -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<List<ConnectedAppEntry>?>(null) }
var nappletAuthors by remember { mutableStateOf<Set<HexKey>>(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<ConnectedAppEntry> {
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 }
}
@@ -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
}
@@ -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<Pair<NappletCapability, GrantState>>,
)
@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<List<NappletGrantsUi>?>(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<NappletGrantsUi> =
ledger
.allPersistedGrants()
.map { (coordinate, caps) ->
val author = coordinate.substringBefore(':')
val identifier = coordinate.substringAfter(':', "")
NappletGrantsUi(
identity = NappletIdentity(authorPubKey = author, identifier = identifier),
title = resolveTitle(author, identifier, 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
}
@@ -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) }) {
@@ -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,
)
}
}
}
}
@@ -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<HexKey>,
)
/**
* 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<ConnectedAppsQueryState>() {
val group =
listOf(
ConnectedAppsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -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<HexKey>,
) {
val state =
remember(accountViewModel.account, authors) {
ConnectedAppsQueryState(accountViewModel.account, authors)
}
LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().connectedApps)
}
@@ -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<ConnectedAppsQueryState>,
) : PerUserEoseManager<ConnectedAppsQueryState>(client, allKeys) {
override fun user(key: ConnectedAppsQueryState) = key.account.userProfile()
override fun updateFilter(
key: ConnectedAppsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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)
}
}
}
@@ -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<Map<String, RelayAuthDecision>>(emptyMap()) }
var reloadKey by remember { mutableIntStateOf(0) }
LaunchedEffect(reloadKey) {
perRelayOverrides = withContext(Dispatchers.IO) { store.allDecisions() }
}
Scaffold(
topBar = { TopBarWithBackButton(stringResource(R.string.relay_auth_settings_title), nav) },
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.relay_auth_global_policy),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(4.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
RelayAuthPolicy.entries.forEach { policy ->
val (titleRes, descRes, symbol) =
when (policy) {
RelayAuthPolicy.ALWAYS ->
Triple(
R.string.relay_auth_policy_always,
R.string.relay_auth_policy_always_desc,
MaterialSymbols.LockOpen,
)
RelayAuthPolicy.NEVER ->
Triple(
R.string.relay_auth_policy_never,
R.string.relay_auth_policy_never_desc,
MaterialSymbols.Lock,
)
RelayAuthPolicy.IF_IN_MY_LIST ->
Triple(
R.string.relay_auth_policy_if_in_my_list,
R.string.relay_auth_policy_if_in_my_list_desc,
MaterialSymbols.PrivacyTip,
)
}
PolicyCard(
selected = globalPolicy == policy,
symbol = symbol,
label = stringResource(titleRes),
description = stringResource(descRes),
onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) },
)
}
}
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
if (perRelayOverrides.isNotEmpty()) {
Text(
text = stringResource(R.string.relay_auth_per_relay_overrides),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(4.dp))
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(4.dp)) {
perRelayOverrides.entries.sortedBy { it.key }.forEachIndexed { index, (url, decision) ->
if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
PerRelayOverrideRow(
url = url,
decision = decision,
onRemove = {
scope.launch {
ledger.clearDecision(url)
reloadKey++
}
},
onToggle = {
scope.launch {
val next =
if (decision == RelayAuthDecision.ALLOW) {
RelayAuthDecision.DENY
} else {
RelayAuthDecision.ALLOW
}
ledger.setDecision(url, next)
reloadKey++
}
},
)
}
}
}
} else {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
text = stringResource(R.string.relay_auth_no_overrides),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun PerRelayOverrideRow(
url: String,
decision: RelayAuthDecision,
onRemove: () -> Unit,
onToggle: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(Modifier.weight(1f)) {
Text(
text = url,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
SuggestionChip(
onClick = onToggle,
label = {
Text(
text =
if (decision == RelayAuthDecision.ALLOW) {
stringResource(R.string.relay_auth_decision_allow)
} else {
stringResource(R.string.relay_auth_decision_deny)
},
style = MaterialTheme.typography.labelSmall,
)
},
colors =
if (decision == RelayAuthDecision.ALLOW) {
SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
labelColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
} else {
SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
labelColor = MaterialTheme.colorScheme.onErrorContainer,
)
},
)
IconButton(onClick = onRemove) {
Icon(MaterialSymbols.Close, contentDescription = stringResource(R.string.relay_auth_remove_override))
}
}
}
@@ -0,0 +1,336 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays
import android.content.Context
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.kinds.KindNames
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
/** Returns the `@StringRes` id for the translated kind name, or -1 if unknown. */
@Suppress("DEPRECATION")
@StringRes
fun kindDisplayName(kind: Int): Int =
when (kind) {
AcceptedBadgeSetEvent.KIND -> R.string.kind_accepted_badge_set
AdvertisedRelayListEvent.KIND -> R.string.kind_outbox_relays
AppDefinitionEvent.KIND -> R.string.kind_apps
AppRecommendationEvent.KIND -> R.string.kind_app_recommendations
AppSpecificDataEvent.KIND -> R.string.kind_user_settings
AudioHeaderEvent.KIND -> R.string.kind_audio_header
AudioTrackEvent.KIND -> R.string.kind_audio_track
MusicTrackEvent.KIND -> R.string.kind_music_track
MusicPlaylistEvent.KIND -> R.string.kind_music_playlist
PodcastEpisodeEvent.KIND -> R.string.kind_podcast_episode
PodcastMetadataEvent.KIND -> R.string.kind_podcast_metadata
AuthoredPodcastsEvent.KIND -> R.string.kind_authored_podcasts
FavoritePodcastsListEvent.KIND -> R.string.kind_favorite_podcasts
AttestationEvent.KIND -> R.string.attestation
AttestationRequestEvent.KIND -> R.string.attestation_request
AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation
AttestorProficiencyEvent.KIND -> R.string.attestor_proficiency
BadgeAwardEvent.KIND -> R.string.kind_badge_awards
BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions
BlockedRelayListEvent.KIND -> R.string.kind_blocked_relays
BlossomServersEvent.KIND -> R.string.kind_blossom_servers
NestsServersEvent.KIND -> R.string.kind_nests_servers
BlossomAuthorizationEvent.KIND -> R.string.kind_blossom_auth
BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays
BookmarkListEvent.KIND -> R.string.kind_bookmark_list
OldBookmarkListEvent.KIND -> R.string.kind_old_bookmark_list
CalendarDateSlotEvent.KIND -> R.string.kind_day_appointment
CalendarEvent.KIND -> R.string.kind_calendar
CalendarTimeSlotEvent.KIND -> R.string.kind_appointment
CalendarRSVPEvent.KIND -> R.string.kind_appt_rsvp
ChessGameEvent.KIND -> R.string.kind_chess_games
JesterEvent.KIND -> R.string.kind_chess_auth
RelayFeedsListEvent.KIND -> R.string.kind_favorite_relays
LiveChessGameChallengeEvent.KIND -> R.string.kind_chess_challenges
LiveChessGameAcceptEvent.KIND -> R.string.kind_chess_game_accept
LiveChessMoveEvent.KIND -> R.string.kind_chess_move
LiveChessGameEndEvent.KIND -> R.string.kind_chess_game_end
LiveChessDrawOfferEvent.KIND -> R.string.kind_chess_draw_offer
ChannelCreateEvent.KIND -> R.string.kind_channel_definition
ChannelHideMessageEvent.KIND -> R.string.kind_channel_hide_msg
ChannelListEvent.KIND -> R.string.kind_channel_list
ChannelMessageEvent.KIND -> R.string.kind_channel_message
ChannelMetadataEvent.KIND -> R.string.kind_channel_metadata
ChannelMuteUserEvent.KIND -> R.string.kind_channel_mute_user
ChatMessageEncryptedFileHeaderEvent.KIND -> R.string.kind_dm_file
ChatMessageEvent.KIND -> R.string.kind_dm_message
ChatMessageRelayListEvent.KIND -> R.string.kind_dm_relays
ClassifiedsEvent.KIND -> R.string.kind_classifieds
CommentEvent.KIND -> R.string.kind_comments
CommunityDefinitionEvent.KIND -> R.string.kind_community_def
CommunityListEvent.KIND -> R.string.kind_community_list
CommunityPostApprovalEvent.KIND -> R.string.kind_community_post
ContactListEvent.KIND -> R.string.kind_follow_list
DeletionEvent.KIND -> R.string.kind_deletions
DraftWrapEvent.KIND -> R.string.kind_drafts
EmojiPackEvent.KIND -> R.string.kind_emoji_packs
EmojiPackSelectionEvent.KIND -> R.string.kind_emoji_pack_list
EphemeralChatEvent.KIND -> R.string.kind_ephemeral_chat
EphemeralChatListEvent.KIND -> R.string.kind_ephemeral_chatrooms
FileHeaderEvent.KIND -> R.string.kind_file_headers
ProfileGalleryEntryEvent.KIND -> R.string.kind_profile_gallery
FileServersEvent.KIND -> R.string.kind_file_servers
FileStorageEvent.KIND -> R.string.kind_blob_data
FileStorageHeaderEvent.KIND -> R.string.kind_blob_headers
FhirResourceEvent.KIND -> R.string.kind_medical_data
FollowListEvent.KIND -> R.string.kind_follow_packs
GenericRepostEvent.KIND -> R.string.kind_reposts_16
GeohashListEvent.KIND -> R.string.kind_geohash_follows
GiftWrapEvent.KIND -> R.string.kind_gift_wraps
EphemeralGiftWrapEvent.KIND -> R.string.kind_gift_wraps
GitIssueEvent.KIND -> R.string.kind_git_issue
GitPatchEvent.KIND -> R.string.kind_git_patch
GitRepositoryEvent.KIND -> R.string.kind_git_repo
GitReplyEvent.KIND -> R.string.kind_git_reply
GoalEvent.KIND -> R.string.kind_zap_goals
HashtagListEvent.KIND -> R.string.kind_hashtag_follows
HighlightEvent.KIND -> R.string.kind_highlights
HTTPAuthorizationEvent.KIND -> R.string.kind_http_auth
IndexerRelayListEvent.KIND -> R.string.kind_index_relay_list
InteractiveStoryPrologueEvent.KIND -> R.string.kind_adventure_prologue
InteractiveStorySceneEvent.KIND -> R.string.kind_adventure_scene
InteractiveStoryReadingStateEvent.KIND -> R.string.kind_adventure_reading
LabeledBookmarkListEvent.KIND -> R.string.kind_named_bookmarks
LiveActivitiesChatMessageEvent.KIND -> R.string.kind_live_chats
LiveActivitiesEvent.KIND -> R.string.kind_live_streams
LnZapEvent.KIND -> R.string.kind_zaps
LnZapPaymentRequestEvent.KIND -> R.string.kind_nwc_request
LnZapPaymentResponseEvent.KIND -> R.string.kind_nwc_response
LnZapPrivateEvent.KIND -> R.string.kind_private_zaps
LnZapRequestEvent.KIND -> R.string.kind_zap_req
LongTextNoteEvent.KIND -> R.string.kind_blogs
MeetingRoomEvent.KIND -> R.string.kind_meeting_room
MeetingRoomPresenceEvent.KIND -> R.string.kind_room_presence
MeetingSpaceEvent.KIND -> R.string.kind_meeting_space
MetadataEvent.KIND -> R.string.kind_profile
MuteListEvent.KIND -> R.string.kind_mute_list
NNSEvent.KIND -> R.string.kind_nns
NipTextEvent.KIND -> R.string.kind_nip
NostrConnectEvent.KIND -> R.string.kind_nostr_connect
NIP90StatusEvent.KIND -> R.string.kind_dvm_status
NIP90ContentDiscoveryRequestEvent.KIND -> R.string.kind_dvm_content_req
NIP90ContentDiscoveryResponseEvent.KIND -> R.string.kind_dvm_content_resp
NIP90UserDiscoveryRequestEvent.KIND -> R.string.kind_dvm_user_req
NIP90UserDiscoveryResponseEvent.KIND -> R.string.kind_dvm_user_resp
OtsEvent.KIND -> R.string.kind_ots
PaymentTargetsEvent.KIND -> R.string.kind_pay_to
PeopleListEvent.KIND -> R.string.kind_people_lists
ProfileBadgesEvent.KIND -> R.string.kind_profile_badges
PictureEvent.KIND -> R.string.kind_pictures
WorkoutRecordEvent.KIND -> R.string.kind_workouts
PinListEvent.KIND -> R.string.kind_pins
ZapPollEvent.KIND -> R.string.kind_zap_poll
PollEvent.KIND -> R.string.kind_poll
PollResponseEvent.KIND -> R.string.kind_poll_response
PrivateDmEvent.KIND -> R.string.kind_nip04_dms
PrivateOutboxRelayListEvent.KIND -> R.string.kind_private_relays
ProxyRelayListEvent.KIND -> R.string.kind_proxy_relays
PublicMessageEvent.KIND -> R.string.kind_public_message
ReactionEvent.KIND -> R.string.kind_reactions
ContactCardEvent.KIND -> R.string.kind_contact_card
RelayAuthEvent.KIND -> R.string.kind_relay_auth
RelayDiscoveryEvent.KIND -> R.string.kind_relay_discovery
RelayMonitorEvent.KIND -> R.string.kind_relay_monitor
RelaySetEvent.KIND -> R.string.kind_relay_set
ReportEvent.KIND -> R.string.kind_reports
RepostEvent.KIND -> R.string.kind_reposts
RequestToVanishEvent.KIND -> R.string.kind_user_delete
SealedRumorEvent.KIND -> R.string.kind_seals
SearchRelayListEvent.KIND -> R.string.kind_search_relays
StatusEvent.KIND -> R.string.kind_user_status
TextNoteEvent.KIND -> R.string.kind_notes
TextNoteModificationEvent.KIND -> R.string.kind_edits
TorrentEvent.KIND -> R.string.kind_torrents
TorrentCommentEvent.KIND -> R.string.kind_torrent_comments
TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays
TrustProviderListEvent.KIND -> R.string.kind_trusted_providers
VideoHorizontalEvent.KIND -> R.string.kind_video_repl
VideoVerticalEvent.KIND -> R.string.kind_shorts_repl
VideoNormalEvent.KIND -> R.string.kind_video
VideoShortEvent.KIND -> R.string.kind_shorts
VoiceEvent.KIND -> R.string.kind_voice_msg
VoiceReplyEvent.KIND -> R.string.kind_voice_reply
WakeUpEvent.KIND -> R.string.kind_wake
WebBookmarkEvent.KIND -> R.string.kind_web_bookmark
WikiNoteEvent.KIND -> R.string.kind_wiki
else -> -1
}
/**
* Returns the translated display name for [kind] using Android string resources when available,
* falling back to the English name from [KindNames], then to "k<number>".
*/
fun kindNameFor(
context: Context,
kind: Int,
): String {
val resId = kindDisplayName(kind)
return if (resId != -1) context.getString(resId) else (KindNames.nameFor(kind) ?: "k$kind")
}
@@ -109,33 +109,8 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.ui.theme.redColorOnSecondSurface
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.kinds.KindNames
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessage
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessageText
@@ -146,128 +121,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -535,157 +397,6 @@ fun RelayInformationBody(
// Active subscriptions + outbox display
// ---------------------------------------------------------------------------
@Suppress("DEPRECATION")
fun kindDisplayName(kind: Int): Int =
when (kind) {
AcceptedBadgeSetEvent.KIND -> R.string.kind_accepted_badge_set
AdvertisedRelayListEvent.KIND -> R.string.kind_outbox_relays
AppDefinitionEvent.KIND -> R.string.kind_apps
AppRecommendationEvent.KIND -> R.string.kind_app_recommendations
AppSpecificDataEvent.KIND -> R.string.kind_user_settings
AudioHeaderEvent.KIND -> R.string.kind_audio_header
AudioTrackEvent.KIND -> R.string.kind_audio_track
MusicTrackEvent.KIND -> R.string.kind_music_track
MusicPlaylistEvent.KIND -> R.string.kind_music_playlist
PodcastEpisodeEvent.KIND -> R.string.kind_podcast_episode
PodcastMetadataEvent.KIND -> R.string.kind_podcast_metadata
AuthoredPodcastsEvent.KIND -> R.string.kind_authored_podcasts
FavoritePodcastsListEvent.KIND -> R.string.kind_favorite_podcasts
AttestationEvent.KIND -> R.string.attestation
AttestationRequestEvent.KIND -> R.string.attestation_request
AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation
AttestorProficiencyEvent.KIND -> R.string.attestor_proficiency
BadgeAwardEvent.KIND -> R.string.kind_badge_awards
BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions
BlockedRelayListEvent.KIND -> R.string.kind_blocked_relays
BlossomServersEvent.KIND -> R.string.kind_blossom_servers
NestsServersEvent.KIND -> R.string.kind_nests_servers
BlossomAuthorizationEvent.KIND -> R.string.kind_blossom_auth
BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays
BookmarkListEvent.KIND -> R.string.kind_bookmark_list
OldBookmarkListEvent.KIND -> R.string.kind_old_bookmark_list
CalendarDateSlotEvent.KIND -> R.string.kind_day_appointment
CalendarEvent.KIND -> R.string.kind_calendar
CalendarTimeSlotEvent.KIND -> R.string.kind_appointment
CalendarRSVPEvent.KIND -> R.string.kind_appt_rsvp
ChessGameEvent.KIND -> R.string.kind_chess_games
JesterEvent.KIND -> R.string.kind_chess_auth
RelayFeedsListEvent.KIND -> R.string.kind_favorite_relays
LiveChessGameChallengeEvent.KIND -> R.string.kind_chess_challenges
LiveChessGameAcceptEvent.KIND -> R.string.kind_chess_game_accept
LiveChessMoveEvent.KIND -> R.string.kind_chess_move
LiveChessGameEndEvent.KIND -> R.string.kind_chess_game_end
LiveChessDrawOfferEvent.KIND -> R.string.kind_chess_draw_offer
ChannelCreateEvent.KIND -> R.string.kind_channel_definition
ChannelHideMessageEvent.KIND -> R.string.kind_channel_hide_msg
ChannelListEvent.KIND -> R.string.kind_channel_list
ChannelMessageEvent.KIND -> R.string.kind_channel_message
ChannelMetadataEvent.KIND -> R.string.kind_channel_metadata
ChannelMuteUserEvent.KIND -> R.string.kind_channel_mute_user
ChatMessageEncryptedFileHeaderEvent.KIND -> R.string.kind_dm_file
ChatMessageEvent.KIND -> R.string.kind_dm_message
ChatMessageRelayListEvent.KIND -> R.string.kind_dm_relays
ClassifiedsEvent.KIND -> R.string.kind_classifieds
CommentEvent.KIND -> R.string.kind_comments
CommunityDefinitionEvent.KIND -> R.string.kind_community_def
CommunityListEvent.KIND -> R.string.kind_community_list
CommunityPostApprovalEvent.KIND -> R.string.kind_community_post
ContactListEvent.KIND -> R.string.kind_follow_list
DeletionEvent.KIND -> R.string.kind_deletions
DraftWrapEvent.KIND -> R.string.kind_drafts
EmojiPackEvent.KIND -> R.string.kind_emoji_packs
EmojiPackSelectionEvent.KIND -> R.string.kind_emoji_pack_list
EphemeralChatEvent.KIND -> R.string.kind_ephemeral_chat
EphemeralChatListEvent.KIND -> R.string.kind_ephemeral_chatrooms
FileHeaderEvent.KIND -> R.string.kind_file_headers
ProfileGalleryEntryEvent.KIND -> R.string.kind_profile_gallery
FileServersEvent.KIND -> R.string.kind_file_servers
FileStorageEvent.KIND -> R.string.kind_blob_data
FileStorageHeaderEvent.KIND -> R.string.kind_blob_headers
FhirResourceEvent.KIND -> R.string.kind_medical_data
FollowListEvent.KIND -> R.string.kind_follow_packs
GenericRepostEvent.KIND -> R.string.kind_reposts_16
GeohashListEvent.KIND -> R.string.kind_geohash_follows
GiftWrapEvent.KIND -> R.string.kind_gift_wraps
EphemeralGiftWrapEvent.KIND -> R.string.kind_gift_wraps
GitIssueEvent.KIND -> R.string.kind_git_issue
GitPatchEvent.KIND -> R.string.kind_git_patch
GitRepositoryEvent.KIND -> R.string.kind_git_repo
GitReplyEvent.KIND -> R.string.kind_git_reply
GoalEvent.KIND -> R.string.kind_zap_goals
HashtagListEvent.KIND -> R.string.kind_hashtag_follows
HighlightEvent.KIND -> R.string.kind_highlights
HTTPAuthorizationEvent.KIND -> R.string.kind_http_auth
IndexerRelayListEvent.KIND -> R.string.kind_index_relay_list
InteractiveStoryPrologueEvent.KIND -> R.string.kind_adventure_prologue
InteractiveStorySceneEvent.KIND -> R.string.kind_adventure_scene
InteractiveStoryReadingStateEvent.KIND -> R.string.kind_adventure_reading
LabeledBookmarkListEvent.KIND -> R.string.kind_named_bookmarks
LiveActivitiesChatMessageEvent.KIND -> R.string.kind_live_chats
LiveActivitiesEvent.KIND -> R.string.kind_live_streams
LnZapEvent.KIND -> R.string.kind_zaps
LnZapPaymentRequestEvent.KIND -> R.string.kind_nwc_request
LnZapPaymentResponseEvent.KIND -> R.string.kind_nwc_response
LnZapPrivateEvent.KIND -> R.string.kind_private_zaps
LnZapRequestEvent.KIND -> R.string.kind_zap_req
LongTextNoteEvent.KIND -> R.string.kind_blogs
MeetingRoomEvent.KIND -> R.string.kind_meeting_room
MeetingRoomPresenceEvent.KIND -> R.string.kind_room_presence
MeetingSpaceEvent.KIND -> R.string.kind_meeting_space
MetadataEvent.KIND -> R.string.kind_profile
MuteListEvent.KIND -> R.string.kind_mute_list
NNSEvent.KIND -> R.string.kind_nns
NipTextEvent.KIND -> R.string.kind_nip
NostrConnectEvent.KIND -> R.string.kind_nostr_connect
NIP90StatusEvent.KIND -> R.string.kind_dvm_status
NIP90ContentDiscoveryRequestEvent.KIND -> R.string.kind_dvm_content_req
NIP90ContentDiscoveryResponseEvent.KIND -> R.string.kind_dvm_content_resp
NIP90UserDiscoveryRequestEvent.KIND -> R.string.kind_dvm_user_req
NIP90UserDiscoveryResponseEvent.KIND -> R.string.kind_dvm_user_resp
OtsEvent.KIND -> R.string.kind_ots
PaymentTargetsEvent.KIND -> R.string.kind_pay_to
PeopleListEvent.KIND -> R.string.kind_people_lists
ProfileBadgesEvent.KIND -> R.string.kind_profile_badges
PictureEvent.KIND -> R.string.kind_pictures
WorkoutRecordEvent.KIND -> R.string.kind_workouts
PinListEvent.KIND -> R.string.kind_pins
ZapPollEvent.KIND -> R.string.kind_zap_poll
PollEvent.KIND -> R.string.kind_poll
PollResponseEvent.KIND -> R.string.kind_poll_response
PrivateDmEvent.KIND -> R.string.kind_nip04_dms
PrivateOutboxRelayListEvent.KIND -> R.string.kind_private_relays
ProxyRelayListEvent.KIND -> R.string.kind_proxy_relays
PublicMessageEvent.KIND -> R.string.kind_public_message
ReactionEvent.KIND -> R.string.kind_reactions
ContactCardEvent.KIND -> R.string.kind_contact_card
RelayAuthEvent.KIND -> R.string.kind_relay_auth
RelayDiscoveryEvent.KIND -> R.string.kind_relay_discovery
RelayMonitorEvent.KIND -> R.string.kind_relay_monitor
RelaySetEvent.KIND -> R.string.kind_relay_set
ReportEvent.KIND -> R.string.kind_reports
RepostEvent.KIND -> R.string.kind_reposts
RequestToVanishEvent.KIND -> R.string.kind_user_delete
SealedRumorEvent.KIND -> R.string.kind_seals
SearchRelayListEvent.KIND -> R.string.kind_search_relays
StatusEvent.KIND -> R.string.kind_user_status
TextNoteEvent.KIND -> R.string.kind_notes
TextNoteModificationEvent.KIND -> R.string.kind_edits
TorrentEvent.KIND -> R.string.kind_torrents
TorrentCommentEvent.KIND -> R.string.kind_torrent_comments
TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays
TrustProviderListEvent.KIND -> R.string.kind_trusted_providers
VideoHorizontalEvent.KIND -> R.string.kind_video_repl
VideoVerticalEvent.KIND -> R.string.kind_shorts_repl
VideoNormalEvent.KIND -> R.string.kind_video
VideoShortEvent.KIND -> R.string.kind_shorts
VoiceEvent.KIND -> R.string.kind_voice_msg
VoiceReplyEvent.KIND -> R.string.kind_voice_reply
WakeUpEvent.KIND -> R.string.kind_wake
WebBookmarkEvent.KIND -> R.string.kind_web_bookmark
WikiNoteEvent.KIND -> R.string.kind_wiki
else -> -1
}
val posts = setOf(0, 1, 6, 7, 16, 30023)
val settings = setOf(3, 10002, 10000, 10001, 10003, 10004, 30000)
val dms = setOf(4, GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, 10050)
@@ -74,6 +74,8 @@ fun buildSettingsCatalog(
symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds),
symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges),
symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets),
symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps),
symEntry(R.string.relay_auth_settings_title, MaterialSymbols.Lock, R.string.relay_auth_search_keywords, Route.RelayAuthSettings),
symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters),
symEntry(R.string.call_settings, MaterialSymbols.Phone, R.string.call_settings_search_keywords, Route.CallSettings),
symEntry(R.string.translations, MaterialSymbols.Translate, R.string.translations_search_keywords, Route.UserSettings),
+73 -1
View File
@@ -701,8 +701,9 @@
<string name="napplet_permissions_empty">No nApplet permissions yet</string>
<string name="napplet_permissions_empty_subtitle">Permissions you grant to nApplets will appear here.</string>
<string name="napplet_permissions_forget">Forget this nApplet</string>
<string name="napplet_permissions_blocked">Blocked</string>
<string name="napplet_permissions_blocked">Requires per-use approval</string>
<string name="napplet_permissions_revoke">Revoke</string>
<string name="napplet_permissions_ask_each_time">Ask me each time</string>
<string name="napplet_none_found">No nApplets found yet.</string>
<string name="napplet_fallback_title">nApplet %1$s…</string>
<!-- Napplet sandbox chrome (host top bar + live action notices) -->
@@ -752,6 +753,77 @@
<item quantity="one">This nApplet wants to pay a Lightning invoice for %1$d sat.</item>
<item quantity="other">This nApplet wants to pay a Lightning invoice for %1$d sats.</item>
</plurals>
<!-- Signer permissions: first-connect dialog -->
<string name="napplet_connect_title">Connect to Nostr</string>
<string name="napplet_connect_subtitle">wants to connect to your Nostr account</string>
<string name="napplet_connect_how_handle">How should this app\'s requests be handled?</string>
<string name="napplet_connect_button">Connect</string>
<string name="napplet_connect_block">Block and ignore %1$s</string>
<!-- Signer trust levels -->
<string name="napplet_policy_full_trust">I fully trust it</string>
<string name="napplet_policy_full_trust_desc">Auto-sign all requests (except payments)</string>
<string name="napplet_policy_reasonable">Let\'s be reasonable</string>
<string name="napplet_policy_reasonable_desc">Auto-approve most common requests</string>
<string name="napplet_policy_paranoid">I\'m a bit paranoid</string>
<string name="napplet_policy_paranoid_desc">Do not sign anything without asking me!</string>
<!-- Signer per-op consent dialog -->
<string name="napplet_consent_wants_to">wants to %1$s</string>
<string name="napplet_consent_show_event">Show Event</string>
<string name="napplet_consent_hide_event">Hide Event</string>
<string name="napplet_consent_more_options">More options</string>
<string name="napplet_consent_fewer_options">Fewer options</string>
<string name="napplet_signer_allow_once">Allow once</string>
<string name="napplet_signer_allow_session">Allow for this session</string>
<string name="napplet_signer_allow_24h">Allow for 24 hours</string>
<string name="napplet_signer_allow_30d">Allow for 30 days</string>
<string name="napplet_signer_allow_op">Don\'t ask again to %1$s</string>
<string name="napplet_signer_allow_all">Don\'t ask again for any Nostr requests</string>
<string name="napplet_signer_deny_once">Deny</string>
<string name="napplet_signer_deny_op">Always deny %1$s</string>
<string name="napplet_signer_last_used">Last used</string>
<string name="napplet_signer_expires">Expires</string>
<!-- Signer op labels -->
<string name="napplet_op_sign_kind">sign kind %1$d event</string>
<string name="napplet_op_sign_kind_named">sign for %1$s (kind: %2$d)</string>
<string name="napplet_op_encrypt">encrypt a message</string>
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<string name="napplet_op_decrypt">read your private messages</string>
<!-- Permissions management screen -->
<string name="napplet_permissions_title">Connected Apps</string>
<string name="napplet_permissions_revoke_all">Revoke all permissions</string>
<string name="napplet_permissions_overrides">Operation overrides</string>
<string name="napplet_signer_permissions_empty">No apps have connected yet.</string>
<string name="napplet_signer_permissions_revoke_all">Revoke all permissions</string>
<string name="napplet_decision_allow">Allow</string>
<string name="napplet_decision_ask">Ask</string>
<string name="napplet_decision_deny">Deny</string>
<string name="napplet_connected_apps_search_keywords">apps permissions signer napplet nsite webapp trust connected</string>
<string name="napplet_connected_app_trust_level">Signing trust level</string>
<string name="napplet_connected_app_capabilities">Capabilities</string>
<string name="napplet_connected_app_forget">Forget this app</string>
<string name="napplet_connected_app_op_overrides">Signing operation overrides</string>
<string name="napplet_connected_app_empty">No apps have connected yet.\n\nWhen a web app connects to your Nostr key, it will appear here.</string>
<!-- Relay Authentication (NIP-42) settings -->
<string name="relay_auth_settings_title">Relay Authentication</string>
<string name="relay_auth_search_keywords">auth authentication relay sign verify nip-42 identity</string>
<string name="relay_auth_global_policy">Global policy</string>
<string name="relay_auth_policy_always">Always authenticate</string>
<string name="relay_auth_policy_always_desc">Sign auth challenges for every relay that requests it</string>
<string name="relay_auth_policy_never">Never authenticate</string>
<string name="relay_auth_policy_never_desc">Ignore auth challenges from all relays</string>
<string name="relay_auth_policy_if_in_my_list">My relays only</string>
<string name="relay_auth_policy_if_in_my_list_desc">Only authenticate with relays in your relay list</string>
<string name="relay_auth_per_relay_overrides">Per-relay overrides</string>
<string name="relay_auth_no_overrides">No per-relay overrides — global policy applies everywhere</string>
<string name="relay_auth_decision_allow">Allow</string>
<string name="relay_auth_decision_deny">Deny</string>
<string name="relay_auth_remove_override">Remove override</string>
<string name="nip82_repository_label">Source: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Download</string>
@@ -25,9 +25,17 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL
import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant
import com.vitorpamplona.amethyst.commons.napplet.signers.toSignerOp
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -46,13 +54,10 @@ import kotlin.coroutines.cancellation.CancellationException
* Two capability-specific policies refine step 2/3:
* - **Per-use capabilities** ([NappletCapability.requiresPerUseConsent], i.e. [NappletCapability.VALUE])
* never auto-approve from a prior grant every payment is confirmed afresh, with the amount shown.
* - **Signer self-gating**: an identity read or a sign-as-user op ([NappletRequest.signsAsUser])
* is gated here only when the key lives in Amethyst (a [NostrSignerInternal]). Remote (NIP-46) and
* external (NIP-55) signers run their own per-request consent UI, so we defer to them rather than
* double-prompt. A standing DENY is still honored, and the applet must still have *declared* the
* capability. This is safe only because the napplet host runs foreground-only, so the signer's
* prompt appears in the clear context of the user interacting with that napplet (it can't be
* fired from the background).
* - All signer types internal, NIP-46 remote, and NIP-55 external are gated through Amethyst's
* consent UI before the operation reaches the signer. External signers add their own per-request
* prompt on top (double-prompting), ensuring the user can differentiate requests from different
* apps inside the external signer.
*
* Security invariants enforced here (never trusted from the applet): the signing identity is
* always the host's signer; the napplet only ever supplies an unsigned template the shell signs
@@ -71,12 +76,29 @@ class NappletBroker(
private val identityReads: NappletIdentityGateway? = null,
private val theme: NappletThemeGateway? = null,
private val notify: NappletNotifyGateway? = null,
private val signerLedger: NostrSignerPermissionLedger? = null,
private val nostrConnectPrompt: NostrConnectPrompt? = null,
private val signerConsentPrompt: NostrSignerConsentPrompt? = null,
) {
// Serializes the consent-prompt path so concurrent requests queue into one dialog at a time
// (see [authorizeWithConsent]). Only the prompt is held here; non-prompting paths and execute()
// run unserialized.
private val consentLock = Mutex()
// Serializes first-connect and per-op signer consent dialogs so concurrent signing requests
// queue into one dialog at a time rather than launching several dialogs simultaneously.
private val signerConsentLock = Mutex()
// In-memory session grants (AllowForSession): cleared when this broker instance is destroyed.
// Only accessed under signerConsentLock.
private val sessionAllows = mutableSetOf<String>()
// Apps whose first-connect dialog the user dismissed with Cancel this session.
// Cancelling means "not now" — we suppress re-prompting within the same session so a
// napplet that fires many requests doesn't show the dialog on every one.
// Only accessed under signerConsentLock.
private val sessionCancelled = mutableSetOf<String>()
/**
* Authorizes and runs [request] on behalf of [identity]. [declared] is the capability set the
* manifest's `requires` resolved to; a request outside it is refused before any prompt.
@@ -105,6 +127,13 @@ class NappletBroker(
return NappletResponse.Denied(capability, "Blocked by a standing denial.")
}
// Show the first-connect dialog if the app has no signer policy yet.
if (signerLedger != null && !signerLedger.hasPolicy(identity.coordinate)) {
if (!ensureConnected(identity, declared)) {
return NappletResponse.Denied(capability, "Connection not authorized.")
}
}
val authorized =
when {
// Keyboard/command action registration is a shell-mediated UI affordance, not key
@@ -112,8 +141,6 @@ class NappletBroker(
request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true
// Cosmetic/negotiation capabilities (theme) never prompt.
!capability.requiresConsent -> true
// Remote/external signers run their own per-request consent UI — defer to them.
signerSelfGates(request) -> true
// A standing allow short-circuits, except for per-use capabilities (e.g. payments).
ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true
else -> authorizeWithConsent(identity, capability, request)
@@ -121,6 +148,14 @@ class NappletBroker(
if (!authorized) return NappletResponse.Denied(capability, "The user declined.")
// Additional per-operation gate for signing/encryption.
if (signerLedger != null) {
val op = request.toSignerOp()
if (op != null && !authorizeSignerOp(identity, op, request)) {
return NappletResponse.Denied(capability, "Signing operation declined.")
}
}
return try {
execute(identity, request)
} catch (e: CancellationException) {
@@ -158,9 +193,6 @@ class NappletBroker(
grant.allowsExecution
}
/** Identity reads and sign-as-user ops are gated by us only when we hold the key; remote/external signers gate themselves. */
private fun signerSelfGates(request: NappletRequest): Boolean = (request.capability == NappletCapability.IDENTITY || request.signsAsUser) && signer !is NostrSignerInternal
/** Downgrades a grant to one-shot when the capability forbids persisting that scope (e.g. payments). */
private fun effectiveGrant(
capability: NappletCapability,
@@ -310,4 +342,106 @@ class NappletBroker(
} else {
tags + arrayOf(arrayOf("p", recipient))
}
/**
* Shows the first-connect "Connect to Nostr" dialog if no signer policy exists yet.
* On success, stores the chosen policy and bulk-grants all declared non-payment capabilities.
* Returns false if the user cancelled or blocked the app.
*/
private suspend fun ensureConnected(
identity: NappletIdentity,
declared: Set<NappletCapability>,
): Boolean =
signerConsentLock.withLock {
val sl = signerLedger ?: return@withLock true
// Re-check after acquiring lock: a sibling request may have set the policy while we waited.
if (sl.hasPolicy(identity.coordinate)) return@withLock true
// Suppress re-prompting if the user already cancelled this session.
if (identity.coordinate in sessionCancelled) return@withLock false
val prompt = nostrConnectPrompt ?: return@withLock true
when (val result = prompt.request(identity)) {
is AppConnectResult.Connected -> {
sl.setPolicy(identity.coordinate, result.policy)
// Bulk-grant non-payment capabilities only for non-paranoid policies.
// PARANOID users chose "ask me for everything" — leave the capability ledger
// empty so each capability prompts on first use.
if (result.policy != AppSignerPolicy.PARANOID) {
for (cap in declared) {
if (!cap.requiresPerUseConsent) {
ledger.record(identity, cap, GrantState.ALLOW_ALWAYS)
}
}
}
true
}
AppConnectResult.Blocked -> {
sl.setPolicy(identity.coordinate, AppSignerPolicy.PARANOID)
for (cap in declared) {
ledger.record(identity, cap, GrantState.DENY)
}
false
}
AppConnectResult.Cancelled -> {
sessionCancelled.add(identity.coordinate)
false
}
}
}
/**
* Gates a specific signing/encryption operation through the signer permission ledger.
* If the ledger says ASK, prompts the user and records their choice.
*/
private suspend fun authorizeSignerOp(
identity: NappletIdentity,
op: NostrSignerOp,
request: NappletRequest,
): Boolean =
signerConsentLock.withLock {
val sl = signerLedger ?: return@withLock true
// Session grants win immediately without touching storage.
if (op.key in sessionAllows) {
sl.updateLastUsed(identity.coordinate)
return@withLock true
}
when (sl.decide(identity.coordinate, op)) {
NostrOpDecision.ALLOW -> {
sl.updateLastUsed(identity.coordinate)
true
}
NostrOpDecision.DENY -> false
NostrOpDecision.ASK -> {
val prompt = signerConsentPrompt ?: return@withLock true
when (val grant = prompt.request(identity, op, request)) {
is SignerOpGrant.AllowAll -> {
sl.setPolicy(identity.coordinate, AppSignerPolicy.FULL_TRUST)
sl.updateLastUsed(identity.coordinate)
true
}
is SignerOpGrant.AllowForOp -> {
sl.setOpDecision(identity.coordinate, op, NostrOpDecision.ALLOW)
sl.updateLastUsed(identity.coordinate)
true
}
is SignerOpGrant.AllowForSession -> {
sessionAllows.add(op.key)
sl.updateLastUsed(identity.coordinate)
true
}
is SignerOpGrant.AllowUntil -> {
sl.setTimedOpDecision(identity.coordinate, op, NostrOpDecision.ALLOW, grant.expiresAt)
sl.updateLastUsed(identity.coordinate)
true
}
is SignerOpGrant.DenyForOp -> {
sl.setOpDecision(identity.coordinate, op, NostrOpDecision.DENY)
false
}
else -> grant.isAllowed
}
}
}
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet.signers
/**
* The user's top-level trust decision for one app's access to the internal Nostr signer.
* Set once on first connection; governs all future signing/encryption operations unless
* overridden by a per-operation [NostrOpDecision] in the [NostrSignerPermissionLedger].
*/
enum class AppSignerPolicy {
/** Auto-approve all signing and encryption operations (except payments, which always prompt). */
FULL_TRUST,
/**
* Auto-approve the most common operations: kind 1 short notes, kind 6 reposts, kind 7
* reactions, and all encrypt/decrypt operations; ask before anything else.
* A reasonable default for most apps.
*/
REASONABLE,
/** Prompt before every single signing or encryption operation. */
PARANOID,
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet.signers
/**
* A per-operation standing decision stored in [NostrSignerPermissionStore].
* Overrides [AppSignerPolicy] for the specific [NostrSignerOp] it is keyed to.
*/
enum class NostrOpDecision {
/** Automatically allow this operation without prompting. */
ALLOW,
/** Prompt the user on each request (default behavior). */
ASK,
/** Always deny this operation without prompting. */
DENY,
}
@@ -0,0 +1,120 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet.signers
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
// ---------------------------------------------------------------------------
// First-connect dialog
// ---------------------------------------------------------------------------
/** The user's response to the "Connect to Nostr" first-connection dialog. */
sealed interface AppConnectResult {
/** The user accepted and chose a trust level. */
data class Connected(
val policy: AppSignerPolicy,
) : AppConnectResult
/** The user chose to block this app permanently. */
data object Blocked : AppConnectResult
/** The user dismissed the dialog without making a choice. */
data object Cancelled : AppConnectResult
}
/**
* Shows the "Connect to Nostr" first-connection dialog for [identity] and suspends
* until the user makes a choice. The result drives the [AppSignerPolicy] stored in
* [NostrSignerPermissionLedger] and the bulk capability grant in [NappletBroker][com.vitorpamplona.amethyst.commons.napplet.NappletBroker].
*/
fun interface NostrConnectPrompt {
suspend fun request(identity: NappletIdentity): AppConnectResult
}
// ---------------------------------------------------------------------------
// Per-operation consent dialog
// ---------------------------------------------------------------------------
/**
* The user's response to a per-signing-operation consent dialog.
* The broker records any "remember" variant before returning [isAllowed].
*/
sealed interface SignerOpGrant {
/** Whether the in-flight request may proceed. */
val isAllowed: Boolean
/** Allow this one request; prompt again next time. */
data object AllowOnce : SignerOpGrant {
override val isAllowed = true
}
/** Allow and remember: don't ask again for [op]. */
data class AllowForOp(
val op: NostrSignerOp,
) : SignerOpGrant {
override val isAllowed = true
}
/** Allow for the current broker session only — not persisted across app restarts. */
data class AllowForSession(
val op: NostrSignerOp,
) : SignerOpGrant {
override val isAllowed = true
}
/** Allow and remember until [expiresAt] (Unix epoch seconds). */
data class AllowUntil(
val op: NostrSignerOp,
val expiresAt: Long,
) : SignerOpGrant {
override val isAllowed = true
}
/** Allow and upgrade to [AppSignerPolicy.FULL_TRUST] for all future requests. */
data object AllowAll : SignerOpGrant {
override val isAllowed = true
}
/** Deny this one request; prompt again next time. */
data object DenyOnce : SignerOpGrant {
override val isAllowed = false
}
/** Deny and remember: always deny [op]. */
data class DenyForOp(
val op: NostrSignerOp,
) : SignerOpGrant {
override val isAllowed = false
}
}
/**
* Prompts the user to authorize (or deny) a specific Nostr operation for [identity].
* Suspends until the user answers. [DenyOnce] is the safe default when dismissed.
*/
fun interface NostrSignerConsentPrompt {
suspend fun request(
identity: NappletIdentity,
op: NostrSignerOp,
request: NappletRequest,
): SignerOpGrant
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet.signers
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
/**
* A Nostr-specific cryptographic operation that requires the internal signer.
* Used to gate signing and encryption independently, per app.
*/
sealed interface NostrSignerOp {
/** Sign (and optionally publish) an event of the given [kind]. */
data class SignKind(
val kind: Int,
) : NostrSignerOp
/** Encrypt a message (NIP-04 or NIP-44). */
data object Encrypt : NostrSignerOp
/** Decrypt a message (NIP-04 or NIP-44). */
data object Decrypt : NostrSignerOp
/** Stable storage key for this operation, used as a DataStore key fragment. */
val key: String
get() =
when (this) {
is SignKind -> "sign:$kind"
Encrypt -> "encrypt"
Decrypt -> "decrypt"
}
companion object {
fun fromKey(key: String): NostrSignerOp? =
when {
key == "encrypt" -> Encrypt
key == "decrypt" -> Decrypt
key.startsWith("sign:") -> key.removePrefix("sign:").toIntOrNull()?.let { SignKind(it) }
else -> null
}
}
}
/**
* Maps a [NappletRequest] to the [NostrSignerOp] it represents, or `null` if the request
* does not involve signing or encryption.
*/
fun NappletRequest.toSignerOp(): NostrSignerOp? =
when (this) {
is NappletRequest.Publish -> NostrSignerOp.SignKind(kind)
is NappletRequest.SignEvent -> NostrSignerOp.SignKind(kind)
is NappletRequest.PublishEncrypted -> NostrSignerOp.Encrypt
else -> null
}
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet.signers
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* The per-app Nostr signer permission ledger. Decides whether a signing or encryption
* operation should auto-allow, auto-deny, or ask the user, by consulting:
*
* 1. Per-operation overrides ([NostrSignerPermissionStore.loadOpDecision]) these always win.
* 2. The app's [AppSignerPolicy] trust level ([NostrSignerPermissionStore.loadPolicy]).
* 3. The built-in "reasonable" set (kind 1/6/7 + encrypt are auto-allowed) when policy is [AppSignerPolicy.REASONABLE].
*
* When no policy has been set (`null`), [decide] returns [NostrOpDecision.ASK], which triggers the
* first-connect dialog in the broker.
*/
class NostrSignerPermissionLedger(
val store: NostrSignerPermissionStore,
) {
/**
* `true` when a trust level has been set for [coordinate] i.e. the "Connect to Nostr"
* dialog has already been shown and the user made a choice.
*/
suspend fun hasPolicy(coordinate: String): Boolean = store.loadPolicy(coordinate) != null
/**
* The authorization verdict for ([coordinate], [op]) based on stored policy + per-op overrides.
* Checks expiry: if a timed override has passed [now], it is cleared and the policy-level decision
* is returned instead.
*/
suspend fun decide(
coordinate: String,
op: NostrSignerOp,
now: Long = TimeUtils.now(),
): NostrOpDecision {
store.loadOpDecision(coordinate, op)?.let { decision ->
val expiresAt = store.loadOpExpiry(coordinate, op)
if (expiresAt != null && now > expiresAt) {
store.clearOpDecision(coordinate, op)
store.clearOpExpiry(coordinate, op)
} else {
return decision
}
}
return when (store.loadPolicy(coordinate)) {
AppSignerPolicy.FULL_TRUST -> NostrOpDecision.ALLOW
AppSignerPolicy.PARANOID -> NostrOpDecision.ASK
AppSignerPolicy.REASONABLE -> reasonableDecision(op)
null -> NostrOpDecision.ASK
}
}
/** Stores the user's chosen trust level for [coordinate]. */
suspend fun setPolicy(
coordinate: String,
policy: AppSignerPolicy,
) = store.storePolicy(coordinate, policy)
/** Stores a per-operation override for ([coordinate], [op]). */
suspend fun setOpDecision(
coordinate: String,
op: NostrSignerOp,
decision: NostrOpDecision,
) = store.storeOpDecision(coordinate, op, decision)
/** Stores a time-bound per-operation override that expires at [expiresAt] (Unix epoch seconds). */
suspend fun setTimedOpDecision(
coordinate: String,
op: NostrSignerOp,
decision: NostrOpDecision,
expiresAt: Long,
) {
store.storeOpDecision(coordinate, op, decision)
store.storeOpExpiry(coordinate, op, expiresAt)
}
/** Records the current time as the last-used timestamp for [coordinate]. */
suspend fun updateLastUsed(
coordinate: String,
now: Long = TimeUtils.now(),
) = store.storeLastUsed(coordinate, now)
/** The last-used timestamp for [coordinate], or `null` if never used. */
suspend fun lastUsed(coordinate: String): Long? = store.loadLastUsed(coordinate)
/** Removes a per-operation override (and any expiry), reverting to the policy-level decision. */
suspend fun revokeOpDecision(
coordinate: String,
op: NostrSignerOp,
) {
store.clearOpDecision(coordinate, op)
store.clearOpExpiry(coordinate, op)
}
/** Removes all signer permissions for [coordinate] — trust level and all per-op overrides. */
suspend fun revokeAll(coordinate: String) = store.clearAll(coordinate)
private fun reasonableDecision(op: NostrSignerOp): NostrOpDecision =
when (op) {
is NostrSignerOp.SignKind ->
when (op.kind) {
1 -> NostrOpDecision.ALLOW
6 -> NostrOpDecision.ALLOW
7 -> NostrOpDecision.ALLOW
else -> NostrOpDecision.ASK
}
NostrSignerOp.Encrypt -> NostrOpDecision.ALLOW
NostrSignerOp.Decrypt -> NostrOpDecision.ASK
}
}
@@ -0,0 +1,188 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet.signers
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
/**
* Persistence for the per-app internal-signer permissions: each app's [AppSignerPolicy]
* trust level and any [NostrOpDecision] per-operation overrides. Keyed by napplet
* coordinate (e.g. `"<authorPubKey>:<identifier>"`).
*
* The Android implementation uses one DataStore file per coordinate so loading one app's
* permissions never reads another app's data essential at scale (1000s of apps).
* Tests use [InMemoryNostrSignerPermissionStore].
*/
interface NostrSignerPermissionStore {
/** The stored trust level for [coordinate], or `null` if no policy has been set yet. */
suspend fun loadPolicy(coordinate: String): AppSignerPolicy?
/** Persist [policy] as the trust level for [coordinate]. */
suspend fun storePolicy(
coordinate: String,
policy: AppSignerPolicy,
)
/** Remove the stored trust level for [coordinate]. */
suspend fun clearPolicy(coordinate: String)
/** The stored per-operation decision for ([coordinate], [op]), or `null` if none set. */
suspend fun loadOpDecision(
coordinate: String,
op: NostrSignerOp,
): NostrOpDecision?
/** Persist [decision] for ([coordinate], [op]). */
suspend fun storeOpDecision(
coordinate: String,
op: NostrSignerOp,
decision: NostrOpDecision,
)
/** Remove the per-operation decision for ([coordinate], [op]). */
suspend fun clearOpDecision(
coordinate: String,
op: NostrSignerOp,
)
/** All stored trust levels, keyed by coordinate — for the permissions-management screen. */
suspend fun allPolicies(): Map<String, AppSignerPolicy>
/**
* All per-operation overrides for [coordinate], keyed by [NostrSignerOp.key] for the
* permissions-management screen.
*/
suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision>
/** Remove all signer permissions (policy + all op overrides) for [coordinate]. */
suspend fun clearAll(coordinate: String)
/** The stored expiry timestamp (Unix epoch seconds) for [op] of [coordinate], or `null` = no expiry. */
suspend fun loadOpExpiry(
coordinate: String,
op: NostrSignerOp,
): Long?
/** Persist [expiresAt] (Unix epoch seconds) for ([coordinate], [op]). */
suspend fun storeOpExpiry(
coordinate: String,
op: NostrSignerOp,
expiresAt: Long,
)
/** Remove the expiry for ([coordinate], [op]). */
suspend fun clearOpExpiry(
coordinate: String,
op: NostrSignerOp,
)
/** The last time (Unix epoch seconds) any auto-approved operation ran for [coordinate], or `null`. */
suspend fun loadLastUsed(coordinate: String): Long?
/** Persist [epochSeconds] as the last-used time for [coordinate]. */
suspend fun storeLastUsed(
coordinate: String,
epochSeconds: Long,
)
}
/** A thread-safe in-memory [NostrSignerPermissionStore] for tests and ephemeral sessions. */
class InMemoryNostrSignerPermissionStore : NostrSignerPermissionStore {
private val lock = KmpLock()
private val policies = mutableMapOf<String, AppSignerPolicy>()
private val opDecisions = mutableMapOf<String, MutableMap<String, NostrOpDecision>>()
private val opExpiries = mutableMapOf<String, MutableMap<String, Long>>()
private val lastUsedMap = mutableMapOf<String, Long>()
override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? = lock.withLock { policies[coordinate] }
override suspend fun storePolicy(
coordinate: String,
policy: AppSignerPolicy,
) = lock.withLock { policies[coordinate] = policy }
override suspend fun clearPolicy(coordinate: String) =
lock.withLock {
policies.remove(coordinate)
Unit
}
override suspend fun loadOpDecision(
coordinate: String,
op: NostrSignerOp,
): NostrOpDecision? = lock.withLock { opDecisions[coordinate]?.get(op.key) }
override suspend fun storeOpDecision(
coordinate: String,
op: NostrSignerOp,
decision: NostrOpDecision,
) = lock.withLock {
opDecisions.getOrPut(coordinate) { mutableMapOf() }[op.key] = decision
}
override suspend fun clearOpDecision(
coordinate: String,
op: NostrSignerOp,
) = lock.withLock {
opDecisions[coordinate]?.remove(op.key)
Unit
}
override suspend fun allPolicies(): Map<String, AppSignerPolicy> = lock.withLock { policies.toMap() }
override suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision> = lock.withLock { opDecisions[coordinate]?.toMap() ?: emptyMap() }
override suspend fun clearAll(coordinate: String) =
lock.withLock {
policies.remove(coordinate)
opDecisions.remove(coordinate)
opExpiries.remove(coordinate)
lastUsedMap.remove(coordinate)
Unit
}
override suspend fun loadOpExpiry(
coordinate: String,
op: NostrSignerOp,
): Long? = lock.withLock { opExpiries[coordinate]?.get(op.key) }
override suspend fun storeOpExpiry(
coordinate: String,
op: NostrSignerOp,
expiresAt: Long,
) = lock.withLock { opExpiries.getOrPut(coordinate) { mutableMapOf() }[op.key] = expiresAt }
override suspend fun clearOpExpiry(
coordinate: String,
op: NostrSignerOp,
) = lock.withLock {
opExpiries[coordinate]?.remove(op.key)
Unit
}
override suspend fun loadLastUsed(coordinate: String): Long? = lock.withLock { lastUsedMap[coordinate] }
override suspend fun storeLastUsed(
coordinate: String,
epochSeconds: Long,
) = lock.withLock { lastUsedMap[coordinate] = epochSeconds }
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayauth
/**
* Per-relay [RelayAuthDecision] overrides. Used by [RelayAuthPermissionLedger] after
* checking the global [RelayAuthPolicy]. The Android implementation uses a single
* shared DataStore file so a relay URL lookup never touches another relay's data.
*/
interface RelayAuthPermissionStore {
/** The stored per-relay override for [relayUrl], or `null` if no override is set. */
suspend fun loadDecision(relayUrl: String): RelayAuthDecision?
/** Persist [decision] as the override for [relayUrl]. */
suspend fun storeDecision(
relayUrl: String,
decision: RelayAuthDecision,
)
/** Remove the per-relay override for [relayUrl]. */
suspend fun clearDecision(relayUrl: String)
/** All per-relay overrides — for the relay auth settings screen. */
suspend fun allDecisions(): Map<String, RelayAuthDecision>
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayauth
/**
* The default policy for authenticating with relays (NIP-42).
* Per-relay overrides stored in [RelayAuthPermissionStore] always take precedence.
*/
enum class RelayAuthPolicy {
/** Authenticate with every relay that requests it. Equivalent to current behavior. */
ALWAYS,
/** Never authenticate; do not reveal your identity to relay operators via NIP-42. */
NEVER,
/** Authenticate only with relays explicitly listed in the user's relay list. */
IF_IN_MY_LIST,
}
/** A persisted per-relay override decision. */
enum class RelayAuthDecision {
ALLOW,
DENY,
}
@@ -405,16 +405,29 @@ class NappletBrokerTest {
}
@Test
fun externalSignerDefersIdentityWithoutPrompting() =
fun externalSignerIsPromptedByAmethystForIdentity() =
runTest {
val prompt = ScriptedPrompt(GrantState.DENY) // would block if we asked
val prompt = ScriptedPrompt(GrantState.DENY)
val external = FakeExternalSigner("dd".repeat(32))
val response =
broker(prompt, signer = external).handle(applet, NappletRequest.GetPublicKey, allDeclared)
assertIs<NappletResponse.Denied>(response) // Amethyst asked and user denied
assertEquals(1, prompt.calls) // Amethyst prompts all signer types
}
@Test
fun externalSignerAllowedByAmethystReturnsPublicKey() =
runTest {
val prompt = ScriptedPrompt(GrantState.ALLOW_ONCE)
val external = FakeExternalSigner("dd".repeat(32))
val response =
broker(prompt, signer = external).handle(applet, NappletRequest.GetPublicKey, allDeclared)
assertEquals(NappletResponse.PublicKey(external.pubKey), response)
assertEquals(0, prompt.calls) // deferred to the external signer; we did not prompt
assertEquals(1, prompt.calls) // Amethyst prompted before the external signer
}
@Test
@@ -246,7 +246,7 @@ class KtorRelayTest {
com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator(
client = client,
scope = scope,
) { template ->
) { _, template ->
listOf(signer.sign(template))
}
try {
@@ -51,7 +51,12 @@ object EmptyIAuthStatus : IAuthStatus {
class RelayAuthenticator(
val client: INostrClient,
val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
val signWithAllLoggedInUsers: suspend (EventTemplate<RelayAuthEvent>) -> List<RelayAuthEvent>,
/**
* Signs the auth template for every currently-logged-in account and returns the signed events.
* The [relay] parameter allows callers to check per-relay auth policy before signing.
* Returns an empty list to skip authentication for this relay.
*/
val signWithAllLoggedInUsers: suspend (relay: NormalizedRelayUrl, EventTemplate<RelayAuthEvent>) -> List<RelayAuthEvent>,
) : IAuthStatus {
// Connection callbacks fire on the per-relay OkHttp dispatcher thread, so
// this state is mutated concurrently — LargeCache wraps a platform-tuned
@@ -93,7 +98,7 @@ class RelayAuthenticator(
// so an uncaught throwable here crashes the whole app. Swallow + log them.
try {
val ev = RelayAuthEvent.build(relay.url, msg.challenge)
signWithAllLoggedInUsers(ev).forEach { authEvent ->
signWithAllLoggedInUsers(relay.url, ev).forEach { authEvent ->
// only send replies to new challenges to avoid infinite loop:
if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) {
relay.sendIfConnected(AuthCmd(authEvent))
@@ -168,6 +168,8 @@ class JacksonMapper {
),
)
fun toJsonPretty(template: EventTemplate<*>): String = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(template)
fun toJson(event: ArrayNode): String = mapper.writeValueAsString(event)
fun toJson(event: ObjectNode?): String = mapper.writeValueAsString(event)
@@ -90,7 +90,7 @@ class RelayAuthenticatorConcurrencyTest {
val authenticator =
RelayAuthenticator(
client = client,
signWithAllLoggedInUsers = { emptyList() },
signWithAllLoggedInUsers = { _, _ -> emptyList() },
)
val listener =
client.captured
@@ -102,7 +102,7 @@ class RelayAuthenticatorTimeoutTest {
RelayAuthenticator(
client = client,
scope = scope,
signWithAllLoggedInUsers = {
signWithAllLoggedInUsers = { _, _ ->
throw SignerExceptions.TimedOutException("User didn't accept or reject in time.")
},
)
@@ -130,7 +130,7 @@ class RelayAuthenticatorTimeoutTest {
RelayAuthenticator(
client = client,
scope = scope,
signWithAllLoggedInUsers = { template ->
signWithAllLoggedInUsers = { _, _ ->
listOf(RelayAuthEvent.create(relay.url, "challenge-123", signer))
},
)