feat(settings): add toggle to disable voice and video calls

New "Enable voice and video calls" switch at the top of the Call
Settings screen. Default is ON (no behavior change for existing users).
When a user turns it OFF:

- Call buttons in the chat room top bar are hidden. ChatroomScreen
  observes account.settings.callsEnabled as a StateFlow and flips
  onCallClick / onVideoCallClick to null, which RenderRoomTopBar
  already treats as "no buttons".

- Incoming CallOfferEvents are silently dropped in CallManager.
  A new isCallsEnabled: () -> Boolean hook on the CallManager
  constructor gates onIncomingCallEvent *before* the followed-user
  check, so the device never rings and no state transition occurs.
  Existing in-flight call signaling (answers/hangups/ICE) still flows
  through so a call that was active when the user flipped the toggle
  continues to clean up normally.

- The rest of the call-related settings (video quality, max bitrate,
  TURN servers) are hidden on the settings screen since they have no
  effect while calls are disabled — the screen becomes just the single
  meaningful toggle.

The setting is persisted per account via SharedPreferences
(PrefKeys.CALLS_ENABLED = "calls_enabled"), loaded in
LocalPreferences.loadFromEncryptedStorageSync, and exposed as a
MutableStateFlow<Boolean> on AccountSettings so both the chat top bar
and the settings screen react to changes without needing to navigate
away and back.

AccountViewModel wires
  isCallsEnabled = { account.settings.callsEnabled.value }
into the CallManager constructor so the flag is read lazily on every
incoming event.

Tests in CallManagerTest:
- incomingOfferIgnoredWhenCallsDisabledInSettings — disabled flag
  drops the offer, no state change, no published events.
- disablingCallsAfterStartDoesNotTearDownInProgressCall — an active
  call keeps working after the toggle flips; only new offers are
  ignored.
- incomingOfferProcessedWhenCallsEnabled — regression guard for the
  default-enabled path.

