diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 242aa6698e..72bdeefe46 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -431,6 +431,20 @@ android:excludeFromRecents="true" android:launchMode="singleTop" android:theme="@android:style/Theme.Translucent.NoTitleBar" /> + + + + event.iconBlob()?.let { IconBlob(it, event.servers()) } else -> null } + +/** + * A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon + * has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the + * WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache. + * + * Early-returns null when [url] is blank or has no parseable host — this early return is stable + * for a given [url] (the host either always parses or never does), so composition structure is + * preserved across recompositions. + */ +@Composable +fun rememberWebAppIconModel(url: String): String? { + val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) } +} + +/** + * A Coil model for the bundled icon of an napplet or nsite identified by [author] and + * [identifier]. Tries the napplet manifest (kinds 15129 / 35129) first, then falls back to the + * nsite manifest (kinds 15128 / 35128). Returns null until the blob lands on disk. + */ +@Composable +fun rememberManifestIconModel( + author: String, + identifier: String, +): String? { + val nappletCoord = + remember(author, identifier) { + if (identifier.isEmpty()) "${RootNappletEvent.KIND}:$author:" else "${NamedNappletEvent.KIND}:$author:$identifier" + } + val nsiteCoord = + remember(author, identifier) { + if (identifier.isEmpty()) "${RootSiteEvent.KIND}:$author:" else "${NamedSiteEvent.KIND}:$author:$identifier" + } + return rememberNappletIconModel(nappletCoord) ?: rememberNappletIconModel(nsiteCoord) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index a9b0a91781..ea25cf75ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListR import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -267,6 +268,7 @@ class AccountSettings( var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720, var callMaxBitrateBps: Int = 1_500_000, val callsEnabled: MutableStateFlow = MutableStateFlow(true), + val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST), ) : EphemeralChatRepository, PublicChatListRepository { val saveable = MutableStateFlow(AccountSettingsUpdater(null)) @@ -1476,6 +1478,13 @@ class AccountSettings( saveAccountSettings() } } + + fun changeDefaultRelayAuthPolicy(policy: RelayAuthPolicy) { + if (defaultRelayAuthPolicy.value != policy) { + defaultRelayAuthPolicy.tryEmit(policy) + saveAccountSettings() + } + } } @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt index 65a5c80c45..87d197065b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt @@ -68,7 +68,8 @@ class TrustedRelayListState( .stateIn( scope, SharingStarted.Eagerly, - emptySet(), + // Synchronously seed public tags from the backup; private tags may be absent on first boot. + settings.backupTrustedRelayList?.let { decryptionCache.cachedRelays(it) } ?: emptySet(), ) suspend fun saveRelayList(trustedRelays: List): TrustedRelayListEvent { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt new file mode 100644 index 0000000000..fa64ed6378 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.flow.first +import java.io.File +import java.security.MessageDigest + +/** + * Per-coordinate DataStore-backed [NostrSignerPermissionStore]. One small `.preferences_pb` + * file per app (keyed by a SHA-256 prefix of the coordinate) so loading or saving one app's + * permissions never touches another app's data — essential at scale with 1000s of apps. + * + * The coordinate is stored inside each file under [KEY_COORDINATE] so [allPolicies] can + * reverse-map file → coordinate without scanning the filesystem. + */ +class DataStoreNostrSignerPermissionStore( + private val filesDir: File, +) : NostrSignerPermissionStore { + constructor(context: Context) : this(context.applicationContext.filesDir) + + private val cache = LargeCache>() + + private fun storeFor(coordinate: String): DataStore { + val file = File(filesDir, "datastore/nsp_${hash(coordinate)}.preferences_pb") + return cache.getOrCreate(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + } + + override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? { + val raw = storeFor(coordinate).data.first()[KEY_POLICY] ?: return null + return runCatching { AppSignerPolicy.valueOf(raw) }.getOrNull() + } + + override suspend fun storePolicy( + coordinate: String, + policy: AppSignerPolicy, + ) { + storeFor(coordinate).edit { + it[KEY_COORDINATE] = coordinate + it[KEY_POLICY] = policy.name + } + } + + override suspend fun clearPolicy(coordinate: String) { + storeFor(coordinate).edit { it.remove(KEY_POLICY) } + } + + override suspend fun loadOpDecision( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision? { + val raw = storeFor(coordinate).data.first()[opKey(op)] ?: return null + return runCatching { NostrOpDecision.valueOf(raw) }.getOrNull() + } + + override suspend fun storeOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) { + storeFor(coordinate).edit { + it[KEY_COORDINATE] = coordinate + it[opKey(op)] = decision.name + } + } + + override suspend fun clearOpDecision( + coordinate: String, + op: NostrSignerOp, + ) { + storeFor(coordinate).edit { it.remove(opKey(op)) } + } + + override suspend fun allPolicies(): Map { + val dir = File(filesDir, "datastore") + if (!dir.exists()) return emptyMap() + val result = mutableMapOf() + for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) { + val ds = + cache.getOrCreate(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue + val policy = loadPolicy(coordinate) ?: continue + result[coordinate] = policy + } + return result + } + + override suspend fun allOpDecisions(coordinate: String): Map { + val prefs = storeFor(coordinate).data.first() + val result = mutableMapOf() + for ((key, value) in prefs.asMap()) { + val name = key.name + if (!name.startsWith(OP_PREFIX)) continue + // Skip expiry metadata keys — they end with the expiry suffix + if (name.endsWith(OP_EXPIRY_SUFFIX)) continue + val opKey = name.removePrefix(OP_PREFIX) + val decision = runCatching { NostrOpDecision.valueOf(value as String) }.getOrNull() ?: continue + result[opKey] = decision + } + return result + } + + override suspend fun loadOpExpiry( + coordinate: String, + op: NostrSignerOp, + ): Long? { + val raw = storeFor(coordinate).data.first()[opExpiryKey(op)] ?: return null + return raw.toLongOrNull() + } + + override suspend fun storeOpExpiry( + coordinate: String, + op: NostrSignerOp, + expiresAt: Long, + ) { + storeFor(coordinate).edit { it[opExpiryKey(op)] = expiresAt.toString() } + } + + override suspend fun clearOpExpiry( + coordinate: String, + op: NostrSignerOp, + ) { + storeFor(coordinate).edit { it.remove(opExpiryKey(op)) } + } + + override suspend fun loadLastUsed(coordinate: String): Long? { + val raw = storeFor(coordinate).data.first()[KEY_LAST_USED] ?: return null + return raw.toLongOrNull() + } + + override suspend fun storeLastUsed( + coordinate: String, + epochSeconds: Long, + ) { + storeFor(coordinate).edit { it[KEY_LAST_USED] = epochSeconds.toString() } + } + + override suspend fun clearAll(coordinate: String) { + storeFor(coordinate).edit { it.clear() } + } + + private fun opKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}") + + private fun opExpiryKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}$OP_EXPIRY_SUFFIX") + + companion object { + private val KEY_COORDINATE = stringPreferencesKey("coordinate") + private val KEY_POLICY = stringPreferencesKey("policy") + private val KEY_LAST_USED = stringPreferencesKey("lastused") + private const val OP_PREFIX = "op:" + private const val OP_EXPIRY_SUFFIX = ":exp" + + private fun hash(coordinate: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray()) + return digest.take(8).joinToString("") { "%02x".format(it) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 06bc236b3f..fc282ec8e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry @@ -75,7 +76,11 @@ class NappletBrokerService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM. - private val ledger by lazy { NappletPermissionLedger(DataStoreNappletPermissionStore(applicationContext)) } + private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } + + // Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only + // instantiated in the main process where the signer lives; never touched from :napplet. + private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } // Per-applet sandboxed key-value store (namespaced by coordinate inside the impl). private val storage by lazy { DataStoreNappletStorage(applicationContext) } @@ -323,6 +328,7 @@ class NappletBrokerService : Service() { // Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes // through Tor when asked + active, and falls back to clearnet otherwise. httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) }, + signerLedger = signerLedger, ).broker() cachedBroker = account to broker return broker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt new file mode 100644 index 0000000000..a5c2f15811 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme + +class NappletConnectActivity : ComponentActivity() { + private var token: String? = null + private var decided = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val token = intent.getStringExtra(NappletConnectCoordinator.EXTRA_TOKEN) + this.token = token + val info = token?.let { NappletConnectCoordinator.infoFor(it) } + if (token == null || info == null) { + finish() + return + } + + setContent { + AmethystTheme { + NappletConnectScreen( + info = info, + onConnect = { policy -> + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy)) + finish() + }, + onBlock = { + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Blocked) + finish() + }, + onCancel = { + decided = true + NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled) + finish() + }, + ) + } + } + } + + override fun finish() { + if (!decided) token?.let { NappletConnectCoordinator.cancel(it) } + super.finish() + } +} + +@Composable +private fun NappletConnectScreen( + info: NappletConnectInfo, + onConnect: (AppSignerPolicy) -> Unit, + onBlock: () -> Unit, + onCancel: () -> Unit, +) { + var selected by remember { mutableStateOf(AppSignerPolicy.REASONABLE) } + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.9f + + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(max = maxHeight), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(vertical = 24.dp), + ) { + // Centered header: icon + app name + connect subtitle + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val isBrowser = info.coordinate.startsWith("browser:") + FavoriteAppIcon( + app = + if (isBrowser) { + FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl) + } else { + FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl) + }, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp), + ) + Text( + info.appletTitle, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.napplet_connect_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + info.domain, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(12.dp)) + + Text( + stringResource(R.string.napplet_connect_how_handle), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) + Spacer(Modifier.height(8.dp)) + + // Trust level options + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + PolicyOption( + selected = selected == AppSignerPolicy.FULL_TRUST, + icon = "❤", + label = stringResource(R.string.napplet_policy_full_trust), + description = stringResource(R.string.napplet_policy_full_trust_desc), + onClick = { selected = AppSignerPolicy.FULL_TRUST }, + ) + PolicyOption( + selected = selected == AppSignerPolicy.REASONABLE, + icon = "👍", + label = stringResource(R.string.napplet_policy_reasonable), + description = stringResource(R.string.napplet_policy_reasonable_desc), + onClick = { selected = AppSignerPolicy.REASONABLE }, + ) + PolicyOption( + selected = selected == AppSignerPolicy.PARANOID, + icon = "🕶", + label = stringResource(R.string.napplet_policy_paranoid), + description = stringResource(R.string.napplet_policy_paranoid_desc), + onClick = { selected = AppSignerPolicy.PARANOID }, + ) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.cancel)) + } + Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.napplet_connect_button)) + } + } + + OutlinedButton( + onClick = onBlock, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text( + stringResource(R.string.napplet_connect_block, info.domain), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +@Composable +private fun PolicyOption( + selected: Boolean, + icon: String, + label: String, + description: String, + onClick: () -> Unit, +) { + val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface + + Surface( + modifier = + Modifier + .fillMaxWidth() + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = bgColor, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(icon, style = MaterialTheme.typography.headlineSmall) + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (selected) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt new file mode 100644 index 0000000000..0467578f2e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Everything the "Connect to Nostr" dialog needs to render. */ +data class NappletConnectInfo( + val appletTitle: String, + val coordinate: String, + val domain: String, + val iconUrl: String? = null, +) + +/** + * Bridges the broker to the "Connect to Nostr" first-connect UI. Suspends in [requestConnect]; + * the Activity resolves the deferred with the user's choice. + * A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant. + */ +object NappletConnectCoordinator { + private class Pending( + val info: NappletConnectInfo, + val deferred: CompletableDeferred, + ) + + private val pending = ConcurrentHashMap() + + suspend fun requestConnect( + context: Context, + info: NappletConnectInfo, + ): AppConnectResult { + val token = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[token] = Pending(info, deferred) + + context.startActivity( + Intent(context, NappletConnectActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token), + ) + + return try { + deferred.await() + } finally { + pending.remove(token) + } + } + + fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info + + fun complete( + token: String, + result: AppConnectResult, + ) { + pending[token]?.deferred?.complete(result) + } + + fun cancel(token: String) { + pending[token]?.deferred?.complete(AppConnectResult.Cancelled) + } + + const val EXTRA_TOKEN = "napplet_connect_token" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt index 04ed6b60fb..94875be170 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt @@ -25,17 +25,34 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState import com.vitorpamplona.amethyst.ui.theme.AmethystTheme @@ -93,49 +110,132 @@ private fun NappletConsentDialog( onDecision: (GrantState) -> Unit, onDismiss: () -> Unit, ) { - AlertDialog( + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f + + Dialog( onDismissRequest = onDismiss, - title = { Text(info.appletTitle) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(info.operationSummary) - Text( - stringResource(R.string.napplet_consent_capability, info.capabilityLabel), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - info.coordinate, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - confirmButton = { - Column(modifier = Modifier.fillMaxWidth()) { - if (info.allowAlways) { - TextButton( - onClick = { onDecision(GrantState.ALLOW_ALWAYS) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_consent_allow_always)) } + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(max = maxHeight), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(vertical = 24.dp), + ) { + // Centered header: icon + app name + capability category + coordinate + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val isBrowser = info.coordinate.startsWith("browser:") + FavoriteAppIcon( + app = + if (isBrowser) { + FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl) + } else { + FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl) + }, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp), + ) + Text( + info.appletTitle, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + Text( + info.capabilityLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } - TextButton( - onClick = { onDecision(GrantState.ALLOW_ONCE) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_consent_allow_once)) } - } - }, - dismissButton = { - Column(modifier = Modifier.fillMaxWidth()) { - TextButton( - onClick = { onDecision(GrantState.DENY) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_consent_deny_always)) } - TextButton( + + // Operation detail box (may include content preview) + if (info.operationSummary.isNotBlank()) { + Spacer(Modifier.height(12.dp)) + Surface( + modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + SelectionContainer { + Text( + info.operationSummary, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + if (info.allowAlways) { + Button( + onClick = { onDecision(GrantState.ALLOW_ALWAYS) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_always)) + } + OutlinedButton( + onClick = { onDecision(GrantState.ALLOW_ONCE) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_once)) + } + } else { + Button( + onClick = { onDecision(GrantState.ALLOW_ONCE) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_once)) + } + } + + Spacer(Modifier.height(4.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + OutlinedButton( onClick = { onDecision(GrantState.ASK) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - ) { Text(stringResource(R.string.napplet_consent_not_now)) } + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text( + stringResource(R.string.napplet_consent_not_now), + style = MaterialTheme.typography.bodyMedium, + ) + } + OutlinedButton( + onClick = { onDecision(GrantState.DENY) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text( + stringResource(R.string.napplet_consent_deny_always), + style = MaterialTheme.typography.bodyMedium, + ) + } } - }, - ) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt index a069f1f0c1..e2a0daa355 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt @@ -35,6 +35,7 @@ data class NappletConsentInfo( val operationSummary: String, /** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */ val allowAlways: Boolean, + val iconUrl: String? = null, ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index c7ff90ac5f..a65238c703 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.quartz.lightning.LnInvoiceUtil @@ -41,13 +43,21 @@ class NappletConsentSummary( capability: NappletCapability, request: NappletRequest, ): NappletConsentInfo { - val title = identity.identifier.ifBlank { context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) } + val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) + val (title, iconUrl) = + if (identity.authorPubKey == "browser") { + val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + host to BrowserIconRegistry.iconModelFor(host) + } else { + resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) + } return NappletConsentInfo( appletTitle = title, coordinate = identity.coordinate, capabilityLabel = context.getString(capability.labelRes()), operationSummary = summaryFor(request), allowAlways = capability.canGrantAlways, + iconUrl = iconUrl, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt new file mode 100644 index 0000000000..067661e93e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletManifestLookup.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent + +/** + * Looks up the best-effort human title and icon URL for a napplet or nsite from the local cache. + * Falls back to the d-identifier or [untitled] when no manifest is cached. + */ +fun resolveNappletMeta( + author: String, + identifier: String, + untitled: String, +): Pair { + val events = + Amethyst.instance.cache + .filter( + Filter( + kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND, RootSiteEvent.KIND, NamedSiteEvent.KIND), + authors = listOf(author), + ), + ).mapNotNull { it.event } + val match = + events.firstOrNull { ev -> + when (ev) { + is NamedNappletEvent -> ev.identifier() == identifier + is RootNappletEvent -> identifier.isEmpty() + is NamedSiteEvent -> ev.identifier() == identifier + is RootSiteEvent -> identifier.isEmpty() + else -> false + } + } + val title = + when (match) { + is NappletManifest -> match.title() + is RootSiteEvent -> match.title() + is NamedSiteEvent -> match.title() + else -> null + }?.ifBlank { null } ?: identifier.ifBlank { untitled } + val iconUrl = + when (match) { + is NappletManifest -> match.icon() + is RootSiteEvent -> match.icon() + is NamedSiteEvent -> match.icon() + else -> null + }?.ifBlank { null } + return title to iconUrl +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt new file mode 100644 index 0000000000..6fa8bad403 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -0,0 +1,325 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme +import com.vitorpamplona.quartz.utils.TimeUtils + +class NappletSignerConsentActivity : ComponentActivity() { + private var token: String? = null + private var decided = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val token = intent.getStringExtra(NappletSignerConsentCoordinator.EXTRA_TOKEN) + this.token = token + val info = token?.let { NappletSignerConsentCoordinator.infoFor(it) } + if (token == null || info == null) { + finish() + return + } + + setContent { + AmethystTheme { + NappletSignerConsentDialog( + info = info, + onGrant = { grant -> + decided = true + NappletSignerConsentCoordinator.complete(token, grant) + finish() + }, + onDismiss = { + decided = true + NappletSignerConsentCoordinator.cancel(token) + finish() + }, + ) + } + } + } + + override fun finish() { + if (!decided) token?.let { NappletSignerConsentCoordinator.cancel(it) } + super.finish() + } +} + +@Composable +private fun NappletSignerConsentDialog( + info: NappletSignerConsentInfo, + onGrant: (SignerOpGrant) -> Unit, + onDismiss: () -> Unit, +) { + var showRawData by remember { mutableStateOf(false) } + var showMoreOptions by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(max = maxHeight), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column( + modifier = + Modifier + .verticalScroll(scrollState) + .padding(vertical = 24.dp), + ) { + // Centered header: icon + title + description + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val isBrowser = info.coordinate.startsWith("browser:") + FavoriteAppIcon( + app = + if (isBrowser) { + FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl) + } else { + FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl) + }, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp), + ) + Text( + info.appletTitle, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.napplet_consent_wants_to, info.operationSummary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank() + if (hasContent) { + Spacer(Modifier.height(12.dp)) + Surface( + modifier = + Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(12.dp)) { + if (info.contentPreview.isNotBlank()) { + Text( + "“${info.contentPreview}”", + style = MaterialTheme.typography.bodySmall, + ) + } + if (info.rawData.isNotBlank()) { + if (showRawData) { + Spacer(Modifier.height(8.dp)) + Box(modifier = Modifier.horizontalScroll(rememberScrollState())) { + SelectionContainer { + Text( + info.rawData, + style = + MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + softWrap = false, + ) + } + } + } + TextButton( + onClick = { showRawData = !showRawData }, + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp), + ) { + Text( + if (showRawData) { + stringResource(R.string.napplet_consent_hide_event) + } else { + stringResource(R.string.napplet_consent_show_event) + }, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + // Primary: always allow this op + Button( + onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_always)) + } + + // Secondary: allow just once + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowOnce) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_once)) + } + + // "More options" toggle: session and time-bound grants + TextButton( + onClick = { showMoreOptions = !showMoreOptions }, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + if (showMoreOptions) { + stringResource(R.string.napplet_consent_fewer_options) + } else { + stringResource(R.string.napplet_consent_more_options) + }, + style = MaterialTheme.typography.bodyMedium, + ) + Icon( + if (showMoreOptions) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + + if (showMoreOptions) { + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_session)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_24h)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_30d)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowAll) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_all)) + } + } + + Spacer(Modifier.height(4.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + OutlinedButton( + onClick = { onGrant(SignerOpGrant.DenyOnce) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.napplet_signer_deny_once)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary)) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt new file mode 100644 index 0000000000..9febb3b0f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant +import kotlinx.coroutines.CompletableDeferred +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Everything the per-operation consent dialog needs to render. */ +data class NappletSignerConsentInfo( + val appletTitle: String, + val coordinate: String, + val op: NostrSignerOp, + val operationSummary: String, + /** Short excerpt shown in the dialog body (≤ 160 chars). */ + val contentPreview: String, + /** + * Full raw content for the "See more" toggle — event JSON for sign/encrypt operations, + * decrypted plaintext for decrypt (Amethyst decrypts first, then asks permission to expose). + */ + val rawData: String = "", + val iconUrl: String? = null, +) + +/** + * Bridges the broker to the per-operation signer consent UI. + * A dismissed dialog resolves to [SignerOpGrant.DenyOnce] — fails closed. + */ +object NappletSignerConsentCoordinator { + private class Pending( + val info: NappletSignerConsentInfo, + val deferred: CompletableDeferred, + ) + + private val pending = ConcurrentHashMap() + + suspend fun requestConsent( + context: Context, + info: NappletSignerConsentInfo, + ): SignerOpGrant { + val token = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[token] = Pending(info, deferred) + + context.startActivity( + Intent(context, NappletSignerConsentActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token), + ) + + return try { + deferred.await() + } finally { + pending.remove(token) + } + } + + fun infoFor(token: String): NappletSignerConsentInfo? = pending[token]?.info + + fun complete( + token: String, + grant: SignerOpGrant, + ) { + pending[token]?.deferred?.complete(grant) + } + + fun cancel(token: String) { + pending[token]?.deferred?.complete(SignerOpGrant.DenyOnce) + } + + const val EXTRA_TOKEN = "napplet_signer_consent_token" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt new file mode 100644 index 0000000000..9c7bb146d9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** Human-readable label for a [NostrSignerOp]. */ +fun NostrSignerOp.label(context: Context): String = + when (this) { + is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind) + NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt) + NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt) + } + +/** Builds the [NappletSignerConsentInfo] needed by the per-op consent dialog. */ +fun buildSignerConsentInfo( + context: Context, + identity: NappletIdentity, + op: NostrSignerOp, + request: NappletRequest, +): NappletSignerConsentInfo { + val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) + val (title, iconUrl) = + if (identity.authorPubKey == "browser") { + val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + host to BrowserIconRegistry.iconModelFor(host) + } else { + resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) + } + val summary = op.label(context) + val preview = + when (request) { + is NappletRequest.Publish -> request.content.take(160).trim() + is NappletRequest.SignEvent -> request.content.take(160).trim() + is NappletRequest.PublishEncrypted -> request.content.take(160).trim() + else -> "" + } + val rawData = + when (request) { + is NappletRequest.Publish -> + JacksonMapper.toJsonPretty(EventTemplate(TimeUtils.now(), request.kind, request.tags, request.content)) + is NappletRequest.SignEvent -> + JacksonMapper.toJsonPretty(EventTemplate(request.createdAt, request.kind, request.tags, request.content)) + is NappletRequest.PublishEncrypted -> { + val node = JacksonMapper.mapper.createObjectNode() + node.put("kind", request.kind) + node.put("recipient", request.recipient) + node.put("encryption", request.encryption) + val tagsNode = node.putArray("tags") + for (tag in request.tags) { + val tagNode = tagsNode.addArray() + for (item in tag) tagNode.add(item) + } + node.put("content", request.content) + JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node) + } + else -> "" + } + return NappletSignerConsentInfo( + appletTitle = title, + coordinate = identity.coordinate, + op = op, + operationSummary = summary, + contentPreview = preview, + rawData = rawData, + iconUrl = iconUrl, + ) +} + +/** Creates a [NappletConnectInfo] for the first-connect dialog. */ +fun buildConnectInfo( + context: Context, + identity: NappletIdentity, +): NappletConnectInfo { + val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) + val (title, iconUrl) = + if (identity.authorPubKey == "browser") { + val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + host to BrowserIconRegistry.iconModelFor(host) + } else { + resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) + } + val domain = + if (identity.authorPubKey == "browser") { + OmniboxInput.hostOf(identity.identifier) ?: identity.identifier + } else { + identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" } + } + return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index cee3ccde5e..89bf3ecf31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -41,10 +41,17 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.napplet.NappletConnectCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentSummary import com.vitorpamplona.amethyst.napplet.NappletNotificationStore +import com.vitorpamplona.amethyst.napplet.NappletSignerConsentCoordinator +import com.vitorpamplona.amethyst.napplet.buildConnectInfo +import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -72,6 +79,7 @@ class AccountNappletGateways( private val ledger: NappletPermissionLedger, private val storage: NappletStorage, private val httpClient: (useProxy: Boolean) -> OkHttpClient, + private val signerLedger: NostrSignerPermissionLedger? = null, ) { private val consentSummary = NappletConsentSummary(context) @@ -129,7 +137,23 @@ class AccountNappletGateways( } } - return NappletBroker(account.signer, ledger, consent, relay, storage, wallet, resource, upload = upload, identityReads = identityReads, theme = theme, notify = notify) + val connectPrompt = + NostrConnectPrompt { identity -> + NappletConnectCoordinator.requestConnect( + context = context, + info = buildConnectInfo(context, identity), + ) + } + + val signerConsent = + NostrSignerConsentPrompt { identity, op, request -> + NappletSignerConsentCoordinator.requestConsent( + context = context, + info = buildSignerConsentInfo(context, identity, op, request), + ) + } + + return NappletBroker(account.signer, ledger, consent, signerLedger = signerLedger, nostrConnectPrompt = connectPrompt, signerConsentPrompt = signerConsent, relay = relay, storage = storage, wallet = wallet, resource = resource, upload = upload, identityReads = identityReads, theme = theme, notify = notify) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt index 9c5c15f1cc..f17945b416 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt @@ -25,8 +25,10 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull @Composable fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator) @@ -36,17 +38,32 @@ fun RelayAuthSubscription( accountViewModel: AccountViewModel, dataSource: AuthCoordinator, ) { - // different screens get different states - // even if they are tracking the same tag. + val account = accountViewModel.account + val state = remember(accountViewModel) { - ScreenAuthAccount(accountViewModel.account) + ScreenAuthAccount(account) } - DisposableEffect(state) { + val ledger = + remember(accountViewModel) { + RelayAuthPermissionLedger( + store = Amethyst.instance.relayAuthPermissionStore, + globalPolicy = { account.settings.defaultRelayAuthPolicy.value }, + isInMyRelayList = { relayUrl -> + val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false + normalized !in account.blockedRelayList.flow.value && + normalized in account.trustedRelays.flow.value + }, + ) + } + + DisposableEffect(state, ledger) { dataSource.subscribe(state) + dataSource.subscribeLedger(ledger) onDispose { dataSource.unsubscribe(state) + dataSource.unsubscribeLedger(ledger) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 80c11d79d5..e8e1252cea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -43,30 +44,56 @@ class AuthCoordinator( NostrSignerSync() } + @Volatile private var relayLedgers: List = emptyList() + + fun subscribeLedger(ledger: RelayAuthPermissionLedger) { + synchronized(this) { relayLedgers = relayLedgers + ledger } + } + + fun unsubscribeLedger(ledger: RelayAuthPermissionLedger) { + synchronized(this) { relayLedgers = relayLedgers - ledger } + } + val receiver = RelayAuthenticator( client, scope, - signWithAllLoggedInUsers = { authTemplate -> - val results = - authWithAccounts.distinct().mapNotNull { - if (it.signer.isWriteable()) { - try { - it.signer.sign(authTemplate) - } catch (e: Exception) { - Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) - null + signWithAllLoggedInUsers = { relayUrl, authTemplate -> + val currentLedgers = relayLedgers + val shouldAuth = + if (currentLedgers.isEmpty()) { + true + } else { + var allow = false + for (ledger in currentLedgers) { + if (ledger.decide(relayUrl.url) == RelayAuthDecision.ALLOW) { + allow = true + break } - } else { - null } + allow } - // Always auth, even with random keys - if (!results.isEmpty()) { - results + if (shouldAuth) { + // distinct() returns Set (the key type U of ListWithUniqueSetCache) + val results = + authWithAccounts.distinct().mapNotNull { + if (it.signer.isWriteable()) { + try { + it.signer.sign(authTemplate) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) + null + } + } else { + null + } + } + + // Always auth, even with random keys + if (results.isNotEmpty()) results else listOf(tempAccount.sign(authTemplate)) } else { - listOf(tempAccount.sign(authTemplate)) + emptyList() } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt new file mode 100644 index 0000000000..67e2b22817 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import kotlinx.coroutines.flow.first +import java.io.File +import java.security.MessageDigest + +/** + * Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides + * live in one `datastore/relay_auth.preferences_pb` file; a SHA-256 prefix of the URL is the + * key so the URL itself is safe in the file (stored separately for reverse-lookup in [allDecisions]). + */ +class DataStoreRelayAuthPermissionStore( + private val filesDir: File, +) : RelayAuthPermissionStore { + constructor(context: Context) : this(context.applicationContext.filesDir) + + private val store: DataStore by lazy { + PreferenceDataStoreFactory.create( + produceFile = { File(filesDir, "datastore/relay_auth.preferences_pb") }, + ) + } + + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { + val raw = store.data.first()[decisionKey(relayUrl)] ?: return null + return runCatching { RelayAuthDecision.valueOf(raw) }.getOrNull() + } + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + store.edit { + it[urlKey(relayUrl)] = relayUrl + it[decisionKey(relayUrl)] = decision.name + } + } + + override suspend fun clearDecision(relayUrl: String) { + store.edit { + it.remove(decisionKey(relayUrl)) + it.remove(urlKey(relayUrl)) + } + } + + override suspend fun allDecisions(): Map { + val prefs = store.data.first() + val result = mutableMapOf() + for ((key, value) in prefs.asMap()) { + val name = key.name + if (!name.startsWith(DECISION_PREFIX)) continue + val hash = name.removePrefix(DECISION_PREFIX) + val url = prefs[stringPreferencesKey("$URL_PREFIX$hash")] ?: continue + val decision = runCatching { RelayAuthDecision.valueOf(value as String) }.getOrNull() ?: continue + result[url] = decision + } + return result + } + + private fun decisionKey(relayUrl: String) = stringPreferencesKey("$DECISION_PREFIX${hash(relayUrl)}") + + private fun urlKey(relayUrl: String) = stringPreferencesKey("$URL_PREFIX${hash(relayUrl)}") + + companion object { + private const val DECISION_PREFIX = "allow:" + private const val URL_PREFIX = "url:" + + private fun hash(relayUrl: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(relayUrl.toByteArray()) + return digest.take(8).joinToString("") { "%02x".format(it) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt new file mode 100644 index 0000000000..e7c5c69ca2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy + +/** + * Decides whether Amethyst should authenticate with a given relay (NIP-42). + * + * Decision order: + * 1. Per-relay override stored in [store] — always wins. + * 2. [globalPolicy]: + * - [RelayAuthPolicy.ALWAYS] → [RelayAuthDecision.ALLOW] + * - [RelayAuthPolicy.NEVER] → [RelayAuthDecision.DENY] + * - [RelayAuthPolicy.IF_IN_MY_LIST] → [RelayAuthDecision.ALLOW] iff [isInMyRelayList] returns true. + */ +class RelayAuthPermissionLedger( + val store: RelayAuthPermissionStore, + val globalPolicy: () -> RelayAuthPolicy, + val isInMyRelayList: (String) -> Boolean = { false }, +) { + /** The authorization verdict for [relayUrl]. */ + suspend fun decide(relayUrl: String): RelayAuthDecision { + store.loadDecision(relayUrl)?.let { return it } + return when (globalPolicy()) { + RelayAuthPolicy.ALWAYS -> RelayAuthDecision.ALLOW + RelayAuthPolicy.NEVER -> RelayAuthDecision.DENY + RelayAuthPolicy.IF_IN_MY_LIST -> + if (isInMyRelayList(relayUrl)) RelayAuthDecision.ALLOW else RelayAuthDecision.DENY + } + } + + /** Stores a per-relay override for [relayUrl]. */ + suspend fun setDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) = store.storeDecision(relayUrl, decision) + + /** Removes the per-relay override for [relayUrl], reverting to the global policy. */ + suspend fun clearDecision(relayUrl: String) = store.clearDecision(relayUrl) + + /** All per-relay overrides — for the settings screen. */ + suspend fun allDecisions(): Map = store.allDecisions() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 9cbfccfa28..87084e060f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.Live import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicPlaylistsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.ConnectedAppsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler @@ -146,6 +147,7 @@ class RelaySubscriptionsCoordinator( val onePodcast = OnePodcastFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) val napplets = NappletsFilterAssembler(client) + val connectedApps = ConnectedAppsFilterAssembler(client) val nsites = NsitesFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 862aa1d516..8a977675fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -159,7 +159,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicPlaylistsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicTracksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicPlaylistScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicTrackScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletPermissionsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppDetailScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen @@ -183,6 +184,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.PublicChatsScre import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth.RelayAuthSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen @@ -337,7 +339,9 @@ fun BuildNavigation( composableFromEnd { FavoriteAppsScreen(accountViewModel, nav) } composableFromEndArgs { WebAppScreen(it.url, accountViewModel, nav) } composableFromEndArgs { NostrAppScreen(it.coordinate, accountViewModel, nav) } - composableFromEnd { NappletPermissionsScreen(accountViewModel, nav) } + composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } + composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } + composableFromEnd { RelayAuthSettingsScreen(accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } composableFromEnd { CalendarCollectionsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index e4817e0995..6bad216548 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -105,7 +105,13 @@ sealed class Route { val coordinate: String, ) : Route() - @Serializable object NappletPermissions : Route() + @Serializable object ConnectedApps : Route() + + @Serializable object RelayAuthSettings : Route() + + @Serializable data class ConnectedAppDetail( + val coordinate: String, + ) : Route() @Serializable data class SoftwareAppDetail( val kind: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index f8bef8becd..a7674daec0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -311,7 +311,7 @@ class AccountViewModel( RelayAuthenticator( newClient, customScope, - signWithAllLoggedInUsers = { authTemplate -> + signWithAllLoggedInUsers = { _, authTemplate -> if (account.signer.isWriteable()) { try { listOf(account.signer.sign(authTemplate)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt new file mode 100644 index 0000000000..b74352c169 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -0,0 +1,567 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState +import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel +import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel +import com.vitorpamplona.amethyst.napplet.descriptionRes +import com.vitorpamplona.amethyst.napplet.labelRes +import com.vitorpamplona.amethyst.napplet.resolveNappletMeta +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import com.vitorpamplona.amethyst.commons.R as CommonsR + +private data class ConnectedAppDetailState( + val title: String, + val coordinate: String, + val iconUrl: String?, + val signerPolicy: AppSignerPolicy?, + val opOverrides: Map, + val capabilities: List>, +) + +@Composable +fun ConnectedAppDetailScreen( + coordinate: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } + val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } + val untitled = stringResource(CommonsR.string.napplet_untitled) + + var state by remember { mutableStateOf(null) } + var reload by remember { mutableIntStateOf(0) } + val scope = rememberCoroutineScope() + + LaunchedEffect(coordinate, reload) { + state = + withContext(Dispatchers.Default) { + loadDetailState(coordinate, capabilityLedger, signerLedger, untitled) + } + } + + fun mutate(block: suspend () -> Unit) { + scope.launch { + block() + reload++ + } + } + + val identity = + remember(coordinate) { + NappletIdentity( + authorPubKey = coordinate.substringBefore(':'), + identifier = coordinate.substringAfter(':', ""), + ) + } + + Scaffold( + topBar = { TopBarWithBackButton(state?.title ?: coordinate.substringAfter(':', "").ifBlank { coordinate.take(12) + "…" }, nav) }, + ) { padding -> + val current = state + if (current == null) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Scaffold + } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // App identity header + AppIdentityHeader(current) + + // Signing trust level section + if (current.signerPolicy != null) { + SectionHeader(stringResource(R.string.napplet_connected_app_trust_level)) + PolicyPicker( + selected = current.signerPolicy, + onSelect = { newPolicy -> + mutate { signerLedger.setPolicy(coordinate, newPolicy) } + }, + ) + } + + // Signing operation overrides section + if (current.opOverrides.isNotEmpty()) { + SectionHeader(stringResource(R.string.napplet_connected_app_op_overrides)) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(4.dp)) { + current.opOverrides.entries.forEachIndexed { index, (opKey, decision) -> + if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + OpOverrideRow( + opKey = opKey, + decision = decision, + onRevoke = { mutate { signerLedger.revokeOpDecision(coordinate, NostrSignerOp.fromKey(opKey) ?: return@mutate) } }, + ) + } + } + } + } + + // Capabilities section + if (current.capabilities.isNotEmpty()) { + SectionHeader(stringResource(R.string.napplet_connected_app_capabilities)) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(4.dp)) { + current.capabilities.forEachIndexed { index, (cap, grant) -> + if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + CapabilityDetailRow( + capability = cap, + grant = grant, + onSetGrant = { newGrant -> + mutate { + if (newGrant == null) { + capabilityLedger.revoke(identity, cap) + } else { + capabilityLedger.record(identity, cap, newGrant) + } + } + }, + ) + } + } + } + } + + // Forget button + Spacer(Modifier.size(8.dp)) + Button( + onClick = { + mutate { + signerLedger.revokeAll(coordinate) + capabilityLedger.revokeAll(identity) + } + nav.popBack() + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer), + ) { + Icon(MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.napplet_connected_app_forget)) + } + } + } +} + +@Composable +private fun AppIdentityHeader(state: ConnectedAppDetailState) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val isBrowserEntry = state.coordinate.startsWith("browser:") + val author = state.coordinate.substringBefore(':') + val identifier = state.coordinate.substringAfter(':', "") + val iconModel: String? + val appForIcon: FavoriteApp + if (isBrowserEntry) { + iconModel = rememberWebAppIconModel(identifier) + appForIcon = FavoriteApp.WebApp(identifier, state.title, 0L) + } else { + iconModel = rememberManifestIconModel(author, identifier) + appForIcon = FavoriteApp.NostrApp(state.coordinate, state.title, 0L, state.iconUrl) + } + FavoriteAppIcon( + app = appForIcon, + iconModel = iconModel, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(48.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + state.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val domain = + if (author == "browser") { + identifier + .removePrefix("https://") + .removePrefix("http://") + .substringBefore('/') + .ifBlank { identifier } + } else { + identifier.ifBlank { author.take(12) + "…" } + } + Text( + domain, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +private fun PolicyPicker( + selected: AppSignerPolicy, + onSelect: (AppSignerPolicy) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + PolicyCard( + selected = selected == AppSignerPolicy.FULL_TRUST, + symbol = MaterialSymbols.Favorite, + label = stringResource(R.string.napplet_policy_full_trust), + description = stringResource(R.string.napplet_policy_full_trust_desc), + onClick = { onSelect(AppSignerPolicy.FULL_TRUST) }, + ) + PolicyCard( + selected = selected == AppSignerPolicy.REASONABLE, + symbol = MaterialSymbols.Shield, + label = stringResource(R.string.napplet_policy_reasonable), + description = stringResource(R.string.napplet_policy_reasonable_desc), + onClick = { onSelect(AppSignerPolicy.REASONABLE) }, + ) + PolicyCard( + selected = selected == AppSignerPolicy.PARANOID, + symbol = MaterialSymbols.Lock, + label = stringResource(R.string.napplet_policy_paranoid), + description = stringResource(R.string.napplet_policy_paranoid_desc), + onClick = { onSelect(AppSignerPolicy.PARANOID) }, + ) + } +} + +@Composable +private fun OpOverrideRow( + opKey: String, + decision: NostrOpDecision, + onRevoke: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + NostrSignerOp.fromKey(opKey)?.opLabel() ?: opKey, + style = MaterialTheme.typography.bodyMedium, + ) + } + Text( + decision.decisionLabel(), + style = MaterialTheme.typography.labelSmall, + color = + when (decision) { + NostrOpDecision.DENY -> MaterialTheme.colorScheme.error + NostrOpDecision.ALLOW -> MaterialTheme.colorScheme.primary + NostrOpDecision.ASK -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + IconButton(onClick = onRevoke) { + Icon( + MaterialSymbols.Delete, + contentDescription = stringResource(R.string.napplet_signer_permissions_revoke_all), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun CapabilityDetailRow( + capability: NappletCapability, + grant: GrantState, + onSetGrant: (GrantState?) -> Unit, +) { + var showDialog by remember { mutableStateOf(false) } + + if (showDialog) { + CapabilityPermissionDialog( + capability = capability, + current = grant, + onSetGrant = { newGrant -> + showDialog = false + onSetGrant(newGrant) + }, + onDismiss = { showDialog = false }, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { showDialog = true } + .padding(horizontal = 12.dp, vertical = 12.dp), + ) { + Icon( + capability.symbol(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.size(12.dp)) + Column(Modifier.weight(1f)) { + Text(stringResource(capability.labelRes()), style = MaterialTheme.typography.bodyMedium) + Text( + stringResource(capability.descriptionRes()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.size(8.dp)) + Text( + when (grant) { + GrantState.ALLOW_ALWAYS -> stringResource(R.string.napplet_consent_allow_always) + GrantState.DENY -> stringResource(R.string.napplet_consent_deny_always) + else -> stringResource(R.string.napplet_permissions_ask_each_time) + }, + style = MaterialTheme.typography.labelSmall, + color = + when (grant) { + GrantState.ALLOW_ALWAYS -> MaterialTheme.colorScheme.primary + GrantState.DENY -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun CapabilityPermissionDialog( + capability: NappletCapability, + current: GrantState, + onSetGrant: (GrantState?) -> Unit, + onDismiss: () -> Unit, +) { + val initial = + when (current) { + GrantState.ALLOW_ALWAYS -> GrantState.ALLOW_ALWAYS + GrantState.DENY -> GrantState.DENY + else -> GrantState.ASK + } + var selected by remember { mutableStateOf(initial) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(capability.labelRes())) }, + text = { + Column { + GrantOption( + label = stringResource(R.string.napplet_permissions_ask_each_time), + selected = selected == GrantState.ASK, + onClick = { selected = GrantState.ASK }, + ) + if (!capability.requiresPerUseConsent) { + GrantOption( + label = stringResource(R.string.napplet_consent_allow_always), + selected = selected == GrantState.ALLOW_ALWAYS, + onClick = { selected = GrantState.ALLOW_ALWAYS }, + ) + } + GrantOption( + label = stringResource(R.string.napplet_consent_deny_always), + selected = selected == GrantState.DENY, + onClick = { selected = GrantState.DENY }, + ) + } + }, + confirmButton = { + TextButton( + onClick = { onSetGrant(if (selected == GrantState.ASK) null else selected) }, + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +@Composable +private fun GrantOption( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + RadioButton(selected = selected, onClick = onClick) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun NostrSignerOp.opLabel(): String = + when (this) { + is NostrSignerOp.SignKind -> stringResource(R.string.napplet_op_sign_kind, kind) + NostrSignerOp.Encrypt -> stringResource(R.string.napplet_op_encrypt) + NostrSignerOp.Decrypt -> stringResource(R.string.napplet_op_decrypt) + } + +@Composable +private fun NostrOpDecision.decisionLabel(): String = + when (this) { + NostrOpDecision.ALLOW -> stringResource(R.string.napplet_decision_allow) + NostrOpDecision.ASK -> stringResource(R.string.napplet_decision_ask) + NostrOpDecision.DENY -> stringResource(R.string.napplet_decision_deny) + } + +private suspend fun loadDetailState( + coordinate: String, + capabilityLedger: NappletPermissionLedger, + signerLedger: NostrSignerPermissionLedger, + untitled: String, +): ConnectedAppDetailState { + val author = coordinate.substringBefore(':') + val identifier = coordinate.substringAfter(':', "") + val identity = NappletIdentity(authorPubKey = author, identifier = identifier) + + val allGrants = capabilityLedger.allPersistedGrants() + val capGrants = + allGrants[coordinate] + ?.entries + ?.sortedBy { it.key.ordinal } + ?.map { it.key to it.value } + ?: emptyList() + val signerPolicy = signerLedger.store.loadPolicy(coordinate) + val opOverrides = signerLedger.store.allOpDecisions(coordinate) + + val (title, iconUrl) = + if (author == "browser") { + val host = OmniboxInput.hostOf(identifier) ?: identifier + host to BrowserIconRegistry.iconModelFor(host) + } else { + resolveNappletMeta(author, identifier, untitled) + } + return ConnectedAppDetailState( + title = title, + coordinate = coordinate, + iconUrl = iconUrl, + signerPolicy = signerPolicy, + opOverrides = opOverrides, + capabilities = capGrants, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt new file mode 100644 index 0000000000..e32255cd30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -0,0 +1,388 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel +import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.ConnectedAppsFilterAssemblerSubscription +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.vitorpamplona.amethyst.commons.R as CommonsR + +/** Author placeholder used by the browser permission path — not a real pubkey. */ +private const val BROWSER_AUTHOR = "browser" + +private data class ConnectedAppEntry( + val coordinate: String, + val signerPolicy: AppSignerPolicy?, +) + +@Composable +fun ConnectedAppsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } + val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } + + var items by remember { mutableStateOf?>(null) } + var nappletAuthors by remember { mutableStateOf>(emptySet()) } + + LaunchedEffect(Unit) { + val initial = + withContext(Dispatchers.Default) { + loadConnectedApps(capabilityLedger, signerLedger) + } + items = initial + // Only include real pubkeys (not the "browser" sentinel) in the relay subscription. + nappletAuthors = + initial + .map { it.coordinate.substringBefore(':') } + .filter { it != BROWSER_AUTHOR } + .toSet() + } + + ConnectedAppsFilterAssemblerSubscription(accountViewModel, nappletAuthors) + + Scaffold( + topBar = { TopBarWithBackButton(stringResource(R.string.napplet_permissions_title), nav) }, + ) { padding -> + val current = items + when { + current == null -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + current.isEmpty() -> + Box(Modifier.fillMaxSize().padding(padding).padding(32.dp), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + MaterialSymbols.Apps, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(56.dp), + ) + Text( + stringResource(R.string.napplet_connected_app_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + + else -> { + val untitled = stringResource(CommonsR.string.napplet_untitled) + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(current, key = { it.coordinate }) { entry -> + ConnectedAppCard( + entry = entry, + untitled = untitled, + onClick = { nav.nav(Route.ConnectedAppDetail(entry.coordinate)) }, + ) + } + } + } + } + } +} + +@Composable +private fun rememberManifestEvent( + author: String, + identifier: String, +): Event? { + val nappletCoord = + remember(author, identifier) { + if (identifier.isEmpty()) "${RootNappletEvent.KIND}:$author:" else "${NamedNappletEvent.KIND}:$author:$identifier" + } + val nsiteCoord = + remember(author, identifier) { + if (identifier.isEmpty()) "${RootSiteEvent.KIND}:$author:" else "${NamedSiteEvent.KIND}:$author:$identifier" + } + val nappletNote = remember(nappletCoord) { LocalCache.checkGetOrCreateAddressableNote(nappletCoord) } ?: return null + val nsiteNote = remember(nsiteCoord) { LocalCache.checkGetOrCreateAddressableNote(nsiteCoord) } ?: return null + val nappletState by nappletNote + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val nsiteState by nsiteNote + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + return nappletState.note.event ?: nsiteState.note.event +} + +@Composable +private fun ConnectedAppCard( + entry: ConnectedAppEntry, + untitled: String, + onClick: () -> Unit, +) { + val author = remember(entry.coordinate) { entry.coordinate.substringBefore(':') } + if (author == BROWSER_AUTHOR) { + val url = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") } + BrowserAppCard(url = url, entry = entry, onClick = onClick) + } else { + NappletAppCard(author = author, entry = entry, untitled = untitled, onClick = onClick) + } +} + +/** Card for a web app permission entry — the user visited this origin in the sandboxed browser. */ +@Composable +private fun BrowserAppCard( + url: String, + entry: ConnectedAppEntry, + onClick: () -> Unit, +) { + val domain = + remember(url) { + url + .removePrefix("https://") + .removePrefix("http://") + .substringBefore('/') + .ifBlank { url } + } + + val iconModel = rememberWebAppIconModel(url) + + ConnectedAppCardLayout( + app = FavoriteApp.WebApp(url, domain, 0L), + iconModel = iconModel, + title = domain, + subtitle = url, + npub = null, + signerPolicy = entry.signerPolicy, + onClick = onClick, + ) +} + +/** Card for a napplet / nsite permission entry — resolves title and icon from the live manifest. */ +@Composable +private fun NappletAppCard( + author: String, + entry: ConnectedAppEntry, + untitled: String, + onClick: () -> Unit, +) { + val identifier = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") } + val kind = if (identifier.isEmpty()) RootNappletEvent.KIND else NamedNappletEvent.KIND + val fullCoordinate = remember(entry.coordinate) { "$kind:$author:$identifier" } + + val iconModel = rememberManifestIconModel(author, identifier) + val event = rememberManifestEvent(author, identifier) + val title = + when (event) { + is NappletManifest -> event.title() + is RootSiteEvent -> event.title() + is NamedSiteEvent -> event.title() + else -> null + }?.ifBlank { null } ?: identifier.ifBlank { untitled } + val iconUrl = + when (event) { + is NappletManifest -> event.icon() + is RootSiteEvent -> event.icon() + is NamedSiteEvent -> event.icon() + else -> null + }?.ifBlank { null } + + val npub = remember(author) { runCatching { NPub.create(author) }.getOrDefault(author.take(12) + "…") } + val domain = identifier.ifBlank { author.take(12) + "…" } + + ConnectedAppCardLayout( + app = FavoriteApp.NostrApp(fullCoordinate, title, 0L, iconUrl), + iconModel = iconModel, + title = title, + subtitle = domain, + npub = npub, + signerPolicy = entry.signerPolicy, + onClick = onClick, + ) +} + +@Composable +private fun ConnectedAppCardLayout( + app: FavoriteApp, + iconModel: Any?, + title: String, + subtitle: String, + npub: String?, + signerPolicy: AppSignerPolicy?, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FavoriteAppIcon( + app = app, + iconModel = iconModel, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(48.dp), + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (npub != null) { + Text( + npub, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (signerPolicy != null) { + SuggestionChip( + onClick = {}, + label = { + Text( + signerPolicy.shortLabel(), + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Composable +private fun AppSignerPolicy.shortLabel(): String = + when (this) { + AppSignerPolicy.FULL_TRUST -> stringResource(R.string.napplet_policy_full_trust) + AppSignerPolicy.REASONABLE -> stringResource(R.string.napplet_policy_reasonable) + AppSignerPolicy.PARANOID -> stringResource(R.string.napplet_policy_paranoid) + } + +private suspend fun loadConnectedApps( + capabilityLedger: NappletPermissionLedger, + signerLedger: NostrSignerPermissionLedger, +): List { + val capGrants = capabilityLedger.allPersistedGrants() + val signerPolicies = signerLedger.store.allPolicies() + val allCoordinates = (capGrants.keys + signerPolicies.keys).toSet() + return allCoordinates + .map { coordinate -> + ConnectedAppEntry( + coordinate = coordinate, + signerPolicy = signerPolicies[coordinate], + ) + }.sortedBy { it.coordinate } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt new file mode 100644 index 0000000000..66bce77ba8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets + +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability + +internal fun NappletCapability.symbol(): MaterialSymbol = + when (this) { + NappletCapability.SHELL -> MaterialSymbols.Tune + NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle + NappletCapability.KEYS -> MaterialSymbols.Key + NappletCapability.RELAY -> MaterialSymbols.Public + NappletCapability.STORAGE -> MaterialSymbols.Storage + NappletCapability.VALUE -> MaterialSymbols.Bolt + NappletCapability.RESOURCE -> MaterialSymbols.Language + NappletCapability.UPLOAD -> MaterialSymbols.Upload + NappletCapability.THEME -> MaterialSymbols.Image + NappletCapability.NOTIFY -> MaterialSymbols.Notifications + NappletCapability.INC -> MaterialSymbols.SwapHoriz + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt deleted file mode 100644 index 7637ae5a49..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt +++ /dev/null @@ -1,347 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.napplet.NappletCapability -import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity -import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState -import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger -import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore -import com.vitorpamplona.amethyst.napplet.descriptionRes -import com.vitorpamplona.amethyst.napplet.labelRes -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent -import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest -import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import com.vitorpamplona.amethyst.commons.R as CommonsR - -/** One napplet's persisted permission grants, ready to render. */ -private data class NappletGrantsUi( - val identity: NappletIdentity, - val title: String, - val capabilities: List>, -) - -@Composable -fun NappletPermissionsScreen( - accountViewModel: AccountViewModel, - nav: INav, -) { - val context = LocalContext.current - val ledger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) } - val untitled = stringResource(CommonsR.string.napplet_untitled) - - var items by remember { mutableStateOf?>(null) } - var reload by remember { mutableIntStateOf(0) } - - LaunchedEffect(reload) { - items = withContext(Dispatchers.Default) { loadGrants(ledger, untitled) } - } - - val scope = rememberCoroutineScope() - - fun mutate(block: suspend () -> Unit) { - scope.launch { - block() - reload++ - } - } - - Scaffold( - topBar = { TopBarWithBackButton(stringResource(R.string.napplet_permissions), nav) }, - ) { padding -> - val current = items - when { - current == null -> - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - - current.isEmpty() -> EmptyState(Modifier.fillMaxSize().padding(padding)) - - else -> - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(current, key = { it.identity.coordinate }) { napplet -> - NappletPermissionCard( - napplet = napplet, - onSetAllowed = { cap, allowed -> - mutate { ledger.record(napplet.identity, cap, if (allowed) GrantState.ALLOW_ALWAYS else GrantState.DENY) } - }, - onRevoke = { cap -> mutate { ledger.revoke(napplet.identity, cap) } }, - onForget = { mutate { ledger.revokeAll(napplet.identity) } }, - ) - } - } - } - } -} - -@Composable -private fun EmptyState(modifier: Modifier) { - Box(modifier, contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(32.dp), - ) { - Icon( - MaterialSymbols.Shield, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(56.dp), - ) - Text( - stringResource(R.string.napplet_permissions_empty), - style = MaterialTheme.typography.titleMedium, - ) - Text( - stringResource(R.string.napplet_permissions_empty_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun NappletPermissionCard( - napplet: NappletGrantsUi, - onSetAllowed: (NappletCapability, Boolean) -> Unit, - onRevoke: (NappletCapability) -> Unit, - onForget: () -> Unit, -) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(44.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - MaterialSymbols.Apps, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(24.dp), - ) - } - } - Spacer(Modifier.size(12.dp)) - Column(Modifier.weight(1f)) { - Text( - napplet.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - napplet.identity.authorPubKey.take(12) + "…", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - - napplet.capabilities.forEach { (cap, grant) -> - CapabilityRow( - capability = cap, - grant = grant, - onSetAllowed = { onSetAllowed(cap, it) }, - onRevoke = { onRevoke(cap) }, - ) - } - - TextButton( - onClick = onForget, - modifier = Modifier.align(Alignment.End), - ) { - Icon(MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(6.dp)) - Text(stringResource(R.string.napplet_permissions_forget)) - } - } - } -} - -@Composable -private fun CapabilityRow( - capability: NappletCapability, - grant: GrantState, - onSetAllowed: (Boolean) -> Unit, - onRevoke: () -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), - ) { - Icon( - capability.symbol(), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) - Spacer(Modifier.size(12.dp)) - Column(Modifier.weight(1f)) { - Text(stringResource(capability.labelRes()), style = MaterialTheme.typography.bodyLarge) - Text( - stringResource(capability.descriptionRes()), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (capability.requiresPerUseConsent) { - // Payments only ever persist a DENY; the user can clear it to allow per-payment prompts again. - Text( - stringResource(R.string.napplet_permissions_blocked), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.error, - ) - } else { - Switch( - checked = grant == GrantState.ALLOW_ALWAYS, - onCheckedChange = onSetAllowed, - ) - } - - Spacer(Modifier.size(4.dp)) - IconButton(onClick = onRevoke) { - Icon( - MaterialSymbols.Block, - contentDescription = stringResource(R.string.napplet_permissions_revoke), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - } -} - -private suspend fun loadGrants( - ledger: NappletPermissionLedger, - untitled: String, -): List = - ledger - .allPersistedGrants() - .map { (coordinate, caps) -> - val author = coordinate.substringBefore(':') - val identifier = coordinate.substringAfter(':', "") - NappletGrantsUi( - identity = NappletIdentity(authorPubKey = author, identifier = identifier), - title = resolveTitle(author, identifier, untitled), - capabilities = caps.entries.sortedBy { it.key.ordinal }.map { it.key to it.value }, - ) - }.sortedBy { it.title.lowercase() } - -/** Best-effort human title from a cached manifest; falls back to the d-identifier or [untitled]. */ -private fun resolveTitle( - author: String, - identifier: String, - untitled: String, -): String { - val events = - Amethyst.instance.cache - .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) - .mapNotNull { it.event } - val match = - events.firstOrNull { ev -> - when (ev) { - is NamedNappletEvent -> ev.identifier() == identifier - is RootNappletEvent -> identifier.isEmpty() - else -> false - } - } - return (match as? NappletManifest)?.title()?.ifBlank { null } - ?: identifier.ifBlank { untitled } -} - -private fun NappletCapability.symbol(): MaterialSymbol = - when (this) { - NappletCapability.SHELL -> MaterialSymbols.Tune - NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle - NappletCapability.KEYS -> MaterialSymbols.Key - NappletCapability.RELAY -> MaterialSymbols.Public - NappletCapability.STORAGE -> MaterialSymbols.Storage - NappletCapability.VALUE -> MaterialSymbols.Bolt - NappletCapability.RESOURCE -> MaterialSymbols.Language - NappletCapability.UPLOAD -> MaterialSymbols.Upload - NappletCapability.THEME -> MaterialSymbols.Image - NappletCapability.NOTIFY -> MaterialSymbols.Notifications - NappletCapability.INC -> MaterialSymbols.SwapHoriz - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt index df1c7f2e9d..e8183abcc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt @@ -78,7 +78,7 @@ fun NappletsTopBar( } }, actions = { - IconButton(onClick = { nav.nav(Route.NappletPermissions) }) { + IconButton(onClick = { nav.nav(Route.ConnectedApps) }) { Icon(MaterialSymbols.Tune, contentDescription = stringResource(R.string.napplet_manage_permissions)) } IconButton(onClick = { nav.nav(Route.Search) }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/PolicyCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/PolicyCard.kt new file mode 100644 index 0000000000..1fa058d801 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/PolicyCard.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols + +/** Bordered selection card used in policy-picker UIs (napplet trust level, relay auth). */ +@Composable +fun PolicyCard( + selected: Boolean, + symbol: MaterialSymbol, + label: String, + description: String, + onClick: () -> Unit, +) { + val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface + + Surface( + modifier = + Modifier + .fillMaxWidth() + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = bgColor, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.titleSmall) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (selected) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssembler.kt new file mode 100644 index 0000000000..331a494a85 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssembler.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +/** + * Keyspace for the connected-apps manifest subscription. Carries the account (for relay selection) + * and the specific authors whose napplet manifests should be fetched — the set of pubkeys that have + * been granted permissions in the user's ledger. + */ +class ConnectedAppsQueryState( + val account: Account, + val authors: Set, +) + +/** + * Live subscription for NIP-5D napplet manifests (kinds 15129/35129) while + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppsScreen] is open. + * Unlike [NappletsFilterAssembler] (which follows the global follow list), this assembler + * only fetches manifests for the specific authors that have entries in the permission ledger. + */ +@Stable +class ConnectedAppsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + ConnectedAppsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..b970fad2f7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssemblerSubscription.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Composable +fun ConnectedAppsFilterAssemblerSubscription( + accountViewModel: AccountViewModel, + authors: Set, +) { + val state = + remember(accountViewModel.account, authors) { + ConnectedAppsQueryState(accountViewModel.account, authors) + } + + LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().connectedApps) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsSubAssembler.kt new file mode 100644 index 0000000000..88241ebde1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsSubAssembler.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies.filterNappletsByAuthors +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +/** + * Builds relay REQs for napplet manifests limited to the authors stored in + * [ConnectedAppsQueryState.authors], queried against the account's home relays. + * The filter is purposely narrow — we only want manifests for apps the user has already + * connected to, not the full follow-list. + */ +class ConnectedAppsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: ConnectedAppsQueryState) = key.account.userProfile() + + override fun updateFilter( + key: ConnectedAppsQueryState, + since: SincePerRelayMap?, + ): List { + if (key.authors.isEmpty()) return emptyList() + val relays = key.account.homeRelays.flow.value + if (relays.isEmpty()) return emptyList() + return relays.flatMap { relay -> + filterNappletsByAuthors(relay, key.authors, since?.get(relay)?.time) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt new file mode 100644 index 0000000000..b228c4812e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.PolicyCard +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun RelayAuthSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val store: DataStoreRelayAuthPermissionStore = Amethyst.instance.relayAuthPermissionStore + val ledger = remember { RelayAuthPermissionLedger(store, { account.settings.defaultRelayAuthPolicy.value }) } + val scope = rememberCoroutineScope() + + val globalPolicy by account.settings.defaultRelayAuthPolicy.collectAsState() + + var perRelayOverrides by remember { mutableStateOf>(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)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt new file mode 100644 index 0000000000..cdd630e3c0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays + +import android.content.Context +import androidx.annotation.StringRes +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.nns.NNSEvent +import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent +import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.kinds.KindNames +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent +import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +/** Returns the `@StringRes` id for the translated kind name, or -1 if unknown. */ +@Suppress("DEPRECATION") +@StringRes +fun kindDisplayName(kind: Int): Int = + when (kind) { + AcceptedBadgeSetEvent.KIND -> R.string.kind_accepted_badge_set + AdvertisedRelayListEvent.KIND -> R.string.kind_outbox_relays + AppDefinitionEvent.KIND -> R.string.kind_apps + AppRecommendationEvent.KIND -> R.string.kind_app_recommendations + AppSpecificDataEvent.KIND -> R.string.kind_user_settings + AudioHeaderEvent.KIND -> R.string.kind_audio_header + AudioTrackEvent.KIND -> R.string.kind_audio_track + MusicTrackEvent.KIND -> R.string.kind_music_track + MusicPlaylistEvent.KIND -> R.string.kind_music_playlist + PodcastEpisodeEvent.KIND -> R.string.kind_podcast_episode + PodcastMetadataEvent.KIND -> R.string.kind_podcast_metadata + AuthoredPodcastsEvent.KIND -> R.string.kind_authored_podcasts + FavoritePodcastsListEvent.KIND -> R.string.kind_favorite_podcasts + AttestationEvent.KIND -> R.string.attestation + AttestationRequestEvent.KIND -> R.string.attestation_request + AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation + AttestorProficiencyEvent.KIND -> R.string.attestor_proficiency + BadgeAwardEvent.KIND -> R.string.kind_badge_awards + BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions + BlockedRelayListEvent.KIND -> R.string.kind_blocked_relays + BlossomServersEvent.KIND -> R.string.kind_blossom_servers + NestsServersEvent.KIND -> R.string.kind_nests_servers + BlossomAuthorizationEvent.KIND -> R.string.kind_blossom_auth + BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays + BookmarkListEvent.KIND -> R.string.kind_bookmark_list + OldBookmarkListEvent.KIND -> R.string.kind_old_bookmark_list + CalendarDateSlotEvent.KIND -> R.string.kind_day_appointment + CalendarEvent.KIND -> R.string.kind_calendar + CalendarTimeSlotEvent.KIND -> R.string.kind_appointment + CalendarRSVPEvent.KIND -> R.string.kind_appt_rsvp + ChessGameEvent.KIND -> R.string.kind_chess_games + JesterEvent.KIND -> R.string.kind_chess_auth + RelayFeedsListEvent.KIND -> R.string.kind_favorite_relays + LiveChessGameChallengeEvent.KIND -> R.string.kind_chess_challenges + LiveChessGameAcceptEvent.KIND -> R.string.kind_chess_game_accept + LiveChessMoveEvent.KIND -> R.string.kind_chess_move + LiveChessGameEndEvent.KIND -> R.string.kind_chess_game_end + LiveChessDrawOfferEvent.KIND -> R.string.kind_chess_draw_offer + ChannelCreateEvent.KIND -> R.string.kind_channel_definition + ChannelHideMessageEvent.KIND -> R.string.kind_channel_hide_msg + ChannelListEvent.KIND -> R.string.kind_channel_list + ChannelMessageEvent.KIND -> R.string.kind_channel_message + ChannelMetadataEvent.KIND -> R.string.kind_channel_metadata + ChannelMuteUserEvent.KIND -> R.string.kind_channel_mute_user + ChatMessageEncryptedFileHeaderEvent.KIND -> R.string.kind_dm_file + ChatMessageEvent.KIND -> R.string.kind_dm_message + ChatMessageRelayListEvent.KIND -> R.string.kind_dm_relays + ClassifiedsEvent.KIND -> R.string.kind_classifieds + CommentEvent.KIND -> R.string.kind_comments + CommunityDefinitionEvent.KIND -> R.string.kind_community_def + CommunityListEvent.KIND -> R.string.kind_community_list + CommunityPostApprovalEvent.KIND -> R.string.kind_community_post + ContactListEvent.KIND -> R.string.kind_follow_list + DeletionEvent.KIND -> R.string.kind_deletions + DraftWrapEvent.KIND -> R.string.kind_drafts + EmojiPackEvent.KIND -> R.string.kind_emoji_packs + EmojiPackSelectionEvent.KIND -> R.string.kind_emoji_pack_list + EphemeralChatEvent.KIND -> R.string.kind_ephemeral_chat + EphemeralChatListEvent.KIND -> R.string.kind_ephemeral_chatrooms + FileHeaderEvent.KIND -> R.string.kind_file_headers + ProfileGalleryEntryEvent.KIND -> R.string.kind_profile_gallery + FileServersEvent.KIND -> R.string.kind_file_servers + FileStorageEvent.KIND -> R.string.kind_blob_data + FileStorageHeaderEvent.KIND -> R.string.kind_blob_headers + FhirResourceEvent.KIND -> R.string.kind_medical_data + FollowListEvent.KIND -> R.string.kind_follow_packs + GenericRepostEvent.KIND -> R.string.kind_reposts_16 + GeohashListEvent.KIND -> R.string.kind_geohash_follows + GiftWrapEvent.KIND -> R.string.kind_gift_wraps + EphemeralGiftWrapEvent.KIND -> R.string.kind_gift_wraps + GitIssueEvent.KIND -> R.string.kind_git_issue + GitPatchEvent.KIND -> R.string.kind_git_patch + GitRepositoryEvent.KIND -> R.string.kind_git_repo + GitReplyEvent.KIND -> R.string.kind_git_reply + GoalEvent.KIND -> R.string.kind_zap_goals + HashtagListEvent.KIND -> R.string.kind_hashtag_follows + HighlightEvent.KIND -> R.string.kind_highlights + HTTPAuthorizationEvent.KIND -> R.string.kind_http_auth + IndexerRelayListEvent.KIND -> R.string.kind_index_relay_list + InteractiveStoryPrologueEvent.KIND -> R.string.kind_adventure_prologue + InteractiveStorySceneEvent.KIND -> R.string.kind_adventure_scene + InteractiveStoryReadingStateEvent.KIND -> R.string.kind_adventure_reading + LabeledBookmarkListEvent.KIND -> R.string.kind_named_bookmarks + LiveActivitiesChatMessageEvent.KIND -> R.string.kind_live_chats + LiveActivitiesEvent.KIND -> R.string.kind_live_streams + LnZapEvent.KIND -> R.string.kind_zaps + LnZapPaymentRequestEvent.KIND -> R.string.kind_nwc_request + LnZapPaymentResponseEvent.KIND -> R.string.kind_nwc_response + LnZapPrivateEvent.KIND -> R.string.kind_private_zaps + LnZapRequestEvent.KIND -> R.string.kind_zap_req + LongTextNoteEvent.KIND -> R.string.kind_blogs + MeetingRoomEvent.KIND -> R.string.kind_meeting_room + MeetingRoomPresenceEvent.KIND -> R.string.kind_room_presence + MeetingSpaceEvent.KIND -> R.string.kind_meeting_space + MetadataEvent.KIND -> R.string.kind_profile + MuteListEvent.KIND -> R.string.kind_mute_list + NNSEvent.KIND -> R.string.kind_nns + NipTextEvent.KIND -> R.string.kind_nip + NostrConnectEvent.KIND -> R.string.kind_nostr_connect + NIP90StatusEvent.KIND -> R.string.kind_dvm_status + NIP90ContentDiscoveryRequestEvent.KIND -> R.string.kind_dvm_content_req + NIP90ContentDiscoveryResponseEvent.KIND -> R.string.kind_dvm_content_resp + NIP90UserDiscoveryRequestEvent.KIND -> R.string.kind_dvm_user_req + NIP90UserDiscoveryResponseEvent.KIND -> R.string.kind_dvm_user_resp + OtsEvent.KIND -> R.string.kind_ots + PaymentTargetsEvent.KIND -> R.string.kind_pay_to + PeopleListEvent.KIND -> R.string.kind_people_lists + ProfileBadgesEvent.KIND -> R.string.kind_profile_badges + PictureEvent.KIND -> R.string.kind_pictures + WorkoutRecordEvent.KIND -> R.string.kind_workouts + PinListEvent.KIND -> R.string.kind_pins + ZapPollEvent.KIND -> R.string.kind_zap_poll + PollEvent.KIND -> R.string.kind_poll + PollResponseEvent.KIND -> R.string.kind_poll_response + PrivateDmEvent.KIND -> R.string.kind_nip04_dms + PrivateOutboxRelayListEvent.KIND -> R.string.kind_private_relays + ProxyRelayListEvent.KIND -> R.string.kind_proxy_relays + PublicMessageEvent.KIND -> R.string.kind_public_message + ReactionEvent.KIND -> R.string.kind_reactions + ContactCardEvent.KIND -> R.string.kind_contact_card + RelayAuthEvent.KIND -> R.string.kind_relay_auth + RelayDiscoveryEvent.KIND -> R.string.kind_relay_discovery + RelayMonitorEvent.KIND -> R.string.kind_relay_monitor + RelaySetEvent.KIND -> R.string.kind_relay_set + ReportEvent.KIND -> R.string.kind_reports + RepostEvent.KIND -> R.string.kind_reposts + RequestToVanishEvent.KIND -> R.string.kind_user_delete + SealedRumorEvent.KIND -> R.string.kind_seals + SearchRelayListEvent.KIND -> R.string.kind_search_relays + StatusEvent.KIND -> R.string.kind_user_status + TextNoteEvent.KIND -> R.string.kind_notes + TextNoteModificationEvent.KIND -> R.string.kind_edits + TorrentEvent.KIND -> R.string.kind_torrents + TorrentCommentEvent.KIND -> R.string.kind_torrent_comments + TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays + TrustProviderListEvent.KIND -> R.string.kind_trusted_providers + VideoHorizontalEvent.KIND -> R.string.kind_video_repl + VideoVerticalEvent.KIND -> R.string.kind_shorts_repl + VideoNormalEvent.KIND -> R.string.kind_video + VideoShortEvent.KIND -> R.string.kind_shorts + VoiceEvent.KIND -> R.string.kind_voice_msg + VoiceReplyEvent.KIND -> R.string.kind_voice_reply + WakeUpEvent.KIND -> R.string.kind_wake + WebBookmarkEvent.KIND -> R.string.kind_web_bookmark + WikiNoteEvent.KIND -> R.string.kind_wiki + else -> -1 + } + +/** + * Returns the translated display name for [kind] using Android string resources when available, + * falling back to the English name from [KindNames], then to "k". + */ +fun kindNameFor( + context: Context, + kind: Int, +): String { + val resId = kindDisplayName(kind) + return if (resId != -1) context.getString(resId) else (KindNames.nameFor(kind) ?: "k$kind") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index c6b530c89f..fe5a328677 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -109,33 +109,8 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.redColorOnSecondSurface -import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent -import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent -import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent -import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent -import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent -import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent -import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent -import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent -import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent -import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent -import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent -import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent -import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.nns.NNSEvent -import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent -import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.kinds.KindNames import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessageText @@ -146,128 +121,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent -import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent -import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent -import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent -import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent -import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent -import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent -import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent -import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent -import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent -import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent -import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent -import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent -import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent -import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent -import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent -import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent -import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent -import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent -import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent -import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent -import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client -import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent -import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent -import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent -import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent -import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent -import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent -import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent -import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent -import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent -import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent -import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -535,157 +397,6 @@ fun RelayInformationBody( // Active subscriptions + outbox display // --------------------------------------------------------------------------- -@Suppress("DEPRECATION") -fun kindDisplayName(kind: Int): Int = - when (kind) { - AcceptedBadgeSetEvent.KIND -> R.string.kind_accepted_badge_set - AdvertisedRelayListEvent.KIND -> R.string.kind_outbox_relays - AppDefinitionEvent.KIND -> R.string.kind_apps - AppRecommendationEvent.KIND -> R.string.kind_app_recommendations - AppSpecificDataEvent.KIND -> R.string.kind_user_settings - AudioHeaderEvent.KIND -> R.string.kind_audio_header - AudioTrackEvent.KIND -> R.string.kind_audio_track - MusicTrackEvent.KIND -> R.string.kind_music_track - MusicPlaylistEvent.KIND -> R.string.kind_music_playlist - PodcastEpisodeEvent.KIND -> R.string.kind_podcast_episode - PodcastMetadataEvent.KIND -> R.string.kind_podcast_metadata - AuthoredPodcastsEvent.KIND -> R.string.kind_authored_podcasts - FavoritePodcastsListEvent.KIND -> R.string.kind_favorite_podcasts - AttestationEvent.KIND -> R.string.attestation - AttestationRequestEvent.KIND -> R.string.attestation_request - AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation - AttestorProficiencyEvent.KIND -> R.string.attestor_proficiency - BadgeAwardEvent.KIND -> R.string.kind_badge_awards - BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions - BlockedRelayListEvent.KIND -> R.string.kind_blocked_relays - BlossomServersEvent.KIND -> R.string.kind_blossom_servers - NestsServersEvent.KIND -> R.string.kind_nests_servers - BlossomAuthorizationEvent.KIND -> R.string.kind_blossom_auth - BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays - BookmarkListEvent.KIND -> R.string.kind_bookmark_list - OldBookmarkListEvent.KIND -> R.string.kind_old_bookmark_list - CalendarDateSlotEvent.KIND -> R.string.kind_day_appointment - CalendarEvent.KIND -> R.string.kind_calendar - CalendarTimeSlotEvent.KIND -> R.string.kind_appointment - CalendarRSVPEvent.KIND -> R.string.kind_appt_rsvp - ChessGameEvent.KIND -> R.string.kind_chess_games - JesterEvent.KIND -> R.string.kind_chess_auth - RelayFeedsListEvent.KIND -> R.string.kind_favorite_relays - LiveChessGameChallengeEvent.KIND -> R.string.kind_chess_challenges - LiveChessGameAcceptEvent.KIND -> R.string.kind_chess_game_accept - LiveChessMoveEvent.KIND -> R.string.kind_chess_move - LiveChessGameEndEvent.KIND -> R.string.kind_chess_game_end - LiveChessDrawOfferEvent.KIND -> R.string.kind_chess_draw_offer - ChannelCreateEvent.KIND -> R.string.kind_channel_definition - ChannelHideMessageEvent.KIND -> R.string.kind_channel_hide_msg - ChannelListEvent.KIND -> R.string.kind_channel_list - ChannelMessageEvent.KIND -> R.string.kind_channel_message - ChannelMetadataEvent.KIND -> R.string.kind_channel_metadata - ChannelMuteUserEvent.KIND -> R.string.kind_channel_mute_user - ChatMessageEncryptedFileHeaderEvent.KIND -> R.string.kind_dm_file - ChatMessageEvent.KIND -> R.string.kind_dm_message - ChatMessageRelayListEvent.KIND -> R.string.kind_dm_relays - ClassifiedsEvent.KIND -> R.string.kind_classifieds - CommentEvent.KIND -> R.string.kind_comments - CommunityDefinitionEvent.KIND -> R.string.kind_community_def - CommunityListEvent.KIND -> R.string.kind_community_list - CommunityPostApprovalEvent.KIND -> R.string.kind_community_post - ContactListEvent.KIND -> R.string.kind_follow_list - DeletionEvent.KIND -> R.string.kind_deletions - DraftWrapEvent.KIND -> R.string.kind_drafts - EmojiPackEvent.KIND -> R.string.kind_emoji_packs - EmojiPackSelectionEvent.KIND -> R.string.kind_emoji_pack_list - EphemeralChatEvent.KIND -> R.string.kind_ephemeral_chat - EphemeralChatListEvent.KIND -> R.string.kind_ephemeral_chatrooms - FileHeaderEvent.KIND -> R.string.kind_file_headers - ProfileGalleryEntryEvent.KIND -> R.string.kind_profile_gallery - FileServersEvent.KIND -> R.string.kind_file_servers - FileStorageEvent.KIND -> R.string.kind_blob_data - FileStorageHeaderEvent.KIND -> R.string.kind_blob_headers - FhirResourceEvent.KIND -> R.string.kind_medical_data - FollowListEvent.KIND -> R.string.kind_follow_packs - GenericRepostEvent.KIND -> R.string.kind_reposts_16 - GeohashListEvent.KIND -> R.string.kind_geohash_follows - GiftWrapEvent.KIND -> R.string.kind_gift_wraps - EphemeralGiftWrapEvent.KIND -> R.string.kind_gift_wraps - GitIssueEvent.KIND -> R.string.kind_git_issue - GitPatchEvent.KIND -> R.string.kind_git_patch - GitRepositoryEvent.KIND -> R.string.kind_git_repo - GitReplyEvent.KIND -> R.string.kind_git_reply - GoalEvent.KIND -> R.string.kind_zap_goals - HashtagListEvent.KIND -> R.string.kind_hashtag_follows - HighlightEvent.KIND -> R.string.kind_highlights - HTTPAuthorizationEvent.KIND -> R.string.kind_http_auth - IndexerRelayListEvent.KIND -> R.string.kind_index_relay_list - InteractiveStoryPrologueEvent.KIND -> R.string.kind_adventure_prologue - InteractiveStorySceneEvent.KIND -> R.string.kind_adventure_scene - InteractiveStoryReadingStateEvent.KIND -> R.string.kind_adventure_reading - LabeledBookmarkListEvent.KIND -> R.string.kind_named_bookmarks - LiveActivitiesChatMessageEvent.KIND -> R.string.kind_live_chats - LiveActivitiesEvent.KIND -> R.string.kind_live_streams - LnZapEvent.KIND -> R.string.kind_zaps - LnZapPaymentRequestEvent.KIND -> R.string.kind_nwc_request - LnZapPaymentResponseEvent.KIND -> R.string.kind_nwc_response - LnZapPrivateEvent.KIND -> R.string.kind_private_zaps - LnZapRequestEvent.KIND -> R.string.kind_zap_req - LongTextNoteEvent.KIND -> R.string.kind_blogs - MeetingRoomEvent.KIND -> R.string.kind_meeting_room - MeetingRoomPresenceEvent.KIND -> R.string.kind_room_presence - MeetingSpaceEvent.KIND -> R.string.kind_meeting_space - MetadataEvent.KIND -> R.string.kind_profile - MuteListEvent.KIND -> R.string.kind_mute_list - NNSEvent.KIND -> R.string.kind_nns - NipTextEvent.KIND -> R.string.kind_nip - NostrConnectEvent.KIND -> R.string.kind_nostr_connect - NIP90StatusEvent.KIND -> R.string.kind_dvm_status - NIP90ContentDiscoveryRequestEvent.KIND -> R.string.kind_dvm_content_req - NIP90ContentDiscoveryResponseEvent.KIND -> R.string.kind_dvm_content_resp - NIP90UserDiscoveryRequestEvent.KIND -> R.string.kind_dvm_user_req - NIP90UserDiscoveryResponseEvent.KIND -> R.string.kind_dvm_user_resp - OtsEvent.KIND -> R.string.kind_ots - PaymentTargetsEvent.KIND -> R.string.kind_pay_to - PeopleListEvent.KIND -> R.string.kind_people_lists - ProfileBadgesEvent.KIND -> R.string.kind_profile_badges - PictureEvent.KIND -> R.string.kind_pictures - WorkoutRecordEvent.KIND -> R.string.kind_workouts - PinListEvent.KIND -> R.string.kind_pins - ZapPollEvent.KIND -> R.string.kind_zap_poll - PollEvent.KIND -> R.string.kind_poll - PollResponseEvent.KIND -> R.string.kind_poll_response - PrivateDmEvent.KIND -> R.string.kind_nip04_dms - PrivateOutboxRelayListEvent.KIND -> R.string.kind_private_relays - ProxyRelayListEvent.KIND -> R.string.kind_proxy_relays - PublicMessageEvent.KIND -> R.string.kind_public_message - ReactionEvent.KIND -> R.string.kind_reactions - ContactCardEvent.KIND -> R.string.kind_contact_card - RelayAuthEvent.KIND -> R.string.kind_relay_auth - RelayDiscoveryEvent.KIND -> R.string.kind_relay_discovery - RelayMonitorEvent.KIND -> R.string.kind_relay_monitor - RelaySetEvent.KIND -> R.string.kind_relay_set - ReportEvent.KIND -> R.string.kind_reports - RepostEvent.KIND -> R.string.kind_reposts - RequestToVanishEvent.KIND -> R.string.kind_user_delete - SealedRumorEvent.KIND -> R.string.kind_seals - SearchRelayListEvent.KIND -> R.string.kind_search_relays - StatusEvent.KIND -> R.string.kind_user_status - TextNoteEvent.KIND -> R.string.kind_notes - TextNoteModificationEvent.KIND -> R.string.kind_edits - TorrentEvent.KIND -> R.string.kind_torrents - TorrentCommentEvent.KIND -> R.string.kind_torrent_comments - TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays - TrustProviderListEvent.KIND -> R.string.kind_trusted_providers - VideoHorizontalEvent.KIND -> R.string.kind_video_repl - VideoVerticalEvent.KIND -> R.string.kind_shorts_repl - VideoNormalEvent.KIND -> R.string.kind_video - VideoShortEvent.KIND -> R.string.kind_shorts - VoiceEvent.KIND -> R.string.kind_voice_msg - VoiceReplyEvent.KIND -> R.string.kind_voice_reply - WakeUpEvent.KIND -> R.string.kind_wake - WebBookmarkEvent.KIND -> R.string.kind_web_bookmark - WikiNoteEvent.KIND -> R.string.kind_wiki - else -> -1 - } - val posts = setOf(0, 1, 6, 7, 16, 30023) val settings = setOf(3, 10002, 10000, 10001, 10003, 10004, 30000) val dms = setOf(4, GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, 10050) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 023e57df6f..b6aab37179 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -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), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9f38cd9547..b88a199ff3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -701,8 +701,9 @@ No nApplet permissions yet Permissions you grant to nApplets will appear here. Forget this nApplet - Blocked + Requires per-use approval Revoke + Ask me each time No nApplets found yet. nApplet %1$s… @@ -752,6 +753,77 @@ This nApplet wants to pay a Lightning invoice for %1$d sat. This nApplet wants to pay a Lightning invoice for %1$d sats. + + Connect to Nostr + wants to connect to your Nostr account + How should this app\'s requests be handled? + Connect + Block and ignore %1$s + + + I fully trust it + Auto-sign all requests (except payments) + Let\'s be reasonable + Auto-approve most common requests + I\'m a bit paranoid + Do not sign anything without asking me! + + + wants to %1$s + Show Event + Hide Event + More options + Fewer options + Allow once + Allow for this session + Allow for 24 hours + Allow for 30 days + Don\'t ask again to %1$s + Don\'t ask again for any Nostr requests + Deny + Always deny %1$s + Last used + Expires + + + sign kind %1$d event + sign for %1$s (kind: %2$d) + encrypt a message + + read your private messages + + + Connected Apps + Revoke all permissions + Operation overrides + No apps have connected yet. + Revoke all permissions + Allow + Ask + Deny + apps permissions signer napplet nsite webapp trust connected + Signing trust level + Capabilities + Forget this app + Signing operation overrides + No apps have connected yet.\n\nWhen a web app connects to your Nostr key, it will appear here. + + + Relay Authentication + auth authentication relay sign verify nip-42 identity + Global policy + Always authenticate + Sign auth challenges for every relay that requests it + Never authenticate + Ignore auth challenges from all relays + My relays only + Only authenticate with relays in your relay list + Per-relay overrides + No per-relay overrides — global policy applies everywhere + Allow + Deny + Remove override + Source: %1$s v%1$s Download diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 0d035a4ad2..9acd8b12e2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -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() + + // Apps whose first-connect dialog the user dismissed with Cancel this session. + // Cancelling means "not now" — we suppress re-prompting within the same session so a + // napplet that fires many requests doesn't show the dialog on every one. + // Only accessed under signerConsentLock. + private val sessionCancelled = mutableSetOf() + /** * Authorizes and runs [request] on behalf of [identity]. [declared] is the capability set the * manifest's `requires` resolved to; a request outside it is refused before any prompt. @@ -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, + ): 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 + } + } + } + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt new file mode 100644 index 0000000000..3a34dc729c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt @@ -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, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt new file mode 100644 index 0000000000..a6cbda8f6b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.signers + +/** + * A per-operation standing decision stored in [NostrSignerPermissionStore]. + * Overrides [AppSignerPolicy] for the specific [NostrSignerOp] it is keyed to. + */ +enum class NostrOpDecision { + /** Automatically allow this operation without prompting. */ + ALLOW, + + /** Prompt the user on each request (default behavior). */ + ASK, + + /** Always deny this operation without prompting. */ + DENY, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt new file mode 100644 index 0000000000..ea6f87bd34 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt @@ -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 +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt new file mode 100644 index 0000000000..bdb31e981f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.signers + +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest + +/** + * A Nostr-specific cryptographic operation that requires the internal signer. + * Used to gate signing and encryption independently, per app. + */ +sealed interface NostrSignerOp { + /** Sign (and optionally publish) an event of the given [kind]. */ + data class SignKind( + val kind: Int, + ) : NostrSignerOp + + /** Encrypt a message (NIP-04 or NIP-44). */ + data object Encrypt : NostrSignerOp + + /** Decrypt a message (NIP-04 or NIP-44). */ + data object Decrypt : NostrSignerOp + + /** Stable storage key for this operation, used as a DataStore key fragment. */ + val key: String + get() = + when (this) { + is SignKind -> "sign:$kind" + Encrypt -> "encrypt" + Decrypt -> "decrypt" + } + + companion object { + fun fromKey(key: String): NostrSignerOp? = + when { + key == "encrypt" -> Encrypt + key == "decrypt" -> Decrypt + key.startsWith("sign:") -> key.removePrefix("sign:").toIntOrNull()?.let { SignKind(it) } + else -> null + } + } +} + +/** + * Maps a [NappletRequest] to the [NostrSignerOp] it represents, or `null` if the request + * does not involve signing or encryption. + */ +fun NappletRequest.toSignerOp(): NostrSignerOp? = + when (this) { + is NappletRequest.Publish -> NostrSignerOp.SignKind(kind) + is NappletRequest.SignEvent -> NostrSignerOp.SignKind(kind) + is NappletRequest.PublishEncrypted -> NostrSignerOp.Encrypt + else -> null + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt new file mode 100644 index 0000000000..2b3a40c27b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt @@ -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 + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt new file mode 100644 index 0000000000..cc0e2bca8a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt @@ -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. `":"`). + * + * The Android implementation uses one DataStore file per coordinate so loading one app's + * permissions never reads another app's data — essential at scale (1000s of apps). + * Tests use [InMemoryNostrSignerPermissionStore]. + */ +interface NostrSignerPermissionStore { + /** The stored trust level for [coordinate], or `null` if no policy has been set yet. */ + suspend fun loadPolicy(coordinate: String): AppSignerPolicy? + + /** Persist [policy] as the trust level for [coordinate]. */ + suspend fun storePolicy( + coordinate: String, + policy: AppSignerPolicy, + ) + + /** Remove the stored trust level for [coordinate]. */ + suspend fun clearPolicy(coordinate: String) + + /** The stored per-operation decision for ([coordinate], [op]), or `null` if none set. */ + suspend fun loadOpDecision( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision? + + /** Persist [decision] for ([coordinate], [op]). */ + suspend fun storeOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) + + /** Remove the per-operation decision for ([coordinate], [op]). */ + suspend fun clearOpDecision( + coordinate: String, + op: NostrSignerOp, + ) + + /** All stored trust levels, keyed by coordinate — for the permissions-management screen. */ + suspend fun allPolicies(): Map + + /** + * All per-operation overrides for [coordinate], keyed by [NostrSignerOp.key] — for the + * permissions-management screen. + */ + suspend fun allOpDecisions(coordinate: String): Map + + /** Remove all signer permissions (policy + all op overrides) for [coordinate]. */ + suspend fun clearAll(coordinate: String) + + /** 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() + private val opDecisions = mutableMapOf>() + private val opExpiries = mutableMapOf>() + private val lastUsedMap = mutableMapOf() + + override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? = lock.withLock { policies[coordinate] } + + override suspend fun storePolicy( + coordinate: String, + policy: AppSignerPolicy, + ) = lock.withLock { policies[coordinate] = policy } + + override suspend fun clearPolicy(coordinate: String) = + lock.withLock { + policies.remove(coordinate) + Unit + } + + override suspend fun loadOpDecision( + coordinate: String, + op: NostrSignerOp, + ): NostrOpDecision? = lock.withLock { opDecisions[coordinate]?.get(op.key) } + + override suspend fun storeOpDecision( + coordinate: String, + op: NostrSignerOp, + decision: NostrOpDecision, + ) = lock.withLock { + opDecisions.getOrPut(coordinate) { mutableMapOf() }[op.key] = decision + } + + override suspend fun clearOpDecision( + coordinate: String, + op: NostrSignerOp, + ) = lock.withLock { + opDecisions[coordinate]?.remove(op.key) + Unit + } + + override suspend fun allPolicies(): Map = lock.withLock { policies.toMap() } + + override suspend fun allOpDecisions(coordinate: String): Map = lock.withLock { opDecisions[coordinate]?.toMap() ?: emptyMap() } + + override suspend fun clearAll(coordinate: String) = + lock.withLock { + policies.remove(coordinate) + opDecisions.remove(coordinate) + opExpiries.remove(coordinate) + lastUsedMap.remove(coordinate) + Unit + } + + override suspend fun loadOpExpiry( + coordinate: String, + op: NostrSignerOp, + ): Long? = lock.withLock { opExpiries[coordinate]?.get(op.key) } + + override suspend fun storeOpExpiry( + coordinate: String, + op: NostrSignerOp, + expiresAt: Long, + ) = lock.withLock { opExpiries.getOrPut(coordinate) { mutableMapOf() }[op.key] = expiresAt } + + override suspend fun clearOpExpiry( + coordinate: String, + op: NostrSignerOp, + ) = lock.withLock { + opExpiries[coordinate]?.remove(op.key) + Unit + } + + override suspend fun loadLastUsed(coordinate: String): Long? = lock.withLock { lastUsedMap[coordinate] } + + override suspend fun storeLastUsed( + coordinate: String, + epochSeconds: Long, + ) = lock.withLock { lastUsedMap[coordinate] = epochSeconds } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt new file mode 100644 index 0000000000..00ee6d66bd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayauth + +/** + * Per-relay [RelayAuthDecision] overrides. Used by [RelayAuthPermissionLedger] after + * checking the global [RelayAuthPolicy]. The Android implementation uses a single + * shared DataStore file so a relay URL lookup never touches another relay's data. + */ +interface RelayAuthPermissionStore { + /** The stored per-relay override for [relayUrl], or `null` if no override is set. */ + suspend fun loadDecision(relayUrl: String): RelayAuthDecision? + + /** Persist [decision] as the override for [relayUrl]. */ + suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) + + /** Remove the per-relay override for [relayUrl]. */ + suspend fun clearDecision(relayUrl: String) + + /** All per-relay overrides — for the relay auth settings screen. */ + suspend fun allDecisions(): Map +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt new file mode 100644 index 0000000000..4287267c36 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayauth + +/** + * The default policy for authenticating with relays (NIP-42). + * Per-relay overrides stored in [RelayAuthPermissionStore] always take precedence. + */ +enum class RelayAuthPolicy { + /** Authenticate with every relay that requests it. Equivalent to current behavior. */ + ALWAYS, + + /** Never authenticate; do not reveal your identity to relay operators via NIP-42. */ + NEVER, + + /** Authenticate only with relays explicitly listed in the user's relay list. */ + IF_IN_MY_LIST, +} + +/** A persisted per-relay override decision. */ +enum class RelayAuthDecision { + ALLOW, + DENY, +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index 5503588eda..ddc8a0304f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -405,16 +405,29 @@ class NappletBrokerTest { } @Test - fun externalSignerDefersIdentityWithoutPrompting() = + fun externalSignerIsPromptedByAmethystForIdentity() = runTest { - val prompt = ScriptedPrompt(GrantState.DENY) // would block if we asked + val prompt = ScriptedPrompt(GrantState.DENY) + val external = FakeExternalSigner("dd".repeat(32)) + + val response = + broker(prompt, signer = external).handle(applet, NappletRequest.GetPublicKey, allDeclared) + + assertIs(response) // Amethyst asked and user denied + assertEquals(1, prompt.calls) // Amethyst prompts all signer types + } + + @Test + fun externalSignerAllowedByAmethystReturnsPublicKey() = + runTest { + val prompt = ScriptedPrompt(GrantState.ALLOW_ONCE) val external = FakeExternalSigner("dd".repeat(32)) val response = broker(prompt, signer = external).handle(applet, NappletRequest.GetPublicKey, allDeclared) assertEquals(NappletResponse.PublicKey(external.pubKey), response) - assertEquals(0, prompt.calls) // deferred to the external signer; we did not prompt + assertEquals(1, prompt.calls) // Amethyst prompted before the external signer } @Test diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt index 4f3245b045..322b964699 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt @@ -246,7 +246,7 @@ class KtorRelayTest { com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator( client = client, scope = scope, - ) { template -> + ) { _, template -> listOf(signer.sign(template)) } try { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index 8124f1aeb0..e53eb05adb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -51,7 +51,12 @@ object EmptyIAuthStatus : IAuthStatus { class RelayAuthenticator( val client: INostrClient, val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - val signWithAllLoggedInUsers: suspend (EventTemplate) -> List, + /** + * Signs the auth template for every currently-logged-in account and returns the signed events. + * The [relay] parameter allows callers to check per-relay auth policy before signing. + * Returns an empty list to skip authentication for this relay. + */ + val signWithAllLoggedInUsers: suspend (relay: NormalizedRelayUrl, EventTemplate) -> List, ) : IAuthStatus { // Connection callbacks fire on the per-relay OkHttp dispatcher thread, so // this state is mutated concurrently — LargeCache wraps a platform-tuned @@ -93,7 +98,7 @@ class RelayAuthenticator( // so an uncaught throwable here crashes the whole app. Swallow + log them. try { val ev = RelayAuthEvent.build(relay.url, msg.challenge) - signWithAllLoggedInUsers(ev).forEach { authEvent -> + signWithAllLoggedInUsers(relay.url, ev).forEach { authEvent -> // only send replies to new challenges to avoid infinite loop: if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) { relay.sendIfConnected(AuthCmd(authEvent)) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index b73047c0e5..0eccbaedaa 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -168,6 +168,8 @@ class JacksonMapper { ), ) + fun toJsonPretty(template: EventTemplate<*>): String = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(template) + fun toJson(event: ArrayNode): String = mapper.writeValueAsString(event) fun toJson(event: ObjectNode?): String = mapper.writeValueAsString(event) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt index 67fa00c2b7..b375d78031 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt @@ -90,7 +90,7 @@ class RelayAuthenticatorConcurrencyTest { val authenticator = RelayAuthenticator( client = client, - signWithAllLoggedInUsers = { emptyList() }, + signWithAllLoggedInUsers = { _, _ -> emptyList() }, ) val listener = client.captured diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt index a6d9522abb..bbd7ad35cf 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt @@ -102,7 +102,7 @@ class RelayAuthenticatorTimeoutTest { RelayAuthenticator( client = client, scope = scope, - signWithAllLoggedInUsers = { + signWithAllLoggedInUsers = { _, _ -> throw SignerExceptions.TimedOutException("User didn't accept or reject in time.") }, ) @@ -130,7 +130,7 @@ class RelayAuthenticatorTimeoutTest { RelayAuthenticator( client = client, scope = scope, - signWithAllLoggedInUsers = { template -> + signWithAllLoggedInUsers = { _, _ -> listOf(RelayAuthEvent.create(relay.url, "challenge-123", signer)) }, )