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