https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
This commit is contained in:
Claude
2026-04-15 01:21:37 +00:00
parent b9d6cefbeb
commit 1290e9151c
8 changed files with 158 additions and 1 deletions
@@ -124,6 +124,7 @@ private object PrefKeys {
const val LATEST_GEOHASH_LIST = "latestGeohashList" const val LATEST_GEOHASH_LIST = "latestGeohashList"
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList" const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList" const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
const val CALLS_ENABLED = "calls_enabled"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" 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 HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
@@ -391,6 +392,7 @@ object LocalPreferences {
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
// migrating from previous design // migrating from previous design
remove(PrefKeys.USE_PROXY) remove(PrefKeys.USE_PROXY)
@@ -494,6 +496,7 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
@@ -651,6 +654,7 @@ object LocalPreferences {
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()), viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()),
pendingAttestations = MutableStateFlow(pendingAttestations.await()), pendingAttestations = MutableStateFlow(pendingAttestations.await()),
backupNipA3PaymentTargets = latestPaymentTargets.await(), backupNipA3PaymentTargets = latestPaymentTargets.await(),
callsEnabled = MutableStateFlow(callsEnabled),
) )
} }
} }
@@ -203,6 +203,7 @@ class AccountSettings(
var callTurnServers: List<CallTurnServer> = emptyList(), var callTurnServers: List<CallTurnServer> = emptyList(),
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720, var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
var callMaxBitrateBps: Int = 1_500_000, var callMaxBitrateBps: Int = 1_500_000,
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
) : EphemeralChatRepository, ) : EphemeralChatRepository,
PublicChatListRepository { PublicChatListRepository {
val saveable = MutableStateFlow(AccountSettingsUpdater(null)) val saveable = MutableStateFlow(AccountSettingsUpdater(null))
@@ -942,6 +943,13 @@ class AccountSettings(
callMaxBitrateBps = bitrate callMaxBitrateBps = bitrate
saveAccountSettings() saveAccountSettings()
} }
fun changeCallsEnabled(enabled: Boolean) {
if (callsEnabled.value != enabled) {
callsEnabled.tryEmit(enabled)
saveAccountSettings()
}
}
} }
@Serializable @Serializable
@@ -199,6 +199,7 @@ class AccountViewModel(
account.publishCallSignaling(wrap) account.publishCallSignaling(wrap)
} }
}, },
isCallsEnabled = { account.settings.callsEnabled.value },
) )
var callController: CallController? = null var callController: CallController? = null
@@ -25,6 +25,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.service.call.CallSessionBridge import com.vitorpamplona.amethyst.service.call.CallSessionBridge
@@ -49,7 +51,9 @@ fun ChatroomScreen(
nav: INav, nav: INav,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val isCallSupported = roomId.users.size <= 5 val callsEnabled by accountViewModel.account.settings.callsEnabled
.collectAsState()
val isCallSupported = roomId.users.size <= 5 && callsEnabled
val startVoiceCall = val startVoiceCall =
rememberCallWithPermission(context) { rememberCallWithPermission(context) {
CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
@@ -42,8 +42,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -85,6 +87,22 @@ fun CallSettingsScreen(
@Composable @Composable
private fun CallSettingsContent(accountViewModel: AccountViewModel) { private fun CallSettingsContent(accountViewModel: AccountViewModel) {
val settings = accountViewModel.account.settings val settings = accountViewModel.account.settings
val callsEnabled by settings.callsEnabled.collectAsState()
EnableCallsSection(
enabled = callsEnabled,
onEnabledChanged = { settings.changeCallsEnabled(it) },
)
if (!callsEnabled) {
// When calls are disabled the remaining settings (video quality,
// TURN servers, etc.) have no effect, so hide them to keep the
// screen focused on the single meaningful toggle.
Spacer(modifier = Modifier.height(16.dp))
return
}
HorizontalDivider(thickness = 4.dp, modifier = Modifier.padding(vertical = 8.dp))
SectionHeader(stringRes(R.string.call_settings_video_quality)) SectionHeader(stringRes(R.string.call_settings_video_quality))
VideoResolutionSection( VideoResolutionSection(
@@ -120,6 +138,39 @@ private fun CallSettingsContent(accountViewModel: AccountViewModel) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
@Composable
private fun EnableCallsSection(
enabled: Boolean,
onEnabledChanged: (Boolean) -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.call_settings_enable_calls),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = stringRes(R.string.call_settings_enable_calls_description),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
Spacer(modifier = Modifier.width(16.dp))
Switch(
checked = enabled,
onCheckedChange = onEnabledChanged,
)
}
}
@Composable @Composable
private fun SectionHeader(title: String) { private fun SectionHeader(title: String) {
Text( Text(
+2
View File
@@ -815,6 +815,8 @@
<string name="call_failed_accept">Failed to accept call</string> <string name="call_failed_accept">Failed to accept call</string>
<string name="call_failed_session">Failed to create call session</string> <string name="call_failed_session">Failed to create call session</string>
<string name="call_settings">Call Settings</string> <string name="call_settings">Call Settings</string>
<string name="call_settings_enable_calls">Enable voice and video calls</string>
<string name="call_settings_enable_calls_description">When disabled, call buttons are hidden from chat screens and all incoming calls are silently ignored.</string>
<string name="call_settings_video_quality">Video Quality</string> <string name="call_settings_video_quality">Video Quality</string>
<string name="call_settings_max_bitrate">Max Video Bitrate</string> <string name="call_settings_max_bitrate">Max Video Bitrate</string>
<string name="call_settings_turn_servers">TURN / STUN Servers</string> <string name="call_settings_turn_servers">TURN / STUN Servers</string>
@@ -49,6 +49,15 @@ class CallManager(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val isFollowing: (HexKey) -> Boolean, private val isFollowing: (HexKey) -> Boolean,
private val publishEvent: (EphemeralGiftWrapEvent) -> Unit, private val publishEvent: (EphemeralGiftWrapEvent) -> Unit,
/**
* Whether the user has enabled calls in Settings. When false, all
* incoming [CallOfferEvent]s are silently ignored so the device never
* rings and no `IncomingCall` state is entered. Signaling for calls that
* are already in progress is still processed so cleanup can complete.
* Defaults to `true` (enabled) so existing callers and tests keep their
* current behavior.
*/
private val isCallsEnabled: () -> Boolean = { true },
) { ) {
private val factory = WebRtcCallFactory() private val factory = WebRtcCallFactory()
@@ -227,6 +236,15 @@ class CallManager(
Log.d("CallManager") { "onIncomingCallEvent: from=${callerPubKey.take(8)}, callId=$callId, type=$callType, sdpOfferLength=${event.sdpOffer().length}" } Log.d("CallManager") { "onIncomingCallEvent: from=${callerPubKey.take(8)}, callId=$callId, type=$callType, sdpOfferLength=${event.sdpOffer().length}" }
// User disabled calls in Settings — silently ignore new incoming
// offers so the device does not ring. Mid-call signaling for calls
// that are already in progress is still processed by the other
// branches in onSignalingEvent so cleanup can complete normally.
if (!isCallsEnabled()) {
Log.d("CallManager") { "onIncomingCallEvent: calls disabled in settings — ignoring" }
return
}
if (!isFollowing(callerPubKey)) { if (!isFollowing(callerPubKey)) {
Log.d("CallManager") { "onIncomingCallEvent: caller not followed — ignoring" } Log.d("CallManager") { "onIncomingCallEvent: caller not followed — ignoring" }
return return
@@ -85,6 +85,7 @@ class CallManagerTest {
private fun TestScope.createManager( private fun TestScope.createManager(
localPubKey: HexKey = bob, localPubKey: HexKey = bob,
followedKeys: Set<HexKey> = setOf(alice, carol), followedKeys: Set<HexKey> = setOf(alice, carol),
isCallsEnabled: () -> Boolean = { true },
): Pair<CallManager, MutableList<EphemeralGiftWrapEvent>> { ): Pair<CallManager, MutableList<EphemeralGiftWrapEvent>> {
val published = mutableListOf<EphemeralGiftWrapEvent>() val published = mutableListOf<EphemeralGiftWrapEvent>()
val signer = signers[localPubKey] ?: error("Unknown test identity: $localPubKey") val signer = signers[localPubKey] ?: error("Unknown test identity: $localPubKey")
@@ -94,6 +95,7 @@ class CallManagerTest {
scope = this, scope = this,
isFollowing = { it in followedKeys }, isFollowing = { it in followedKeys },
publishEvent = { published.add(it) }, publishEvent = { published.add(it) },
isCallsEnabled = isCallsEnabled,
) )
return manager to published return manager to published
} }
@@ -1534,4 +1536,71 @@ class CallManagerTest {
assertIs<CallState.Idle>(aliceManager.state.value) assertIs<CallState.Idle>(aliceManager.state.value)
assertIs<CallState.Idle>(bobManager.state.value) assertIs<CallState.Idle>(bobManager.state.value)
} }
// ========================================================================
// User has disabled calls in Settings
// ========================================================================
/**
* When [CallManager.isCallsEnabled] returns false, an incoming
* [CallOfferEvent] is silently dropped no state change, no ringing,
* no published reject.
*/
@Test
fun incomingOfferIgnoredWhenCallsDisabledInSettings() =
runTest {
val (manager, published) = createManager(localPubKey = bob, isCallsEnabled = { false })
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
assertIs<CallState.Idle>(manager.state.value)
assertTrue(
published.isEmpty(),
"Disabled calls must not publish any signaling events in response to an incoming offer",
)
}
/**
* Toggling the flag to false after a call is already in progress does
* not affect the in-flight call CallManager only gates *new* incoming
* offers. Signaling for the active call continues to flow so cleanup
* (hangups, answers, ICE candidates) can complete.
*/
@Test
fun disablingCallsAfterStartDoesNotTearDownInProgressCall() =
runTest {
var enabled = true
val (manager, _) = createManager(localPubKey = bob, isCallsEnabled = { enabled })
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
manager.acceptCall(sdpAnswer)
manager.onPeerConnected()
assertIs<CallState.Connected>(manager.state.value)
// User flips the toggle off mid-call.
enabled = false
// The existing call is unaffected — Bob can still receive
// hangup/answer/ICE traffic for the current call.
assertIs<CallState.Connected>(manager.state.value)
// But a *new* offer for a different call is silently ignored.
val newCall = makeOffer(from = carol, to = bob, callId = callId2)
manager.onSignalingEvent(newCall)
assertIs<CallState.Connected>(manager.state.value)
}
/**
* Regression: when calls are enabled (the default) the incoming-offer
* path still works exactly as before.
*/
@Test
fun incomingOfferProcessedWhenCallsEnabled() =
runTest {
val (manager, _) = createManager(localPubKey = bob, isCallsEnabled = { true })
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
assertIs<CallState.IncomingCall>(manager.state.value)
}
} }