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
@@ -49,6 +49,15 @@ class CallManager(
private val scope: CoroutineScope,
private val isFollowing: (HexKey) -> Boolean,
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()
@@ -227,6 +236,15 @@ class CallManager(
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)) {
Log.d("CallManager") { "onIncomingCallEvent: caller not followed — ignoring" }
return
@@ -85,6 +85,7 @@ class CallManagerTest {
private fun TestScope.createManager(
localPubKey: HexKey = bob,
followedKeys: Set<HexKey> = setOf(alice, carol),
isCallsEnabled: () -> Boolean = { true },
): Pair<CallManager, MutableList<EphemeralGiftWrapEvent>> {
val published = mutableListOf<EphemeralGiftWrapEvent>()
val signer = signers[localPubKey] ?: error("Unknown test identity: $localPubKey")
@@ -94,6 +95,7 @@ class CallManagerTest {
scope = this,
isFollowing = { it in followedKeys },
publishEvent = { published.add(it) },
isCallsEnabled = isCallsEnabled,
)
return manager to published
}
@@ -1534,4 +1536,71 @@ class CallManagerTest {
assertIs<CallState.Idle>(aliceManager.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)
}
}