mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(relayauth): replace the policy list with Always / Never / Custom + toggles
Restructure the global auth control into a top-level mode — Always authenticate,
Never authenticate, or Custom — where Custom reveals independent per-situation
toggles instead of the confusing single sub-toggle:
- My relays and venues (own relays + joined/subscribed/favorited venues) — on
- Read posts from people I follow — on
- Message people I follow (DMs, replies, notifications) — on
- Message anyone / strangers — off by default (you're asked each time instead)
RelayAuthPolicy is now {ALWAYS, NEVER, CUSTOM}. The resolver takes a
RelayAuthCustomToggles plus split serves-facts (followed-read, followed-write,
stranger-write, own-relay, venue) and, under CUSTOM, allows if any enabled
category matches — else falls through to a prompt. There is deliberately no
"read strangers' posts" category, so that always prompts.
Account settings replace the single delivery flag with four persisted booleans
(default policy CUSTOM; no migration, unreleased). The contextual DM/notification
prompt button now switches to CUSTOM and enables both message toggles. Resolver
tests rewritten per-toggle, including that reading a stranger is never
auto-allowed. Old IF_IN_MY_LIST / TRUSTED_FOLLOWS policies and their strings are
removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
This commit is contained in:
@@ -162,7 +162,10 @@ private object PrefKeys {
|
||||
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
|
||||
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
|
||||
const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode"
|
||||
const val RELAY_AUTH_TRUST_MESSAGE_DELIVERY = "relay_auth_trust_message_delivery"
|
||||
const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues"
|
||||
const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows"
|
||||
const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows"
|
||||
const val RELAY_AUTH_TRUST_MESSAGE_STRANGERS = "relay_auth_trust_message_strangers"
|
||||
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
|
||||
const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications"
|
||||
|
||||
@@ -518,7 +521,10 @@ object LocalPreferences {
|
||||
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
|
||||
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
|
||||
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
|
||||
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_DELIVERY, settings.relayAuthTrustMessageDelivery.value)
|
||||
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value)
|
||||
putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value)
|
||||
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value)
|
||||
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, settings.relayAuthTrustMessageStrangers.value)
|
||||
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
|
||||
@@ -638,9 +644,12 @@ object LocalPreferences {
|
||||
val defaultRelayAuthPolicy =
|
||||
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
|
||||
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
|
||||
?: RelayAuthPolicy.TRUSTED_FOLLOWS
|
||||
?: RelayAuthPolicy.CUSTOM
|
||||
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
|
||||
val relayAuthTrustMessageDelivery = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_DELIVERY, false)
|
||||
val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true)
|
||||
val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true)
|
||||
val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true)
|
||||
val relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false)
|
||||
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()
|
||||
@@ -850,7 +859,10 @@ object LocalPreferences {
|
||||
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
|
||||
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
|
||||
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
|
||||
relayAuthTrustMessageDelivery = MutableStateFlow(relayAuthTrustMessageDelivery),
|
||||
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
|
||||
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
|
||||
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
|
||||
relayAuthTrustMessageStrangers = MutableStateFlow(relayAuthTrustMessageStrangers),
|
||||
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
|
||||
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
|
||||
backupUserMetadata = latestUserMetadataResolved,
|
||||
|
||||
@@ -273,9 +273,13 @@ 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.TRUSTED_FOLLOWS),
|
||||
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.CUSTOM),
|
||||
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
|
||||
val relayAuthTrustMessageDelivery: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
|
||||
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val relayAuthTrustMessageFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val relayAuthTrustMessageStrangers: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
) : EphemeralChatRepository,
|
||||
RelayGroupRepository,
|
||||
PublicChatListRepository {
|
||||
@@ -1526,12 +1530,23 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeRelayAuthTrustMessageDelivery(enabled: Boolean) {
|
||||
if (relayAuthTrustMessageDelivery.value != enabled) {
|
||||
relayAuthTrustMessageDelivery.tryEmit(enabled)
|
||||
private fun changeToggle(
|
||||
flow: MutableStateFlow<Boolean>,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
if (flow.value != enabled) {
|
||||
flow.tryEmit(enabled)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeRelayAuthTrustMyRelaysAndVenues(enabled: Boolean) = changeToggle(relayAuthTrustMyRelaysAndVenues, enabled)
|
||||
|
||||
fun changeRelayAuthTrustReadFollows(enabled: Boolean) = changeToggle(relayAuthTrustReadFollows, enabled)
|
||||
|
||||
fun changeRelayAuthTrustMessageFollows(enabled: Boolean) = changeToggle(relayAuthTrustMessageFollows, enabled)
|
||||
|
||||
fun changeRelayAuthTrustMessageStrangers(enabled: Boolean) = changeToggle(relayAuthTrustMessageStrangers, enabled)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
+9
-1
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
|
||||
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
|
||||
@@ -54,6 +55,14 @@ fun RelayAuthSubscription(
|
||||
RelayAuthPermissionLedger(
|
||||
store = Amethyst.instance.relayAuthPermissionStore,
|
||||
globalPolicy = { account.settings.defaultRelayAuthPolicy.value },
|
||||
customToggles = {
|
||||
RelayAuthCustomToggles(
|
||||
myRelaysAndVenues = account.settings.relayAuthTrustMyRelaysAndVenues.value,
|
||||
readFollows = account.settings.relayAuthTrustReadFollows.value,
|
||||
messageFollows = account.settings.relayAuthTrustMessageFollows.value,
|
||||
messageStrangers = account.settings.relayAuthTrustMessageStrangers.value,
|
||||
)
|
||||
},
|
||||
isInMyRelayList = { relayUrl ->
|
||||
val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false
|
||||
normalized in account.trustedRelays.flow.value
|
||||
@@ -72,7 +81,6 @@ fun RelayAuthSubscription(
|
||||
venueId in account.communityList.flowSet.value ||
|
||||
venueOwnerPubkey(venueId)?.let { it in account.allFollows.flow.value.authors } == true
|
||||
},
|
||||
messageDeliveryTrustEnabled = { account.settings.relayAuthTrustMessageDelivery.value },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
-5
@@ -176,14 +176,15 @@ private fun RelayAuthPromptDialog(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(stringRes(R.string.relay_auth_allow_once)) }
|
||||
// For a DM/notification prompt, offer the broad rule: always log in to deliver my
|
||||
// messages to whoever I'm talking to, so these prompts stop appearing. That trust
|
||||
// only applies under TRUSTED_FOLLOWS, so set both to make the promise hold on any
|
||||
// policy.
|
||||
// messages to whoever I'm talking to, so these prompts stop appearing. Those toggles
|
||||
// only apply under CUSTOM, so switch to it and turn both message toggles on.
|
||||
if (primary?.kind == AuthPurposeKind.SEND_DM || primary?.kind == AuthPurposeKind.NOTIFY_INBOX) {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
accountViewModel.account.settings.changeDefaultRelayAuthPolicy(RelayAuthPolicy.TRUSTED_FOLLOWS)
|
||||
accountViewModel.account.settings.changeRelayAuthTrustMessageDelivery(true)
|
||||
val settings = accountViewModel.account.settings
|
||||
settings.changeDefaultRelayAuthPolicy(RelayAuthPolicy.CUSTOM)
|
||||
settings.changeRelayAuthTrustMessageFollows(true)
|
||||
settings.changeRelayAuthTrustMessageStrangers(true)
|
||||
onChoice(UserAuthChoice.ALLOW_ONCE)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
+18
-16
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthInputs
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
@@ -33,44 +34,45 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
* Decides whether Amethyst should authenticate with a given relay (NIP-42), for one account.
|
||||
*
|
||||
* Precedence (see [RelayAuthResolver]): blocked-relay list → per-relay override → global
|
||||
* [globalPolicy] → prompt-if-attributable-else-deny. The follow-graph half of
|
||||
* [RelayAuthPolicy.TRUSTED_FOLLOWS] uses [isFollowed] against the counterparties carried in the
|
||||
* [RelayAuthContext].
|
||||
* [globalPolicy] → prompt-if-attributable-else-deny. Under [RelayAuthPolicy.CUSTOM] the
|
||||
* [customToggles] gate each category, using [isFollowed] to split the counterparties carried in the
|
||||
* [RelayAuthContext] into followed vs. stranger.
|
||||
*/
|
||||
class RelayAuthPermissionLedger(
|
||||
val store: RelayAuthPermissionStore,
|
||||
val globalPolicy: () -> RelayAuthPolicy,
|
||||
val customToggles: () -> RelayAuthCustomToggles = { RelayAuthCustomToggles() },
|
||||
val isInMyRelayList: (String) -> Boolean = { false },
|
||||
val isBlocked: (String) -> Boolean = { false },
|
||||
val isFollowed: (String) -> Boolean = { false },
|
||||
val isTrustedVenue: (String) -> Boolean = { false },
|
||||
val messageDeliveryTrustEnabled: () -> Boolean = { false },
|
||||
) {
|
||||
/** The authorization verdict for [ctx], taking the challenge's purpose into account. */
|
||||
suspend fun decide(ctx: RelayAuthContext): RelayAuthVerdict {
|
||||
fun isWrite(kind: AuthPurposeKind) = kind == AuthPurposeKind.SEND_DM || kind == AuthPurposeKind.NOTIFY_INBOX
|
||||
val inputs =
|
||||
RelayAuthInputs(
|
||||
storedOverride = store.loadDecision(ctx.relayUrl),
|
||||
isBlocked = isBlocked(ctx.relayUrl),
|
||||
policy = globalPolicy(),
|
||||
toggles = customToggles(),
|
||||
isInMyRelayList = isInMyRelayList(ctx.relayUrl),
|
||||
// A followed user is a counterparty here, whether we're reading them (outbox) or
|
||||
// reaching them (DM / notification inbox).
|
||||
servesFollowedCounterparty =
|
||||
ctx.purposes.any { p -> p.counterparties.any(isFollowed) },
|
||||
// This relay is an inbox for someone we're messaging (DM or notification), follow
|
||||
// or not — the target of the "deliver my messages" toggle.
|
||||
servesWriteCounterparty =
|
||||
ctx.purposes.any { p ->
|
||||
(p.kind == AuthPurposeKind.SEND_DM || p.kind == AuthPurposeKind.NOTIFY_INBOX) &&
|
||||
p.counterparties.isNotEmpty()
|
||||
},
|
||||
servesTrustedVenue =
|
||||
ctx.purposes.any { p ->
|
||||
(p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) &&
|
||||
p.venues.any(isTrustedVenue)
|
||||
},
|
||||
messageDeliveryTrustEnabled = messageDeliveryTrustEnabled(),
|
||||
// Reading a followed author's outbox.
|
||||
servesFollowedReadCounterparty =
|
||||
ctx.purposes.any { p ->
|
||||
p.kind == AuthPurposeKind.READ_OUTBOX && p.counterparties.any(isFollowed)
|
||||
},
|
||||
// Messaging a followed user's inbox (DM / notification).
|
||||
servesFollowedWriteCounterparty =
|
||||
ctx.purposes.any { p -> isWrite(p.kind) && p.counterparties.any(isFollowed) },
|
||||
// Messaging a non-followed user's inbox.
|
||||
servesStrangerWriteCounterparty =
|
||||
ctx.purposes.any { p -> isWrite(p.kind) && p.counterparties.any { !isFollowed(it) } },
|
||||
hasAttributablePurpose =
|
||||
ctx.purposes.any {
|
||||
it.kind == AuthPurposeKind.MY_OWN_RELAY ||
|
||||
|
||||
+65
-26
@@ -30,6 +30,7 @@ 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.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -148,17 +149,11 @@ fun RelayAuthSettingsScreen(
|
||||
R.string.relay_auth_policy_never_desc,
|
||||
MaterialSymbols.Lock,
|
||||
)
|
||||
RelayAuthPolicy.IF_IN_MY_LIST ->
|
||||
RelayAuthPolicy.CUSTOM ->
|
||||
Triple(
|
||||
R.string.relay_auth_policy_if_in_my_list,
|
||||
R.string.relay_auth_policy_if_in_my_list_desc,
|
||||
MaterialSymbols.PrivacyTip,
|
||||
)
|
||||
RelayAuthPolicy.TRUSTED_FOLLOWS ->
|
||||
Triple(
|
||||
R.string.relay_auth_policy_trusted_follows,
|
||||
R.string.relay_auth_policy_trusted_follows_desc,
|
||||
MaterialSymbols.Group,
|
||||
R.string.relay_auth_policy_custom,
|
||||
R.string.relay_auth_policy_custom_desc,
|
||||
MaterialSymbols.Tune,
|
||||
)
|
||||
}
|
||||
PolicyCard(
|
||||
@@ -171,27 +166,43 @@ fun RelayAuthSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (globalPolicy == RelayAuthPolicy.TRUSTED_FOLLOWS) {
|
||||
val trustDelivery by account.settings.relayAuthTrustMessageDelivery.collectAsState()
|
||||
Row(
|
||||
if (globalPolicy == RelayAuthPolicy.CUSTOM) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.relay_auth_trust_delivery),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
val myRelays by account.settings.relayAuthTrustMyRelaysAndVenues.collectAsState()
|
||||
val readFollows by account.settings.relayAuthTrustReadFollows.collectAsState()
|
||||
val messageFollows by account.settings.relayAuthTrustMessageFollows.collectAsState()
|
||||
val messageStrangers by account.settings.relayAuthTrustMessageStrangers.collectAsState()
|
||||
|
||||
AuthToggleRow(
|
||||
title = stringResource(R.string.relay_auth_toggle_my_relays),
|
||||
description = stringResource(R.string.relay_auth_toggle_my_relays_desc),
|
||||
checked = myRelays,
|
||||
onCheckedChange = { account.settings.changeRelayAuthTrustMyRelaysAndVenues(it) },
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.relay_auth_trust_delivery_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
AuthToggleRow(
|
||||
title = stringResource(R.string.relay_auth_toggle_read_follows),
|
||||
description = stringResource(R.string.relay_auth_toggle_read_follows_desc),
|
||||
checked = readFollows,
|
||||
onCheckedChange = { account.settings.changeRelayAuthTrustReadFollows(it) },
|
||||
)
|
||||
AuthToggleRow(
|
||||
title = stringResource(R.string.relay_auth_toggle_message_follows),
|
||||
description = stringResource(R.string.relay_auth_toggle_message_follows_desc),
|
||||
checked = messageFollows,
|
||||
onCheckedChange = { account.settings.changeRelayAuthTrustMessageFollows(it) },
|
||||
)
|
||||
AuthToggleRow(
|
||||
title = stringResource(R.string.relay_auth_toggle_message_strangers),
|
||||
description = stringResource(R.string.relay_auth_toggle_message_strangers_desc),
|
||||
checked = messageStrangers,
|
||||
onCheckedChange = { account.settings.changeRelayAuthTrustMessageStrangers(it) },
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = trustDelivery,
|
||||
onCheckedChange = { account.settings.changeRelayAuthTrustMessageDelivery(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +279,34 @@ fun RelayAuthSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** A labelled Switch row for one [RelayAuthPolicy.CUSTOM] trust toggle. */
|
||||
@Composable
|
||||
private fun AuthToggleRow(
|
||||
title: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One relay's card in the merged list: NIP-11 icon + shortened URL (tap the card to open the relay's
|
||||
* info screen), when it was last used, an Allow/Deny chip, a Forget button, and a facepile of the
|
||||
|
||||
@@ -823,12 +823,16 @@
|
||||
<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_policy_trusted_follows">My relays and people I follow</string>
|
||||
<string name="relay_auth_policy_trusted_follows_desc">Also authenticate with relays that serve people you follow, such as sending a message to a friend. You\'ll be asked about anyone else.</string>
|
||||
<string name="relay_auth_trust_delivery">Also log in to deliver my messages</string>
|
||||
<string name="relay_auth_trust_delivery_desc">On its own, the option above only logs in for people you follow. Turn this on to also log in to send DMs, replies or notifications to anyone you\'re talking to, even if you don\'t follow them.</string>
|
||||
<string name="relay_auth_policy_custom">Custom</string>
|
||||
<string name="relay_auth_policy_custom_desc">Choose exactly which relays to log in to. You\'ll be asked about anything you haven\'t allowed below.</string>
|
||||
<string name="relay_auth_toggle_my_relays">My relays and venues</string>
|
||||
<string name="relay_auth_toggle_my_relays_desc">Log in to your own relays and to public chats, communities and live streams you\'ve joined or favorited.</string>
|
||||
<string name="relay_auth_toggle_read_follows">Read posts from people I follow</string>
|
||||
<string name="relay_auth_toggle_read_follows_desc">Log in to a follow\'s relays to download their posts.</string>
|
||||
<string name="relay_auth_toggle_message_follows">Message people I follow</string>
|
||||
<string name="relay_auth_toggle_message_follows_desc">Log in to a follow\'s relays to send DMs, replies and notifications.</string>
|
||||
<string name="relay_auth_toggle_message_strangers">Message anyone</string>
|
||||
<string name="relay_auth_toggle_message_strangers_desc">Log in to strangers\' relays to send DMs, replies and notifications. Off by default; you\'ll be asked each time instead.</string>
|
||||
<string name="relay_auth_prompt_title">Confirm it\'s you to this relay?</string>
|
||||
<string name="relay_auth_prompt_message">This relay wants to confirm it\'s really you first. Its operator will see which account you are.</string>
|
||||
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ class RelayAuthGrantRationaleTest {
|
||||
private val bob = "b".repeat(64)
|
||||
private val carol = "c".repeat(64)
|
||||
|
||||
private fun ledger(store: RelayAuthPermissionStore) = RelayAuthPermissionLedger(store, { RelayAuthPolicy.TRUSTED_FOLLOWS })
|
||||
private fun ledger(store: RelayAuthPermissionStore) = RelayAuthPermissionLedger(store, { RelayAuthPolicy.CUSTOM })
|
||||
|
||||
@Test
|
||||
fun recordsCounterpartiesGroupedByPurpose() =
|
||||
|
||||
+6
-10
@@ -21,26 +21,22 @@
|
||||
package com.vitorpamplona.amethyst.commons.relayauth
|
||||
|
||||
/**
|
||||
* The default policy for authenticating with relays (NIP-42).
|
||||
* The top-level mode 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. */
|
||||
/** Authenticate with every relay that requests it. */
|
||||
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,
|
||||
|
||||
/**
|
||||
* Authenticate with relays in the user's own list, and additionally with relays that
|
||||
* serve someone the user follows (any follow list) for the current purpose — e.g. the
|
||||
* DM inbox of a friend you're messaging. Relays that can't be attributed to a followed
|
||||
* counterparty fall through to an explicit prompt ([RelayAuthVerdict.ASK]).
|
||||
* Apply the per-situation [RelayAuthCustomToggles]: authenticate only for the categories the
|
||||
* user turned on (own relays/venues, reading or messaging follows, messaging strangers).
|
||||
* Situations no toggle covers fall through to an explicit prompt ([RelayAuthVerdict.ASK]).
|
||||
*/
|
||||
TRUSTED_FOLLOWS,
|
||||
CUSTOM,
|
||||
}
|
||||
|
||||
/** A persisted per-relay override decision. */
|
||||
|
||||
+43
-29
@@ -20,6 +20,25 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayauth
|
||||
|
||||
/**
|
||||
* The per-situation switches applied under [RelayAuthPolicy.CUSTOM]. Each independently authorizes
|
||||
* one category of relay; a situation with no matching toggle falls through to a prompt.
|
||||
*
|
||||
* @param myRelaysAndVenues your own relays, plus venues (public chats, communities, live streams)
|
||||
* you've joined, subscribed to, or favorited.
|
||||
* @param readFollows a relay serving the outbox of someone you follow (to download their posts).
|
||||
* @param messageFollows a relay serving the inbox of someone you follow (to send DMs, replies,
|
||||
* notifications).
|
||||
* @param messageStrangers a relay serving the inbox of someone you *don't* follow. Off by default —
|
||||
* sending to a stranger otherwise prompts.
|
||||
*/
|
||||
data class RelayAuthCustomToggles(
|
||||
val myRelaysAndVenues: Boolean = true,
|
||||
val readFollows: Boolean = true,
|
||||
val messageFollows: Boolean = true,
|
||||
val messageStrangers: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Everything the resolver needs to decide an auth challenge, gathered by the host (which owns
|
||||
* the blocked-relay list, the user's relay lists, and the follow graph). Kept as plain values
|
||||
@@ -27,16 +46,14 @@ package com.vitorpamplona.amethyst.commons.relayauth
|
||||
*
|
||||
* @param storedOverride an explicit per-relay decision the user set previously, or null.
|
||||
* @param isBlocked the relay is on the user's blocked-relay list (kind 10006).
|
||||
* @param policy the global [RelayAuthPolicy].
|
||||
* @param policy the top-level [RelayAuthPolicy].
|
||||
* @param toggles the [RelayAuthCustomToggles] applied when [policy] is [RelayAuthPolicy.CUSTOM].
|
||||
* @param isInMyRelayList the relay is in the user's own relay list.
|
||||
* @param servesFollowedCounterparty a user the person follows is a counterparty for this relay —
|
||||
* whether they're reading that user (their outbox) or reaching them (DM / notification inbox).
|
||||
* @param servesWriteCounterparty this relay serves the inbox of *someone the user is sending to*
|
||||
* (a DM or a notification), whether or not that person is followed.
|
||||
* @param servesTrustedVenue this relay hosts a venue (public chat, community, or live stream) the
|
||||
* user has joined, or whose owner they follow. Trusts both reading and posting to it.
|
||||
* @param messageDeliveryTrustEnabled the "also log in to deliver my messages to anyone I'm talking
|
||||
* to" toggle, which extends trust to [servesWriteCounterparty] relays beyond the follow graph.
|
||||
* user has joined, subscribed to, or favorited.
|
||||
* @param servesFollowedReadCounterparty a followed user's outbox is served here (reading them).
|
||||
* @param servesFollowedWriteCounterparty a followed user's inbox is served here (messaging them).
|
||||
* @param servesStrangerWriteCounterparty a non-followed user's inbox is served here (messaging them).
|
||||
* @param hasAttributablePurpose we know *why* this relay wants auth (so a prompt can explain it).
|
||||
* When false, an unresolved challenge is denied silently rather than prompting.
|
||||
*/
|
||||
@@ -44,11 +61,12 @@ data class RelayAuthInputs(
|
||||
val storedOverride: RelayAuthDecision?,
|
||||
val isBlocked: Boolean,
|
||||
val policy: RelayAuthPolicy,
|
||||
val toggles: RelayAuthCustomToggles,
|
||||
val isInMyRelayList: Boolean,
|
||||
val servesFollowedCounterparty: Boolean,
|
||||
val servesWriteCounterparty: Boolean,
|
||||
val servesTrustedVenue: Boolean,
|
||||
val messageDeliveryTrustEnabled: Boolean,
|
||||
val servesFollowedReadCounterparty: Boolean,
|
||||
val servesFollowedWriteCounterparty: Boolean,
|
||||
val servesStrangerWriteCounterparty: Boolean,
|
||||
val hasAttributablePurpose: Boolean,
|
||||
)
|
||||
|
||||
@@ -57,14 +75,12 @@ data class RelayAuthInputs(
|
||||
*
|
||||
* 1. Blocked-relay list → [RelayAuthVerdict.DENY] (never reveal identity to a blocked relay).
|
||||
* 2. Explicit per-relay override → honor it.
|
||||
* 3. Global [RelayAuthPolicy]:
|
||||
* 3. Top-level [RelayAuthPolicy]:
|
||||
* - [RelayAuthPolicy.NEVER] → DENY
|
||||
* - [RelayAuthPolicy.ALWAYS] → ALLOW
|
||||
* - [RelayAuthPolicy.IF_IN_MY_LIST] → ALLOW if in my list, else fall through
|
||||
* - [RelayAuthPolicy.TRUSTED_FOLLOWS] → ALLOW if in my list, a venue the user joined/follows is
|
||||
* served, a followed user is a counterparty (reading them or reaching them), or (when
|
||||
* [RelayAuthInputs.messageDeliveryTrustEnabled]) the relay serves the inbox of anyone the user
|
||||
* is messaging; else fall through
|
||||
* - [RelayAuthPolicy.CUSTOM] → ALLOW if any *enabled* [RelayAuthCustomToggles] category matches
|
||||
* this relay (own relays/venues, reading follows, messaging follows, messaging strangers);
|
||||
* else fall through
|
||||
* 4. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY.
|
||||
*/
|
||||
object RelayAuthResolver {
|
||||
@@ -81,20 +97,18 @@ object RelayAuthResolver {
|
||||
return when (inputs.policy) {
|
||||
RelayAuthPolicy.NEVER -> RelayAuthVerdict.DENY
|
||||
RelayAuthPolicy.ALWAYS -> RelayAuthVerdict.ALLOW
|
||||
RelayAuthPolicy.IF_IN_MY_LIST ->
|
||||
if (inputs.isInMyRelayList) RelayAuthVerdict.ALLOW else fallThrough(inputs)
|
||||
RelayAuthPolicy.TRUSTED_FOLLOWS ->
|
||||
if (inputs.isInMyRelayList ||
|
||||
inputs.servesTrustedVenue ||
|
||||
inputs.servesFollowedCounterparty ||
|
||||
(inputs.messageDeliveryTrustEnabled && inputs.servesWriteCounterparty)
|
||||
) {
|
||||
RelayAuthVerdict.ALLOW
|
||||
} else {
|
||||
fallThrough(inputs)
|
||||
}
|
||||
RelayAuthPolicy.CUSTOM ->
|
||||
if (customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun customAllows(inputs: RelayAuthInputs): Boolean {
|
||||
val t = inputs.toggles
|
||||
return (t.myRelaysAndVenues && (inputs.isInMyRelayList || inputs.servesTrustedVenue)) ||
|
||||
(t.readFollows && inputs.servesFollowedReadCounterparty) ||
|
||||
(t.messageFollows && inputs.servesFollowedWriteCounterparty) ||
|
||||
(t.messageStrangers && inputs.servesStrangerWriteCounterparty)
|
||||
}
|
||||
|
||||
private fun fallThrough(inputs: RelayAuthInputs): RelayAuthVerdict = if (inputs.hasAttributablePurpose) RelayAuthVerdict.ASK else RelayAuthVerdict.DENY
|
||||
}
|
||||
|
||||
+48
-36
@@ -27,22 +27,24 @@ class RelayAuthResolverTest {
|
||||
private fun inputs(
|
||||
storedOverride: RelayAuthDecision? = null,
|
||||
isBlocked: Boolean = false,
|
||||
policy: RelayAuthPolicy = RelayAuthPolicy.TRUSTED_FOLLOWS,
|
||||
policy: RelayAuthPolicy = RelayAuthPolicy.CUSTOM,
|
||||
toggles: RelayAuthCustomToggles = RelayAuthCustomToggles(),
|
||||
isInMyRelayList: Boolean = false,
|
||||
servesFollowedCounterparty: Boolean = false,
|
||||
servesWriteCounterparty: Boolean = false,
|
||||
servesTrustedVenue: Boolean = false,
|
||||
messageDeliveryTrustEnabled: Boolean = false,
|
||||
servesFollowedReadCounterparty: Boolean = false,
|
||||
servesFollowedWriteCounterparty: Boolean = false,
|
||||
servesStrangerWriteCounterparty: Boolean = false,
|
||||
hasAttributablePurpose: Boolean = true,
|
||||
) = RelayAuthInputs(
|
||||
storedOverride = storedOverride,
|
||||
isBlocked = isBlocked,
|
||||
policy = policy,
|
||||
toggles = toggles,
|
||||
isInMyRelayList = isInMyRelayList,
|
||||
servesFollowedCounterparty = servesFollowedCounterparty,
|
||||
servesWriteCounterparty = servesWriteCounterparty,
|
||||
servesTrustedVenue = servesTrustedVenue,
|
||||
messageDeliveryTrustEnabled = messageDeliveryTrustEnabled,
|
||||
servesFollowedReadCounterparty = servesFollowedReadCounterparty,
|
||||
servesFollowedWriteCounterparty = servesFollowedWriteCounterparty,
|
||||
servesStrangerWriteCounterparty = servesStrangerWriteCounterparty,
|
||||
hasAttributablePurpose = hasAttributablePurpose,
|
||||
)
|
||||
|
||||
@@ -70,51 +72,61 @@ class RelayAuthResolverTest {
|
||||
|
||||
@Test
|
||||
fun neverAndAlwaysAreUnconditional() {
|
||||
assertEquals(RelayAuthVerdict.DENY, resolve(inputs(policy = RelayAuthPolicy.NEVER, servesFollowedCounterparty = true)))
|
||||
assertEquals(RelayAuthVerdict.DENY, resolve(inputs(policy = RelayAuthPolicy.NEVER, isInMyRelayList = true)))
|
||||
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(policy = RelayAuthPolicy.ALWAYS, hasAttributablePurpose = false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ifInMyListAllowsOnlyMyRelaysElseAsksWhenAttributable() {
|
||||
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(policy = RelayAuthPolicy.IF_IN_MY_LIST, isInMyRelayList = true)))
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(policy = RelayAuthPolicy.IF_IN_MY_LIST, isInMyRelayList = false)))
|
||||
fun customMyRelaysAndVenuesToggleGatesOwnRelaysAndVenues() {
|
||||
// On (default): my own relay and any joined venue auto-auth.
|
||||
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(isInMyRelayList = true)))
|
||||
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesTrustedVenue = true)))
|
||||
// Off: even my own relay prompts.
|
||||
val off = RelayAuthCustomToggles(myRelaysAndVenues = false)
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(isInMyRelayList = true, toggles = off)))
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesTrustedVenue = true, toggles = off)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trustedFollowsAllowsAnyFollowedCounterparty() {
|
||||
// Reading a followed author's outbox OR reaching them (DM/notification) -> auto-auth,
|
||||
// independent of the delivery toggle.
|
||||
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedCounterparty = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trustedFollowsAsksToMessageAStrangerUnlessDeliveryToggleOn() {
|
||||
// Sending to someone I don't follow: prompts by default...
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesWriteCounterparty = true, messageDeliveryTrustEnabled = false)))
|
||||
// ...auto-auths only when the "deliver my messages" toggle is enabled.
|
||||
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesWriteCounterparty = true, messageDeliveryTrustEnabled = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deliveryToggleDoesNotCoverReadingAStranger() {
|
||||
// The delivery toggle is write-only: reading a non-followed author (no write counterparty)
|
||||
// still prompts even with the toggle on.
|
||||
fun customReadFollowsToggleGatesReadingFollows() {
|
||||
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedReadCounterparty = true)))
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ASK,
|
||||
resolve(inputs(servesWriteCounterparty = false, servesFollowedCounterparty = false, messageDeliveryTrustEnabled = true)),
|
||||
resolve(inputs(servesFollowedReadCounterparty = true, toggles = RelayAuthCustomToggles(readFollows = false))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trustedFollowsAllowsVenueYouJoinedOrFollow() {
|
||||
// A public chat / community / live stream you've joined (or whose owner you follow) —
|
||||
// auto-auth for both reading and posting, regardless of the delivery toggle.
|
||||
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesTrustedVenue = true, messageDeliveryTrustEnabled = false)))
|
||||
fun customMessageFollowsToggleGatesMessagingFollows() {
|
||||
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedWriteCounterparty = true)))
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ASK,
|
||||
resolve(inputs(servesFollowedWriteCounterparty = true, toggles = RelayAuthCustomToggles(messageFollows = false))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trustedFollowsFallsThroughForStranger() {
|
||||
// Not my relay, no followed counterparty -> prompt when we know why, else silent deny.
|
||||
fun customMessageStrangersIsOffByDefault() {
|
||||
// Default off: messaging a stranger prompts...
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesStrangerWriteCounterparty = true)))
|
||||
// ...on: auto-auth.
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ALLOW,
|
||||
resolve(inputs(servesStrangerWriteCounterparty = true, toggles = RelayAuthCustomToggles(messageStrangers = true))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun customHasNoToggleForReadingStrangers() {
|
||||
// Reading a non-followed author (no matching category) always prompts, even with every
|
||||
// toggle on — there is deliberately no "read strangers" trust category.
|
||||
val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true)
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, hasAttributablePurpose = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun customFallsThroughForUncoveredSituation() {
|
||||
// Nothing matches -> prompt when we know why, else silent deny.
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(hasAttributablePurpose = true)))
|
||||
assertEquals(RelayAuthVerdict.DENY, resolve(inputs(hasAttributablePurpose = false)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user