diff --git a/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt b/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt index cdfc01528..a12ae0c50 100644 --- a/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt +++ b/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt @@ -128,6 +128,8 @@ class SettingsRepository // Privacy preferences val BLOCK_UNKNOWN_SENDERS = booleanPreferencesKey("block_unknown_senders") + val ALLOW_CALLS_FROM_CONTACTS_ONLY = booleanPreferencesKey("allow_calls_from_contacts_only") + val ALLOW_VOICE_CALLS = booleanPreferencesKey("allow_voice_calls") // Telemetry collector preferences val TELEMETRY_COLLECTOR_ADDRESS = stringPreferencesKey("telemetry_collector_address") @@ -1577,6 +1579,8 @@ class SettingsRepository // Cross-process SharedPreferences keys (for settings read by the service) const val CROSS_PROCESS_PREFS_NAME = "cross_process_settings" const val KEY_BLOCK_UNKNOWN_SENDERS = "block_unknown_senders" + const val KEY_ALLOW_CALLS_FROM_CONTACTS_ONLY = "allow_calls_from_contacts_only" + const val KEY_ALLOW_VOICE_CALLS = "allow_voice_calls" /** Default telemetry send interval: 5 minutes */ const val DEFAULT_TELEMETRY_SEND_INTERVAL_SECONDS = 300 @@ -1775,6 +1779,121 @@ class SettingsRepository .apply() } + /** + * Flow of the calls-from-contacts-only setting. + * When enabled, only contacts can establish incoming voice calls. + * Non-contact callers' link attempts are silently dropped after + * identification (no STATUS_RINGING, no UI surface). + * Defaults to false if not set (allow all callers - preserves existing behavior). + */ + val allowCallsFromContactsOnlyFlow: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.ALLOW_CALLS_FROM_CONTACTS_ONLY] ?: false + }.distinctUntilChanged() + + /** + * Get the calls-from-contacts-only setting (non-flow). + */ + suspend fun getAllowCallsFromContactsOnly(): Boolean = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.ALLOW_CALLS_FROM_CONTACTS_ONLY] ?: false + }.first() + + /** + * Save the calls-from-contacts-only setting. + * + * Also writes to SharedPreferences with MODE_MULTI_PROCESS so the service process + * can read it (DataStore doesn't support reliable cross-process reads). + * + * @param enabled Whether to gate incoming calls to contacts only + */ + @Suppress("DEPRECATION") // MODE_MULTI_PROCESS needed for cross-process reads + suspend fun saveAllowCallsFromContactsOnly(enabled: Boolean) { + // Write to DataStore for local flow/UI + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.ALLOW_CALLS_FROM_CONTACTS_ONLY] = enabled + } + // Write to SharedPreferences for cross-process access by the service + context + .getSharedPreferences(CROSS_PROCESS_PREFS_NAME, Context.MODE_MULTI_PROCESS) + .edit() + .putBoolean(KEY_ALLOW_CALLS_FROM_CONTACTS_ONLY, enabled) + .apply() + } + + /** + * Flow of the master allow-voice-calls setting. + * When false, the inbound LXST telephony destination is deregistered and + * no announces are sent — peers see the device as unreachable for calls. + * Outbound calls remain functional regardless. + * Defaults to true if not set (allow incoming - preserves existing behavior). + */ + val allowVoiceCallsFlow: Flow = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.ALLOW_VOICE_CALLS] ?: true + }.distinctUntilChanged() + + /** + * Get the allow-voice-calls setting (non-flow). + */ + suspend fun getAllowVoiceCalls(): Boolean = + context.dataStore.data + .map { preferences -> + preferences[PreferencesKeys.ALLOW_VOICE_CALLS] ?: true + }.first() + + /** + * Save the master allow-voice-calls setting. + * + * Also writes to SharedPreferences with MODE_MULTI_PROCESS so the service process + * can read it (DataStore doesn't support reliable cross-process reads). + * + * @param enabled Whether to accept incoming voice calls + */ + @Suppress("DEPRECATION") // MODE_MULTI_PROCESS needed for cross-process reads + suspend fun saveAllowVoiceCalls(enabled: Boolean) { + // Write to DataStore for local flow/UI + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.ALLOW_VOICE_CALLS] = enabled + } + // Write to SharedPreferences so the :reticulum service can re-read + // the persisted value on next process start (the SharedPreferences + // change listener does NOT fire across processes — see Intent below). + context + .getSharedPreferences(CROSS_PROCESS_PREFS_NAME, Context.MODE_MULTI_PROCESS) + .edit() + .putBoolean(KEY_ALLOW_VOICE_CALLS, enabled) + .apply() + // Signal the :reticulum service to apply the new state at runtime. + // SharedPreferences listeners only fire in the writing process on + // Android, so the persisted value alone isn't enough — without + // this Intent the service keeps the destination registered until + // its next cold start. Mirrors the existing ACTION_RESTART_BLE + // notification pattern. + try { + val intent = + android.content.Intent( + context, + com.lxmf.messenger.service.ReticulumService::class.java, + ).apply { + action = com.lxmf.messenger.service.ReticulumService.ACTION_SET_ALLOW_VOICE_CALLS + putExtra( + com.lxmf.messenger.service.ReticulumService.EXTRA_ALLOW_VOICE_CALLS, + enabled, + ) + } + context.startService(intent) + } catch (e: Exception) { + android.util.Log.w( + "SettingsRepository", + "Failed to signal ReticulumService of Allow voice calls change: ${e.message}", + ) + } + } + // Custom theme methods (delegated to CustomThemeRepository) /** diff --git a/app/src/main/java/com/lxmf/messenger/service/ReticulumService.kt b/app/src/main/java/com/lxmf/messenger/service/ReticulumService.kt index f5707894a..df5776465 100644 --- a/app/src/main/java/com/lxmf/messenger/service/ReticulumService.kt +++ b/app/src/main/java/com/lxmf/messenger/service/ReticulumService.kt @@ -36,6 +36,8 @@ class ReticulumService : Service() { const val ACTION_START = "com.lxmf.messenger.service.START" const val ACTION_STOP = "com.lxmf.messenger.service.STOP" const val ACTION_RESTART_BLE = "com.lxmf.messenger.RESTART_BLE" + const val ACTION_SET_ALLOW_VOICE_CALLS = "com.lxmf.messenger.SET_ALLOW_VOICE_CALLS" + const val EXTRA_ALLOW_VOICE_CALLS = "allow_voice_calls" } // Coroutine scope for background tasks @@ -212,6 +214,20 @@ class ReticulumService : Service() { // Restart BLE interface after permissions granted handleRestartBle(intent) } + ACTION_SET_ALLOW_VOICE_CALLS -> { + // UI process flipped the master "Allow voice calls" toggle. + // SharedPreferences.OnSharedPreferenceChangeListener does NOT + // fire across processes, so the UI explicitly signals the + // service via this Intent. The default `true` keeps existing + // behaviour if the extra is missing (e.g., stale broadcast). + val allowed = intent.getBooleanExtra(EXTRA_ALLOW_VOICE_CALLS, true) + Log.i(TAG, "Received ACTION_SET_ALLOW_VOICE_CALLS → $allowed") + if (::binder.isInitialized) { + binder.setAllowVoiceCalls(allowed) + } else { + Log.w(TAG, "Service not yet initialized — Allow voice calls state will be applied at setupCallManager") + } + } } return START_STICKY diff --git a/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt b/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt index e92605ac2..04f4c2dcf 100644 --- a/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt +++ b/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt @@ -27,6 +27,7 @@ import com.lxmf.messenger.service.manager.PythonWrapperManager.Companion.getDict import com.lxmf.messenger.service.manager.RoutingManager import com.lxmf.messenger.service.manager.ServiceNotificationManager import com.lxmf.messenger.service.persistence.ServicePersistenceManager +import com.lxmf.messenger.service.persistence.ServiceSettingsAccessor import com.lxmf.messenger.service.state.ServiceState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -63,6 +64,7 @@ class ReticulumServiceBinder( private val notificationManager: ServiceNotificationManager, private val bleCoordinator: BleCoordinator, private val persistenceManager: ServicePersistenceManager, + private val settingsAccessor: ServiceSettingsAccessor, private val scope: CoroutineScope, private val onInitialized: () -> Unit, private val onShutdown: () -> Unit, @@ -75,6 +77,7 @@ class ReticulumServiceBinder( // RNode bridge - created lazily when needed private var rnodeBridge: KotlinRNodeBridge? = null + // =========================================== // Lifecycle Methods // =========================================== @@ -1477,12 +1480,54 @@ class ReticulumServiceBinder( if (callManagerInitialized) { registerCallCoordinatorListeners() wrapperManager.setupTelephone() + // Register the contact-check predicate so Python can gate + // incoming links by the "Calls from contacts only" toggle. + wrapperManager.setupContactCheckCallback() + // Apply the master "Allow voice calls" toggle's current state. + // Runtime changes are signalled via ACTION_SET_ALLOW_VOICE_CALLS + // because SharedPreferences.OnSharedPreferenceChangeListener does + // not fire across processes. + applyInitialAllowVoiceCallsState() } } catch (e: Exception) { Log.w(TAG, "Failed to setup CallManager: ${e.message}", e) } } + /** + * Read the persisted "Allow voice calls" toggle and apply it to Python. + * + * Default is true (preserves existing behaviour for users who haven't + * touched the toggle). When false, Python.disable_lxst_incoming() is + * invoked immediately after setupCallManager so the IN destination is + * never visible to the network for this session. + */ + private fun applyInitialAllowVoiceCallsState() { + try { + val allowed = settingsAccessor.getAllowVoiceCalls() + Log.d(TAG, "Initial Allow voice calls state: $allowed") + if (!allowed) { + wrapperManager.setLxstIncomingEnabled(false) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to read initial Allow voice calls state: ${e.message}", e) + } + } + + /** + * Apply a runtime change to the master "Allow voice calls" toggle. + * + * Invoked from [ReticulumService.onStartCommand] when the UI process + * sends `ACTION_SET_ALLOW_VOICE_CALLS`. The Intent path is used instead + * of `SharedPreferences.OnSharedPreferenceChangeListener` because + * SharedPreferences change listeners only fire in the process that + * wrote the value — they do not propagate across processes on Android. + */ + fun setAllowVoiceCalls(allowed: Boolean) { + Log.i(TAG, "Allow voice calls runtime change → $allowed") + wrapperManager.setLxstIncomingEnabled(allowed) + } + /** Register listeners for IPC notification to UI process. */ private fun registerCallCoordinatorListeners() { val callBridge = diff --git a/app/src/main/java/com/lxmf/messenger/service/di/ServiceModule.kt b/app/src/main/java/com/lxmf/messenger/service/di/ServiceModule.kt index 8eba410f0..e9f95b5ca 100644 --- a/app/src/main/java/com/lxmf/messenger/service/di/ServiceModule.kt +++ b/app/src/main/java/com/lxmf/messenger/service/di/ServiceModule.kt @@ -89,8 +89,8 @@ object ServiceModule { val settingsAccessor = ServiceSettingsAccessor(context) val persistenceManager = ServicePersistenceManager(context, scope, settingsAccessor) - // Phase 2: Python wrapper (depends on state, context, scope) - val wrapperManager = PythonWrapperManager(state, context, scope) + // Phase 2: Python wrapper (depends on state, context, scope, settingsAccessor) + val wrapperManager = PythonWrapperManager(state, context, scope, settingsAccessor) // Phase 3: Health monitoring (depends on wrapperManager, scope) // Started after initialization completes (see ReticulumServiceBinder) @@ -170,6 +170,7 @@ object ServiceModule { notificationManager = managers.notificationManager, bleCoordinator = managers.bleCoordinator, persistenceManager = managers.persistenceManager, + settingsAccessor = managers.settingsAccessor, scope = scope, onInitialized = onInitialized, onShutdown = onShutdown, diff --git a/app/src/main/java/com/lxmf/messenger/service/manager/PythonWrapperManager.kt b/app/src/main/java/com/lxmf/messenger/service/manager/PythonWrapperManager.kt index 74bcda083..3fba8382c 100644 --- a/app/src/main/java/com/lxmf/messenger/service/manager/PythonWrapperManager.kt +++ b/app/src/main/java/com/lxmf/messenger/service/manager/PythonWrapperManager.kt @@ -8,6 +8,8 @@ import com.lxmf.messenger.crypto.StampGenerator import com.lxmf.messenger.reticulum.ble.bridge.KotlinBLEBridge import com.lxmf.messenger.reticulum.bridge.KotlinReticulumBridge import com.lxmf.messenger.reticulum.call.telephone.PythonNetworkTransport +import com.lxmf.messenger.service.di.ServiceDatabaseProvider +import com.lxmf.messenger.service.persistence.ServiceSettingsAccessor import com.lxmf.messenger.service.state.ServiceState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -42,6 +44,7 @@ class PythonWrapperManager( private val state: ServiceState, private val context: Context, private val scope: CoroutineScope, + private val settingsAccessor: ServiceSettingsAccessor, ) { // Thread-safety: Mutex protects wrapper state transitions (check-then-act patterns) private val wrapperLock = Mutex() @@ -648,6 +651,140 @@ class PythonWrapperManager( */ fun getTelephone(): Telephone? = telephone + /** + * Toggle inbound LXST voice-call reception on or off (Feature 2 master). + * + * When [enabled] is false, Python deregisters the inbound destination from + * RNS.Transport and tears down any in-flight call. New inbound link + * requests fail at the transport layer — the device looks "unreachable + * for calls" to peers. + * + * When [enabled] is true, Python rebuilds the destination from the same + * identity (deterministic hash, so peers' cached paths still point to the + * right place once they receive the fresh announce), reinstalls the + * link-established callback, announces, and respawns the announce-loop + * thread. + * + * Outbound calls are unaffected in either state — CallManager.call() + * constructs an OUT destination per-call from the remote's identity. + * + * Idempotent on both sides: calling enable when already enabled (or + * disable when already disabled) is a no-op in Python. + */ + fun setLxstIncomingEnabled(enabled: Boolean) { + withWrapper { wrapper -> + try { + val method = if (enabled) "enable_lxst_incoming" else "disable_lxst_incoming" + wrapper.callAttr(method)?.close() + Log.i(TAG, "📞 LXST incoming ${if (enabled) "enabled" else "disabled"}") + } catch (e: Exception) { + Log.w(TAG, "Failed to toggle LXST incoming to $enabled: ${e.message}", e) + } + } + } + + /** + * Register the Kotlin-side contact-check callback with Python. + * + * Python's CallManager.__caller_identified invokes this synchronously + * once per incoming link to decide whether to silently drop the caller. + * Returning false drops the link with no signalling — the originator + * times out as if the called party were unreachable. + * + * Must be called after setupCallManager() — depends on callManagerPyObject. + * + * @return true if the callback was registered, false otherwise + */ + fun setupContactCheckCallback(): Boolean { + val callManager = + callManagerPyObject ?: run { + Log.w(TAG, "Cannot setup contact-check callback: call_manager not initialized") + return false + } + return try { + // Wrap as java.util.function.Function to expose a single SAM to Chaquopy. + // A bare Kotlin method reference implements Function1 + + // KotlinGenericDeclaration (+ KFunction, KCallable, ...), and + // Chaquopy's get_sam aborts with "implements multiple functional + // interfaces" when Python invokes it — same trap as + // setStampGeneratorCallback / set_kotlin_telephone_callback. + val callback = + java.util.function.Function { identityHashHex -> + isCallerInContacts(identityHashHex) + } + callManager.callAttr("set_kotlin_contact_check_callback", callback) + Log.d(TAG, "Kotlin contact-check callback registered with Python") + true + } catch (e: Exception) { + Log.w(TAG, "Failed to set contact-check callback: ${e.message}", e) + false + } + } + + /** + * Synchronous predicate: is this caller in the user's contacts? + * + * Called from Python via Chaquopy during __caller_identified. + * + * Logic: + * 1. If the "Calls from contacts only" toggle is OFF, return true + * (no gate — every caller is allowed). + * 2. Otherwise, look up the caller's announce by identity hash to find + * their destination hash, then check contacts table against the + * active local identity. + * 3. Fails open on any DB error to avoid bricking calls — mirrors + * ServicePersistenceManager.shouldBlockUnknownSender for messages. + * + * @param identityHashHex caller's identity hash as 32-char lowercase hex + * @return true if the caller is allowed through, false to silently drop + */ + fun isCallerInContacts(identityHashHex: String): Boolean = + try { + // Toggle off → no gate, allow everyone + if (!settingsAccessor.getAllowCallsFromContactsOnly()) { + true + } else { + runBlocking(Dispatchers.IO) { // THREADING: allowed — Python JNI callback requires synchronous return + val database = ServiceDatabaseProvider.getDatabase(context) + val announceDao = database.announceDao() + val contactDao = database.contactDao() + val localIdentityDao = database.localIdentityDao() + + val normalised = identityHashHex.lowercase() + val announce = announceDao.getAnnounceByIdentityHash(normalised) + if (announce == null) { + Log.d(TAG, "Contact check: no announce for $normalised, treating as unknown") + false + } else { + val activeIdentity = localIdentityDao.getActiveIdentitySync() + if (activeIdentity == null) { + // Fail open: no active identity means we can't isolate per-identity, + // don't punish the user with a broken inbox. + Log.w(TAG, "Contact check: no active local identity, failing open") + true + } else { + val isContact = + contactDao.contactExists( + announce.destinationHash, + activeIdentity.identityHash, + ) + Log.d( + TAG, + "Contact check for ${normalised.take(16)}: " + + "destinationHash=${announce.destinationHash.take(16)}, isContact=$isContact", + ) + isContact + } + } + } + } + } catch (e: Exception) { + // Fail open: any DB / IO error should not brick calls. The user can + // always tighten security by adding the caller as a contact. + Log.w(TAG, "Contact check failed for $identityHashHex, failing open: ${e.message}") + true + } + /** * Set native Kotlin stamp generator callback. * diff --git a/app/src/main/java/com/lxmf/messenger/service/persistence/ServiceSettingsAccessor.kt b/app/src/main/java/com/lxmf/messenger/service/persistence/ServiceSettingsAccessor.kt index d3a91e32b..bdf769788 100644 --- a/app/src/main/java/com/lxmf/messenger/service/persistence/ServiceSettingsAccessor.kt +++ b/app/src/main/java/com/lxmf/messenger/service/persistence/ServiceSettingsAccessor.kt @@ -21,6 +21,8 @@ class ServiceSettingsAccessor( // Keys for cross-process settings const val KEY_BLOCK_UNKNOWN_SENDERS = "block_unknown_senders" + const val KEY_ALLOW_CALLS_FROM_CONTACTS_ONLY = "allow_calls_from_contacts_only" + const val KEY_ALLOW_VOICE_CALLS = "allow_voice_calls" const val KEY_NETWORK_CHANGE_ANNOUNCE_TIME = "network_change_announce_time" const val KEY_LAST_AUTO_ANNOUNCE_TIME = "last_auto_announce_time" } @@ -63,4 +65,23 @@ class ServiceSettingsAccessor( * @return true if unknown senders should be blocked, false otherwise (default) */ fun getBlockUnknownSenders(): Boolean = getCrossProcessPrefs().getBoolean(KEY_BLOCK_UNKNOWN_SENDERS, false) + + /** + * Get the calls-from-contacts-only setting. + * When enabled, incoming LXST link requests from non-contacts are silently + * dropped after identification. + * + * @return true if non-contact callers should be silently dropped, false otherwise (default) + */ + fun getAllowCallsFromContactsOnly(): Boolean = + getCrossProcessPrefs().getBoolean(KEY_ALLOW_CALLS_FROM_CONTACTS_ONLY, false) + + /** + * Get the master allow-voice-calls setting. + * When false, the inbound LXST destination is deregistered and no announces are sent. + * Outbound calls remain functional regardless. + * + * @return true if incoming voice calls should be accepted, false otherwise (default true) + */ + fun getAllowVoiceCalls(): Boolean = getCrossProcessPrefs().getBoolean(KEY_ALLOW_VOICE_CALLS, true) } diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt index 854bacee2..a84ef0165 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt @@ -282,6 +282,8 @@ fun SettingsScreen( onExpandedChange = { viewModel.toggleCardExpanded(SettingsCardId.PRIVACY, it) }, blockUnknownSenders = state.blockUnknownSenders, onBlockUnknownSendersChange = { viewModel.setBlockUnknownSenders(it) }, + allowCallsFromContactsOnly = state.allowCallsFromContactsOnly, + onAllowCallsFromContactsOnlyChange = { viewModel.setAllowCallsFromContactsOnly(it) }, blockedPeerCount = blockedPeerCount, onNavigateToBlockedUsers = onNavigateToBlockedUsers, ) @@ -297,6 +299,8 @@ fun SettingsScreen( VoiceCallPermissionsCard( isExpanded = state.cardExpansionStates[SettingsCardId.VOICE_CALL_PERMISSIONS.name] ?: false, onExpandedChange = { viewModel.toggleCardExpanded(SettingsCardId.VOICE_CALL_PERMISSIONS, it) }, + allowVoiceCalls = state.allowVoiceCalls, + onAllowVoiceCallsChange = { viewModel.setAllowVoiceCalls(it) }, ) AutoAnnounceCard( diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/PrivacyCard.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/PrivacyCard.kt index 3fe032127..1e60b0c75 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/PrivacyCard.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/PrivacyCard.kt @@ -27,6 +27,8 @@ fun PrivacyCard( onExpandedChange: (Boolean) -> Unit, blockUnknownSenders: Boolean, onBlockUnknownSendersChange: (Boolean) -> Unit, + allowCallsFromContactsOnly: Boolean, + onAllowCallsFromContactsOnlyChange: (Boolean) -> Unit, blockedPeerCount: Int = 0, onNavigateToBlockedUsers: () -> Unit = {}, ) { @@ -35,14 +37,29 @@ fun PrivacyCard( icon = Icons.Default.Security, isExpanded = isExpanded, onExpandedChange = onExpandedChange, - headerAction = { + ) { + // Messages-from-contacts-only toggle row. Moved out of the card header + // so it's visually equal-billed with the calls toggle below. + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Messages from contacts only", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .weight(1f) + .padding(end = 12.dp), + ) Switch( checked = blockUnknownSenders, onCheckedChange = onBlockUnknownSendersChange, ) - }, - ) { - // Description + } Text( text = if (blockUnknownSenders) { @@ -50,7 +67,41 @@ fun PrivacyCard( } else { "Anyone can send you messages, including unknown senders." }, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Calls-from-contacts-only toggle row (independent of block_unknown_senders). + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Calls from contacts only", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .weight(1f) + .padding(end = 12.dp), + ) + Switch( + checked = allowCallsFromContactsOnly, + onCheckedChange = onAllowCallsFromContactsOnlyChange, + ) + } + Text( + text = + if (allowCallsFromContactsOnly) { + "Only contacts can call you. Other callers' link attempts are silently dropped." + } else { + "Anyone can call you, including unknown callers." + }, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/VoiceCallPermissionsCard.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/VoiceCallPermissionsCard.kt index 89a8b64c3..784e57e44 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/VoiceCallPermissionsCard.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/VoiceCallPermissionsCard.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -52,6 +53,8 @@ import kotlinx.coroutines.delay fun VoiceCallPermissionsCard( isExpanded: Boolean, onExpandedChange: (Boolean) -> Unit, + allowVoiceCalls: Boolean, + onAllowVoiceCallsChange: (Boolean) -> Unit, ) { // Not relevant below Android 10 — background activity launch restrictions // only became an issue starting with Q @@ -145,6 +148,15 @@ fun VoiceCallPermissionsCard( ) } + // Master "Allow voice calls" switch — placed to the left of the + // chevron so it stays visible whether the card is expanded or + // collapsed. When OFF, the inbound LXST destination is + // deregistered service-side; outbound calls still work. + Switch( + checked = allowVoiceCalls, + onCheckedChange = onAllowVoiceCallsChange, + ) + Icon( imageVector = if (isExpanded) { @@ -166,6 +178,18 @@ fun VoiceCallPermissionsCard( Column( verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (!allowVoiceCalls) { + // Master toggle is OFF — let the user know inbound is + // disabled before they see (and worry about) the + // permission-status content below. + Text( + text = + "Incoming voice calls are currently disabled. Outgoing calls still work.", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = contentColor, + ) + } if (isCheckingStatus) { CircularProgressIndicator(modifier = Modifier.size(24.dp)) } else if (allGranted) { diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt index c0cfa1e2d..a9c5905d2 100644 --- a/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt @@ -124,6 +124,8 @@ data class SettingsState( val notificationsEnabled: Boolean = true, // Privacy state val blockUnknownSenders: Boolean = false, + val allowCallsFromContactsOnly: Boolean = false, + val allowVoiceCalls: Boolean = true, // Incoming message size limit (default 1MB) val incomingMessageSizeLimitKb: Int = 1024, // Image compression state @@ -441,6 +443,8 @@ class SettingsViewModel notificationsEnabled = _state.value.notificationsEnabled, // Preserve privacy state from loadPrivacySettings() blockUnknownSenders = _state.value.blockUnknownSenders, + allowCallsFromContactsOnly = _state.value.allowCallsFromContactsOnly, + allowVoiceCalls = _state.value.allowVoiceCalls, // Preserve message size limit from loadLocationSharingSettings() incomingMessageSizeLimitKb = _state.value.incomingMessageSizeLimitKb, // Preserve message sorting from loadLocationSharingSettings() @@ -1502,6 +1506,16 @@ class SettingsViewModel _state.update { it.copy(blockUnknownSenders = enabled) } } } + viewModelScope.launch { + settingsRepository.allowCallsFromContactsOnlyFlow.collect { enabled -> + _state.update { it.copy(allowCallsFromContactsOnly = enabled) } + } + } + viewModelScope.launch { + settingsRepository.allowVoiceCallsFlow.collect { enabled -> + _state.update { it.copy(allowVoiceCalls = enabled) } + } + } } /** @@ -1516,6 +1530,33 @@ class SettingsViewModel } } + /** + * Set the calls-from-contacts-only setting. + * When enabled, only contacts can establish incoming voice calls; + * non-contact callers' link attempts are silently dropped. + */ + fun setAllowCallsFromContactsOnly(enabled: Boolean) { + viewModelScope.launch { + settingsRepository.saveAllowCallsFromContactsOnly(enabled) + _state.update { it.copy(allowCallsFromContactsOnly = enabled) } + Log.d(TAG, "Calls-from-contacts-only ${if (enabled) "enabled" else "disabled"}") + } + } + + /** + * Set the master allow-voice-calls setting. + * When disabled, the inbound LXST telephony destination is deregistered + * and no announces are sent; peers see this device as unreachable for + * calls. Outbound calls are unaffected. + */ + fun setAllowVoiceCalls(enabled: Boolean) { + viewModelScope.launch { + settingsRepository.saveAllowVoiceCalls(enabled) + _state.update { it.copy(allowVoiceCalls = enabled) } + Log.d(TAG, "Allow voice calls ${if (enabled) "enabled" else "disabled"}") + } + } + // Transport node methods /** diff --git a/app/src/test/java/com/lxmf/messenger/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/lxmf/messenger/repository/SettingsRepositoryTest.kt index 3053099f2..e2ba9af18 100644 --- a/app/src/test/java/com/lxmf/messenger/repository/SettingsRepositoryTest.kt +++ b/app/src/test/java/com/lxmf/messenger/repository/SettingsRepositoryTest.kt @@ -1100,6 +1100,122 @@ class SettingsRepositoryTest { assertTrue("Both should be true", methodValue) } + // ========== Allow Calls From Contacts Only (Privacy) Flow Tests ========== + + @Test + fun allowCallsFromContactsOnlyFlow_emitsOnlyOnChange() = + runTest { + repository.allowCallsFromContactsOnlyFlow.test(timeout = 5.seconds) { + val initial = awaitItem() + + // Save same value - should NOT emit (distinctUntilChanged) + repository.saveAllowCallsFromContactsOnly(initial) + expectNoEvents() + + // Save opposite value - should emit + repository.saveAllowCallsFromContactsOnly(!initial) + assertEquals(!initial, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun getAllowCallsFromContactsOnly_matchesFlow() = + runTest { + // Set to a known value + repository.saveAllowCallsFromContactsOnly(true) + testDispatcher.scheduler.advanceUntilIdle() + + val flowValue = repository.allowCallsFromContactsOnlyFlow.first() + val methodValue = repository.getAllowCallsFromContactsOnly() + + assertEquals("Flow and method should return same value", flowValue, methodValue) + assertTrue("Both should be true", methodValue) + + // Reset for subsequent tests + repository.saveAllowCallsFromContactsOnly(false) + } + + @Test + fun saveAllowCallsFromContactsOnly_writesCrossProcessPref() = + runTest { + repository.saveAllowCallsFromContactsOnly(true) + testDispatcher.scheduler.advanceUntilIdle() + + // The dual-write contract: the service-process accessor should see the change + @Suppress("DEPRECATION") + val crossProcess = + context.getSharedPreferences( + "cross_process_settings", + android.content.Context.MODE_MULTI_PROCESS, + ) + assertTrue( + "Cross-process SharedPreferences should reflect saved value", + crossProcess.getBoolean("allow_calls_from_contacts_only", false), + ) + + // Reset for subsequent tests + repository.saveAllowCallsFromContactsOnly(false) + } + + // ========== Allow Voice Calls (master) Flow Tests ========== + + @Test + fun allowVoiceCallsFlow_emitsOnlyOnChange() = + runTest { + repository.allowVoiceCallsFlow.test(timeout = 5.seconds) { + val initial = awaitItem() + + repository.saveAllowVoiceCalls(initial) + expectNoEvents() + + repository.saveAllowVoiceCalls(!initial) + assertEquals(!initial, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun getAllowVoiceCalls_matchesFlow() = + runTest { + repository.saveAllowVoiceCalls(false) + testDispatcher.scheduler.advanceUntilIdle() + + val flowValue = repository.allowVoiceCallsFlow.first() + val methodValue = repository.getAllowVoiceCalls() + + assertEquals("Flow and method should return same value", flowValue, methodValue) + assertFalse("Both should be false", methodValue) + + // Reset for subsequent tests + repository.saveAllowVoiceCalls(true) + } + + @Test + fun saveAllowVoiceCalls_writesCrossProcessPref() = + runTest { + repository.saveAllowVoiceCalls(false) + testDispatcher.scheduler.advanceUntilIdle() + + @Suppress("DEPRECATION") + val crossProcess = + context.getSharedPreferences( + "cross_process_settings", + android.content.Context.MODE_MULTI_PROCESS, + ) + // Cross-process should see false; the default (read with default=true) should + // therefore return false since the key is explicitly set. + assertFalse( + "Cross-process SharedPreferences should reflect saved value", + crossProcess.getBoolean("allow_voice_calls", true), + ) + + // Reset for subsequent tests + repository.saveAllowVoiceCalls(true) + } + // ========== Telemetry Collector Flow Tests ========== @Test diff --git a/app/src/test/java/com/lxmf/messenger/service/binder/ReticulumServiceBinderTest.kt b/app/src/test/java/com/lxmf/messenger/service/binder/ReticulumServiceBinderTest.kt index 07cef0f43..e7c10e0a3 100644 --- a/app/src/test/java/com/lxmf/messenger/service/binder/ReticulumServiceBinderTest.kt +++ b/app/src/test/java/com/lxmf/messenger/service/binder/ReticulumServiceBinderTest.kt @@ -15,6 +15,7 @@ import com.lxmf.messenger.service.manager.PythonWrapperManager import com.lxmf.messenger.service.manager.RoutingManager import com.lxmf.messenger.service.manager.ServiceNotificationManager import com.lxmf.messenger.service.persistence.ServicePersistenceManager +import com.lxmf.messenger.service.persistence.ServiceSettingsAccessor import com.lxmf.messenger.service.state.ServiceState import io.mockk.Runs import io.mockk.clearAllMocks @@ -60,6 +61,7 @@ class ReticulumServiceBinderTest { private lateinit var notificationManager: ServiceNotificationManager private lateinit var bleCoordinator: BleCoordinator private lateinit var persistenceManager: ServicePersistenceManager + private lateinit var settingsAccessor: ServiceSettingsAccessor private lateinit var networkStatusMock: AtomicReference private lateinit var binder: ReticulumServiceBinder @@ -87,6 +89,7 @@ class ReticulumServiceBinderTest { notificationManager = mockk() bleCoordinator = mockk() persistenceManager = mockk() + settingsAccessor = mockk(relaxed = true) // Setup networkStatus as a real AtomicReference for verification networkStatusMock = mockk() @@ -135,6 +138,7 @@ class ReticulumServiceBinderTest { notificationManager = notificationManager, bleCoordinator = bleCoordinator, persistenceManager = persistenceManager, + settingsAccessor = settingsAccessor, scope = testScope, onInitialized = {}, onShutdown = { onShutdownCalled = true }, diff --git a/app/src/test/java/com/lxmf/messenger/service/manager/PythonWrapperManagerContactCheckTest.kt b/app/src/test/java/com/lxmf/messenger/service/manager/PythonWrapperManagerContactCheckTest.kt new file mode 100644 index 000000000..afb49ac12 --- /dev/null +++ b/app/src/test/java/com/lxmf/messenger/service/manager/PythonWrapperManagerContactCheckTest.kt @@ -0,0 +1,68 @@ +package com.lxmf.messenger.service.manager + +import com.lxmf.messenger.service.persistence.ServiceSettingsAccessor +import com.lxmf.messenger.service.state.ServiceState +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for the toggle-off short-circuit in + * [PythonWrapperManager.isCallerInContacts]. + * + * The DAO path is exercised indirectly by [com.lxmf.messenger.repository.SettingsRepositoryTest] + * (which covers the cross-process pref read) and by manual on-device QA; + * full DAO coverage would require Robolectric + Room which is overkill for + * this thin façade. Instead we verify the security-critical invariant: + * - When the toggle is OFF, the predicate returns true WITHOUT consulting + * the database (cheap, predictable, no DAO mocking needed). + * - When the predicate throws because of an empty/mock context, the + * fail-open contract still returns true (so a DB error never bricks + * incoming calls). + */ +class PythonWrapperManagerContactCheckTest { + private lateinit var state: ServiceState + private lateinit var settingsAccessor: ServiceSettingsAccessor + private lateinit var manager: PythonWrapperManager + + @Before + fun setup() { + state = ServiceState() + settingsAccessor = mockk() + manager = + PythonWrapperManager( + state = state, + context = mockk(), + scope = TestScope(), + settingsAccessor = settingsAccessor, + ) + } + + @Test + fun `isCallerInContacts returns true when toggle is off`() { + // Toggle OFF — no contact check should run; predicate short-circuits true. + every { settingsAccessor.getAllowCallsFromContactsOnly() } returns false + + val result = manager.isCallerInContacts("a".repeat(32)) + + assertTrue("Toggle off must allow every caller (no gate)", result) + } + + @Test + fun `isCallerInContacts fails open when toggle is on but DB access throws`() { + // Toggle ON, but the manager has a relaxed/mock Context — opening + // the Room database will throw. The fail-open contract requires + // we return true rather than silently brick calls. + every { settingsAccessor.getAllowCallsFromContactsOnly() } returns true + + val result = manager.isCallerInContacts("a".repeat(32)) + + assertTrue( + "DB error must fail open (return true) so infra hiccups don't brick calls", + result, + ) + } +} diff --git a/app/src/test/java/com/lxmf/messenger/service/manager/PythonWrapperManagerShutdownGuardTest.kt b/app/src/test/java/com/lxmf/messenger/service/manager/PythonWrapperManagerShutdownGuardTest.kt index 69bce2e40..c534c64de 100644 --- a/app/src/test/java/com/lxmf/messenger/service/manager/PythonWrapperManagerShutdownGuardTest.kt +++ b/app/src/test/java/com/lxmf/messenger/service/manager/PythonWrapperManagerShutdownGuardTest.kt @@ -30,6 +30,7 @@ class PythonWrapperManagerShutdownGuardTest { state = state, context = mockk(), scope = TestScope(), + settingsAccessor = mockk(), ) } diff --git a/app/src/test/java/com/lxmf/messenger/service/persistence/ServiceSettingsAccessorTest.kt b/app/src/test/java/com/lxmf/messenger/service/persistence/ServiceSettingsAccessorTest.kt index cc6181bb7..48b51cd60 100644 --- a/app/src/test/java/com/lxmf/messenger/service/persistence/ServiceSettingsAccessorTest.kt +++ b/app/src/test/java/com/lxmf/messenger/service/persistence/ServiceSettingsAccessorTest.kt @@ -146,10 +146,60 @@ class ServiceSettingsAccessorTest { @Test fun `companion object exposes correct key constants`() { assertEquals("block_unknown_senders", ServiceSettingsAccessor.KEY_BLOCK_UNKNOWN_SENDERS) + assertEquals("allow_calls_from_contacts_only", ServiceSettingsAccessor.KEY_ALLOW_CALLS_FROM_CONTACTS_ONLY) + assertEquals("allow_voice_calls", ServiceSettingsAccessor.KEY_ALLOW_VOICE_CALLS) assertEquals("network_change_announce_time", ServiceSettingsAccessor.KEY_NETWORK_CHANGE_ANNOUNCE_TIME) assertEquals("last_auto_announce_time", ServiceSettingsAccessor.KEY_LAST_AUTO_ANNOUNCE_TIME) } + // ========== getAllowCallsFromContactsOnly() Tests ========== + + @Test + fun `getAllowCallsFromContactsOnly returns false by default`() { + assertFalse(accessor.getAllowCallsFromContactsOnly()) + } + + @Test + fun `getAllowCallsFromContactsOnly returns true when set`() { + prefs.edit().putBoolean(ServiceSettingsAccessor.KEY_ALLOW_CALLS_FROM_CONTACTS_ONLY, true).apply() + + assertTrue(accessor.getAllowCallsFromContactsOnly()) + } + + @Test + fun `getAllowCallsFromContactsOnly reflects changes between calls`() { + assertFalse(accessor.getAllowCallsFromContactsOnly()) + + prefs.edit().putBoolean(ServiceSettingsAccessor.KEY_ALLOW_CALLS_FROM_CONTACTS_ONLY, true).apply() + + assertTrue(accessor.getAllowCallsFromContactsOnly()) + } + + // ========== getAllowVoiceCalls() Tests ========== + + @Test + fun `getAllowVoiceCalls returns true by default`() { + // Critical: must default to TRUE so users who never touched the toggle + // keep their existing call-receiving behaviour. + assertTrue(accessor.getAllowVoiceCalls()) + } + + @Test + fun `getAllowVoiceCalls returns false when explicitly disabled`() { + prefs.edit().putBoolean(ServiceSettingsAccessor.KEY_ALLOW_VOICE_CALLS, false).apply() + + assertFalse(accessor.getAllowVoiceCalls()) + } + + @Test + fun `getAllowVoiceCalls reflects changes between calls`() { + assertTrue(accessor.getAllowVoiceCalls()) + + prefs.edit().putBoolean(ServiceSettingsAccessor.KEY_ALLOW_VOICE_CALLS, false).apply() + + assertFalse(accessor.getAllowVoiceCalls()) + } + // ========== Integration Tests ========== @Test diff --git a/app/src/test/java/com/lxmf/messenger/ui/screens/settings/cards/PrivacyCardTest.kt b/app/src/test/java/com/lxmf/messenger/ui/screens/settings/cards/PrivacyCardTest.kt index 4df398c20..6537ddc3e 100644 --- a/app/src/test/java/com/lxmf/messenger/ui/screens/settings/cards/PrivacyCardTest.kt +++ b/app/src/test/java/com/lxmf/messenger/ui/screens/settings/cards/PrivacyCardTest.kt @@ -36,10 +36,12 @@ class PrivacyCardTest { // ========== Callback Tracking Variables ========== private var blockUnknownSendersChanged: Boolean? = null + private var allowCallsFromContactsOnlyChanged: Boolean? = null @Before fun resetCallbackTrackers() { blockUnknownSendersChanged = null + allowCallsFromContactsOnlyChanged = null } // ========== Setup Helper ========== @@ -47,6 +49,7 @@ class PrivacyCardTest { private fun setUpCard( isExpanded: Boolean = true, blockUnknownSenders: Boolean = false, + allowCallsFromContactsOnly: Boolean = false, ) { composeTestRule.setContent { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { @@ -55,6 +58,8 @@ class PrivacyCardTest { onExpandedChange = {}, blockUnknownSenders = blockUnknownSenders, onBlockUnknownSendersChange = { blockUnknownSendersChanged = it }, + allowCallsFromContactsOnly = allowCallsFromContactsOnly, + onAllowCallsFromContactsOnlyChange = { allowCallsFromContactsOnlyChanged = it }, ) } } @@ -120,4 +125,43 @@ class PrivacyCardTest { "Anyone can send you messages, including unknown senders.", ).assertIsDisplayed() } + + // ========== Messages-From-Contacts-Only Tests ========== + + @Test + fun privacyCard_displaysMessagesFromContactsOnly_rowLabel() { + // The block-unknown-senders toggle was moved out of the card header into + // the body so it's equal-billed with the calls toggle. Verify the row + // label renders alongside the existing description text. + setUpCard(isExpanded = true) + + composeTestRule.onNodeWithText("Messages from contacts only").assertIsDisplayed() + } + + // ========== Calls-From-Contacts-Only Tests (Feature 1) ========== + + @Test + fun privacyCard_displaysCallsFromContactsOnly_rowLabel() { + setUpCard(isExpanded = true) + + composeTestRule.onNodeWithText("Calls from contacts only").assertIsDisplayed() + } + + @Test + fun privacyCard_callsFromContactsOnly_displaysOnSubtitle_whenEnabled() { + setUpCard(isExpanded = true, allowCallsFromContactsOnly = true) + + composeTestRule.onNodeWithText( + "Only contacts can call you. Other callers' link attempts are silently dropped.", + ).assertIsDisplayed() + } + + @Test + fun privacyCard_callsFromContactsOnly_displaysOffSubtitle_whenDisabled() { + setUpCard(isExpanded = true, allowCallsFromContactsOnly = false) + + composeTestRule.onNodeWithText( + "Anyone can call you, including unknown callers.", + ).assertIsDisplayed() + } } diff --git a/app/src/test/java/com/lxmf/messenger/ui/screens/settings/cards/VoiceCallPermissionsCardTest.kt b/app/src/test/java/com/lxmf/messenger/ui/screens/settings/cards/VoiceCallPermissionsCardTest.kt new file mode 100644 index 000000000..4fd7cb84d --- /dev/null +++ b/app/src/test/java/com/lxmf/messenger/ui/screens/settings/cards/VoiceCallPermissionsCardTest.kt @@ -0,0 +1,94 @@ +package com.lxmf.messenger.ui.screens.settings.cards + +import android.app.Application +import android.os.Build +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.lxmf.messenger.test.RegisterComponentActivityRule +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * UI tests for VoiceCallPermissionsCard. + * Tests the master "Allow voice calls" toggle (Feature 2) and the + * banner rendered when the toggle is OFF. + * + * The card itself early-returns on SDK < Q, so all tests run under SDK 34. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class VoiceCallPermissionsCardTest { + private val registerActivityRule = RegisterComponentActivityRule() + private val composeRule = createComposeRule() + + @get:Rule + val ruleChain: RuleChain = RuleChain.outerRule(registerActivityRule).around(composeRule) + + val composeTestRule get() = composeRule + + private var allowVoiceCallsChanged: Boolean? = null + + @Before + fun resetCallbackTrackers() { + allowVoiceCallsChanged = null + } + + private fun setUpCard( + isExpanded: Boolean = true, + allowVoiceCalls: Boolean = true, + ) { + composeTestRule.setContent { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + VoiceCallPermissionsCard( + isExpanded = isExpanded, + onExpandedChange = {}, + allowVoiceCalls = allowVoiceCalls, + onAllowVoiceCallsChange = { allowVoiceCallsChanged = it }, + ) + } + } + } + + @Test + fun voiceCallPermissionsCard_displaysCardTitle() { + setUpCard() + + composeTestRule.onNodeWithText("Voice Call Permissions").assertIsDisplayed() + } + + @Test + fun voiceCallPermissionsCard_displaysDisabledBanner_whenAllowVoiceCallsOff() { + // Skip on pre-Q where the card returns nothing + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + + setUpCard(isExpanded = true, allowVoiceCalls = false) + + composeTestRule.onNodeWithText( + "Incoming voice calls are currently disabled. Outgoing calls still work.", + ).assertIsDisplayed() + } + + @Test + fun voiceCallPermissionsCard_hidesDisabledBanner_whenAllowVoiceCallsOn() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + + setUpCard(isExpanded = true, allowVoiceCalls = true) + + // No banner when toggle is ON — assert by negative match: the disabled + // text is NOT shown. + composeTestRule + .onNodeWithText( + "Incoming voice calls are currently disabled. Outgoing calls still work.", + ).assertDoesNotExist() + } +} diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelIncomingMessageLimitTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelIncomingMessageLimitTest.kt index 888fd2d38..e3d872ded 100644 --- a/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelIncomingMessageLimitTest.kt +++ b/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelIncomingMessageLimitTest.kt @@ -177,6 +177,8 @@ class SettingsViewModelIncomingMessageLimitTest { // Privacy settings flows every { settingsRepository.blockUnknownSendersFlow } returns MutableStateFlow(false) + every { settingsRepository.allowCallsFromContactsOnlyFlow } returns MutableStateFlow(false) + every { settingsRepository.allowVoiceCallsFlow } returns MutableStateFlow(true) // Telemetry request settings flows every { settingsRepository.telemetryRequestEnabledFlow } returns MutableStateFlow(false) diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelTest.kt index d5bfb50c8..26a5ae81c 100644 --- a/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelTest.kt +++ b/app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelTest.kt @@ -166,6 +166,8 @@ class SettingsViewModelTest { every { settingsRepository.incomingMessageSizeLimitKbFlow } returns flowOf(500) every { settingsRepository.notificationsEnabledFlow } returns flowOf(true) every { settingsRepository.blockUnknownSendersFlow } returns flowOf(false) + every { settingsRepository.allowCallsFromContactsOnlyFlow } returns flowOf(false) + every { settingsRepository.allowVoiceCallsFlow } returns flowOf(true) every { settingsRepository.telemetryCollectorEnabledFlow } returns flowOf(false) every { settingsRepository.telemetryCollectorAddressFlow } returns flowOf(null) every { settingsRepository.telemetrySendIntervalSecondsFlow } returns flowOf(60) diff --git a/python/lxst_modules/call_manager.py b/python/lxst_modules/call_manager.py index 60921b850..137da38e9 100644 --- a/python/lxst_modules/call_manager.py +++ b/python/lxst_modules/call_manager.py @@ -71,6 +71,10 @@ def has_path(h): def request_path(h): pass + @staticmethod + def deregister_destination(d): + pass + class Link: ACTIVE = 0x00 @@ -203,6 +207,7 @@ def __init__(self, identity): self._kotlin_call_bridge = None self._kotlin_network_bridge = None self._kotlin_telephone_callback = None + self._contact_check_callback = None # java.util.function.Function self._initialized = False self._active_call_identity = None self._call_start_time = None @@ -210,6 +215,12 @@ def __init__(self, identity): self._cancel_event = threading.Event() self._last_announce = 0 # Epoch 0 → first announce fires immediately + # Master inbound enable/disable (Feature 2). When True, __jobs and + # _announce_telephony skip; new link-establish events are ignored. + # Toggled by enable_incoming()/disable_incoming() under _call_handler_lock. + self._incoming_disabled = False + self._jobs_thread = None + # TX batching state self._tx_batch = [] # Accumulated frames awaiting send self._tx_batch_start = 0.0 # time.time() of first frame in current batch @@ -246,7 +257,8 @@ def initialize(self, kotlin_call_bridge=None, kotlin_network_bridge=None): RNS.log(f"CallManager announced on {RNS.prettyhexrep(self.destination.hash)}", RNS.LOG_DEBUG) # Start background jobs thread for periodic re-announcing - threading.Thread(target=self.__jobs, daemon=True).start() + self._jobs_thread = threading.Thread(target=self.__jobs, daemon=True) + self._jobs_thread.start() self._initialized = True RNS.log("CallManager initialized with raw Reticulum transport", RNS.LOG_INFO) @@ -273,13 +285,18 @@ def __jobs(self): """Background thread for periodic re-announcing. Matches reference LXST Telephony.__jobs pattern: - - Runs while destination exists + - Runs while destination exists and incoming is not disabled - Re-announces every ANNOUNCE_INTERVAL (3 hours) + + Exits cleanly when self.destination is None (disable_incoming() or + teardown()). enable_incoming() respawns a fresh thread. """ - while self.destination is not None: + while self.destination is not None and not self._incoming_disabled: time.sleep(self.JOB_INTERVAL) + if self._incoming_disabled or self.destination is None: + break if time.time() > self._last_announce + self.ANNOUNCE_INTERVAL: - if self.destination is not None: + if self.destination is not None and not self._incoming_disabled: try: self.destination.announce() self._last_announce = time.time() @@ -299,6 +316,21 @@ def set_kotlin_telephone_callback(self, callback): self._kotlin_telephone_callback = callback RNS.log("Kotlin Telephone callback set", RNS.LOG_DEBUG) + def set_kotlin_contact_check_callback(self, callback): + """Set callback for checking whether a caller identity is a contact. + + The callback is invoked synchronously from __caller_identified with the + identity hash (hex string) and must return a Boolean: True if the + caller is in the user's contacts (or the calls-from-contacts-only + toggle is off — Kotlin gates that), False otherwise. + + Args: + callback: java.util.function.Function or any + Python callable taking a hex string and returning a bool + """ + self._contact_check_callback = callback + RNS.log("Kotlin contact-check callback set", RNS.LOG_DEBUG) + # ===== Call Actions (called from Kotlin) ===== def call(self, destination_hash_hex, profile=None): @@ -469,6 +501,20 @@ def hangup(self): def __incoming_link_established(self, link): """Handle new incoming link (someone is calling us).""" with self._call_handler_lock: + # Master toggle: if incoming was disabled between when the link + # request was admitted by Transport and when this callback fires + # (TOCTOU window), silently teardown without signalling. This is + # defence-in-depth — disable_incoming() deregisters the destination + # so new requests should not reach us, but a pre-flight request + # can still complete here. + if self._incoming_disabled: + RNS.log("Incoming call but incoming disabled, silently tearing down", RNS.LOG_DEBUG) + try: + link.teardown() + except Exception: + pass + return + if self.active_call is not None or self._busy: RNS.log("Incoming call, but line busy, signalling busy", RNS.LOG_DEBUG) self._send_signal_to_remote(STATUS_BUSY, link) @@ -491,6 +537,20 @@ def __caller_identified(self, link, identity): link.teardown() return + # Silent-drop gate (Feature 1: "Calls from contacts only"). + # Must run BEFORE _send_signal_to_remote(STATUS_RINGING) and BEFORE + # any Kotlin notify — the goal is for the caller to see "link + # established → nothing" so they fall through to their wait-time + # timeout, indistinguishable from "remote went away". We send NO + # signals (not even STATUS_BUSY) so non-contacts cannot distinguish + # "you are blocked" from "you are unreachable". + if self._should_silently_drop(identity): + try: + link.teardown() + except Exception: + pass + return + if not self._is_allowed(identity): RNS.log(f"Caller not allowed, signalling busy", RNS.LOG_DEBUG) self._send_signal_to_remote(STATUS_BUSY, link) @@ -801,9 +861,115 @@ def _notify_kotlin(self, event, identity_hash=None, extra=None): def _is_allowed(self, identity): """Check if caller identity is allowed. - Returns True for all callers (allow-all). Contact-based filtering - can be added in a future phase. + Returns True for all callers (allow-all). This is the "explicit reject" + gate — when it returns False, the caller is signalled BUSY (i.e., they + learn the device is reachable but rejecting them). Currently always + returns True. Contact-based silent-drop filtering lives in + _should_silently_drop() instead. """ identity_hash = identity.hash.hex() if identity else "unknown" RNS.log(f"Allow check for {identity_hash[:16]}...: allowed", RNS.LOG_DEBUG) return True + + def _should_silently_drop(self, identity): + """Check whether to silently drop a caller after identification. + + Unlike _is_allowed, a True result here means "do not signal anything + to the caller and do not notify Kotlin". The caller's link will be + torn down with no STATUS_BUSY / STATUS_REJECTED — they fall through + to their wait-time timeout, indistinguishable from "remote went away". + + The decision is delegated to the Kotlin contact-check callback, which + consults the user's contacts DB and the "calls from contacts only" + toggle. If the callback returns True, the caller IS a contact (or the + toggle is off) — allow. If False, silently drop. + + Fails open: any exception or missing callback results in "allow". + """ + if self._contact_check_callback is None: + return False # No gate installed → allow everyone + identity_hash = identity.hash.hex() if identity else None + if identity_hash is None: + return False # Unknown identity → allow (fail open) + try: + is_allowed = self._contact_check_callback(identity_hash) + if is_allowed is None: + return False # Defensive: treat None as allow + allowed_bool = bool(is_allowed) + if not allowed_bool: + RNS.log( + f"Calls-only-from-contacts: dropping link from {identity_hash[:16]}", + RNS.LOG_INFO, + ) + return not allowed_bool + except Exception as e: + # Fail open: any error in the Kotlin callback should not brick calls + RNS.log(f"Contact-check callback raised, allowing: {e}", RNS.LOG_WARNING) + return False + + def disable_incoming(self): + """Tear down the inbound destination & stop announces. + + Outbound calls (CallManager.call) still work because they construct + a fresh OUT Destination per call. Active calls are torn down. + + Note: Reticulum has no public "revoke announce" API — remote peers' + path tables retain stale entries for up to ~14 days (TTL-based) but + new link requests will not resolve to a live destination. + """ + with self._call_handler_lock: + if self._incoming_disabled: + return # Already disabled + self._incoming_disabled = True + + if self.destination is not None: + try: + RNS.Transport.deregister_destination(self.destination) + RNS.log("Telephony destination deregistered", RNS.LOG_INFO) + except Exception as e: + RNS.log(f"Error deregistering destination: {e}", RNS.LOG_WARNING) + self.destination = None + + # Tear down any active/ringing call so the remote sees a clean drop + link_to_teardown = self.active_call + self.active_call = None + self._active_call_identity = None + self._call_start_time = None + + if link_to_teardown is not None: + try: + if hasattr(link_to_teardown, 'status') and link_to_teardown.status == RNS.Link.ACTIVE: + link_to_teardown.teardown() + except Exception as e: + RNS.log(f"Error tearing down active call on disable: {e}", RNS.LOG_ERROR) + + def enable_incoming(self): + """Re-create the inbound destination, install the callback, and announce. + + Respawns the __jobs thread since the previous one exits when + self.destination becomes None. + """ + with self._call_handler_lock: + if not self._incoming_disabled and self.destination is not None: + return # Already enabled + self._incoming_disabled = False + try: + self.destination = RNS.Destination( + self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, + APP_NAME, PRIMITIVE_NAME + ) + self.destination.set_proof_strategy(RNS.Destination.PROVE_NONE) + self.destination.set_link_established_callback(self.__incoming_link_established) + self.destination.announce() + self._last_announce = time.time() + RNS.log("Telephony destination re-announced after enable", RNS.LOG_INFO) + except Exception as e: + RNS.log(f"Error re-creating destination on enable: {e}", RNS.LOG_ERROR) + self.destination = None + self._incoming_disabled = True + return + + # Respawn the jobs thread (the previous one exited when destination became None) + if self._jobs_thread is None or not self._jobs_thread.is_alive(): + self._jobs_thread = threading.Thread(target=self.__jobs, daemon=True) + self._jobs_thread.start() diff --git a/python/reticulum_wrapper.py b/python/reticulum_wrapper.py index cdb449021..0cf4fa3ce 100644 --- a/python/reticulum_wrapper.py +++ b/python/reticulum_wrapper.py @@ -974,6 +974,43 @@ def shutdown_call_manager(self): except Exception as e: log_error("ReticulumWrapper", "shutdown_call_manager", f"Error: {e}") + def disable_lxst_incoming(self): + """Tear down the inbound LXST destination, stopping incoming voice calls. + + Outbound calls remain functional — only the IN destination is + deregistered. The user can still dial out to anyone. This implements + the master "Allow voice calls" toggle when OFF. + + Returns: + Dict with 'success' boolean + """ + try: + if self._call_manager is not None: + self._call_manager.disable_incoming() + log_info("ReticulumWrapper", "disable_lxst_incoming", "Inbound LXST disabled") + return {'success': True} + except Exception as e: + log_error("ReticulumWrapper", "disable_lxst_incoming", f"Error: {e}") + return {'success': False, 'error': str(e)} + + def enable_lxst_incoming(self): + """Re-register the inbound LXST destination and re-announce. + + Counterpart to disable_lxst_incoming. Used when the master "Allow + voice calls" toggle is flipped back ON. + + Returns: + Dict with 'success' boolean + """ + try: + if self._call_manager is not None: + self._call_manager.enable_incoming() + log_info("ReticulumWrapper", "enable_lxst_incoming", "Inbound LXST enabled") + return {'success': True} + except Exception as e: + log_error("ReticulumWrapper", "enable_lxst_incoming", f"Error: {e}") + return {'success': False, 'error': str(e)} + def get_call_manager(self): """ Get the LXST CallManager instance. diff --git a/python/test_call_manager.py b/python/test_call_manager.py index f68b3f55f..5f7266992 100644 --- a/python/test_call_manager.py +++ b/python/test_call_manager.py @@ -1069,3 +1069,200 @@ def test_call_handles_general_exception(self): result = manager.call("abc123def456789012345678901234567890") assert result["success"] is False + + +class TestCallManagerDisableEnableIncoming: + """Test disable_incoming / enable_incoming (Feature 2 plumbing).""" + + def test_disable_incoming_nulls_destination(self): + """disable_incoming() should set self.destination to None.""" + mock_identity = MagicMock() + manager = CallManager(mock_identity) + manager.initialize() + assert manager.destination is not None + + manager.disable_incoming() + assert manager.destination is None + + def test_disable_incoming_sets_flag(self): + """disable_incoming() should set _incoming_disabled True.""" + mock_identity = MagicMock() + manager = CallManager(mock_identity) + manager.initialize() + + manager.disable_incoming() + assert manager._incoming_disabled is True + + def test_disable_incoming_calls_transport_deregister(self): + """disable_incoming() should call RNS.Transport.deregister_destination.""" + mock_identity = MagicMock() + manager = CallManager(mock_identity) + with patch('lxst_modules.call_manager.RNS') as mock_rns: + mock_dest = MagicMock() + mock_rns.Destination.return_value = mock_dest + manager.initialize() + + manager.disable_incoming() + mock_rns.Transport.deregister_destination.assert_called_once_with(mock_dest) + + def test_disable_incoming_tears_down_active_call(self): + """disable_incoming() should hangup any active call.""" + mock_identity = MagicMock() + manager = CallManager(mock_identity) + manager.initialize() + + mock_link = MagicMock() + mock_link.status = RNS.Link.ACTIVE + manager.active_call = mock_link + manager._active_call_identity = "deadbeef" + + manager.disable_incoming() + + assert manager.active_call is None + assert manager._active_call_identity is None + mock_link.teardown.assert_called_once() + + def test_disable_incoming_is_idempotent(self): + """Calling disable_incoming() twice should be a no-op the second time.""" + mock_identity = MagicMock() + manager = CallManager(mock_identity) + with patch('lxst_modules.call_manager.RNS') as mock_rns: + mock_rns.Destination.return_value = MagicMock() + manager.initialize() + + manager.disable_incoming() + mock_rns.Transport.deregister_destination.reset_mock() + + manager.disable_incoming() + mock_rns.Transport.deregister_destination.assert_not_called() + + def test_enable_incoming_rebuilds_destination(self): + """enable_incoming() after disable should create a fresh destination.""" + mock_identity = MagicMock() + manager = CallManager(mock_identity) + manager.initialize() + + manager.disable_incoming() + assert manager.destination is None + + manager.enable_incoming() + assert manager.destination is not None + assert manager._incoming_disabled is False + + def test_enable_incoming_announces(self): + """enable_incoming() should announce the fresh destination.""" + mock_identity = MagicMock() + manager = CallManager(mock_identity) + with patch('lxst_modules.call_manager.RNS') as mock_rns: + first_dest = MagicMock() + second_dest = MagicMock() + mock_rns.Destination.return_value = first_dest + manager.initialize() + + mock_rns.Destination.return_value = second_dest + manager.disable_incoming() + manager.enable_incoming() + + # New destination announce after enable + second_dest.announce.assert_called() + + def test_enable_incoming_is_idempotent(self): + """Calling enable_incoming() when already enabled is a no-op.""" + mock_identity = MagicMock() + manager = CallManager(mock_identity) + manager.initialize() + + first_dest = manager.destination + manager.enable_incoming() # already enabled + assert manager.destination is first_dest + + +class TestCallManagerContactCheckGate: + """Test the silent-drop gate (Feature 1).""" + + def test_should_silently_drop_returns_false_when_no_callback(self): + """No callback registered → allow everyone (fail open).""" + mock_identity = MagicMock() + mock_caller = MagicMock() + mock_caller.hash.hex.return_value = "deadbeef" * 4 + manager = CallManager(mock_identity) + + assert manager._should_silently_drop(mock_caller) is False + + def test_should_silently_drop_when_callback_returns_false(self): + """Callback returns false (not a contact) → drop.""" + mock_identity = MagicMock() + mock_caller = MagicMock() + mock_caller.hash.hex.return_value = "deadbeef" * 4 + manager = CallManager(mock_identity) + manager.set_kotlin_contact_check_callback(lambda h: False) + + assert manager._should_silently_drop(mock_caller) is True + + def test_should_not_drop_when_callback_returns_true(self): + """Callback returns true (is a contact, or toggle off) → allow.""" + mock_identity = MagicMock() + mock_caller = MagicMock() + mock_caller.hash.hex.return_value = "deadbeef" * 4 + manager = CallManager(mock_identity) + manager.set_kotlin_contact_check_callback(lambda h: True) + + assert manager._should_silently_drop(mock_caller) is False + + def test_should_not_drop_when_callback_raises_fail_open(self): + """Callback raises → fail open (allow) — mirrors message-side semantic.""" + mock_identity = MagicMock() + mock_caller = MagicMock() + mock_caller.hash.hex.return_value = "deadbeef" * 4 + + def bad_callback(h): + raise RuntimeError("Room DB exploded") + + manager = CallManager(mock_identity) + manager.set_kotlin_contact_check_callback(bad_callback) + + assert manager._should_silently_drop(mock_caller) is False + + def test_caller_identified_silent_drop_sends_no_status_busy(self): + """When the gate fires, NO signals are sent to remote — pure silent drop.""" + mock_identity = MagicMock() + manager = CallManager(mock_identity) + manager._initialized = True + # Toggle: caller is NOT a contact + manager.set_kotlin_contact_check_callback(lambda h: False) + + mock_link = MagicMock() + mock_caller_identity = MagicMock() + mock_caller_identity.hash.hex.return_value = "deadbeef" * 4 + + with patch.object(manager, "_send_signal_to_remote") as send_signal_mock: + manager._CallManager__caller_identified(mock_link, mock_caller_identity) + + # The gate fires before STATUS_RINGING/STATUS_BUSY; nothing should be sent. + send_signal_mock.assert_not_called() + # Link IS torn down so the originator sees the link drop. + mock_link.teardown.assert_called_once() + # No Kotlin notify should have fired either — active_call should not be set. + assert manager.active_call is None + + def test_caller_identified_passes_through_when_callback_allows(self): + """When callback returns True, normal flow continues (STATUS_RINGING, notify).""" + mock_identity = MagicMock() + manager = CallManager(mock_identity) + manager._initialized = True + # Toggle ON, but caller IS a contact + manager.set_kotlin_contact_check_callback(lambda h: True) + manager._kotlin_call_bridge = MagicMock() + manager._kotlin_telephone_callback = MagicMock() + + mock_link = MagicMock() + mock_caller_identity = MagicMock() + mock_caller_identity.hash.hex.return_value = "deadbeef" * 4 + + with patch.object(manager, "_send_signal_to_remote") as send_signal_mock: + manager._CallManager__caller_identified(mock_link, mock_caller_identity) + + # Normal flow: STATUS_RINGING signalled, Kotlin notified, active_call set. + send_signal_mock.assert_called_with(STATUS_RINGING, mock_link) + assert manager.active_call is mock_link + manager._kotlin_call_bridge.onIncomingCall.assert_called_once()