mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
Merge remote-tracking branch 'origin/main' into claude/git-repo-readme-code-tabs-e4uf6c
This commit is contained in:
@@ -431,6 +431,20 @@
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- First-connect "Connect to Nostr" dialog. -->
|
||||
<activity
|
||||
android:name=".napplet.NappletConnectActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- Per-operation signer consent dialog. -->
|
||||
<activity
|
||||
android:name=".napplet.NappletSignerConsentActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<!-- Main-process broker: holds the signer and brokers capabilities for the sandbox. -->
|
||||
<service
|
||||
|
||||
@@ -44,6 +44,8 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
|
||||
import com.vitorpamplona.amethyst.model.torState.TorRelayState
|
||||
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
|
||||
import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.service.cast.CastRegistry
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
|
||||
@@ -73,6 +75,7 @@ import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
|
||||
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
|
||||
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
@@ -529,6 +532,15 @@ class AppModules(
|
||||
// Show messages from the Relay and controls their dismissal
|
||||
val notifyCoordinator = NotifyCoordinator(client)
|
||||
|
||||
// Persists per-relay NIP-42 ALLOW/DENY overrides across app restarts.
|
||||
val relayAuthPermissionStore by lazy {
|
||||
DataStoreRelayAuthPermissionStore(appContext)
|
||||
}
|
||||
|
||||
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
|
||||
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
|
||||
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
|
||||
|
||||
// Authenticates with relays.
|
||||
val authCoordinator = AuthCoordinator(client, applicationIOScope)
|
||||
|
||||
@@ -794,6 +806,13 @@ class AppModules(
|
||||
resourceCacheInit()
|
||||
}
|
||||
|
||||
// Initialize napplet permission stores on an IO thread to avoid StrictMode violations
|
||||
// when ConnectedAppsScreen first accesses them on the main thread.
|
||||
applicationIOScope.launch {
|
||||
nappletPermissionStore
|
||||
signerPermissionStore
|
||||
}
|
||||
|
||||
// registers to receive events
|
||||
pokeyReceiver.register(appContext)
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.core.content.edit
|
||||
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
|
||||
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
|
||||
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.UiSettings
|
||||
@@ -155,6 +156,7 @@ private object PrefKeys {
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
|
||||
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
|
||||
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
|
||||
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
|
||||
const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications"
|
||||
|
||||
@@ -506,6 +508,7 @@ object LocalPreferences {
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
|
||||
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
|
||||
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
|
||||
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
|
||||
putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value)
|
||||
// Any account that reaches a save has its notification filter in its
|
||||
@@ -622,6 +625,10 @@ object LocalPreferences {
|
||||
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
||||
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
|
||||
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
|
||||
val defaultRelayAuthPolicy =
|
||||
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
|
||||
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
|
||||
?: RelayAuthPolicy.IF_IN_MY_LIST
|
||||
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
|
||||
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
|
||||
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||
@@ -825,6 +832,7 @@ object LocalPreferences {
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
|
||||
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
|
||||
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
|
||||
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
|
||||
backupUserMetadata = latestUserMetadataResolved,
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletBlobCache
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
|
||||
@@ -107,3 +108,40 @@ private fun resolveIconBlob(event: Event?): IconBlob? =
|
||||
is NamedSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon
|
||||
* has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the
|
||||
* WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache.
|
||||
*
|
||||
* Early-returns null when [url] is blank or has no parseable host — this early return is stable
|
||||
* for a given [url] (the host either always parses or never does), so composition structure is
|
||||
* preserved across recompositions.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWebAppIconModel(url: String): String? {
|
||||
val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null
|
||||
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
|
||||
return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A Coil model for the bundled icon of an napplet or nsite identified by [author] and
|
||||
* [identifier]. Tries the napplet manifest (kinds 15129 / 35129) first, then falls back to the
|
||||
* nsite manifest (kinds 15128 / 35128). Returns null until the blob lands on disk.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberManifestIconModel(
|
||||
author: String,
|
||||
identifier: String,
|
||||
): String? {
|
||||
val nappletCoord =
|
||||
remember(author, identifier) {
|
||||
if (identifier.isEmpty()) "${RootNappletEvent.KIND}:$author:" else "${NamedNappletEvent.KIND}:$author:$identifier"
|
||||
}
|
||||
val nsiteCoord =
|
||||
remember(author, identifier) {
|
||||
if (identifier.isEmpty()) "${RootSiteEvent.KIND}:$author:" else "${NamedSiteEvent.KIND}:$author:$identifier"
|
||||
}
|
||||
return rememberNappletIconModel(nappletCoord) ?: rememberNappletIconModel(nsiteCoord)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListR
|
||||
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
@@ -267,6 +268,7 @@ class AccountSettings(
|
||||
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
|
||||
var callMaxBitrateBps: Int = 1_500_000,
|
||||
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST),
|
||||
) : EphemeralChatRepository,
|
||||
PublicChatListRepository {
|
||||
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
|
||||
@@ -1476,6 +1478,13 @@ class AccountSettings(
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultRelayAuthPolicy(policy: RelayAuthPolicy) {
|
||||
if (defaultRelayAuthPolicy.value != policy) {
|
||||
defaultRelayAuthPolicy.tryEmit(policy)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
+2
-1
@@ -68,7 +68,8 @@ class TrustedRelayListState(
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
// Synchronously seed public tags from the backup; private tags may be absent on first boot.
|
||||
settings.backupTrustedRelayList?.let { decryptionCache.cachedRelays(it) } ?: emptySet(),
|
||||
)
|
||||
|
||||
suspend fun saveRelayList(trustedRelays: List<NormalizedRelayUrl>): TrustedRelayListEvent {
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Per-coordinate DataStore-backed [NostrSignerPermissionStore]. One small `.preferences_pb`
|
||||
* file per app (keyed by a SHA-256 prefix of the coordinate) so loading or saving one app's
|
||||
* permissions never touches another app's data — essential at scale with 1000s of apps.
|
||||
*
|
||||
* The coordinate is stored inside each file under [KEY_COORDINATE] so [allPolicies] can
|
||||
* reverse-map file → coordinate without scanning the filesystem.
|
||||
*/
|
||||
class DataStoreNostrSignerPermissionStore(
|
||||
private val filesDir: File,
|
||||
) : NostrSignerPermissionStore {
|
||||
constructor(context: Context) : this(context.applicationContext.filesDir)
|
||||
|
||||
private val cache = LargeCache<String, DataStore<Preferences>>()
|
||||
|
||||
private fun storeFor(coordinate: String): DataStore<Preferences> {
|
||||
val file = File(filesDir, "datastore/nsp_${hash(coordinate)}.preferences_pb")
|
||||
return cache.getOrCreate(file.absolutePath) {
|
||||
PreferenceDataStoreFactory.create(produceFile = { file })
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? {
|
||||
val raw = storeFor(coordinate).data.first()[KEY_POLICY] ?: return null
|
||||
return runCatching { AppSignerPolicy.valueOf(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override suspend fun storePolicy(
|
||||
coordinate: String,
|
||||
policy: AppSignerPolicy,
|
||||
) {
|
||||
storeFor(coordinate).edit {
|
||||
it[KEY_COORDINATE] = coordinate
|
||||
it[KEY_POLICY] = policy.name
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearPolicy(coordinate: String) {
|
||||
storeFor(coordinate).edit { it.remove(KEY_POLICY) }
|
||||
}
|
||||
|
||||
override suspend fun loadOpDecision(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
): NostrOpDecision? {
|
||||
val raw = storeFor(coordinate).data.first()[opKey(op)] ?: return null
|
||||
return runCatching { NostrOpDecision.valueOf(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override suspend fun storeOpDecision(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
decision: NostrOpDecision,
|
||||
) {
|
||||
storeFor(coordinate).edit {
|
||||
it[KEY_COORDINATE] = coordinate
|
||||
it[opKey(op)] = decision.name
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearOpDecision(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
) {
|
||||
storeFor(coordinate).edit { it.remove(opKey(op)) }
|
||||
}
|
||||
|
||||
override suspend fun allPolicies(): Map<String, AppSignerPolicy> {
|
||||
val dir = File(filesDir, "datastore")
|
||||
if (!dir.exists()) return emptyMap()
|
||||
val result = mutableMapOf<String, AppSignerPolicy>()
|
||||
for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) {
|
||||
val ds =
|
||||
cache.getOrCreate(file.absolutePath) {
|
||||
PreferenceDataStoreFactory.create(produceFile = { file })
|
||||
}
|
||||
val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue
|
||||
val policy = loadPolicy(coordinate) ?: continue
|
||||
result[coordinate] = policy
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision> {
|
||||
val prefs = storeFor(coordinate).data.first()
|
||||
val result = mutableMapOf<String, NostrOpDecision>()
|
||||
for ((key, value) in prefs.asMap()) {
|
||||
val name = key.name
|
||||
if (!name.startsWith(OP_PREFIX)) continue
|
||||
// Skip expiry metadata keys — they end with the expiry suffix
|
||||
if (name.endsWith(OP_EXPIRY_SUFFIX)) continue
|
||||
val opKey = name.removePrefix(OP_PREFIX)
|
||||
val decision = runCatching { NostrOpDecision.valueOf(value as String) }.getOrNull() ?: continue
|
||||
result[opKey] = decision
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun loadOpExpiry(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
): Long? {
|
||||
val raw = storeFor(coordinate).data.first()[opExpiryKey(op)] ?: return null
|
||||
return raw.toLongOrNull()
|
||||
}
|
||||
|
||||
override suspend fun storeOpExpiry(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
expiresAt: Long,
|
||||
) {
|
||||
storeFor(coordinate).edit { it[opExpiryKey(op)] = expiresAt.toString() }
|
||||
}
|
||||
|
||||
override suspend fun clearOpExpiry(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
) {
|
||||
storeFor(coordinate).edit { it.remove(opExpiryKey(op)) }
|
||||
}
|
||||
|
||||
override suspend fun loadLastUsed(coordinate: String): Long? {
|
||||
val raw = storeFor(coordinate).data.first()[KEY_LAST_USED] ?: return null
|
||||
return raw.toLongOrNull()
|
||||
}
|
||||
|
||||
override suspend fun storeLastUsed(
|
||||
coordinate: String,
|
||||
epochSeconds: Long,
|
||||
) {
|
||||
storeFor(coordinate).edit { it[KEY_LAST_USED] = epochSeconds.toString() }
|
||||
}
|
||||
|
||||
override suspend fun clearAll(coordinate: String) {
|
||||
storeFor(coordinate).edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun opKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}")
|
||||
|
||||
private fun opExpiryKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}$OP_EXPIRY_SUFFIX")
|
||||
|
||||
companion object {
|
||||
private val KEY_COORDINATE = stringPreferencesKey("coordinate")
|
||||
private val KEY_POLICY = stringPreferencesKey("policy")
|
||||
private val KEY_LAST_USED = stringPreferencesKey("lastused")
|
||||
private const val OP_PREFIX = "op:"
|
||||
private const val OP_EXPIRY_SUFFIX = ":exp"
|
||||
|
||||
private fun hash(coordinate: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray())
|
||||
return digest.take(8).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
@@ -75,7 +76,11 @@ class NappletBrokerService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM.
|
||||
private val ledger by lazy { NappletPermissionLedger(DataStoreNappletPermissionStore(applicationContext)) }
|
||||
private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
|
||||
|
||||
// Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only
|
||||
// instantiated in the main process where the signer lives; never touched from :napplet.
|
||||
private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
|
||||
|
||||
// Per-applet sandboxed key-value store (namespaced by coordinate inside the impl).
|
||||
private val storage by lazy { DataStoreNappletStorage(applicationContext) }
|
||||
@@ -323,6 +328,7 @@ class NappletBrokerService : Service() {
|
||||
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
|
||||
// through Tor when asked + active, and falls back to clearnet otherwise.
|
||||
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
|
||||
signerLedger = signerLedger,
|
||||
).broker()
|
||||
cachedBroker = account to broker
|
||||
return broker
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
class NappletConnectActivity : ComponentActivity() {
|
||||
private var token: String? = null
|
||||
private var decided = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val token = intent.getStringExtra(NappletConnectCoordinator.EXTRA_TOKEN)
|
||||
this.token = token
|
||||
val info = token?.let { NappletConnectCoordinator.infoFor(it) }
|
||||
if (token == null || info == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
NappletConnectScreen(
|
||||
info = info,
|
||||
onConnect = { policy ->
|
||||
decided = true
|
||||
NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy))
|
||||
finish()
|
||||
},
|
||||
onBlock = {
|
||||
decided = true
|
||||
NappletConnectCoordinator.complete(token, AppConnectResult.Blocked)
|
||||
finish()
|
||||
},
|
||||
onCancel = {
|
||||
decided = true
|
||||
NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled)
|
||||
finish()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
if (!decided) token?.let { NappletConnectCoordinator.cancel(it) }
|
||||
super.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NappletConnectScreen(
|
||||
info: NappletConnectInfo,
|
||||
onConnect: (AppSignerPolicy) -> Unit,
|
||||
onBlock: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var selected by remember { mutableStateOf(AppSignerPolicy.REASONABLE) }
|
||||
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.9f
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.heightIn(max = maxHeight),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 24.dp),
|
||||
) {
|
||||
// Centered header: icon + app name + connect subtitle
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val isBrowser = info.coordinate.startsWith("browser:")
|
||||
FavoriteAppIcon(
|
||||
app =
|
||||
if (isBrowser) {
|
||||
FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
|
||||
} else {
|
||||
FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
|
||||
},
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
Text(
|
||||
info.appletTitle,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.napplet_connect_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
info.domain,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.napplet_connect_how_handle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Trust level options
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
PolicyOption(
|
||||
selected = selected == AppSignerPolicy.FULL_TRUST,
|
||||
icon = "❤",
|
||||
label = stringResource(R.string.napplet_policy_full_trust),
|
||||
description = stringResource(R.string.napplet_policy_full_trust_desc),
|
||||
onClick = { selected = AppSignerPolicy.FULL_TRUST },
|
||||
)
|
||||
PolicyOption(
|
||||
selected = selected == AppSignerPolicy.REASONABLE,
|
||||
icon = "👍",
|
||||
label = stringResource(R.string.napplet_policy_reasonable),
|
||||
description = stringResource(R.string.napplet_policy_reasonable_desc),
|
||||
onClick = { selected = AppSignerPolicy.REASONABLE },
|
||||
)
|
||||
PolicyOption(
|
||||
selected = selected == AppSignerPolicy.PARANOID,
|
||||
icon = "🕶",
|
||||
label = stringResource(R.string.napplet_policy_paranoid),
|
||||
description = stringResource(R.string.napplet_policy_paranoid_desc),
|
||||
onClick = { selected = AppSignerPolicy.PARANOID },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.napplet_connect_button))
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onBlock,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.napplet_connect_block, info.domain),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PolicyOption(
|
||||
selected: Boolean,
|
||||
icon: String,
|
||||
label: String,
|
||||
description: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = bgColor,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(icon, style = MaterialTheme.typography.headlineSmall)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (selected) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** Everything the "Connect to Nostr" dialog needs to render. */
|
||||
data class NappletConnectInfo(
|
||||
val appletTitle: String,
|
||||
val coordinate: String,
|
||||
val domain: String,
|
||||
val iconUrl: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bridges the broker to the "Connect to Nostr" first-connect UI. Suspends in [requestConnect];
|
||||
* the Activity resolves the deferred with the user's choice.
|
||||
* A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant.
|
||||
*/
|
||||
object NappletConnectCoordinator {
|
||||
private class Pending(
|
||||
val info: NappletConnectInfo,
|
||||
val deferred: CompletableDeferred<AppConnectResult>,
|
||||
)
|
||||
|
||||
private val pending = ConcurrentHashMap<String, Pending>()
|
||||
|
||||
suspend fun requestConnect(
|
||||
context: Context,
|
||||
info: NappletConnectInfo,
|
||||
): AppConnectResult {
|
||||
val token = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<AppConnectResult>()
|
||||
pending[token] = Pending(info, deferred)
|
||||
|
||||
context.startActivity(
|
||||
Intent(context, NappletConnectActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_TOKEN, token),
|
||||
)
|
||||
|
||||
return try {
|
||||
deferred.await()
|
||||
} finally {
|
||||
pending.remove(token)
|
||||
}
|
||||
}
|
||||
|
||||
fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info
|
||||
|
||||
fun complete(
|
||||
token: String,
|
||||
result: AppConnectResult,
|
||||
) {
|
||||
pending[token]?.deferred?.complete(result)
|
||||
}
|
||||
|
||||
fun cancel(token: String) {
|
||||
pending[token]?.deferred?.complete(AppConnectResult.Cancelled)
|
||||
}
|
||||
|
||||
const val EXTRA_TOKEN = "napplet_connect_token"
|
||||
}
|
||||
+143
-43
@@ -25,17 +25,34 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
@@ -93,49 +110,132 @@ private fun NappletConsentDialog(
|
||||
onDecision: (GrantState) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(info.appletTitle) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(info.operationSummary)
|
||||
Text(
|
||||
stringResource(R.string.napplet_consent_capability, info.capabilityLabel),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
info.coordinate,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
if (info.allowAlways) {
|
||||
TextButton(
|
||||
onClick = { onDecision(GrantState.ALLOW_ALWAYS) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) { Text(stringResource(R.string.napplet_consent_allow_always)) }
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.heightIn(max = maxHeight),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 24.dp),
|
||||
) {
|
||||
// Centered header: icon + app name + capability category + coordinate
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val isBrowser = info.coordinate.startsWith("browser:")
|
||||
FavoriteAppIcon(
|
||||
app =
|
||||
if (isBrowser) {
|
||||
FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
|
||||
} else {
|
||||
FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
|
||||
},
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
Text(
|
||||
info.appletTitle,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
info.capabilityLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = { onDecision(GrantState.ALLOW_ONCE) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) { Text(stringResource(R.string.napplet_consent_allow_once)) }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
TextButton(
|
||||
onClick = { onDecision(GrantState.DENY) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) { Text(stringResource(R.string.napplet_consent_deny_always)) }
|
||||
TextButton(
|
||||
|
||||
// Operation detail box (may include content preview)
|
||||
if (info.operationSummary.isNotBlank()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
info.operationSummary,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (info.allowAlways) {
|
||||
Button(
|
||||
onClick = { onDecision(GrantState.ALLOW_ALWAYS) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.napplet_consent_allow_always))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onDecision(GrantState.ALLOW_ONCE) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.napplet_consent_allow_once))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { onDecision(GrantState.ALLOW_ONCE) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.napplet_consent_allow_once))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onDecision(GrantState.ASK) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) { Text(stringResource(R.string.napplet_consent_not_now)) }
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.napplet_consent_not_now),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onDecision(GrantState.DENY) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.napplet_consent_deny_always),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ data class NappletConsentInfo(
|
||||
val operationSummary: String,
|
||||
/** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */
|
||||
val allowAlways: Boolean,
|
||||
val iconUrl: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
|
||||
@@ -41,13 +43,21 @@ class NappletConsentSummary(
|
||||
capability: NappletCapability,
|
||||
request: NappletRequest,
|
||||
): NappletConsentInfo {
|
||||
val title = identity.identifier.ifBlank { context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) }
|
||||
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
|
||||
host to BrowserIconRegistry.iconModelFor(host)
|
||||
} else {
|
||||
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
|
||||
}
|
||||
return NappletConsentInfo(
|
||||
appletTitle = title,
|
||||
coordinate = identity.coordinate,
|
||||
capabilityLabel = context.getString(capability.labelRes()),
|
||||
operationSummary = summaryFor(request),
|
||||
allowAlways = capability.canGrantAlways,
|
||||
iconUrl = iconUrl,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
|
||||
/**
|
||||
* Looks up the best-effort human title and icon URL for a napplet or nsite from the local cache.
|
||||
* Falls back to the d-identifier or [untitled] when no manifest is cached.
|
||||
*/
|
||||
fun resolveNappletMeta(
|
||||
author: String,
|
||||
identifier: String,
|
||||
untitled: String,
|
||||
): Pair<String, String?> {
|
||||
val events =
|
||||
Amethyst.instance.cache
|
||||
.filter(
|
||||
Filter(
|
||||
kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND, RootSiteEvent.KIND, NamedSiteEvent.KIND),
|
||||
authors = listOf(author),
|
||||
),
|
||||
).mapNotNull { it.event }
|
||||
val match =
|
||||
events.firstOrNull { ev ->
|
||||
when (ev) {
|
||||
is NamedNappletEvent -> ev.identifier() == identifier
|
||||
is RootNappletEvent -> identifier.isEmpty()
|
||||
is NamedSiteEvent -> ev.identifier() == identifier
|
||||
is RootSiteEvent -> identifier.isEmpty()
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
val title =
|
||||
when (match) {
|
||||
is NappletManifest -> match.title()
|
||||
is RootSiteEvent -> match.title()
|
||||
is NamedSiteEvent -> match.title()
|
||||
else -> null
|
||||
}?.ifBlank { null } ?: identifier.ifBlank { untitled }
|
||||
val iconUrl =
|
||||
when (match) {
|
||||
is NappletManifest -> match.icon()
|
||||
is RootSiteEvent -> match.icon()
|
||||
is NamedSiteEvent -> match.icon()
|
||||
else -> null
|
||||
}?.ifBlank { null }
|
||||
return title to iconUrl
|
||||
}
|
||||
+325
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** Everything the per-operation consent dialog needs to render. */
|
||||
data class NappletSignerConsentInfo(
|
||||
val appletTitle: String,
|
||||
val coordinate: String,
|
||||
val op: NostrSignerOp,
|
||||
val operationSummary: String,
|
||||
/** Short excerpt shown in the dialog body (≤ 160 chars). */
|
||||
val contentPreview: String,
|
||||
/**
|
||||
* Full raw content for the "See more" toggle — event JSON for sign/encrypt operations,
|
||||
* decrypted plaintext for decrypt (Amethyst decrypts first, then asks permission to expose).
|
||||
*/
|
||||
val rawData: String = "",
|
||||
val iconUrl: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bridges the broker to the per-operation signer consent UI.
|
||||
* A dismissed dialog resolves to [SignerOpGrant.DenyOnce] — fails closed.
|
||||
*/
|
||||
object NappletSignerConsentCoordinator {
|
||||
private class Pending(
|
||||
val info: NappletSignerConsentInfo,
|
||||
val deferred: CompletableDeferred<SignerOpGrant>,
|
||||
)
|
||||
|
||||
private val pending = ConcurrentHashMap<String, Pending>()
|
||||
|
||||
suspend fun requestConsent(
|
||||
context: Context,
|
||||
info: NappletSignerConsentInfo,
|
||||
): SignerOpGrant {
|
||||
val token = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<SignerOpGrant>()
|
||||
pending[token] = Pending(info, deferred)
|
||||
|
||||
context.startActivity(
|
||||
Intent(context, NappletSignerConsentActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_TOKEN, token),
|
||||
)
|
||||
|
||||
return try {
|
||||
deferred.await()
|
||||
} finally {
|
||||
pending.remove(token)
|
||||
}
|
||||
}
|
||||
|
||||
fun infoFor(token: String): NappletSignerConsentInfo? = pending[token]?.info
|
||||
|
||||
fun complete(
|
||||
token: String,
|
||||
grant: SignerOpGrant,
|
||||
) {
|
||||
pending[token]?.deferred?.complete(grant)
|
||||
}
|
||||
|
||||
fun cancel(token: String) {
|
||||
pending[token]?.deferred?.complete(SignerOpGrant.DenyOnce)
|
||||
}
|
||||
|
||||
const val EXTRA_TOKEN = "napplet_signer_consent_token"
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/** Human-readable label for a [NostrSignerOp]. */
|
||||
fun NostrSignerOp.label(context: Context): String =
|
||||
when (this) {
|
||||
is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind)
|
||||
NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt)
|
||||
NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt)
|
||||
}
|
||||
|
||||
/** Builds the [NappletSignerConsentInfo] needed by the per-op consent dialog. */
|
||||
fun buildSignerConsentInfo(
|
||||
context: Context,
|
||||
identity: NappletIdentity,
|
||||
op: NostrSignerOp,
|
||||
request: NappletRequest,
|
||||
): NappletSignerConsentInfo {
|
||||
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
|
||||
host to BrowserIconRegistry.iconModelFor(host)
|
||||
} else {
|
||||
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
|
||||
}
|
||||
val summary = op.label(context)
|
||||
val preview =
|
||||
when (request) {
|
||||
is NappletRequest.Publish -> request.content.take(160).trim()
|
||||
is NappletRequest.SignEvent -> request.content.take(160).trim()
|
||||
is NappletRequest.PublishEncrypted -> request.content.take(160).trim()
|
||||
else -> ""
|
||||
}
|
||||
val rawData =
|
||||
when (request) {
|
||||
is NappletRequest.Publish ->
|
||||
JacksonMapper.toJsonPretty(EventTemplate<Nothing>(TimeUtils.now(), request.kind, request.tags, request.content))
|
||||
is NappletRequest.SignEvent ->
|
||||
JacksonMapper.toJsonPretty(EventTemplate<Nothing>(request.createdAt, request.kind, request.tags, request.content))
|
||||
is NappletRequest.PublishEncrypted -> {
|
||||
val node = JacksonMapper.mapper.createObjectNode()
|
||||
node.put("kind", request.kind)
|
||||
node.put("recipient", request.recipient)
|
||||
node.put("encryption", request.encryption)
|
||||
val tagsNode = node.putArray("tags")
|
||||
for (tag in request.tags) {
|
||||
val tagNode = tagsNode.addArray()
|
||||
for (item in tag) tagNode.add(item)
|
||||
}
|
||||
node.put("content", request.content)
|
||||
JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node)
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
return NappletSignerConsentInfo(
|
||||
appletTitle = title,
|
||||
coordinate = identity.coordinate,
|
||||
op = op,
|
||||
operationSummary = summary,
|
||||
contentPreview = preview,
|
||||
rawData = rawData,
|
||||
iconUrl = iconUrl,
|
||||
)
|
||||
}
|
||||
|
||||
/** Creates a [NappletConnectInfo] for the first-connect dialog. */
|
||||
fun buildConnectInfo(
|
||||
context: Context,
|
||||
identity: NappletIdentity,
|
||||
): NappletConnectInfo {
|
||||
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
|
||||
host to BrowserIconRegistry.iconModelFor(host)
|
||||
} else {
|
||||
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
|
||||
}
|
||||
val domain =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
|
||||
} else {
|
||||
identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" }
|
||||
}
|
||||
return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl)
|
||||
}
|
||||
+25
-1
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+21
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-15
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -43,30 +44,56 @@ class AuthCoordinator(
|
||||
NostrSignerSync()
|
||||
}
|
||||
|
||||
@Volatile private var relayLedgers: List<RelayAuthPermissionLedger> = emptyList()
|
||||
|
||||
fun subscribeLedger(ledger: RelayAuthPermissionLedger) {
|
||||
synchronized(this) { relayLedgers = relayLedgers + ledger }
|
||||
}
|
||||
|
||||
fun unsubscribeLedger(ledger: RelayAuthPermissionLedger) {
|
||||
synchronized(this) { relayLedgers = relayLedgers - ledger }
|
||||
}
|
||||
|
||||
val receiver =
|
||||
RelayAuthenticator(
|
||||
client,
|
||||
scope,
|
||||
signWithAllLoggedInUsers = { authTemplate ->
|
||||
val results =
|
||||
authWithAccounts.distinct().mapNotNull {
|
||||
if (it.signer.isWriteable()) {
|
||||
try {
|
||||
it.signer.sign(authTemplate)
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
|
||||
null
|
||||
signWithAllLoggedInUsers = { relayUrl, authTemplate ->
|
||||
val currentLedgers = relayLedgers
|
||||
val shouldAuth =
|
||||
if (currentLedgers.isEmpty()) {
|
||||
true
|
||||
} else {
|
||||
var allow = false
|
||||
for (ledger in currentLedgers) {
|
||||
if (ledger.decide(relayUrl.url) == RelayAuthDecision.ALLOW) {
|
||||
allow = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
allow
|
||||
}
|
||||
|
||||
// Always auth, even with random keys
|
||||
if (!results.isEmpty()) {
|
||||
results
|
||||
if (shouldAuth) {
|
||||
// distinct() returns Set<Account> (the key type U of ListWithUniqueSetCache)
|
||||
val results =
|
||||
authWithAccounts.distinct().mapNotNull {
|
||||
if (it.signer.isWriteable()) {
|
||||
try {
|
||||
it.signer.sign(authTemplate)
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Always auth, even with random keys
|
||||
if (results.isNotEmpty()) results else listOf(tempAccount.sign(authTemplate))
|
||||
} else {
|
||||
listOf(tempAccount.sign(authTemplate))
|
||||
emptyList()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides
|
||||
* live in one `datastore/relay_auth.preferences_pb` file; a SHA-256 prefix of the URL is the
|
||||
* key so the URL itself is safe in the file (stored separately for reverse-lookup in [allDecisions]).
|
||||
*/
|
||||
class DataStoreRelayAuthPermissionStore(
|
||||
private val filesDir: File,
|
||||
) : RelayAuthPermissionStore {
|
||||
constructor(context: Context) : this(context.applicationContext.filesDir)
|
||||
|
||||
private val store: DataStore<Preferences> by lazy {
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { File(filesDir, "datastore/relay_auth.preferences_pb") },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? {
|
||||
val raw = store.data.first()[decisionKey(relayUrl)] ?: return null
|
||||
return runCatching { RelayAuthDecision.valueOf(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override suspend fun storeDecision(
|
||||
relayUrl: String,
|
||||
decision: RelayAuthDecision,
|
||||
) {
|
||||
store.edit {
|
||||
it[urlKey(relayUrl)] = relayUrl
|
||||
it[decisionKey(relayUrl)] = decision.name
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearDecision(relayUrl: String) {
|
||||
store.edit {
|
||||
it.remove(decisionKey(relayUrl))
|
||||
it.remove(urlKey(relayUrl))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun allDecisions(): Map<String, RelayAuthDecision> {
|
||||
val prefs = store.data.first()
|
||||
val result = mutableMapOf<String, RelayAuthDecision>()
|
||||
for ((key, value) in prefs.asMap()) {
|
||||
val name = key.name
|
||||
if (!name.startsWith(DECISION_PREFIX)) continue
|
||||
val hash = name.removePrefix(DECISION_PREFIX)
|
||||
val url = prefs[stringPreferencesKey("$URL_PREFIX$hash")] ?: continue
|
||||
val decision = runCatching { RelayAuthDecision.valueOf(value as String) }.getOrNull() ?: continue
|
||||
result[url] = decision
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun decisionKey(relayUrl: String) = stringPreferencesKey("$DECISION_PREFIX${hash(relayUrl)}")
|
||||
|
||||
private fun urlKey(relayUrl: String) = stringPreferencesKey("$URL_PREFIX${hash(relayUrl)}")
|
||||
|
||||
companion object {
|
||||
private const val DECISION_PREFIX = "allow:"
|
||||
private const val URL_PREFIX = "url:"
|
||||
|
||||
private fun hash(relayUrl: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(relayUrl.toByteArray())
|
||||
return digest.take(8).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
|
||||
/**
|
||||
* Decides whether Amethyst should authenticate with a given relay (NIP-42).
|
||||
*
|
||||
* Decision order:
|
||||
* 1. Per-relay override stored in [store] — always wins.
|
||||
* 2. [globalPolicy]:
|
||||
* - [RelayAuthPolicy.ALWAYS] → [RelayAuthDecision.ALLOW]
|
||||
* - [RelayAuthPolicy.NEVER] → [RelayAuthDecision.DENY]
|
||||
* - [RelayAuthPolicy.IF_IN_MY_LIST] → [RelayAuthDecision.ALLOW] iff [isInMyRelayList] returns true.
|
||||
*/
|
||||
class RelayAuthPermissionLedger(
|
||||
val store: RelayAuthPermissionStore,
|
||||
val globalPolicy: () -> RelayAuthPolicy,
|
||||
val isInMyRelayList: (String) -> Boolean = { false },
|
||||
) {
|
||||
/** The authorization verdict for [relayUrl]. */
|
||||
suspend fun decide(relayUrl: String): RelayAuthDecision {
|
||||
store.loadDecision(relayUrl)?.let { return it }
|
||||
return when (globalPolicy()) {
|
||||
RelayAuthPolicy.ALWAYS -> RelayAuthDecision.ALLOW
|
||||
RelayAuthPolicy.NEVER -> RelayAuthDecision.DENY
|
||||
RelayAuthPolicy.IF_IN_MY_LIST ->
|
||||
if (isInMyRelayList(relayUrl)) RelayAuthDecision.ALLOW else RelayAuthDecision.DENY
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores a per-relay override for [relayUrl]. */
|
||||
suspend fun setDecision(
|
||||
relayUrl: String,
|
||||
decision: RelayAuthDecision,
|
||||
) = store.storeDecision(relayUrl, decision)
|
||||
|
||||
/** Removes the per-relay override for [relayUrl], reverting to the global policy. */
|
||||
suspend fun clearDecision(relayUrl: String) = store.clearDecision(relayUrl)
|
||||
|
||||
/** All per-relay overrides — for the settings screen. */
|
||||
suspend fun allDecisions(): Map<String, RelayAuthDecision> = store.allDecisions()
|
||||
}
|
||||
+2
@@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.Live
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicPlaylistsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.ConnectedAppsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler
|
||||
@@ -146,6 +147,7 @@ class RelaySubscriptionsCoordinator(
|
||||
val onePodcast = OnePodcastFilterAssembler(client)
|
||||
val softwareApps = SoftwareAppsFilterAssembler(client)
|
||||
val napplets = NappletsFilterAssembler(client)
|
||||
val connectedApps = ConnectedAppsFilterAssembler(client)
|
||||
val nsites = NsitesFilterAssembler(client)
|
||||
val badges = BadgesFilterAssembler(client)
|
||||
val profileBadges = ProfileBadgesFilterAssembler(client)
|
||||
|
||||
@@ -159,7 +159,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicPlaylistsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicTracksScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicPlaylistScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicTrackScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletPermissionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppDetailScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen
|
||||
@@ -183,6 +184,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.PublicChatsScre
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth.RelayAuthSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen
|
||||
@@ -335,7 +337,9 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.FavoriteApps> { FavoriteAppsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.WebApp> { WebAppScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.NostrApp> { NostrAppScreen(it.coordinate, accountViewModel, nav) }
|
||||
composableFromEnd<Route.NappletPermissions> { NappletPermissionsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ConnectedApps> { ConnectedAppsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ConnectedAppDetail> { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayAuthSettings> { RelayAuthSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -105,7 +105,13 @@ sealed class Route {
|
||||
val coordinate: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable object NappletPermissions : Route()
|
||||
@Serializable object ConnectedApps : Route()
|
||||
|
||||
@Serializable object RelayAuthSettings : Route()
|
||||
|
||||
@Serializable data class ConnectedAppDetail(
|
||||
val coordinate: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class SoftwareAppDetail(
|
||||
val kind: Int,
|
||||
|
||||
+1
-1
@@ -308,7 +308,7 @@ class AccountViewModel(
|
||||
RelayAuthenticator(
|
||||
newClient,
|
||||
customScope,
|
||||
signWithAllLoggedInUsers = { authTemplate ->
|
||||
signWithAllLoggedInUsers = { _, authTemplate ->
|
||||
if (account.signer.isWriteable()) {
|
||||
try {
|
||||
listOf(account.signer.sign(authTemplate))
|
||||
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel
|
||||
import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel
|
||||
import com.vitorpamplona.amethyst.napplet.descriptionRes
|
||||
import com.vitorpamplona.amethyst.napplet.labelRes
|
||||
import com.vitorpamplona.amethyst.napplet.resolveNappletMeta
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.vitorpamplona.amethyst.commons.R as CommonsR
|
||||
|
||||
private data class ConnectedAppDetailState(
|
||||
val title: String,
|
||||
val coordinate: String,
|
||||
val iconUrl: String?,
|
||||
val signerPolicy: AppSignerPolicy?,
|
||||
val opOverrides: Map<String, NostrOpDecision>,
|
||||
val capabilities: List<Pair<NappletCapability, GrantState>>,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ConnectedAppDetailScreen(
|
||||
coordinate: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
|
||||
val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
|
||||
val untitled = stringResource(CommonsR.string.napplet_untitled)
|
||||
|
||||
var state by remember { mutableStateOf<ConnectedAppDetailState?>(null) }
|
||||
var reload by remember { mutableIntStateOf(0) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(coordinate, reload) {
|
||||
state =
|
||||
withContext(Dispatchers.Default) {
|
||||
loadDetailState(coordinate, capabilityLedger, signerLedger, untitled)
|
||||
}
|
||||
}
|
||||
|
||||
fun mutate(block: suspend () -> Unit) {
|
||||
scope.launch {
|
||||
block()
|
||||
reload++
|
||||
}
|
||||
}
|
||||
|
||||
val identity =
|
||||
remember(coordinate) {
|
||||
NappletIdentity(
|
||||
authorPubKey = coordinate.substringBefore(':'),
|
||||
identifier = coordinate.substringAfter(':', ""),
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(state?.title ?: coordinate.substringAfter(':', "").ifBlank { coordinate.take(12) + "…" }, nav) },
|
||||
) { padding ->
|
||||
val current = state
|
||||
if (current == null) {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// App identity header
|
||||
AppIdentityHeader(current)
|
||||
|
||||
// Signing trust level section
|
||||
if (current.signerPolicy != null) {
|
||||
SectionHeader(stringResource(R.string.napplet_connected_app_trust_level))
|
||||
PolicyPicker(
|
||||
selected = current.signerPolicy,
|
||||
onSelect = { newPolicy ->
|
||||
mutate { signerLedger.setPolicy(coordinate, newPolicy) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Signing operation overrides section
|
||||
if (current.opOverrides.isNotEmpty()) {
|
||||
SectionHeader(stringResource(R.string.napplet_connected_app_op_overrides))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(4.dp)) {
|
||||
current.opOverrides.entries.forEachIndexed { index, (opKey, decision) ->
|
||||
if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
|
||||
OpOverrideRow(
|
||||
opKey = opKey,
|
||||
decision = decision,
|
||||
onRevoke = { mutate { signerLedger.revokeOpDecision(coordinate, NostrSignerOp.fromKey(opKey) ?: return@mutate) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capabilities section
|
||||
if (current.capabilities.isNotEmpty()) {
|
||||
SectionHeader(stringResource(R.string.napplet_connected_app_capabilities))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(4.dp)) {
|
||||
current.capabilities.forEachIndexed { index, (cap, grant) ->
|
||||
if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
|
||||
CapabilityDetailRow(
|
||||
capability = cap,
|
||||
grant = grant,
|
||||
onSetGrant = { newGrant ->
|
||||
mutate {
|
||||
if (newGrant == null) {
|
||||
capabilityLedger.revoke(identity, cap)
|
||||
} else {
|
||||
capabilityLedger.record(identity, cap, newGrant)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forget button
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
mutate {
|
||||
signerLedger.revokeAll(coordinate)
|
||||
capabilityLedger.revokeAll(identity)
|
||||
}
|
||||
nav.popBack()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer),
|
||||
) {
|
||||
Icon(MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.napplet_connected_app_forget))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppIdentityHeader(state: ConnectedAppDetailState) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
val isBrowserEntry = state.coordinate.startsWith("browser:")
|
||||
val author = state.coordinate.substringBefore(':')
|
||||
val identifier = state.coordinate.substringAfter(':', "")
|
||||
val iconModel: String?
|
||||
val appForIcon: FavoriteApp
|
||||
if (isBrowserEntry) {
|
||||
iconModel = rememberWebAppIconModel(identifier)
|
||||
appForIcon = FavoriteApp.WebApp(identifier, state.title, 0L)
|
||||
} else {
|
||||
iconModel = rememberManifestIconModel(author, identifier)
|
||||
appForIcon = FavoriteApp.NostrApp(state.coordinate, state.title, 0L, state.iconUrl)
|
||||
}
|
||||
FavoriteAppIcon(
|
||||
app = appForIcon,
|
||||
iconModel = iconModel,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
state.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val domain =
|
||||
if (author == "browser") {
|
||||
identifier
|
||||
.removePrefix("https://")
|
||||
.removePrefix("http://")
|
||||
.substringBefore('/')
|
||||
.ifBlank { identifier }
|
||||
} else {
|
||||
identifier.ifBlank { author.take(12) + "…" }
|
||||
}
|
||||
Text(
|
||||
domain,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PolicyPicker(
|
||||
selected: AppSignerPolicy,
|
||||
onSelect: (AppSignerPolicy) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
PolicyCard(
|
||||
selected = selected == AppSignerPolicy.FULL_TRUST,
|
||||
symbol = MaterialSymbols.Favorite,
|
||||
label = stringResource(R.string.napplet_policy_full_trust),
|
||||
description = stringResource(R.string.napplet_policy_full_trust_desc),
|
||||
onClick = { onSelect(AppSignerPolicy.FULL_TRUST) },
|
||||
)
|
||||
PolicyCard(
|
||||
selected = selected == AppSignerPolicy.REASONABLE,
|
||||
symbol = MaterialSymbols.Shield,
|
||||
label = stringResource(R.string.napplet_policy_reasonable),
|
||||
description = stringResource(R.string.napplet_policy_reasonable_desc),
|
||||
onClick = { onSelect(AppSignerPolicy.REASONABLE) },
|
||||
)
|
||||
PolicyCard(
|
||||
selected = selected == AppSignerPolicy.PARANOID,
|
||||
symbol = MaterialSymbols.Lock,
|
||||
label = stringResource(R.string.napplet_policy_paranoid),
|
||||
description = stringResource(R.string.napplet_policy_paranoid_desc),
|
||||
onClick = { onSelect(AppSignerPolicy.PARANOID) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OpOverrideRow(
|
||||
opKey: String,
|
||||
decision: NostrOpDecision,
|
||||
onRevoke: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
NostrSignerOp.fromKey(opKey)?.opLabel() ?: opKey,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
decision.decisionLabel(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color =
|
||||
when (decision) {
|
||||
NostrOpDecision.DENY -> MaterialTheme.colorScheme.error
|
||||
NostrOpDecision.ALLOW -> MaterialTheme.colorScheme.primary
|
||||
NostrOpDecision.ASK -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
IconButton(onClick = onRevoke) {
|
||||
Icon(
|
||||
MaterialSymbols.Delete,
|
||||
contentDescription = stringResource(R.string.napplet_signer_permissions_revoke_all),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CapabilityDetailRow(
|
||||
capability: NappletCapability,
|
||||
grant: GrantState,
|
||||
onSetGrant: (GrantState?) -> Unit,
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDialog) {
|
||||
CapabilityPermissionDialog(
|
||||
capability = capability,
|
||||
current = grant,
|
||||
onSetGrant = { newGrant ->
|
||||
showDialog = false
|
||||
onSetGrant(newGrant)
|
||||
},
|
||||
onDismiss = { showDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { showDialog = true }
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
) {
|
||||
Icon(
|
||||
capability.symbol(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(capability.labelRes()), style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
stringResource(capability.descriptionRes()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
when (grant) {
|
||||
GrantState.ALLOW_ALWAYS -> stringResource(R.string.napplet_consent_allow_always)
|
||||
GrantState.DENY -> stringResource(R.string.napplet_consent_deny_always)
|
||||
else -> stringResource(R.string.napplet_permissions_ask_each_time)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color =
|
||||
when (grant) {
|
||||
GrantState.ALLOW_ALWAYS -> MaterialTheme.colorScheme.primary
|
||||
GrantState.DENY -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Icon(
|
||||
MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CapabilityPermissionDialog(
|
||||
capability: NappletCapability,
|
||||
current: GrantState,
|
||||
onSetGrant: (GrantState?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val initial =
|
||||
when (current) {
|
||||
GrantState.ALLOW_ALWAYS -> GrantState.ALLOW_ALWAYS
|
||||
GrantState.DENY -> GrantState.DENY
|
||||
else -> GrantState.ASK
|
||||
}
|
||||
var selected by remember { mutableStateOf(initial) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(capability.labelRes())) },
|
||||
text = {
|
||||
Column {
|
||||
GrantOption(
|
||||
label = stringResource(R.string.napplet_permissions_ask_each_time),
|
||||
selected = selected == GrantState.ASK,
|
||||
onClick = { selected = GrantState.ASK },
|
||||
)
|
||||
if (!capability.requiresPerUseConsent) {
|
||||
GrantOption(
|
||||
label = stringResource(R.string.napplet_consent_allow_always),
|
||||
selected = selected == GrantState.ALLOW_ALWAYS,
|
||||
onClick = { selected = GrantState.ALLOW_ALWAYS },
|
||||
)
|
||||
}
|
||||
GrantOption(
|
||||
label = stringResource(R.string.napplet_consent_deny_always),
|
||||
selected = selected == GrantState.DENY,
|
||||
onClick = { selected = GrantState.DENY },
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onSetGrant(if (selected == GrantState.ASK) null else selected) },
|
||||
) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GrantOption(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NostrSignerOp.opLabel(): String =
|
||||
when (this) {
|
||||
is NostrSignerOp.SignKind -> stringResource(R.string.napplet_op_sign_kind, kind)
|
||||
NostrSignerOp.Encrypt -> stringResource(R.string.napplet_op_encrypt)
|
||||
NostrSignerOp.Decrypt -> stringResource(R.string.napplet_op_decrypt)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NostrOpDecision.decisionLabel(): String =
|
||||
when (this) {
|
||||
NostrOpDecision.ALLOW -> stringResource(R.string.napplet_decision_allow)
|
||||
NostrOpDecision.ASK -> stringResource(R.string.napplet_decision_ask)
|
||||
NostrOpDecision.DENY -> stringResource(R.string.napplet_decision_deny)
|
||||
}
|
||||
|
||||
private suspend fun loadDetailState(
|
||||
coordinate: String,
|
||||
capabilityLedger: NappletPermissionLedger,
|
||||
signerLedger: NostrSignerPermissionLedger,
|
||||
untitled: String,
|
||||
): ConnectedAppDetailState {
|
||||
val author = coordinate.substringBefore(':')
|
||||
val identifier = coordinate.substringAfter(':', "")
|
||||
val identity = NappletIdentity(authorPubKey = author, identifier = identifier)
|
||||
|
||||
val allGrants = capabilityLedger.allPersistedGrants()
|
||||
val capGrants =
|
||||
allGrants[coordinate]
|
||||
?.entries
|
||||
?.sortedBy { it.key.ordinal }
|
||||
?.map { it.key to it.value }
|
||||
?: emptyList()
|
||||
val signerPolicy = signerLedger.store.loadPolicy(coordinate)
|
||||
val opOverrides = signerLedger.store.allOpDecisions(coordinate)
|
||||
|
||||
val (title, iconUrl) =
|
||||
if (author == "browser") {
|
||||
val host = OmniboxInput.hostOf(identifier) ?: identifier
|
||||
host to BrowserIconRegistry.iconModelFor(host)
|
||||
} else {
|
||||
resolveNappletMeta(author, identifier, untitled)
|
||||
}
|
||||
return ConnectedAppDetailState(
|
||||
title = title,
|
||||
coordinate = coordinate,
|
||||
iconUrl = iconUrl,
|
||||
signerPolicy = signerPolicy,
|
||||
opOverrides = opOverrides,
|
||||
capabilities = capGrants,
|
||||
)
|
||||
}
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel
|
||||
import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.ConnectedAppsFilterAssemblerSubscription
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.vitorpamplona.amethyst.commons.R as CommonsR
|
||||
|
||||
/** Author placeholder used by the browser permission path — not a real pubkey. */
|
||||
private const val BROWSER_AUTHOR = "browser"
|
||||
|
||||
private data class ConnectedAppEntry(
|
||||
val coordinate: String,
|
||||
val signerPolicy: AppSignerPolicy?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ConnectedAppsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
|
||||
val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
|
||||
|
||||
var items by remember { mutableStateOf<List<ConnectedAppEntry>?>(null) }
|
||||
var nappletAuthors by remember { mutableStateOf<Set<HexKey>>(emptySet()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val initial =
|
||||
withContext(Dispatchers.Default) {
|
||||
loadConnectedApps(capabilityLedger, signerLedger)
|
||||
}
|
||||
items = initial
|
||||
// Only include real pubkeys (not the "browser" sentinel) in the relay subscription.
|
||||
nappletAuthors =
|
||||
initial
|
||||
.map { it.coordinate.substringBefore(':') }
|
||||
.filter { it != BROWSER_AUTHOR }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
ConnectedAppsFilterAssemblerSubscription(accountViewModel, nappletAuthors)
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringResource(R.string.napplet_permissions_title), nav) },
|
||||
) { padding ->
|
||||
val current = items
|
||||
when {
|
||||
current == null ->
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
current.isEmpty() ->
|
||||
Box(Modifier.fillMaxSize().padding(padding).padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Apps,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.napplet_connected_app_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val untitled = stringResource(CommonsR.string.napplet_untitled)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(current, key = { it.coordinate }) { entry ->
|
||||
ConnectedAppCard(
|
||||
entry = entry,
|
||||
untitled = untitled,
|
||||
onClick = { nav.nav(Route.ConnectedAppDetail(entry.coordinate)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberManifestEvent(
|
||||
author: String,
|
||||
identifier: String,
|
||||
): Event? {
|
||||
val nappletCoord =
|
||||
remember(author, identifier) {
|
||||
if (identifier.isEmpty()) "${RootNappletEvent.KIND}:$author:" else "${NamedNappletEvent.KIND}:$author:$identifier"
|
||||
}
|
||||
val nsiteCoord =
|
||||
remember(author, identifier) {
|
||||
if (identifier.isEmpty()) "${RootSiteEvent.KIND}:$author:" else "${NamedSiteEvent.KIND}:$author:$identifier"
|
||||
}
|
||||
val nappletNote = remember(nappletCoord) { LocalCache.checkGetOrCreateAddressableNote(nappletCoord) } ?: return null
|
||||
val nsiteNote = remember(nsiteCoord) { LocalCache.checkGetOrCreateAddressableNote(nsiteCoord) } ?: return null
|
||||
val nappletState by nappletNote
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val nsiteState by nsiteNote
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
return nappletState.note.event ?: nsiteState.note.event
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectedAppCard(
|
||||
entry: ConnectedAppEntry,
|
||||
untitled: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val author = remember(entry.coordinate) { entry.coordinate.substringBefore(':') }
|
||||
if (author == BROWSER_AUTHOR) {
|
||||
val url = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") }
|
||||
BrowserAppCard(url = url, entry = entry, onClick = onClick)
|
||||
} else {
|
||||
NappletAppCard(author = author, entry = entry, untitled = untitled, onClick = onClick)
|
||||
}
|
||||
}
|
||||
|
||||
/** Card for a web app permission entry — the user visited this origin in the sandboxed browser. */
|
||||
@Composable
|
||||
private fun BrowserAppCard(
|
||||
url: String,
|
||||
entry: ConnectedAppEntry,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val domain =
|
||||
remember(url) {
|
||||
url
|
||||
.removePrefix("https://")
|
||||
.removePrefix("http://")
|
||||
.substringBefore('/')
|
||||
.ifBlank { url }
|
||||
}
|
||||
|
||||
val iconModel = rememberWebAppIconModel(url)
|
||||
|
||||
ConnectedAppCardLayout(
|
||||
app = FavoriteApp.WebApp(url, domain, 0L),
|
||||
iconModel = iconModel,
|
||||
title = domain,
|
||||
subtitle = url,
|
||||
npub = null,
|
||||
signerPolicy = entry.signerPolicy,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** Card for a napplet / nsite permission entry — resolves title and icon from the live manifest. */
|
||||
@Composable
|
||||
private fun NappletAppCard(
|
||||
author: String,
|
||||
entry: ConnectedAppEntry,
|
||||
untitled: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val identifier = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") }
|
||||
val kind = if (identifier.isEmpty()) RootNappletEvent.KIND else NamedNappletEvent.KIND
|
||||
val fullCoordinate = remember(entry.coordinate) { "$kind:$author:$identifier" }
|
||||
|
||||
val iconModel = rememberManifestIconModel(author, identifier)
|
||||
val event = rememberManifestEvent(author, identifier)
|
||||
val title =
|
||||
when (event) {
|
||||
is NappletManifest -> event.title()
|
||||
is RootSiteEvent -> event.title()
|
||||
is NamedSiteEvent -> event.title()
|
||||
else -> null
|
||||
}?.ifBlank { null } ?: identifier.ifBlank { untitled }
|
||||
val iconUrl =
|
||||
when (event) {
|
||||
is NappletManifest -> event.icon()
|
||||
is RootSiteEvent -> event.icon()
|
||||
is NamedSiteEvent -> event.icon()
|
||||
else -> null
|
||||
}?.ifBlank { null }
|
||||
|
||||
val npub = remember(author) { runCatching { NPub.create(author) }.getOrDefault(author.take(12) + "…") }
|
||||
val domain = identifier.ifBlank { author.take(12) + "…" }
|
||||
|
||||
ConnectedAppCardLayout(
|
||||
app = FavoriteApp.NostrApp(fullCoordinate, title, 0L, iconUrl),
|
||||
iconModel = iconModel,
|
||||
title = title,
|
||||
subtitle = domain,
|
||||
npub = npub,
|
||||
signerPolicy = entry.signerPolicy,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectedAppCardLayout(
|
||||
app: FavoriteApp,
|
||||
iconModel: Any?,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
npub: String?,
|
||||
signerPolicy: AppSignerPolicy?,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
FavoriteAppIcon(
|
||||
app = app,
|
||||
iconModel = iconModel,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (npub != null) {
|
||||
Text(
|
||||
npub,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (signerPolicy != null) {
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
signerPolicy.shortLabel(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppSignerPolicy.shortLabel(): String =
|
||||
when (this) {
|
||||
AppSignerPolicy.FULL_TRUST -> stringResource(R.string.napplet_policy_full_trust)
|
||||
AppSignerPolicy.REASONABLE -> stringResource(R.string.napplet_policy_reasonable)
|
||||
AppSignerPolicy.PARANOID -> stringResource(R.string.napplet_policy_paranoid)
|
||||
}
|
||||
|
||||
private suspend fun loadConnectedApps(
|
||||
capabilityLedger: NappletPermissionLedger,
|
||||
signerLedger: NostrSignerPermissionLedger,
|
||||
): List<ConnectedAppEntry> {
|
||||
val capGrants = capabilityLedger.allPersistedGrants()
|
||||
val signerPolicies = signerLedger.store.allPolicies()
|
||||
val allCoordinates = (capGrants.keys + signerPolicies.keys).toSet()
|
||||
return allCoordinates
|
||||
.map { coordinate ->
|
||||
ConnectedAppEntry(
|
||||
coordinate = coordinate,
|
||||
signerPolicy = signerPolicies[coordinate],
|
||||
)
|
||||
}.sortedBy { it.coordinate }
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
-347
@@ -1,347 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
|
||||
import com.vitorpamplona.amethyst.napplet.descriptionRes
|
||||
import com.vitorpamplona.amethyst.napplet.labelRes
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.vitorpamplona.amethyst.commons.R as CommonsR
|
||||
|
||||
/** One napplet's persisted permission grants, ready to render. */
|
||||
private data class NappletGrantsUi(
|
||||
val identity: NappletIdentity,
|
||||
val title: String,
|
||||
val capabilities: List<Pair<NappletCapability, GrantState>>,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NappletPermissionsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val ledger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) }
|
||||
val untitled = stringResource(CommonsR.string.napplet_untitled)
|
||||
|
||||
var items by remember { mutableStateOf<List<NappletGrantsUi>?>(null) }
|
||||
var reload by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(reload) {
|
||||
items = withContext(Dispatchers.Default) { loadGrants(ledger, untitled) }
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun mutate(block: suspend () -> Unit) {
|
||||
scope.launch {
|
||||
block()
|
||||
reload++
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringResource(R.string.napplet_permissions), nav) },
|
||||
) { padding ->
|
||||
val current = items
|
||||
when {
|
||||
current == null ->
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
current.isEmpty() -> EmptyState(Modifier.fillMaxSize().padding(padding))
|
||||
|
||||
else ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(current, key = { it.identity.coordinate }) { napplet ->
|
||||
NappletPermissionCard(
|
||||
napplet = napplet,
|
||||
onSetAllowed = { cap, allowed ->
|
||||
mutate { ledger.record(napplet.identity, cap, if (allowed) GrantState.ALLOW_ALWAYS else GrantState.DENY) }
|
||||
},
|
||||
onRevoke = { cap -> mutate { ledger.revoke(napplet.identity, cap) } },
|
||||
onForget = { mutate { ledger.revokeAll(napplet.identity) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(modifier: Modifier) {
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Shield,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.napplet_permissions_empty),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.napplet_permissions_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NappletPermissionCard(
|
||||
napplet: NappletGrantsUi,
|
||||
onSetAllowed: (NappletCapability, Boolean) -> Unit,
|
||||
onRevoke: (NappletCapability) -> Unit,
|
||||
onForget: () -> Unit,
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier.size(44.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
MaterialSymbols.Apps,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
napplet.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
napplet.identity.authorPubKey.take(12) + "…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
napplet.capabilities.forEach { (cap, grant) ->
|
||||
CapabilityRow(
|
||||
capability = cap,
|
||||
grant = grant,
|
||||
onSetAllowed = { onSetAllowed(cap, it) },
|
||||
onRevoke = { onRevoke(cap) },
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onForget,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Icon(MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text(stringResource(R.string.napplet_permissions_forget))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CapabilityRow(
|
||||
capability: NappletCapability,
|
||||
grant: GrantState,
|
||||
onSetAllowed: (Boolean) -> Unit,
|
||||
onRevoke: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
capability.symbol(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(capability.labelRes()), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
stringResource(capability.descriptionRes()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (capability.requiresPerUseConsent) {
|
||||
// Payments only ever persist a DENY; the user can clear it to allow per-payment prompts again.
|
||||
Text(
|
||||
stringResource(R.string.napplet_permissions_blocked),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} else {
|
||||
Switch(
|
||||
checked = grant == GrantState.ALLOW_ALWAYS,
|
||||
onCheckedChange = onSetAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(4.dp))
|
||||
IconButton(onClick = onRevoke) {
|
||||
Icon(
|
||||
MaterialSymbols.Block,
|
||||
contentDescription = stringResource(R.string.napplet_permissions_revoke),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadGrants(
|
||||
ledger: NappletPermissionLedger,
|
||||
untitled: String,
|
||||
): List<NappletGrantsUi> =
|
||||
ledger
|
||||
.allPersistedGrants()
|
||||
.map { (coordinate, caps) ->
|
||||
val author = coordinate.substringBefore(':')
|
||||
val identifier = coordinate.substringAfter(':', "")
|
||||
NappletGrantsUi(
|
||||
identity = NappletIdentity(authorPubKey = author, identifier = identifier),
|
||||
title = resolveTitle(author, identifier, untitled),
|
||||
capabilities = caps.entries.sortedBy { it.key.ordinal }.map { it.key to it.value },
|
||||
)
|
||||
}.sortedBy { it.title.lowercase() }
|
||||
|
||||
/** Best-effort human title from a cached manifest; falls back to the d-identifier or [untitled]. */
|
||||
private fun resolveTitle(
|
||||
author: String,
|
||||
identifier: String,
|
||||
untitled: String,
|
||||
): String {
|
||||
val events =
|
||||
Amethyst.instance.cache
|
||||
.filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author)))
|
||||
.mapNotNull { it.event }
|
||||
val match =
|
||||
events.firstOrNull { ev ->
|
||||
when (ev) {
|
||||
is NamedNappletEvent -> ev.identifier() == identifier
|
||||
is RootNappletEvent -> identifier.isEmpty()
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
return (match as? NappletManifest)?.title()?.ifBlank { null }
|
||||
?: identifier.ifBlank { untitled }
|
||||
}
|
||||
|
||||
private fun NappletCapability.symbol(): MaterialSymbol =
|
||||
when (this) {
|
||||
NappletCapability.SHELL -> MaterialSymbols.Tune
|
||||
NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle
|
||||
NappletCapability.KEYS -> MaterialSymbols.Key
|
||||
NappletCapability.RELAY -> MaterialSymbols.Public
|
||||
NappletCapability.STORAGE -> MaterialSymbols.Storage
|
||||
NappletCapability.VALUE -> MaterialSymbols.Bolt
|
||||
NappletCapability.RESOURCE -> MaterialSymbols.Language
|
||||
NappletCapability.UPLOAD -> MaterialSymbols.Upload
|
||||
NappletCapability.THEME -> MaterialSymbols.Image
|
||||
NappletCapability.NOTIFY -> MaterialSymbols.Notifications
|
||||
NappletCapability.INC -> MaterialSymbols.SwapHoriz
|
||||
}
|
||||
+1
-1
@@ -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) }) {
|
||||
|
||||
+88
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
/**
|
||||
* Keyspace for the connected-apps manifest subscription. Carries the account (for relay selection)
|
||||
* and the specific authors whose napplet manifests should be fetched — the set of pubkeys that have
|
||||
* been granted permissions in the user's ledger.
|
||||
*/
|
||||
class ConnectedAppsQueryState(
|
||||
val account: Account,
|
||||
val authors: Set<HexKey>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Live subscription for NIP-5D napplet manifests (kinds 15129/35129) while
|
||||
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.ConnectedAppsScreen] is open.
|
||||
* Unlike [NappletsFilterAssembler] (which follows the global follow list), this assembler
|
||||
* only fetches manifests for the specific authors that have entries in the permission ledger.
|
||||
*/
|
||||
@Stable
|
||||
class ConnectedAppsFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<ConnectedAppsQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
ConnectedAppsSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@Composable
|
||||
fun ConnectedAppsFilterAssemblerSubscription(
|
||||
accountViewModel: AccountViewModel,
|
||||
authors: Set<HexKey>,
|
||||
) {
|
||||
val state =
|
||||
remember(accountViewModel.account, authors) {
|
||||
ConnectedAppsQueryState(accountViewModel.account, authors)
|
||||
}
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().connectedApps)
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies.filterNappletsByAuthors
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
/**
|
||||
* Builds relay REQs for napplet manifests limited to the authors stored in
|
||||
* [ConnectedAppsQueryState.authors], queried against the account's home relays.
|
||||
* The filter is purposely narrow — we only want manifests for apps the user has already
|
||||
* connected to, not the full follow-list.
|
||||
*/
|
||||
class ConnectedAppsSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<ConnectedAppsQueryState>,
|
||||
) : PerUserEoseManager<ConnectedAppsQueryState>(client, allKeys) {
|
||||
override fun user(key: ConnectedAppsQueryState) = key.account.userProfile()
|
||||
|
||||
override fun updateFilter(
|
||||
key: ConnectedAppsQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (key.authors.isEmpty()) return emptyList()
|
||||
val relays = key.account.homeRelays.flow.value
|
||||
if (relays.isEmpty()) return emptyList()
|
||||
return relays.flatMap { relay ->
|
||||
filterNappletsByAuthors(relay, key.authors, since?.get(relay)?.time)
|
||||
}
|
||||
}
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.SuggestionChipDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.PolicyCard
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun RelayAuthSettingsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val store: DataStoreRelayAuthPermissionStore = Amethyst.instance.relayAuthPermissionStore
|
||||
val ledger = remember { RelayAuthPermissionLedger(store, { account.settings.defaultRelayAuthPolicy.value }) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val globalPolicy by account.settings.defaultRelayAuthPolicy.collectAsState()
|
||||
|
||||
var perRelayOverrides by remember { mutableStateOf<Map<String, RelayAuthDecision>>(emptyMap()) }
|
||||
var reloadKey by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(reloadKey) {
|
||||
perRelayOverrides = withContext(Dispatchers.IO) { store.allDecisions() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringResource(R.string.relay_auth_settings_title), nav) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.relay_auth_global_policy),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RelayAuthPolicy.entries.forEach { policy ->
|
||||
val (titleRes, descRes, symbol) =
|
||||
when (policy) {
|
||||
RelayAuthPolicy.ALWAYS ->
|
||||
Triple(
|
||||
R.string.relay_auth_policy_always,
|
||||
R.string.relay_auth_policy_always_desc,
|
||||
MaterialSymbols.LockOpen,
|
||||
)
|
||||
RelayAuthPolicy.NEVER ->
|
||||
Triple(
|
||||
R.string.relay_auth_policy_never,
|
||||
R.string.relay_auth_policy_never_desc,
|
||||
MaterialSymbols.Lock,
|
||||
)
|
||||
RelayAuthPolicy.IF_IN_MY_LIST ->
|
||||
Triple(
|
||||
R.string.relay_auth_policy_if_in_my_list,
|
||||
R.string.relay_auth_policy_if_in_my_list_desc,
|
||||
MaterialSymbols.PrivacyTip,
|
||||
)
|
||||
}
|
||||
PolicyCard(
|
||||
selected = globalPolicy == policy,
|
||||
symbol = symbol,
|
||||
label = stringResource(titleRes),
|
||||
description = stringResource(descRes),
|
||||
onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (perRelayOverrides.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.relay_auth_per_relay_overrides),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(4.dp)) {
|
||||
perRelayOverrides.entries.sortedBy { it.key }.forEachIndexed { index, (url, decision) ->
|
||||
if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
|
||||
PerRelayOverrideRow(
|
||||
url = url,
|
||||
decision = decision,
|
||||
onRemove = {
|
||||
scope.launch {
|
||||
ledger.clearDecision(url)
|
||||
reloadKey++
|
||||
}
|
||||
},
|
||||
onToggle = {
|
||||
scope.launch {
|
||||
val next =
|
||||
if (decision == RelayAuthDecision.ALLOW) {
|
||||
RelayAuthDecision.DENY
|
||||
} else {
|
||||
RelayAuthDecision.ALLOW
|
||||
}
|
||||
ledger.setDecision(url, next)
|
||||
reloadKey++
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringResource(R.string.relay_auth_no_overrides),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PerRelayOverrideRow(
|
||||
url: String,
|
||||
decision: RelayAuthDecision,
|
||||
onRemove: () -> Unit,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = url,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
SuggestionChip(
|
||||
onClick = onToggle,
|
||||
label = {
|
||||
Text(
|
||||
text =
|
||||
if (decision == RelayAuthDecision.ALLOW) {
|
||||
stringResource(R.string.relay_auth_decision_allow)
|
||||
} else {
|
||||
stringResource(R.string.relay_auth_decision_deny)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
},
|
||||
colors =
|
||||
if (decision == RelayAuthDecision.ALLOW) {
|
||||
SuggestionChipDefaults.suggestionChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
labelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
} else {
|
||||
SuggestionChipDefaults.suggestionChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
labelColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
},
|
||||
)
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(MaterialSymbols.Close, contentDescription = stringResource(R.string.relay_auth_remove_override))
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
|
||||
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
|
||||
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.kinds.KindNames
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
|
||||
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
|
||||
/** Returns the `@StringRes` id for the translated kind name, or -1 if unknown. */
|
||||
@Suppress("DEPRECATION")
|
||||
@StringRes
|
||||
fun kindDisplayName(kind: Int): Int =
|
||||
when (kind) {
|
||||
AcceptedBadgeSetEvent.KIND -> R.string.kind_accepted_badge_set
|
||||
AdvertisedRelayListEvent.KIND -> R.string.kind_outbox_relays
|
||||
AppDefinitionEvent.KIND -> R.string.kind_apps
|
||||
AppRecommendationEvent.KIND -> R.string.kind_app_recommendations
|
||||
AppSpecificDataEvent.KIND -> R.string.kind_user_settings
|
||||
AudioHeaderEvent.KIND -> R.string.kind_audio_header
|
||||
AudioTrackEvent.KIND -> R.string.kind_audio_track
|
||||
MusicTrackEvent.KIND -> R.string.kind_music_track
|
||||
MusicPlaylistEvent.KIND -> R.string.kind_music_playlist
|
||||
PodcastEpisodeEvent.KIND -> R.string.kind_podcast_episode
|
||||
PodcastMetadataEvent.KIND -> R.string.kind_podcast_metadata
|
||||
AuthoredPodcastsEvent.KIND -> R.string.kind_authored_podcasts
|
||||
FavoritePodcastsListEvent.KIND -> R.string.kind_favorite_podcasts
|
||||
AttestationEvent.KIND -> R.string.attestation
|
||||
AttestationRequestEvent.KIND -> R.string.attestation_request
|
||||
AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation
|
||||
AttestorProficiencyEvent.KIND -> R.string.attestor_proficiency
|
||||
BadgeAwardEvent.KIND -> R.string.kind_badge_awards
|
||||
BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions
|
||||
BlockedRelayListEvent.KIND -> R.string.kind_blocked_relays
|
||||
BlossomServersEvent.KIND -> R.string.kind_blossom_servers
|
||||
NestsServersEvent.KIND -> R.string.kind_nests_servers
|
||||
BlossomAuthorizationEvent.KIND -> R.string.kind_blossom_auth
|
||||
BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays
|
||||
BookmarkListEvent.KIND -> R.string.kind_bookmark_list
|
||||
OldBookmarkListEvent.KIND -> R.string.kind_old_bookmark_list
|
||||
CalendarDateSlotEvent.KIND -> R.string.kind_day_appointment
|
||||
CalendarEvent.KIND -> R.string.kind_calendar
|
||||
CalendarTimeSlotEvent.KIND -> R.string.kind_appointment
|
||||
CalendarRSVPEvent.KIND -> R.string.kind_appt_rsvp
|
||||
ChessGameEvent.KIND -> R.string.kind_chess_games
|
||||
JesterEvent.KIND -> R.string.kind_chess_auth
|
||||
RelayFeedsListEvent.KIND -> R.string.kind_favorite_relays
|
||||
LiveChessGameChallengeEvent.KIND -> R.string.kind_chess_challenges
|
||||
LiveChessGameAcceptEvent.KIND -> R.string.kind_chess_game_accept
|
||||
LiveChessMoveEvent.KIND -> R.string.kind_chess_move
|
||||
LiveChessGameEndEvent.KIND -> R.string.kind_chess_game_end
|
||||
LiveChessDrawOfferEvent.KIND -> R.string.kind_chess_draw_offer
|
||||
ChannelCreateEvent.KIND -> R.string.kind_channel_definition
|
||||
ChannelHideMessageEvent.KIND -> R.string.kind_channel_hide_msg
|
||||
ChannelListEvent.KIND -> R.string.kind_channel_list
|
||||
ChannelMessageEvent.KIND -> R.string.kind_channel_message
|
||||
ChannelMetadataEvent.KIND -> R.string.kind_channel_metadata
|
||||
ChannelMuteUserEvent.KIND -> R.string.kind_channel_mute_user
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND -> R.string.kind_dm_file
|
||||
ChatMessageEvent.KIND -> R.string.kind_dm_message
|
||||
ChatMessageRelayListEvent.KIND -> R.string.kind_dm_relays
|
||||
ClassifiedsEvent.KIND -> R.string.kind_classifieds
|
||||
CommentEvent.KIND -> R.string.kind_comments
|
||||
CommunityDefinitionEvent.KIND -> R.string.kind_community_def
|
||||
CommunityListEvent.KIND -> R.string.kind_community_list
|
||||
CommunityPostApprovalEvent.KIND -> R.string.kind_community_post
|
||||
ContactListEvent.KIND -> R.string.kind_follow_list
|
||||
DeletionEvent.KIND -> R.string.kind_deletions
|
||||
DraftWrapEvent.KIND -> R.string.kind_drafts
|
||||
EmojiPackEvent.KIND -> R.string.kind_emoji_packs
|
||||
EmojiPackSelectionEvent.KIND -> R.string.kind_emoji_pack_list
|
||||
EphemeralChatEvent.KIND -> R.string.kind_ephemeral_chat
|
||||
EphemeralChatListEvent.KIND -> R.string.kind_ephemeral_chatrooms
|
||||
FileHeaderEvent.KIND -> R.string.kind_file_headers
|
||||
ProfileGalleryEntryEvent.KIND -> R.string.kind_profile_gallery
|
||||
FileServersEvent.KIND -> R.string.kind_file_servers
|
||||
FileStorageEvent.KIND -> R.string.kind_blob_data
|
||||
FileStorageHeaderEvent.KIND -> R.string.kind_blob_headers
|
||||
FhirResourceEvent.KIND -> R.string.kind_medical_data
|
||||
FollowListEvent.KIND -> R.string.kind_follow_packs
|
||||
GenericRepostEvent.KIND -> R.string.kind_reposts_16
|
||||
GeohashListEvent.KIND -> R.string.kind_geohash_follows
|
||||
GiftWrapEvent.KIND -> R.string.kind_gift_wraps
|
||||
EphemeralGiftWrapEvent.KIND -> R.string.kind_gift_wraps
|
||||
GitIssueEvent.KIND -> R.string.kind_git_issue
|
||||
GitPatchEvent.KIND -> R.string.kind_git_patch
|
||||
GitRepositoryEvent.KIND -> R.string.kind_git_repo
|
||||
GitReplyEvent.KIND -> R.string.kind_git_reply
|
||||
GoalEvent.KIND -> R.string.kind_zap_goals
|
||||
HashtagListEvent.KIND -> R.string.kind_hashtag_follows
|
||||
HighlightEvent.KIND -> R.string.kind_highlights
|
||||
HTTPAuthorizationEvent.KIND -> R.string.kind_http_auth
|
||||
IndexerRelayListEvent.KIND -> R.string.kind_index_relay_list
|
||||
InteractiveStoryPrologueEvent.KIND -> R.string.kind_adventure_prologue
|
||||
InteractiveStorySceneEvent.KIND -> R.string.kind_adventure_scene
|
||||
InteractiveStoryReadingStateEvent.KIND -> R.string.kind_adventure_reading
|
||||
LabeledBookmarkListEvent.KIND -> R.string.kind_named_bookmarks
|
||||
LiveActivitiesChatMessageEvent.KIND -> R.string.kind_live_chats
|
||||
LiveActivitiesEvent.KIND -> R.string.kind_live_streams
|
||||
LnZapEvent.KIND -> R.string.kind_zaps
|
||||
LnZapPaymentRequestEvent.KIND -> R.string.kind_nwc_request
|
||||
LnZapPaymentResponseEvent.KIND -> R.string.kind_nwc_response
|
||||
LnZapPrivateEvent.KIND -> R.string.kind_private_zaps
|
||||
LnZapRequestEvent.KIND -> R.string.kind_zap_req
|
||||
LongTextNoteEvent.KIND -> R.string.kind_blogs
|
||||
MeetingRoomEvent.KIND -> R.string.kind_meeting_room
|
||||
MeetingRoomPresenceEvent.KIND -> R.string.kind_room_presence
|
||||
MeetingSpaceEvent.KIND -> R.string.kind_meeting_space
|
||||
MetadataEvent.KIND -> R.string.kind_profile
|
||||
MuteListEvent.KIND -> R.string.kind_mute_list
|
||||
NNSEvent.KIND -> R.string.kind_nns
|
||||
NipTextEvent.KIND -> R.string.kind_nip
|
||||
NostrConnectEvent.KIND -> R.string.kind_nostr_connect
|
||||
NIP90StatusEvent.KIND -> R.string.kind_dvm_status
|
||||
NIP90ContentDiscoveryRequestEvent.KIND -> R.string.kind_dvm_content_req
|
||||
NIP90ContentDiscoveryResponseEvent.KIND -> R.string.kind_dvm_content_resp
|
||||
NIP90UserDiscoveryRequestEvent.KIND -> R.string.kind_dvm_user_req
|
||||
NIP90UserDiscoveryResponseEvent.KIND -> R.string.kind_dvm_user_resp
|
||||
OtsEvent.KIND -> R.string.kind_ots
|
||||
PaymentTargetsEvent.KIND -> R.string.kind_pay_to
|
||||
PeopleListEvent.KIND -> R.string.kind_people_lists
|
||||
ProfileBadgesEvent.KIND -> R.string.kind_profile_badges
|
||||
PictureEvent.KIND -> R.string.kind_pictures
|
||||
WorkoutRecordEvent.KIND -> R.string.kind_workouts
|
||||
PinListEvent.KIND -> R.string.kind_pins
|
||||
ZapPollEvent.KIND -> R.string.kind_zap_poll
|
||||
PollEvent.KIND -> R.string.kind_poll
|
||||
PollResponseEvent.KIND -> R.string.kind_poll_response
|
||||
PrivateDmEvent.KIND -> R.string.kind_nip04_dms
|
||||
PrivateOutboxRelayListEvent.KIND -> R.string.kind_private_relays
|
||||
ProxyRelayListEvent.KIND -> R.string.kind_proxy_relays
|
||||
PublicMessageEvent.KIND -> R.string.kind_public_message
|
||||
ReactionEvent.KIND -> R.string.kind_reactions
|
||||
ContactCardEvent.KIND -> R.string.kind_contact_card
|
||||
RelayAuthEvent.KIND -> R.string.kind_relay_auth
|
||||
RelayDiscoveryEvent.KIND -> R.string.kind_relay_discovery
|
||||
RelayMonitorEvent.KIND -> R.string.kind_relay_monitor
|
||||
RelaySetEvent.KIND -> R.string.kind_relay_set
|
||||
ReportEvent.KIND -> R.string.kind_reports
|
||||
RepostEvent.KIND -> R.string.kind_reposts
|
||||
RequestToVanishEvent.KIND -> R.string.kind_user_delete
|
||||
SealedRumorEvent.KIND -> R.string.kind_seals
|
||||
SearchRelayListEvent.KIND -> R.string.kind_search_relays
|
||||
StatusEvent.KIND -> R.string.kind_user_status
|
||||
TextNoteEvent.KIND -> R.string.kind_notes
|
||||
TextNoteModificationEvent.KIND -> R.string.kind_edits
|
||||
TorrentEvent.KIND -> R.string.kind_torrents
|
||||
TorrentCommentEvent.KIND -> R.string.kind_torrent_comments
|
||||
TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays
|
||||
TrustProviderListEvent.KIND -> R.string.kind_trusted_providers
|
||||
VideoHorizontalEvent.KIND -> R.string.kind_video_repl
|
||||
VideoVerticalEvent.KIND -> R.string.kind_shorts_repl
|
||||
VideoNormalEvent.KIND -> R.string.kind_video
|
||||
VideoShortEvent.KIND -> R.string.kind_shorts
|
||||
VoiceEvent.KIND -> R.string.kind_voice_msg
|
||||
VoiceReplyEvent.KIND -> R.string.kind_voice_reply
|
||||
WakeUpEvent.KIND -> R.string.kind_wake
|
||||
WebBookmarkEvent.KIND -> R.string.kind_web_bookmark
|
||||
WikiNoteEvent.KIND -> R.string.kind_wiki
|
||||
else -> -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translated display name for [kind] using Android string resources when available,
|
||||
* falling back to the English name from [KindNames], then to "k<number>".
|
||||
*/
|
||||
fun kindNameFor(
|
||||
context: Context,
|
||||
kind: Int,
|
||||
): String {
|
||||
val resId = kindDisplayName(kind)
|
||||
return if (resId != -1) context.getString(resId) else (KindNames.nameFor(kind) ?: "k$kind")
|
||||
}
|
||||
-289
@@ -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)
|
||||
|
||||
+2
@@ -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),
|
||||
|
||||
@@ -701,8 +701,9 @@
|
||||
<string name="napplet_permissions_empty">No nApplet permissions yet</string>
|
||||
<string name="napplet_permissions_empty_subtitle">Permissions you grant to nApplets will appear here.</string>
|
||||
<string name="napplet_permissions_forget">Forget this nApplet</string>
|
||||
<string name="napplet_permissions_blocked">Blocked</string>
|
||||
<string name="napplet_permissions_blocked">Requires per-use approval</string>
|
||||
<string name="napplet_permissions_revoke">Revoke</string>
|
||||
<string name="napplet_permissions_ask_each_time">Ask me each time</string>
|
||||
<string name="napplet_none_found">No nApplets found yet.</string>
|
||||
<string name="napplet_fallback_title">nApplet %1$s…</string>
|
||||
<!-- Napplet sandbox chrome (host top bar + live action notices) -->
|
||||
@@ -752,6 +753,77 @@
|
||||
<item quantity="one">This nApplet wants to pay a Lightning invoice for %1$d sat.</item>
|
||||
<item quantity="other">This nApplet wants to pay a Lightning invoice for %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Connect to Nostr</string>
|
||||
<string name="napplet_connect_subtitle">wants to connect to your Nostr account</string>
|
||||
<string name="napplet_connect_how_handle">How should this app\'s requests be handled?</string>
|
||||
<string name="napplet_connect_button">Connect</string>
|
||||
<string name="napplet_connect_block">Block and ignore %1$s</string>
|
||||
|
||||
<!-- Signer trust levels -->
|
||||
<string name="napplet_policy_full_trust">I fully trust it</string>
|
||||
<string name="napplet_policy_full_trust_desc">Auto-sign all requests (except payments)</string>
|
||||
<string name="napplet_policy_reasonable">Let\'s be reasonable</string>
|
||||
<string name="napplet_policy_reasonable_desc">Auto-approve most common requests</string>
|
||||
<string name="napplet_policy_paranoid">I\'m a bit paranoid</string>
|
||||
<string name="napplet_policy_paranoid_desc">Do not sign anything without asking me!</string>
|
||||
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<string name="napplet_consent_wants_to">wants to %1$s</string>
|
||||
<string name="napplet_consent_show_event">Show Event</string>
|
||||
<string name="napplet_consent_hide_event">Hide Event</string>
|
||||
<string name="napplet_consent_more_options">More options</string>
|
||||
<string name="napplet_consent_fewer_options">Fewer options</string>
|
||||
<string name="napplet_signer_allow_once">Allow once</string>
|
||||
<string name="napplet_signer_allow_session">Allow for this session</string>
|
||||
<string name="napplet_signer_allow_24h">Allow for 24 hours</string>
|
||||
<string name="napplet_signer_allow_30d">Allow for 30 days</string>
|
||||
<string name="napplet_signer_allow_op">Don\'t ask again to %1$s</string>
|
||||
<string name="napplet_signer_allow_all">Don\'t ask again for any Nostr requests</string>
|
||||
<string name="napplet_signer_deny_once">Deny</string>
|
||||
<string name="napplet_signer_deny_op">Always deny %1$s</string>
|
||||
<string name="napplet_signer_last_used">Last used</string>
|
||||
<string name="napplet_signer_expires">Expires</string>
|
||||
|
||||
<!-- Signer op labels -->
|
||||
<string name="napplet_op_sign_kind">sign kind %1$d event</string>
|
||||
<string name="napplet_op_sign_kind_named">sign for %1$s (kind: %2$d)</string>
|
||||
<string name="napplet_op_encrypt">encrypt a message</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">read your private messages</string>
|
||||
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Connected Apps</string>
|
||||
<string name="napplet_permissions_revoke_all">Revoke all permissions</string>
|
||||
<string name="napplet_permissions_overrides">Operation overrides</string>
|
||||
<string name="napplet_signer_permissions_empty">No apps have connected yet.</string>
|
||||
<string name="napplet_signer_permissions_revoke_all">Revoke all permissions</string>
|
||||
<string name="napplet_decision_allow">Allow</string>
|
||||
<string name="napplet_decision_ask">Ask</string>
|
||||
<string name="napplet_decision_deny">Deny</string>
|
||||
<string name="napplet_connected_apps_search_keywords">apps permissions signer napplet nsite webapp trust connected</string>
|
||||
<string name="napplet_connected_app_trust_level">Signing trust level</string>
|
||||
<string name="napplet_connected_app_capabilities">Capabilities</string>
|
||||
<string name="napplet_connected_app_forget">Forget this app</string>
|
||||
<string name="napplet_connected_app_op_overrides">Signing operation overrides</string>
|
||||
<string name="napplet_connected_app_empty">No apps have connected yet.\n\nWhen a web app connects to your Nostr key, it will appear here.</string>
|
||||
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
<string name="relay_auth_settings_title">Relay Authentication</string>
|
||||
<string name="relay_auth_search_keywords">auth authentication relay sign verify nip-42 identity</string>
|
||||
<string name="relay_auth_global_policy">Global policy</string>
|
||||
<string name="relay_auth_policy_always">Always authenticate</string>
|
||||
<string name="relay_auth_policy_always_desc">Sign auth challenges for every relay that requests it</string>
|
||||
<string name="relay_auth_policy_never">Never authenticate</string>
|
||||
<string name="relay_auth_policy_never_desc">Ignore auth challenges from all relays</string>
|
||||
<string name="relay_auth_policy_if_in_my_list">My relays only</string>
|
||||
<string name="relay_auth_policy_if_in_my_list_desc">Only authenticate with relays in your relay list</string>
|
||||
<string name="relay_auth_per_relay_overrides">Per-relay overrides</string>
|
||||
<string name="relay_auth_no_overrides">No per-relay overrides — global policy applies everywhere</string>
|
||||
<string name="relay_auth_decision_allow">Allow</string>
|
||||
<string name="relay_auth_decision_deny">Deny</string>
|
||||
<string name="relay_auth_remove_override">Remove override</string>
|
||||
|
||||
<string name="nip82_repository_label">Source: %1$s</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="nip82_download">Download</string>
|
||||
|
||||
+147
-13
@@ -25,9 +25,17 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.toSignerOp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -46,13 +54,10 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
* Two capability-specific policies refine step 2/3:
|
||||
* - **Per-use capabilities** ([NappletCapability.requiresPerUseConsent], i.e. [NappletCapability.VALUE])
|
||||
* never auto-approve from a prior grant — every payment is confirmed afresh, with the amount shown.
|
||||
* - **Signer self-gating**: an identity read or a sign-as-user op ([NappletRequest.signsAsUser])
|
||||
* is gated here only when the key lives in Amethyst (a [NostrSignerInternal]). Remote (NIP-46) and
|
||||
* external (NIP-55) signers run their own per-request consent UI, so we defer to them rather than
|
||||
* double-prompt. A standing DENY is still honored, and the applet must still have *declared* the
|
||||
* capability. This is safe only because the napplet host runs foreground-only, so the signer's
|
||||
* prompt appears in the clear context of the user interacting with that napplet (it can't be
|
||||
* fired from the background).
|
||||
* - All signer types — internal, NIP-46 remote, and NIP-55 external — are gated through Amethyst's
|
||||
* consent UI before the operation reaches the signer. External signers add their own per-request
|
||||
* prompt on top (double-prompting), ensuring the user can differentiate requests from different
|
||||
* apps inside the external signer.
|
||||
*
|
||||
* Security invariants enforced here (never trusted from the applet): the signing identity is
|
||||
* always the host's signer; the napplet only ever supplies an unsigned template — the shell signs
|
||||
@@ -71,12 +76,29 @@ class NappletBroker(
|
||||
private val identityReads: NappletIdentityGateway? = null,
|
||||
private val theme: NappletThemeGateway? = null,
|
||||
private val notify: NappletNotifyGateway? = null,
|
||||
private val signerLedger: NostrSignerPermissionLedger? = null,
|
||||
private val nostrConnectPrompt: NostrConnectPrompt? = null,
|
||||
private val signerConsentPrompt: NostrSignerConsentPrompt? = null,
|
||||
) {
|
||||
// Serializes the consent-prompt path so concurrent requests queue into one dialog at a time
|
||||
// (see [authorizeWithConsent]). Only the prompt is held here; non-prompting paths and execute()
|
||||
// run unserialized.
|
||||
private val consentLock = Mutex()
|
||||
|
||||
// Serializes first-connect and per-op signer consent dialogs so concurrent signing requests
|
||||
// queue into one dialog at a time rather than launching several dialogs simultaneously.
|
||||
private val signerConsentLock = Mutex()
|
||||
|
||||
// In-memory session grants (AllowForSession): cleared when this broker instance is destroyed.
|
||||
// Only accessed under signerConsentLock.
|
||||
private val sessionAllows = mutableSetOf<String>()
|
||||
|
||||
// Apps whose first-connect dialog the user dismissed with Cancel this session.
|
||||
// Cancelling means "not now" — we suppress re-prompting within the same session so a
|
||||
// napplet that fires many requests doesn't show the dialog on every one.
|
||||
// Only accessed under signerConsentLock.
|
||||
private val sessionCancelled = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* Authorizes and runs [request] on behalf of [identity]. [declared] is the capability set the
|
||||
* manifest's `requires` resolved to; a request outside it is refused before any prompt.
|
||||
@@ -105,6 +127,13 @@ class NappletBroker(
|
||||
return NappletResponse.Denied(capability, "Blocked by a standing denial.")
|
||||
}
|
||||
|
||||
// Show the first-connect dialog if the app has no signer policy yet.
|
||||
if (signerLedger != null && !signerLedger.hasPolicy(identity.coordinate)) {
|
||||
if (!ensureConnected(identity, declared)) {
|
||||
return NappletResponse.Denied(capability, "Connection not authorized.")
|
||||
}
|
||||
}
|
||||
|
||||
val authorized =
|
||||
when {
|
||||
// Keyboard/command action registration is a shell-mediated UI affordance, not key
|
||||
@@ -112,8 +141,6 @@ class NappletBroker(
|
||||
request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true
|
||||
// Cosmetic/negotiation capabilities (theme) never prompt.
|
||||
!capability.requiresConsent -> true
|
||||
// Remote/external signers run their own per-request consent UI — defer to them.
|
||||
signerSelfGates(request) -> true
|
||||
// A standing allow short-circuits, except for per-use capabilities (e.g. payments).
|
||||
ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true
|
||||
else -> authorizeWithConsent(identity, capability, request)
|
||||
@@ -121,6 +148,14 @@ class NappletBroker(
|
||||
|
||||
if (!authorized) return NappletResponse.Denied(capability, "The user declined.")
|
||||
|
||||
// Additional per-operation gate for signing/encryption.
|
||||
if (signerLedger != null) {
|
||||
val op = request.toSignerOp()
|
||||
if (op != null && !authorizeSignerOp(identity, op, request)) {
|
||||
return NappletResponse.Denied(capability, "Signing operation declined.")
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
execute(identity, request)
|
||||
} catch (e: CancellationException) {
|
||||
@@ -158,9 +193,6 @@ class NappletBroker(
|
||||
grant.allowsExecution
|
||||
}
|
||||
|
||||
/** Identity reads and sign-as-user ops are gated by us only when we hold the key; remote/external signers gate themselves. */
|
||||
private fun signerSelfGates(request: NappletRequest): Boolean = (request.capability == NappletCapability.IDENTITY || request.signsAsUser) && signer !is NostrSignerInternal
|
||||
|
||||
/** Downgrades a grant to one-shot when the capability forbids persisting that scope (e.g. payments). */
|
||||
private fun effectiveGrant(
|
||||
capability: NappletCapability,
|
||||
@@ -310,4 +342,106 @@ class NappletBroker(
|
||||
} else {
|
||||
tags + arrayOf(arrayOf("p", recipient))
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the first-connect "Connect to Nostr" dialog if no signer policy exists yet.
|
||||
* On success, stores the chosen policy and bulk-grants all declared non-payment capabilities.
|
||||
* Returns false if the user cancelled or blocked the app.
|
||||
*/
|
||||
private suspend fun ensureConnected(
|
||||
identity: NappletIdentity,
|
||||
declared: Set<NappletCapability>,
|
||||
): Boolean =
|
||||
signerConsentLock.withLock {
|
||||
val sl = signerLedger ?: return@withLock true
|
||||
// Re-check after acquiring lock: a sibling request may have set the policy while we waited.
|
||||
if (sl.hasPolicy(identity.coordinate)) return@withLock true
|
||||
|
||||
// Suppress re-prompting if the user already cancelled this session.
|
||||
if (identity.coordinate in sessionCancelled) return@withLock false
|
||||
|
||||
val prompt = nostrConnectPrompt ?: return@withLock true
|
||||
when (val result = prompt.request(identity)) {
|
||||
is AppConnectResult.Connected -> {
|
||||
sl.setPolicy(identity.coordinate, result.policy)
|
||||
// Bulk-grant non-payment capabilities only for non-paranoid policies.
|
||||
// PARANOID users chose "ask me for everything" — leave the capability ledger
|
||||
// empty so each capability prompts on first use.
|
||||
if (result.policy != AppSignerPolicy.PARANOID) {
|
||||
for (cap in declared) {
|
||||
if (!cap.requiresPerUseConsent) {
|
||||
ledger.record(identity, cap, GrantState.ALLOW_ALWAYS)
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
AppConnectResult.Blocked -> {
|
||||
sl.setPolicy(identity.coordinate, AppSignerPolicy.PARANOID)
|
||||
for (cap in declared) {
|
||||
ledger.record(identity, cap, GrantState.DENY)
|
||||
}
|
||||
false
|
||||
}
|
||||
AppConnectResult.Cancelled -> {
|
||||
sessionCancelled.add(identity.coordinate)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gates a specific signing/encryption operation through the signer permission ledger.
|
||||
* If the ledger says ASK, prompts the user and records their choice.
|
||||
*/
|
||||
private suspend fun authorizeSignerOp(
|
||||
identity: NappletIdentity,
|
||||
op: NostrSignerOp,
|
||||
request: NappletRequest,
|
||||
): Boolean =
|
||||
signerConsentLock.withLock {
|
||||
val sl = signerLedger ?: return@withLock true
|
||||
// Session grants win immediately without touching storage.
|
||||
if (op.key in sessionAllows) {
|
||||
sl.updateLastUsed(identity.coordinate)
|
||||
return@withLock true
|
||||
}
|
||||
when (sl.decide(identity.coordinate, op)) {
|
||||
NostrOpDecision.ALLOW -> {
|
||||
sl.updateLastUsed(identity.coordinate)
|
||||
true
|
||||
}
|
||||
NostrOpDecision.DENY -> false
|
||||
NostrOpDecision.ASK -> {
|
||||
val prompt = signerConsentPrompt ?: return@withLock true
|
||||
when (val grant = prompt.request(identity, op, request)) {
|
||||
is SignerOpGrant.AllowAll -> {
|
||||
sl.setPolicy(identity.coordinate, AppSignerPolicy.FULL_TRUST)
|
||||
sl.updateLastUsed(identity.coordinate)
|
||||
true
|
||||
}
|
||||
is SignerOpGrant.AllowForOp -> {
|
||||
sl.setOpDecision(identity.coordinate, op, NostrOpDecision.ALLOW)
|
||||
sl.updateLastUsed(identity.coordinate)
|
||||
true
|
||||
}
|
||||
is SignerOpGrant.AllowForSession -> {
|
||||
sessionAllows.add(op.key)
|
||||
sl.updateLastUsed(identity.coordinate)
|
||||
true
|
||||
}
|
||||
is SignerOpGrant.AllowUntil -> {
|
||||
sl.setTimedOpDecision(identity.coordinate, op, NostrOpDecision.ALLOW, grant.expiresAt)
|
||||
sl.updateLastUsed(identity.coordinate)
|
||||
true
|
||||
}
|
||||
is SignerOpGrant.DenyForOp -> {
|
||||
sl.setOpDecision(identity.coordinate, op, NostrOpDecision.DENY)
|
||||
false
|
||||
}
|
||||
else -> grant.isAllowed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -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,
|
||||
}
|
||||
+36
@@ -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,
|
||||
}
|
||||
+120
@@ -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
|
||||
}
|
||||
+71
@@ -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
|
||||
}
|
||||
+129
@@ -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
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.napplet.signers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
|
||||
/**
|
||||
* Persistence for the per-app internal-signer permissions: each app's [AppSignerPolicy]
|
||||
* trust level and any [NostrOpDecision] per-operation overrides. Keyed by napplet
|
||||
* coordinate (e.g. `"<authorPubKey>:<identifier>"`).
|
||||
*
|
||||
* The Android implementation uses one DataStore file per coordinate so loading one app's
|
||||
* permissions never reads another app's data — essential at scale (1000s of apps).
|
||||
* Tests use [InMemoryNostrSignerPermissionStore].
|
||||
*/
|
||||
interface NostrSignerPermissionStore {
|
||||
/** The stored trust level for [coordinate], or `null` if no policy has been set yet. */
|
||||
suspend fun loadPolicy(coordinate: String): AppSignerPolicy?
|
||||
|
||||
/** Persist [policy] as the trust level for [coordinate]. */
|
||||
suspend fun storePolicy(
|
||||
coordinate: String,
|
||||
policy: AppSignerPolicy,
|
||||
)
|
||||
|
||||
/** Remove the stored trust level for [coordinate]. */
|
||||
suspend fun clearPolicy(coordinate: String)
|
||||
|
||||
/** The stored per-operation decision for ([coordinate], [op]), or `null` if none set. */
|
||||
suspend fun loadOpDecision(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
): NostrOpDecision?
|
||||
|
||||
/** Persist [decision] for ([coordinate], [op]). */
|
||||
suspend fun storeOpDecision(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
decision: NostrOpDecision,
|
||||
)
|
||||
|
||||
/** Remove the per-operation decision for ([coordinate], [op]). */
|
||||
suspend fun clearOpDecision(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
)
|
||||
|
||||
/** All stored trust levels, keyed by coordinate — for the permissions-management screen. */
|
||||
suspend fun allPolicies(): Map<String, AppSignerPolicy>
|
||||
|
||||
/**
|
||||
* All per-operation overrides for [coordinate], keyed by [NostrSignerOp.key] — for the
|
||||
* permissions-management screen.
|
||||
*/
|
||||
suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision>
|
||||
|
||||
/** Remove all signer permissions (policy + all op overrides) for [coordinate]. */
|
||||
suspend fun clearAll(coordinate: String)
|
||||
|
||||
/** The stored expiry timestamp (Unix epoch seconds) for [op] of [coordinate], or `null` = no expiry. */
|
||||
suspend fun loadOpExpiry(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
): Long?
|
||||
|
||||
/** Persist [expiresAt] (Unix epoch seconds) for ([coordinate], [op]). */
|
||||
suspend fun storeOpExpiry(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
expiresAt: Long,
|
||||
)
|
||||
|
||||
/** Remove the expiry for ([coordinate], [op]). */
|
||||
suspend fun clearOpExpiry(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
)
|
||||
|
||||
/** The last time (Unix epoch seconds) any auto-approved operation ran for [coordinate], or `null`. */
|
||||
suspend fun loadLastUsed(coordinate: String): Long?
|
||||
|
||||
/** Persist [epochSeconds] as the last-used time for [coordinate]. */
|
||||
suspend fun storeLastUsed(
|
||||
coordinate: String,
|
||||
epochSeconds: Long,
|
||||
)
|
||||
}
|
||||
|
||||
/** A thread-safe in-memory [NostrSignerPermissionStore] for tests and ephemeral sessions. */
|
||||
class InMemoryNostrSignerPermissionStore : NostrSignerPermissionStore {
|
||||
private val lock = KmpLock()
|
||||
private val policies = mutableMapOf<String, AppSignerPolicy>()
|
||||
private val opDecisions = mutableMapOf<String, MutableMap<String, NostrOpDecision>>()
|
||||
private val opExpiries = mutableMapOf<String, MutableMap<String, Long>>()
|
||||
private val lastUsedMap = mutableMapOf<String, Long>()
|
||||
|
||||
override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? = lock.withLock { policies[coordinate] }
|
||||
|
||||
override suspend fun storePolicy(
|
||||
coordinate: String,
|
||||
policy: AppSignerPolicy,
|
||||
) = lock.withLock { policies[coordinate] = policy }
|
||||
|
||||
override suspend fun clearPolicy(coordinate: String) =
|
||||
lock.withLock {
|
||||
policies.remove(coordinate)
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun loadOpDecision(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
): NostrOpDecision? = lock.withLock { opDecisions[coordinate]?.get(op.key) }
|
||||
|
||||
override suspend fun storeOpDecision(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
decision: NostrOpDecision,
|
||||
) = lock.withLock {
|
||||
opDecisions.getOrPut(coordinate) { mutableMapOf() }[op.key] = decision
|
||||
}
|
||||
|
||||
override suspend fun clearOpDecision(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
) = lock.withLock {
|
||||
opDecisions[coordinate]?.remove(op.key)
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun allPolicies(): Map<String, AppSignerPolicy> = lock.withLock { policies.toMap() }
|
||||
|
||||
override suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision> = lock.withLock { opDecisions[coordinate]?.toMap() ?: emptyMap() }
|
||||
|
||||
override suspend fun clearAll(coordinate: String) =
|
||||
lock.withLock {
|
||||
policies.remove(coordinate)
|
||||
opDecisions.remove(coordinate)
|
||||
opExpiries.remove(coordinate)
|
||||
lastUsedMap.remove(coordinate)
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun loadOpExpiry(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
): Long? = lock.withLock { opExpiries[coordinate]?.get(op.key) }
|
||||
|
||||
override suspend fun storeOpExpiry(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
expiresAt: Long,
|
||||
) = lock.withLock { opExpiries.getOrPut(coordinate) { mutableMapOf() }[op.key] = expiresAt }
|
||||
|
||||
override suspend fun clearOpExpiry(
|
||||
coordinate: String,
|
||||
op: NostrSignerOp,
|
||||
) = lock.withLock {
|
||||
opExpiries[coordinate]?.remove(op.key)
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun loadLastUsed(coordinate: String): Long? = lock.withLock { lastUsedMap[coordinate] }
|
||||
|
||||
override suspend fun storeLastUsed(
|
||||
coordinate: String,
|
||||
epochSeconds: Long,
|
||||
) = lock.withLock { lastUsedMap[coordinate] = epochSeconds }
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayauth
|
||||
|
||||
/**
|
||||
* Per-relay [RelayAuthDecision] overrides. Used by [RelayAuthPermissionLedger] after
|
||||
* checking the global [RelayAuthPolicy]. The Android implementation uses a single
|
||||
* shared DataStore file so a relay URL lookup never touches another relay's data.
|
||||
*/
|
||||
interface RelayAuthPermissionStore {
|
||||
/** The stored per-relay override for [relayUrl], or `null` if no override is set. */
|
||||
suspend fun loadDecision(relayUrl: String): RelayAuthDecision?
|
||||
|
||||
/** Persist [decision] as the override for [relayUrl]. */
|
||||
suspend fun storeDecision(
|
||||
relayUrl: String,
|
||||
decision: RelayAuthDecision,
|
||||
)
|
||||
|
||||
/** Remove the per-relay override for [relayUrl]. */
|
||||
suspend fun clearDecision(relayUrl: String)
|
||||
|
||||
/** All per-relay overrides — for the relay auth settings screen. */
|
||||
suspend fun allDecisions(): Map<String, RelayAuthDecision>
|
||||
}
|
||||
+42
@@ -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,
|
||||
}
|
||||
+16
-3
@@ -405,16 +405,29 @@ class NappletBrokerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun externalSignerDefersIdentityWithoutPrompting() =
|
||||
fun externalSignerIsPromptedByAmethystForIdentity() =
|
||||
runTest {
|
||||
val prompt = ScriptedPrompt(GrantState.DENY) // would block if we asked
|
||||
val prompt = ScriptedPrompt(GrantState.DENY)
|
||||
val external = FakeExternalSigner("dd".repeat(32))
|
||||
|
||||
val response =
|
||||
broker(prompt, signer = external).handle(applet, NappletRequest.GetPublicKey, allDeclared)
|
||||
|
||||
assertIs<NappletResponse.Denied>(response) // Amethyst asked and user denied
|
||||
assertEquals(1, prompt.calls) // Amethyst prompts all signer types
|
||||
}
|
||||
|
||||
@Test
|
||||
fun externalSignerAllowedByAmethystReturnsPublicKey() =
|
||||
runTest {
|
||||
val prompt = ScriptedPrompt(GrantState.ALLOW_ONCE)
|
||||
val external = FakeExternalSigner("dd".repeat(32))
|
||||
|
||||
val response =
|
||||
broker(prompt, signer = external).handle(applet, NappletRequest.GetPublicKey, allDeclared)
|
||||
|
||||
assertEquals(NappletResponse.PublicKey(external.pubKey), response)
|
||||
assertEquals(0, prompt.calls) // deferred to the external signer; we did not prompt
|
||||
assertEquals(1, prompt.calls) // Amethyst prompted before the external signer
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -246,7 +246,7 @@ class KtorRelayTest {
|
||||
com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
) { template ->
|
||||
) { _, template ->
|
||||
listOf(signer.sign(template))
|
||||
}
|
||||
try {
|
||||
|
||||
+7
-2
@@ -51,7 +51,12 @@ object EmptyIAuthStatus : IAuthStatus {
|
||||
class RelayAuthenticator(
|
||||
val client: INostrClient,
|
||||
val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
val signWithAllLoggedInUsers: suspend (EventTemplate<RelayAuthEvent>) -> List<RelayAuthEvent>,
|
||||
/**
|
||||
* Signs the auth template for every currently-logged-in account and returns the signed events.
|
||||
* The [relay] parameter allows callers to check per-relay auth policy before signing.
|
||||
* Returns an empty list to skip authentication for this relay.
|
||||
*/
|
||||
val signWithAllLoggedInUsers: suspend (relay: NormalizedRelayUrl, EventTemplate<RelayAuthEvent>) -> List<RelayAuthEvent>,
|
||||
) : IAuthStatus {
|
||||
// Connection callbacks fire on the per-relay OkHttp dispatcher thread, so
|
||||
// this state is mutated concurrently — LargeCache wraps a platform-tuned
|
||||
@@ -93,7 +98,7 @@ class RelayAuthenticator(
|
||||
// so an uncaught throwable here crashes the whole app. Swallow + log them.
|
||||
try {
|
||||
val ev = RelayAuthEvent.build(relay.url, msg.challenge)
|
||||
signWithAllLoggedInUsers(ev).forEach { authEvent ->
|
||||
signWithAllLoggedInUsers(relay.url, ev).forEach { authEvent ->
|
||||
// only send replies to new challenges to avoid infinite loop:
|
||||
if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) {
|
||||
relay.sendIfConnected(AuthCmd(authEvent))
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ class RelayAuthenticatorConcurrencyTest {
|
||||
val authenticator =
|
||||
RelayAuthenticator(
|
||||
client = client,
|
||||
signWithAllLoggedInUsers = { emptyList() },
|
||||
signWithAllLoggedInUsers = { _, _ -> emptyList() },
|
||||
)
|
||||
val listener =
|
||||
client.captured
|
||||
|
||||
+2
-2
@@ -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))
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user