From bf4bd1e03e3a410f9eda192c20dfb1872df05eb3 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 16:23:17 -0400 Subject: [PATCH 01/11] feat: add allow_calls_from_contacts_only + allow_voice_calls DataStore prefs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new privacy-related boolean preferences alongside the existing block_unknown_senders pattern: - ALLOW_CALLS_FROM_CONTACTS_ONLY (default false): inbound LXST call gate - ALLOW_VOICE_CALLS (default true): master inbound LXST enable/disable Mirrors the existing blockUnknownSenders triplet exactly: DataStore Flow, suspend getter, dual-write setter (DataStore + MODE_MULTI_PROCESS SharedPreferences) so the service process can read it. ServiceSettingsAccessor exposes getAllowCallsFromContactsOnly() and getAllowVoiceCalls() for cross-process reads from the :reticulum process. No behaviour change yet — the new flows/getters are not wired into any gate or destination lifecycle in this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../repository/SettingsRepository.kt | 92 +++++++++++++++++++ .../persistence/ServiceSettingsAccessor.kt | 21 +++++ 2 files changed, 113 insertions(+) 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..a3f83a513 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,94 @@ 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 for cross-process access by the service + context + .getSharedPreferences(CROSS_PROCESS_PREFS_NAME, Context.MODE_MULTI_PROCESS) + .edit() + .putBoolean(KEY_ALLOW_VOICE_CALLS, enabled) + .apply() + } + // Custom theme methods (delegated to CustomThemeRepository) /** 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) } From b1bf474b0a2a4ce34a1f4481217b56b77194e331 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 16:25:58 -0400 Subject: [PATCH 02/11] feat(python): add disable_incoming/enable_incoming to CallManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Python-side primitives for the master "Allow voice calls" toggle (Feature 2) and the contact-check callback hook for the "Calls from contacts only" gate (Feature 1). CallManager: - _incoming_disabled flag, guarded by _call_handler_lock, checked in __jobs() and __incoming_link_established() for race safety against toggle-off / mid-flight link admission. - disable_incoming(): Transport.deregister_destination(self.destination), null destination, tear down any active call. __jobs() exits its loop. - enable_incoming(): rebuild RNS.Destination from self.identity, install the link-established callback, announce, respawn the __jobs daemon. - set_kotlin_contact_check_callback(): registers a synchronous predicate that __caller_identified will consult (wired up in a later commit) to decide whether to silently drop a non-contact link. - _should_silently_drop(identity): fail-open helper that invokes the contact-check callback and converts its result. Logs the drop at INFO. - _is_allowed() is unchanged but its docstring now distinguishes the "explicit busy" path (existing behaviour) from the new "silent drop" path that _should_silently_drop() will gate in the next commit. reticulum_wrapper.py: - disable_lxst_incoming() / enable_lxst_incoming(): thin delegations to CallManager, exposed for Kotlin to invoke via Chaquopy. No behaviour change yet — _should_silently_drop is wired into __caller_identified in the next commit, and the new wrapper methods are not yet called by Kotlin. All 90 existing test_call_manager.py tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- python/lxst_modules/call_manager.py | 164 +++++++++++++++++++++++++++- python/reticulum_wrapper.py | 37 +++++++ 2 files changed, 195 insertions(+), 6 deletions(-) diff --git a/python/lxst_modules/call_manager.py b/python/lxst_modules/call_manager.py index 60921b850..c078387e1 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) @@ -801,9 +847,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. From f32de27abacf8c697ccb9e51ab6095918ad670fa Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 16:29:50 -0400 Subject: [PATCH 03/11] feat: wire contact-only LXST gate (Feature 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connects the Python silent-drop gate to the Kotlin contacts DB and the "Calls from contacts only" toggle. Python (call_manager.py): - __caller_identified now consults _should_silently_drop(identity) BEFORE _send_signal_to_remote(STATUS_RINGING) and BEFORE any Kotlin notify. Goal: a non-contact caller's wire trace is "link → identify → nothing", indistinguishable from "remote went away". No STATUS_BUSY, no STATUS_REJECTED — anything else would leak that the device is reachable but blocking. Kotlin (PythonWrapperManager): - isCallerInContacts(identityHashHex): synchronous blocking predicate callable from Python. Returns true if the toggle is OFF, otherwise looks up the announce by identity hash, then checks contacts table against the active local identity. Fail-open semantics: any DB error or missing active identity returns true (don't brick calls on infra hiccups, mirrors ServicePersistenceManager.shouldBlockUnknownSender). - setupContactCheckCallback(): wraps isCallerInContacts as a java.util.function.Function (Chaquopy SAM ambiguity workaround, same as setStampGeneratorCallback and set_kotlin_telephone_callback) and registers it on call_manager. - Constructor: now takes ServiceSettingsAccessor for cross-process toggle reads. ServiceModule passes it through; the existing shutdown-guard unit test gets a mockk for the new param. ReticulumServiceBinder.setupLxstCallManager: calls wrapperManager.setupContactCheckCallback() after setupTelephone(). Behavioural summary when toggle ON and B is NOT in A's contacts: 1. B initiates link, A accepts (Reticulum auto-proves). 2. A sends STATUS_AVAILABLE (unavoidable, identification needs it). 3. B identifies; A's __caller_identified runs. 4. _should_silently_drop returns true; A teardowns the link silently. 5. B sees link drop after identify, falls through to wait-time timeout. A's UI never sees the call. No notification, no IncomingCallActivity, no logcat line beyond a single INFO log on the drop. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../service/binder/ReticulumServiceBinder.kt | 3 + .../messenger/service/di/ServiceModule.kt | 4 +- .../service/manager/PythonWrapperManager.kt | 105 ++++++++++++++++++ .../PythonWrapperManagerShutdownGuardTest.kt | 1 + python/lxst_modules/call_manager.py | 14 +++ 5 files changed, 125 insertions(+), 2 deletions(-) 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..c8f4c23b9 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 @@ -1477,6 +1477,9 @@ 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() } } catch (e: Exception) { Log.w(TAG, "Failed to setup CallManager: ${e.message}", e) 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..d85d56f3b 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) 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..2f9be2001 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,108 @@ class PythonWrapperManager( */ fun getTelephone(): Telephone? = telephone + /** + * 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: blocking call back into Kotlin + 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/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/python/lxst_modules/call_manager.py b/python/lxst_modules/call_manager.py index c078387e1..137da38e9 100644 --- a/python/lxst_modules/call_manager.py +++ b/python/lxst_modules/call_manager.py @@ -537,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) From 6f41e349fd3ccc7bbaa25c0ff9d98c5e6d5ec150 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 16:32:02 -0400 Subject: [PATCH 04/11] feat: wire master LXST enable/disable toggle (Feature 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks the "Allow voice calls" toggle to Python destination lifecycle. PythonWrapperManager.setLxstIncomingEnabled(enabled): - enabled=false: invokes Python disable_lxst_incoming → CallManager deregisters its IN destination from RNS.Transport, tears down any active call, sets _incoming_disabled flag, and the __jobs announce loop exits cleanly. - enabled=true: invokes Python enable_lxst_incoming → CallManager rebuilds an IN destination from the same Identity (deterministic hash, so peers' cached paths still point here after re-announce), reinstalls the link-established callback, announces, and respawns __jobs. ReticulumServiceBinder.setupLxstCallManager: after setupTelephone + setupContactCheckCallback, calls applyInitialAllowVoiceCallsState() to read the persisted toggle and disable inbound if OFF, then registers a SharedPreferences.OnSharedPreferenceChangeListener on cross_process_settings to track UI-process toggle changes. The listener is unregistered in shutdown(). Constructor: ReticulumServiceBinder now takes ServiceSettingsAccessor; ServiceModule.createBinder passes it through. Outbound calls remain functional in both states — CallManager.call() builds an OUT destination per-call from the remote's identity, separate from the IN destination this toggle controls. No path-revocation announce: Reticulum has no public revocation API and spoofed announces would break protocol. Remote peers' path tables expire by TTL (~14 days). Documented inline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../service/binder/ReticulumServiceBinder.kt | 78 +++++++++++++++++++ .../messenger/service/di/ServiceModule.kt | 1 + .../service/manager/PythonWrapperManager.kt | 32 ++++++++ 3 files changed, 111 insertions(+) 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 c8f4c23b9..0af80151f 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,12 @@ class ReticulumServiceBinder( // RNode bridge - created lazily when needed private var rnodeBridge: KotlinRNodeBridge? = null + // Cross-process pref listener for the master "Allow voice calls" toggle. + // Held for service lifecycle so the SharedPreferences instance does not + // GC the listener — see SharedPreferences.registerOnSharedPreferenceChangeListener doc. + private var allowVoiceCallsPrefListener: + android.content.SharedPreferences.OnSharedPreferenceChangeListener? = null + // =========================================== // Lifecycle Methods // =========================================== @@ -376,6 +384,21 @@ class ReticulumServiceBinder( Log.w(TAG, "Error during BLE immediate shutdown", e) } + // Unregister the Allow-voice-calls cross-process pref listener + try { + allowVoiceCallsPrefListener?.let { listener -> + @Suppress("DEPRECATION") // MODE_MULTI_PROCESS required to match the registration + context + .getSharedPreferences( + ServiceSettingsAccessor.CROSS_PROCESS_PREFS_NAME, + Context.MODE_MULTI_PROCESS, + ).unregisterOnSharedPreferenceChangeListener(listener) + } + allowVoiceCallsPrefListener = null + } catch (e: Exception) { + Log.w(TAG, "Error unregistering Allow voice calls listener", e) + } + // Update status state.networkStatus.set("RESTARTING") broadcaster.broadcastStatusChange("RESTARTING") @@ -1480,12 +1503,67 @@ class ReticulumServiceBinder( // 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 and + // start listening for cross-process changes. + applyInitialAllowVoiceCallsState() + registerAllowVoiceCallsListener() } } 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) + } + } + + /** + * Subscribe to cross-process changes to the "Allow voice calls" toggle. + * + * The UI process writes the new value via SettingsRepository.saveAllowVoiceCalls + * which dual-writes DataStore + MODE_MULTI_PROCESS SharedPreferences. The + * service process picks up the change via this listener and toggles the + * Python destination accordingly. + */ + @Suppress("DEPRECATION") // MODE_MULTI_PROCESS is deprecated but required for cross-process + private fun registerAllowVoiceCallsListener() { + try { + val prefs = + context.getSharedPreferences( + ServiceSettingsAccessor.CROSS_PROCESS_PREFS_NAME, + Context.MODE_MULTI_PROCESS, + ) + val listener = + android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPrefs, changedKey -> + if (changedKey == ServiceSettingsAccessor.KEY_ALLOW_VOICE_CALLS) { + val allowed = sharedPrefs.getBoolean(changedKey, true) + Log.i(TAG, "Allow voice calls toggle changed → $allowed") + wrapperManager.setLxstIncomingEnabled(allowed) + } + } + prefs.registerOnSharedPreferenceChangeListener(listener) + allowVoiceCallsPrefListener = listener + } catch (e: Exception) { + Log.w(TAG, "Failed to register Allow voice calls listener: ${e.message}", e) + } + } + /** 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 d85d56f3b..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 @@ -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 2f9be2001..a46a16480 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 @@ -651,6 +651,38 @@ 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. * From 70d256e4a98c5c18369781d7135dd23048ba34ba Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 16:37:32 -0400 Subject: [PATCH 05/11] feat(ui): add Calls-from-contacts-only toggle to Privacy card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces Feature 1 in Settings → Privacy. PrivacyCard: gains allowCallsFromContactsOnly + onAllowCallsFromContactsOnlyChange parameters and renders a secondary toggle row inside the expanded content (below the block-unknown-senders description, above Blocked Users). The row has its own descriptive text that mirrors the existing block-unknown- senders messaging style: - ON: "Only contacts can call you. Other callers' link attempts are silently dropped." - OFF: "Anyone can call you, including unknown callers." The toggle is independent of block_unknown_senders: messages and calls have separate gate semantics (messages: silent discard server-side, no network signalling either way; calls: silent link teardown after identify, no STATUS_RINGING). SettingsViewModel.SettingsState: adds allowCallsFromContactsOnly = false. loadPrivacySettings() collects from settingsRepository.allowCallsFromContactsOnlyFlow. setAllowCallsFromContactsOnly(enabled) writes through to repo + state. The "preserve privacy state" combine-flow branch in loadSettings now preserves the new field so theme reloads don't clobber it. SettingsScreen passes the two new parameters from state/viewmodel. Tests: - PrivacyCardTest: setUpCard helper extended with the new params + a callback tracker. - SettingsViewModelTest: setup mocks allowCallsFromContactsOnlyFlow and allowVoiceCallsFlow (the latter is required by upcoming Feature 2 UI work but stubbing both here keeps the test setup atomic). - ReticulumServiceBinderTest: stubs the new settingsAccessor constructor parameter that landed in commit 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../messenger/ui/screens/SettingsScreen.kt | 2 ++ .../ui/screens/settings/cards/PrivacyCard.kt | 36 +++++++++++++++++++ .../messenger/viewmodel/SettingsViewModel.kt | 20 +++++++++++ .../binder/ReticulumServiceBinderTest.kt | 4 +++ .../screens/settings/cards/PrivacyCardTest.kt | 5 +++ .../viewmodel/SettingsViewModelTest.kt | 2 ++ 6 files changed, 69 insertions(+) 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..e134871bd 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, ) 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..f1aacc0ec 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 = {}, ) { @@ -56,6 +58,40 @@ fun PrivacyCard( 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, + ) + + Spacer(modifier = Modifier.height(12.dp)) + // Blocked Users navigation row Row( modifier = 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..8dcaea862 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,7 @@ data class SettingsState( val notificationsEnabled: Boolean = true, // Privacy state val blockUnknownSenders: Boolean = false, + val allowCallsFromContactsOnly: Boolean = false, // Incoming message size limit (default 1MB) val incomingMessageSizeLimitKb: Int = 1024, // Image compression state @@ -441,6 +442,7 @@ class SettingsViewModel notificationsEnabled = _state.value.notificationsEnabled, // Preserve privacy state from loadPrivacySettings() blockUnknownSenders = _state.value.blockUnknownSenders, + allowCallsFromContactsOnly = _state.value.allowCallsFromContactsOnly, // Preserve message size limit from loadLocationSharingSettings() incomingMessageSizeLimitKb = _state.value.incomingMessageSizeLimitKb, // Preserve message sorting from loadLocationSharingSettings() @@ -1502,6 +1504,11 @@ class SettingsViewModel _state.update { it.copy(blockUnknownSenders = enabled) } } } + viewModelScope.launch { + settingsRepository.allowCallsFromContactsOnlyFlow.collect { enabled -> + _state.update { it.copy(allowCallsFromContactsOnly = enabled) } + } + } } /** @@ -1516,6 +1523,19 @@ 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"}") + } + } + // Transport node methods /** 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/ui/screens/settings/cards/PrivacyCardTest.kt b/app/src/test/java/com/lxmf/messenger/ui/screens/settings/cards/PrivacyCardTest.kt index 4df398c20..7b55c344b 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 }, ) } } 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) From b75b4d44f7de3c2a5a89aec2500f6650e569c226 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 16:39:51 -0400 Subject: [PATCH 06/11] feat(ui): add master Allow voice calls toggle to Voice Call Permissions card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces Feature 2 in Settings → Voice Call Permissions. VoiceCallPermissionsCard: adds allowVoiceCalls + onAllowVoiceCallsChange parameters. A Material3 Switch is placed in the header row to the left of the chevron icon, so the toggle stays accessible whether the card is expanded or collapsed. When the toggle is OFF, the expanded content shows a leading "Incoming voice calls are currently disabled. Outgoing calls still work." banner above the existing permission rows — this prevents user confusion when permissions are all granted but no calls are arriving. The card already early-returns on SDK < Q (background activity launch restrictions), which is fine: LXST itself requires Q+ for the full- screen incoming call screen, so the toggle would have no observable effect on older devices. SettingsViewModel.SettingsState: adds allowVoiceCalls = true. The default is true (preserve existing behaviour). loadPrivacySettings collects allowVoiceCallsFlow. setAllowVoiceCalls writes through to repo + state — the repo's dual-write to MODE_MULTI_PROCESS SharedPreferences triggers the service-process listener wired up in commit 4. SettingsScreen passes the state and callback through. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../messenger/ui/screens/SettingsScreen.kt | 2 ++ .../cards/VoiceCallPermissionsCard.kt | 24 +++++++++++++++++++ .../messenger/viewmodel/SettingsViewModel.kt | 21 ++++++++++++++++ 3 files changed, 47 insertions(+) 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 e134871bd..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 @@ -299,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/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 8dcaea862..a9c5905d2 100644 --- a/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt @@ -125,6 +125,7 @@ data class SettingsState( // 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 @@ -443,6 +444,7 @@ class SettingsViewModel // 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() @@ -1509,6 +1511,11 @@ class SettingsViewModel _state.update { it.copy(allowCallsFromContactsOnly = enabled) } } } + viewModelScope.launch { + settingsRepository.allowVoiceCallsFlow.collect { enabled -> + _state.update { it.copy(allowVoiceCalls = enabled) } + } + } } /** @@ -1536,6 +1543,20 @@ class SettingsViewModel } } + /** + * 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 /** From 3d2d9d53f09c436e3c2b7476af6fd884e65bcd37 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 16:49:37 -0400 Subject: [PATCH 07/11] test: add unit tests for new gates and toggles SettingsRepositoryTest: - allowCallsFromContactsOnlyFlow_emitsOnlyOnChange - getAllowCallsFromContactsOnly_matchesFlow - saveAllowCallsFromContactsOnly_writesCrossProcessPref - allowVoiceCallsFlow_emitsOnlyOnChange - getAllowVoiceCalls_matchesFlow - saveAllowVoiceCalls_writesCrossProcessPref Each test resets the value at the end so a deterministic-but-arbitrary JUnit method ordering doesn't leak state between tests (the DataStore singleton persists across methods in Robolectric). ServiceSettingsAccessorTest: - getAllowCallsFromContactsOnly returns false by default - getAllowCallsFromContactsOnly returns true/changes when set - getAllowVoiceCalls returns TRUE by default (critical: existing users without the toggle set must keep receiving calls) - getAllowVoiceCalls reflects changes - Key constants test extended with the new key names PrivacyCardTest: - New row label "Calls from contacts only" displays - ON subtitle shows "Only contacts can call you..." - OFF subtitle shows "Anyone can call you..." VoiceCallPermissionsCardTest (new file): - Card title displays - "Incoming voice calls are currently disabled" banner shows when toggle is OFF - Banner hidden when toggle is ON PythonWrapperManagerContactCheckTest (new file): - Toggle OFF short-circuit returns true WITHOUT touching DB - DB access failure (mock Context) fails open with true python/test_call_manager.py: TestCallManagerDisableEnableIncoming: - disable_incoming nulls destination, sets flag, calls Transport deregister, tears down active call, is idempotent - enable_incoming rebuilds destination, announces, is idempotent TestCallManagerContactCheckGate: - _should_silently_drop fail-open with no callback / on exception - gate fires when callback returns false - gate passes through when callback returns true - __caller_identified sends NO signals when gate fires (purely silent) - __caller_identified follows the normal STATUS_RINGING / Kotlin-notify path when gate allows All 104 Python tests pass (was 90); all touched Kotlin tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../repository/SettingsRepositoryTest.kt | 116 +++++++++++ .../PythonWrapperManagerContactCheckTest.kt | 68 ++++++ .../ServiceSettingsAccessorTest.kt | 50 +++++ .../screens/settings/cards/PrivacyCardTest.kt | 27 +++ .../cards/VoiceCallPermissionsCardTest.kt | 94 +++++++++ python/test_call_manager.py | 197 ++++++++++++++++++ 6 files changed, 552 insertions(+) create mode 100644 app/src/test/java/com/lxmf/messenger/service/manager/PythonWrapperManagerContactCheckTest.kt create mode 100644 app/src/test/java/com/lxmf/messenger/ui/screens/settings/cards/VoiceCallPermissionsCardTest.kt 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/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/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 7b55c344b..a735d3e6c 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 @@ -125,4 +125,31 @@ class PrivacyCardTest { "Anyone can send you messages, including unknown senders.", ).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/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() From 25f14fe9c1c63848d856866d55e27119248aec74 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 17:07:15 -0400 Subject: [PATCH 08/11] test: stub allowCallsFromContactsOnly + allowVoiceCalls flows in SettingsViewModelIncomingMessageLimitTest Follow-up to commit 5/6: this sibling ViewModel test file mocks every SettingsRepository flow that the ViewModel collects on init. After the two new privacy flows were added to loadPrivacySettings, this test class started failing in setup() with MockKException "no answer found for SettingsRepository.getAllowCallsFromContactsOnlyFlow()". Adds the two new MutableStateFlow stubs alongside the existing blockUnknownSendersFlow mock, matching defaults (false / true). This restores all 6065 :app unit tests to green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../viewmodel/SettingsViewModelIncomingMessageLimitTest.kt | 2 ++ 1 file changed, 2 insertions(+) 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) From a9e876f434c2d7aa1fef800aa7206a3adc5a2745 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 18:14:31 -0400 Subject: [PATCH 09/11] refactor(ui): move block-unknown-senders toggle into PrivacyCard body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block-unknown-senders switch lived in the card's `headerAction` slot, while the new "Calls from contacts only" switch added in this PR sits in the body alongside a description. Visually the messages toggle felt secondary even though it's the more foundational of the two. Move it into the body so both toggles are equal-billed: same row shape ("[Label] ........ [Switch]"), same description-below-switch pattern, same vertical spacing. The card header now just shows title + chevron. Existing description text is unchanged; only the layout (header → body) and the label "Messages from contacts only" (added next to the switch for parity with the calls row) are new. Updated the card test to assert the new label renders. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ui/screens/settings/cards/PrivacyCard.kt | 25 +++++++++++++++---- .../screens/settings/cards/PrivacyCardTest.kt | 12 +++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) 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 f1aacc0ec..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 @@ -37,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) { @@ -52,7 +67,7 @@ fun PrivacyCard( } else { "Anyone can send you messages, including unknown senders." }, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) 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 a735d3e6c..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 @@ -126,6 +126,18 @@ class PrivacyCardTest { ).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 From 8d5c1f3595b5b1ec6e5313d4b1b22a97cd3edbf2 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 18:46:30 -0400 Subject: [PATCH 10/11] fix(threading): tag isCallerInContacts runBlocking with audit allowlist marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The threading-architecture audit greps for the literal `// THREADING: allowed` suffix on any `runBlocking` site in production code. The new contact-check helper landed with a descriptive marker ("// THREADING: blocking call back into Kotlin") that did not match the audit's allowlist regex, so CI failed even though the runBlocking is intentional and necessary: Python JNI callbacks must return synchronously, and the only way to bridge to Room (which exposes suspend DAOs) is via runBlocking. Match the existing `generateStampForPython` allowlist style — keep the explanatory text after the marker. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/lxmf/messenger/service/manager/PythonWrapperManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a46a16480..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 @@ -744,7 +744,7 @@ class PythonWrapperManager( if (!settingsAccessor.getAllowCallsFromContactsOnly()) { true } else { - runBlocking(Dispatchers.IO) { // THREADING: blocking call back into Kotlin + runBlocking(Dispatchers.IO) { // THREADING: allowed — Python JNI callback requires synchronous return val database = ServiceDatabaseProvider.getDatabase(context) val announceDao = database.announceDao() val contactDao = database.contactDao() From 0bdbfe78c133872a1c7e7a7116b2bdb01cb97256 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 18:58:24 -0400 Subject: [PATCH 11/11] fix(voice): signal :reticulum via Intent when Allow voice calls flips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The master "Allow voice calls" toggle wired a SharedPreferences.OnSharedPreferenceChangeListener in the :reticulum process to react to runtime flips. That listener is a no-op for our purpose: SharedPreferences change callbacks only fire IN THE PROCESS that wrote the value. Cross-process notifications are not part of the SharedPreferences contract on Android — well-known platform behaviour. Symptom: toggling the master off in Settings persisted to disk and showed the OFF state in UI, but the :reticulum process never invoked disable_lxst_incoming() and incoming calls kept landing. Confirmed on-device. Replace the broken listener with an explicit Intent signal, mirroring ACTION_RESTART_BLE's existing shape: - ReticulumService gains ACTION_SET_ALLOW_VOICE_CALLS + EXTRA_ALLOW_VOICE_CALLS, dispatching to a new ReticulumServiceBinder.setAllowVoiceCalls(allowed) that wraps the existing wrapperManager.setLxstIncomingEnabled. - SettingsRepository.saveAllowVoiceCalls now sends that Intent via context.startService after the DataStore + SharedPreferences writes, so the runtime change reaches the service immediately. The persisted SharedPreferences write stays — it's what the service reads at cold start via applyInitialAllowVoiceCallsState. - The dead OnSharedPreferenceChangeListener plumbing is removed (registerAllowVoiceCallsListener, the held listener field, and the unregister call in onDestroy). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../repository/SettingsRepository.kt | 29 ++++++++- .../messenger/service/ReticulumService.kt | 16 +++++ .../service/binder/ReticulumServiceBinder.kt | 62 ++++--------------- 3 files changed, 57 insertions(+), 50 deletions(-) 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 a3f83a513..a12ae0c50 100644 --- a/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt +++ b/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt @@ -1859,12 +1859,39 @@ class SettingsRepository context.dataStore.edit { preferences -> preferences[PreferencesKeys.ALLOW_VOICE_CALLS] = enabled } - // Write to SharedPreferences for cross-process access by the service + // 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 0af80151f..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 @@ -77,11 +77,6 @@ class ReticulumServiceBinder( // RNode bridge - created lazily when needed private var rnodeBridge: KotlinRNodeBridge? = null - // Cross-process pref listener for the master "Allow voice calls" toggle. - // Held for service lifecycle so the SharedPreferences instance does not - // GC the listener — see SharedPreferences.registerOnSharedPreferenceChangeListener doc. - private var allowVoiceCallsPrefListener: - android.content.SharedPreferences.OnSharedPreferenceChangeListener? = null // =========================================== // Lifecycle Methods @@ -384,21 +379,6 @@ class ReticulumServiceBinder( Log.w(TAG, "Error during BLE immediate shutdown", e) } - // Unregister the Allow-voice-calls cross-process pref listener - try { - allowVoiceCallsPrefListener?.let { listener -> - @Suppress("DEPRECATION") // MODE_MULTI_PROCESS required to match the registration - context - .getSharedPreferences( - ServiceSettingsAccessor.CROSS_PROCESS_PREFS_NAME, - Context.MODE_MULTI_PROCESS, - ).unregisterOnSharedPreferenceChangeListener(listener) - } - allowVoiceCallsPrefListener = null - } catch (e: Exception) { - Log.w(TAG, "Error unregistering Allow voice calls listener", e) - } - // Update status state.networkStatus.set("RESTARTING") broadcaster.broadcastStatusChange("RESTARTING") @@ -1503,10 +1483,11 @@ class ReticulumServiceBinder( // 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 and - // start listening for cross-process changes. + // 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() - registerAllowVoiceCallsListener() } } catch (e: Exception) { Log.w(TAG, "Failed to setup CallManager: ${e.message}", e) @@ -1534,34 +1515,17 @@ class ReticulumServiceBinder( } /** - * Subscribe to cross-process changes to the "Allow voice calls" toggle. + * Apply a runtime change to the master "Allow voice calls" toggle. * - * The UI process writes the new value via SettingsRepository.saveAllowVoiceCalls - * which dual-writes DataStore + MODE_MULTI_PROCESS SharedPreferences. The - * service process picks up the change via this listener and toggles the - * Python destination accordingly. + * 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. */ - @Suppress("DEPRECATION") // MODE_MULTI_PROCESS is deprecated but required for cross-process - private fun registerAllowVoiceCallsListener() { - try { - val prefs = - context.getSharedPreferences( - ServiceSettingsAccessor.CROSS_PROCESS_PREFS_NAME, - Context.MODE_MULTI_PROCESS, - ) - val listener = - android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPrefs, changedKey -> - if (changedKey == ServiceSettingsAccessor.KEY_ALLOW_VOICE_CALLS) { - val allowed = sharedPrefs.getBoolean(changedKey, true) - Log.i(TAG, "Allow voice calls toggle changed → $allowed") - wrapperManager.setLxstIncomingEnabled(allowed) - } - } - prefs.registerOnSharedPreferenceChangeListener(listener) - allowVoiceCallsPrefListener = listener - } catch (e: Exception) { - Log.w(TAG, "Failed to register Allow voice calls listener: ${e.message}", e) - } + fun setAllowVoiceCalls(allowed: Boolean) { + Log.i(TAG, "Allow voice calls runtime change → $allowed") + wrapperManager.setLxstIncomingEnabled(allowed) } /** Register listeners for IPC notification to UI process. */