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:
@@ -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
|
||||
|
||||
+69
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user