feat: LXST call privacy gates (contacts-only + master disable)#930
feat: LXST call privacy gates (contacts-only + master disable)#930torlando-tech wants to merge 11 commits into
Conversation
…e prefs 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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<String, Boolean> (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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ns card 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ingsViewModelIncomingMessageLimitTest 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) <noreply@anthropic.com>
❌ Threading Architecture Audit FailedView Audit ReportPlease fix the dispatcher violations before merging. |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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) <noreply@anthropic.com>
❌ Threading Architecture Audit FailedView Audit ReportPlease fix the dispatcher violations before merging. |
…st marker
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Greptile SummaryThis PR adds two inbound-call privacy gates to the LXST voice subsystem: a "Calls from contacts only" silent-drop filter (Feature 1) and a master "Allow voice calls" toggle that fully deregisters the RNS destination (Feature 2). The plumbing is solid — dual-write DataStore + cross-process SharedPreferences, Intent-based runtime signalling to the service process, a fail-open Kotlin contact-check callback, and correct idempotency on both
Confidence Score: 3/5Not safe to merge as-is: toggling the master Allow voice calls switch mid-call leaves the Kotlin UI frozen in an active or ringing state, and a narrow timing window allows an in-flight call to complete identification after the toggle is flipped OFF. Two bugs both live in disable_incoming() and __caller_identified in call_manager.py. The Kotlin notification gap means any user who flips the master toggle while ringing or in a call will see a hung UI. The __caller_identified TOCTOU means a single call can slip through the master disable in a narrow window. Both affect a new privacy feature on the hot path. The rest of the stack — Kotlin wiring, persistence, UI, tests — is correct and well-structured, but the core Python gate logic needs the fixes before this ships. python/lxst_modules/call_manager.py — specifically disable_incoming() (missing onCallEnded notification) and __caller_identified (missing _incoming_disabled guard) Important Files Changed
Sequence DiagramsequenceDiagram
participant Peer as Remote Peer
participant RNS as Reticulum Transport
participant CM as CallManager (Python)
participant KT as Kotlin CallCoordinator
Note over CM,KT: Feature 2 - Master disable mid-call
Peer->>RNS: Link Request
RNS->>CM: __incoming_link_established(link)
CM->>CM: _incoming_disabled check OK
CM->>Peer: STATUS_AVAILABLE
Peer->>CM: IDENTIFY caller identity
CM->>CM: "__caller_identified sets active_call=link"
CM->>KT: onIncomingCall(identityHash)
Note over KT: UI shows incoming call
Note over CM: User toggles Allow voice calls OFF
CM->>CM: "disable_incoming clears active_call=None"
CM->>RNS: deregister_destination()
CM->>CM: link.teardown() outside lock
CM-->>CM: "__link_closed link != active_call(None) skip"
Note over KT: onCallEnded never called UI stuck
Note over CM,KT: Feature 1 - Non-contact silently dropped
Peer->>RNS: Link Request
RNS->>CM: __incoming_link_established(link)
Peer->>CM: IDENTIFY
CM->>KT: isCallerInContacts(identityHash)
KT-->>CM: false not a contact
CM->>CM: link.teardown() no signals no Kotlin notify
Note over Peer: Sees link drop times out ~70s
|
| 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, |
There was a problem hiding this comment.
disable_incoming() doesn't notify Kotlin when tearing down a live call
active_call is cleared to None under the lock before teardown() is called. When teardown() fires __link_closed, the guard if link == self.active_call evaluates to if link == None → False, so should_notify stays False and _kotlin_call_bridge.onCallEnded() / _send_signal_to_kotlin(STATUS_AVAILABLE) are never called. The PR description says this relies on "Kotlin's existing __link_closed path", but that path explicitly requires link == self.active_call to proceed.
Concrete failure: user toggles "Allow voice calls" OFF while ringing or in-call → Python tears down the link cleanly → Kotlin's CallCoordinator state machine never receives onCallEnded → the incoming-call UI stays on-screen indefinitely, or the in-call timer keeps running with no way to dismiss it. Compare to hangup(), which explicitly calls self._kotlin_call_bridge.onCallEnded(self._active_call_identity) inside the lock before clearing _active_call_identity.
Prompt To Fix With AI
This is a comment left during a code review.
Path: python/lxst_modules/call_manager.py
Line: 930-958
Comment:
**`disable_incoming()` doesn't notify Kotlin when tearing down a live call**
`active_call` is cleared to `None` under the lock before `teardown()` is called. When `teardown()` fires `__link_closed`, the guard `if link == self.active_call` evaluates to `if link == None` → `False`, so `should_notify` stays `False` and `_kotlin_call_bridge.onCallEnded()` / `_send_signal_to_kotlin(STATUS_AVAILABLE)` are never called. The PR description says this relies on "Kotlin's existing `__link_closed` path", but that path explicitly requires `link == self.active_call` to proceed.
Concrete failure: user toggles "Allow voice calls" OFF while ringing or in-call → Python tears down the link cleanly → Kotlin's `CallCoordinator` state machine never receives `onCallEnded` → the incoming-call UI stays on-screen indefinitely, or the in-call timer keeps running with no way to dismiss it. Compare to `hangup()`, which explicitly calls `self._kotlin_call_bridge.onCallEnded(self._active_call_identity)` inside the lock before clearing `_active_call_identity`.
How can I resolve this? If you propose a fix, please make it concise.| # 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 |
There was a problem hiding this comment.
Suggested fix for missing Kotlin
onCallEnded notification in disable_incoming()
Mirror the hangup() pattern: notify Kotlin before clearing _active_call_identity, inside the lock, so the CallCoordinator and incoming-call UI are torn down correctly when the master toggle is flipped mid-call.
| # 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 | |
| # Tear down any active/ringing call so the remote sees a clean drop | |
| link_to_teardown = self.active_call | |
| self.active_call = None | |
| # Notify Kotlin that the call ended (mirrors hangup() pattern). | |
| # Must happen BEFORE clearing _active_call_identity so the identity | |
| # is available for the callback. __link_closed will NOT fire this | |
| # because active_call is already None when teardown() runs below. | |
| if link_to_teardown is not None and self._kotlin_call_bridge is not None: | |
| try: | |
| self._kotlin_call_bridge.onCallEnded(self._active_call_identity) | |
| except Exception as e: | |
| RNS.log(f"Error notifying Kotlin of call end on disable: {e}", RNS.LOG_WARNING) | |
| self._active_call_identity = None | |
| self._call_start_time = None |
Prompt To Fix With AI
This is a comment left during a code review.
Path: python/lxst_modules/call_manager.py
Line: 933-937
Comment:
**Suggested fix for missing Kotlin `onCallEnded` notification in `disable_incoming()`**
Mirror the `hangup()` pattern: notify Kotlin before clearing `_active_call_identity`, inside the lock, so the `CallCoordinator` and incoming-call UI are torn down correctly when the master toggle is flipped mid-call.
```suggestion
# Tear down any active/ringing call so the remote sees a clean drop
link_to_teardown = self.active_call
self.active_call = None
# Notify Kotlin that the call ended (mirrors hangup() pattern).
# Must happen BEFORE clearing _active_call_identity so the identity
# is available for the callback. __link_closed will NOT fire this
# because active_call is already None when teardown() runs below.
if link_to_teardown is not None and self._kotlin_call_bridge is not None:
try:
self._kotlin_call_bridge.onCallEnded(self._active_call_identity)
except Exception as e:
RNS.log(f"Error notifying Kotlin of call end on disable: {e}", RNS.LOG_WARNING)
self._active_call_identity = None
self._call_start_time = None
```
How can I resolve this? If you propose a fix, please make it concise.| # 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() |
There was a problem hiding this comment.
enable_incoming() holds _call_handler_lock across destination.announce()
initialize() (the startup path) calls destination.announce() without holding any lock. enable_incoming() calls it inside _call_handler_lock. If Reticulum's transport layer ever invokes __incoming_link_established synchronously during announce() (e.g., a queued-up link request completing immediately on the same thread), that callback will try to re-acquire the same non-reentrant _call_handler_lock and deadlock. The announce and thread-spawn should happen after the lock is released, consistent with initialize().
| # 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() | |
| # Announce and respawn jobs thread OUTSIDE the lock — consistent with | |
| # initialize() — so that any synchronous link-established callback | |
| # triggered by announce() does not re-enter _call_handler_lock. | |
| if self.destination is not None: | |
| try: | |
| 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 announcing on enable: {e}", RNS.LOG_ERROR) | |
| 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() |
Prompt To Fix With AI
This is a comment left during a code review.
Path: python/lxst_modules/call_manager.py
Line: 972-975
Comment:
**`enable_incoming()` holds `_call_handler_lock` across `destination.announce()`**
`initialize()` (the startup path) calls `destination.announce()` without holding any lock. `enable_incoming()` calls it inside `_call_handler_lock`. If Reticulum's transport layer ever invokes `__incoming_link_established` synchronously during `announce()` (e.g., a queued-up link request completing immediately on the same thread), that callback will try to re-acquire the same non-reentrant `_call_handler_lock` and deadlock. The announce and thread-spawn should happen after the lock is released, consistent with `initialize()`.
```suggestion
# Announce and respawn jobs thread OUTSIDE the lock — consistent with
# initialize() — so that any synchronous link-established callback
# triggered by announce() does not re-enter _call_handler_lock.
if self.destination is not None:
try:
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 announcing on enable: {e}", RNS.LOG_ERROR)
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()
```
How can I resolve this? If you propose a fix, please make it concise.
Summary
Adds two privacy controls to Settings, both targeted at the LXST voice-call subsystem on the
release/v0.10.xline:Privacy → "Calls from contacts only" (default OFF). When ON, inbound LXST link requests whose source identity is not in the Room contacts table are silently dropped. Gate fires inside Python's
__caller_identifiedbeforeSTATUS_RINGINGis sent and before any Kotlin notify, so the originator just times out at ~70s with no "rejected" or "busy" indicator — indistinguishable from "no answer."Voice Call Permissions → "Allow voice calls" (default ON). When OFF, calls
RNS.Transport.deregister_destinationon thelxst.telephonyDestination and nulls the Python reference. No announces, no link-request handling — peers see the destination as nonexistent. Outbound calls still work in both features (matches the existing inbound-only semantic ofblock_unknown_sendersfor messages).Architecture notes
Function<String, Boolean>callback registered atsetupCallManagertime. Kotlin does the lookup via existingannounceDao.getAnnounceByIdentityHash→contactDao.contactExists. Fail-open if Room throws.MODE_MULTI_PROCESSSharedPreferences, mirroringblock_unknown_senders. Migration-safe defaults (Feature 1 = false, Feature 2 = true) preserve existing behaviour for upgraders.active_call.teardown()and Kotlin's existing__link_closedpath.Commits
bf4bd1e0— add the two DataStore prefs + cross-process mirroring (plumbing only)b1bf474b— Python:disable_incoming/enable_incomingonCallManager+_incoming_disabledrace-safety flagf32de27a— Feature 1: contact-only gate wiring (Python→Kotlin callback + silent-drop branch)6f41e349— Feature 2: master toggle wiring (Python API + service-side pref observer)70d256e4— Feature 1 UI in Privacy cardb75b4d44— Feature 2 UI in Voice Call Permissions card (master switch in header + OFF-state banner)3d2d9d53— unit tests for new flows, gates, and UI cards (+ 14 new Python tests)25f14fe9— follow-up: stub the two new SettingsState fields in a sibling ViewModel testTest plan
Unit tests are green across
:app(6065 tests) andpython/test_call_manager.py(104 tests, +14 new). On-device verification still pending — manual two-phone matrix to run:Feature 1 (contacts-only)
IncomingCallActivity, no entry in call history. Originator times out at ~70s indistinguishable from "no answer."__caller_identifiedtime and the call goes through.Feature 2 (master disable)
lxst.telephonyat all; outbound still worksrnpath -tresolves again within secondsCombined
What does NOT change
Telephone/CallCoordinatorstate machines (gate is upstream in Python)block_unknown_senders(orthogonal, messages-only)