diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 8fe883a1e..2ac3fce84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -124,6 +124,7 @@ private object PrefKeys { const val LATEST_GEOHASH_LIST = "latestGeohashList" const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList" 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_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" 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_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) + putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value) // migrating from previous design remove(PrefKeys.USE_PROXY) @@ -494,6 +496,7 @@ object LocalPreferences { val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_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 dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) @@ -651,6 +654,7 @@ object LocalPreferences { viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()), pendingAttestations = MutableStateFlow(pendingAttestations.await()), backupNipA3PaymentTargets = latestPaymentTargets.await(), + callsEnabled = MutableStateFlow(callsEnabled), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index f6b681b61..924b439b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -203,6 +203,7 @@ class AccountSettings( var callTurnServers: List = emptyList(), var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720, var callMaxBitrateBps: Int = 1_500_000, + val callsEnabled: MutableStateFlow = MutableStateFlow(true), ) : EphemeralChatRepository, PublicChatListRepository { val saveable = MutableStateFlow(AccountSettingsUpdater(null)) @@ -942,6 +943,13 @@ class AccountSettings( callMaxBitrateBps = bitrate saveAccountSettings() } + + fun changeCallsEnabled(enabled: Boolean) { + if (callsEnabled.value != enabled) { + callsEnabled.tryEmit(enabled) + saveAccountSettings() + } + } } @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index af27793ee..8cb6ebac8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -199,6 +199,7 @@ class AccountViewModel( account.publishCallSignaling(wrap) } }, + isCallsEnabled = { account.settings.callsEnabled.value }, ) var callController: CallController? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 397099f7c..f320c8341 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -25,6 +25,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.service.call.CallSessionBridge @@ -49,7 +51,9 @@ fun ChatroomScreen( nav: INav, ) { 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 = rememberCallWithPermission(context) { CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt index a8b638f3c..4d8fa3bb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt @@ -42,8 +42,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -85,6 +87,22 @@ fun CallSettingsScreen( @Composable private fun CallSettingsContent(accountViewModel: AccountViewModel) { 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)) VideoResolutionSection( @@ -120,6 +138,39 @@ private fun CallSettingsContent(accountViewModel: AccountViewModel) { 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 private fun SectionHeader(title: String) { Text( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b43403e23..4fffe2ae2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -815,6 +815,8 @@ Failed to accept call Failed to create call session Call Settings + Enable voice and video calls + When disabled, call buttons are hidden from chat screens and all incoming calls are silently ignored. Video Quality Max Video Bitrate TURN / STUN Servers diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 106cf40cf..44bbe8d96 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt index 6c508a39a..0c487cc0d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt @@ -85,6 +85,7 @@ class CallManagerTest { private fun TestScope.createManager( localPubKey: HexKey = bob, followedKeys: Set = setOf(alice, carol), + isCallsEnabled: () -> Boolean = { true }, ): Pair> { val published = mutableListOf() 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(aliceManager.state.value) assertIs(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(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(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(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(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(manager.state.value) + } }