Skip to content

feat: LXST call privacy gates (contacts-only + master disable)#930

Open
torlando-tech wants to merge 11 commits into
release/v0.10.xfrom
feat/lxst-call-privacy-gates-v0.10.x
Open

feat: LXST call privacy gates (contacts-only + master disable)#930
torlando-tech wants to merge 11 commits into
release/v0.10.xfrom
feat/lxst-call-privacy-gates-v0.10.x

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

Adds two privacy controls to Settings, both targeted at the LXST voice-call subsystem on the release/v0.10.x line:

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_identified before STATUS_RINGING is 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_destination on the lxst.telephony Destination 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 of block_unknown_senders for messages).

Architecture notes

  • Contact check: synchronous Python→Kotlin Function<String, Boolean> callback registered at setupCallManager time. Kotlin does the lookup via existing announceDao.getAnnounceByIdentityHashcontactDao.contactExists. Fail-open if Room throws.
  • Persistence: dual-write DataStore + MODE_MULTI_PROCESS SharedPreferences, mirroring block_unknown_senders. Migration-safe defaults (Feature 1 = false, Feature 2 = true) preserve existing behaviour for upgraders.
  • Mid-call toggle handling: Feature 2 OFF while ringing or active tears down the live link cleanly via active_call.teardown() and Kotlin's existing __link_closed path.
  • Path revocation on Feature 2 OFF: intentionally not attempted. Reticulum has no clean revoke API; peers' path tables expire on ~14d TTL naturally (matches Sideband behaviour).

Commits

  1. bf4bd1e0 — add the two DataStore prefs + cross-process mirroring (plumbing only)
  2. b1bf474b — Python: disable_incoming/enable_incoming on CallManager + _incoming_disabled race-safety flag
  3. f32de27a — Feature 1: contact-only gate wiring (Python→Kotlin callback + silent-drop branch)
  4. 6f41e349 — Feature 2: master toggle wiring (Python API + service-side pref observer)
  5. 70d256e4 — Feature 1 UI in Privacy card
  6. b75b4d44 — Feature 2 UI in Voice Call Permissions card (master switch in header + OFF-state banner)
  7. 3d2d9d53 — unit tests for new flows, gates, and UI cards (+ 14 new Python tests)
  8. 25f14fe9 — follow-up: stub the two new SettingsState fields in a sibling ViewModel test

Test plan

Unit tests are green across :app (6065 tests) and python/test_call_manager.py (104 tests, +14 new). On-device verification still pending — manual two-phone matrix to run:

Feature 1 (contacts-only)

  • Toggle OFF, non-contact peer can call (existing behaviour)
  • Toggle OFF, contact peer can call (existing behaviour)
  • Toggle ON, contact peer can call (call rings, UI normal)
  • Toggle ON, non-contact peer's call is silently dropped: no ringtone, no notification, no IncomingCallActivity, no entry in call history. Originator times out at ~70s indistinguishable from "no answer."
  • Add a non-contact to contacts mid-dial: gate re-evaluates at __caller_identified time and the call goes through.

Feature 2 (master disable)

  • Toggle ON (default), peer can call as today; outbound also works
  • Toggle OFF, peer cannot reach the device's lxst.telephony at all; outbound still works
  • Toggle ON→OFF mid-ring: ring stops on receiver, originator's link drops
  • Toggle ON→OFF during active call: call hangs up cleanly on both ends
  • Toggle OFF→ON: destination re-registers, announce fires, peer's rnpath -t resolves again within seconds

Combined

  • Both ON, peer is a contact: call works
  • Both ON, peer is not a contact: Feature 1 silent-drops
  • Feature 2 OFF, Feature 1 anything: peer can't reach destination at all (Feature 2 supersedes)

What does NOT change

  • Outbound calls in both features (you can still dial out to anyone)
  • LXMF messaging (separate destination, untouched)
  • LXST-kt Telephone / CallCoordinator state machines (gate is upstream in Python)
  • Existing block_unknown_senders (orthogonal, messages-only)

torlando-agent Bot and others added 8 commits May 17, 2026 16:23
…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>
@github-actions
Copy link
Copy Markdown

❌ Threading Architecture Audit Failed

View Audit Report
Dispatcher Audit Report - Sun May 17 22:04:00 UTC 2026
========================================


═══════════════════════════════════════
1. Checking for runBlocking in Production Code
═══════════════════════════════════════
❌ VIOLATION: runBlocking found: app/src/main/java/com/lxmf/messenger/service/manager/PythonWrapperManager.kt:747:                runBlocking(Dispatchers.IO) { // THREADING: blocking call back into Kotlin

═══════════════════════════════════════
2. Checking for Forbidden Patterns
═══════════════════════════════════════
✅ PASS: No GlobalScope usage found
✅ PASS: No Dispatchers.Unconfined usage found

═══════════════════════════════════════
3. Checking Python Initialization Uses Main.immediate
═══════════════════════════════════════
✅ PASS: Python initialization uses Dispatchers.Main.immediate

═══════════════════════════════════════
4. Summary - CI Optimized Check Complete
═══════════════════════════════════════
ℹ️  INFO: Sections 5-9 skipped for CI performance (non-critical checks)
ℹ️  INFO: All critical threading violations checked (runBlocking, GlobalScope, Unconfined, Python init)
ℹ️  INFO: For comprehensive analysis, run audit-dispatchers-full.sh locally

Please fix the dispatcher violations before merging.

@sentry
Copy link
Copy Markdown
Contributor

sentry Bot commented May 17, 2026

Codecov Report

❌ Patch coverage is 62.74510% with 38 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
python/lxst_modules/call_manager.py 73.80% 22 Missing ⚠️
python/reticulum_wrapper.py 11.11% 16 Missing ⚠️

📢 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>
@github-actions
Copy link
Copy Markdown

❌ Threading Architecture Audit Failed

View Audit Report
Dispatcher Audit Report - Sun May 17 22:14:45 UTC 2026
========================================


═══════════════════════════════════════
1. Checking for runBlocking in Production Code
═══════════════════════════════════════
❌ VIOLATION: runBlocking found: app/src/main/java/com/lxmf/messenger/service/manager/PythonWrapperManager.kt:747:                runBlocking(Dispatchers.IO) { // THREADING: blocking call back into Kotlin

═══════════════════════════════════════
2. Checking for Forbidden Patterns
═══════════════════════════════════════
✅ PASS: No GlobalScope usage found
✅ PASS: No Dispatchers.Unconfined usage found

═══════════════════════════════════════
3. Checking Python Initialization Uses Main.immediate
═══════════════════════════════════════
✅ PASS: Python initialization uses Dispatchers.Main.immediate

═══════════════════════════════════════
4. Summary - CI Optimized Check Complete
═══════════════════════════════════════
ℹ️  INFO: Sections 5-9 skipped for CI performance (non-critical checks)
ℹ️  INFO: All critical threading violations checked (runBlocking, GlobalScope, Unconfined, Python init)
ℹ️  INFO: For comprehensive analysis, run audit-dispatchers-full.sh locally

Please fix the dispatcher violations before merging.

torlando-agent Bot and others added 2 commits May 17, 2026 18:46
…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>
@torlando-tech torlando-tech marked this pull request as ready for review May 17, 2026 23:51
@torlando-tech torlando-tech linked an issue May 17, 2026 that may be closed by this pull request
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 17, 2026

Greptile Summary

This 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 disable_incoming()/enable_incoming().

  • Feature 1 (_should_silently_drop): callback delegation, gate placement before STATUS_RINGING, and fail-open behaviour are all correct. The __caller_identified path has a TOCTOU window where a link established just before the toggle is flipped OFF can slip through.
  • Feature 2 (disable_incoming/enable_incoming): destination deregistration and jobs-thread lifecycle are handled correctly, but mid-call teardown bypasses __link_closed's Kotlin notification because active_call is pre-cleared — Kotlin's CallCoordinator is left in an inconsistent state. The enable_incoming() lock scope also wraps announce() while initialize() does not, introducing an inconsistency in lock discipline.

Confidence Score: 3/5

Not 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

Filename Overview
python/lxst_modules/call_manager.py Core privacy gate logic — two bugs: disable_incoming() doesn't notify Kotlin onCallEnded (UI stuck), and __caller_identified lacks _incoming_disabled check (TOCTOU bypass of master toggle)
python/reticulum_wrapper.py Thin delegation layer exposing disable_lxst_incoming/enable_lxst_incoming — straightforward, correct, fail-safe error handling
app/src/main/java/com/lxmf/messenger/service/manager/PythonWrapperManager.kt Contact-check callback and setLxstIncomingEnabled wired correctly; isCallerInContacts uses runBlocking on IO dispatcher with appropriate fail-open
app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt Dual-write DataStore + MODE_MULTI_PROCESS SharedPreferences; saveAllowVoiceCalls calls startService() which may start the service unintentionally if stopped; rest mirrors existing block_unknown_senders pattern correctly
app/src/main/java/com/lxmf/messenger/service/ReticulumService.kt ACTION_SET_ALLOW_VOICE_CALLS intent handling added; default true guards against stale intents; falls back gracefully when binder not yet initialized
app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt setupContactCheckCallback and applyInitialAllowVoiceCallsState wired cleanly after setupCallManager; setAllowVoiceCalls runtime path correct
app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt New state fields and save/load flows follow existing blockUnknownSenders pattern; optimistic state update plus flow collection consistent with codebase convention
python/test_call_manager.py 14 new tests cover disable/enable plumbing and silent-drop gate; missing coverage for mid-call Kotlin notification gap and __caller_identified TOCTOU window

Sequence Diagram

sequenceDiagram
    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
Loading

Comments Outside Diff (1)

  1. python/lxst_modules/call_manager.py, line 531-534 (link)

    P1 __caller_identified doesn't check _incoming_disabled — TOCTOU bypass of master toggle

    __incoming_link_established has a guard for the TOCTOU window, but __caller_identified has no equivalent check. If a link is established while the toggle is ON and the caller sends IDENTIFY after the toggle is flipped OFF, disable_incoming() will have already cleared active_call = None (nothing to tear down at that moment), and then __caller_identified runs, sets active_call = link, and calls onIncomingCall on Kotlin — the call rings on-device despite the master disable. Adding the same early-exit pattern used in __incoming_link_established closes this window.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: python/lxst_modules/call_manager.py
    Line: 531-534
    
    Comment:
    **`__caller_identified` doesn't check `_incoming_disabled` — TOCTOU bypass of master toggle**
    
    `__incoming_link_established` has a guard for the TOCTOU window, but `__caller_identified` has no equivalent check. If a link is established while the toggle is ON and the caller sends IDENTIFY after the toggle is flipped OFF, `disable_incoming()` will have already cleared `active_call = None` (nothing to tear down at that moment), and then `__caller_identified` runs, sets `active_call = link`, and calls `onIncomingCall` on Kotlin — the call rings on-device despite the master disable. Adding the same early-exit pattern used in `__incoming_link_established` closes this window.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
python/lxst_modules/call_manager.py:930-958
**`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`.

### Issue 2 of 4
python/lxst_modules/call_manager.py:933-937
**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
```

### Issue 3 of 4
python/lxst_modules/call_manager.py:531-534
**`__caller_identified` doesn't check `_incoming_disabled` — TOCTOU bypass of master toggle**

`__incoming_link_established` has a guard for the TOCTOU window, but `__caller_identified` has no equivalent check. If a link is established while the toggle is ON and the caller sends IDENTIFY after the toggle is flipped OFF, `disable_incoming()` will have already cleared `active_call = None` (nothing to tear down at that moment), and then `__caller_identified` runs, sets `active_call = link`, and calls `onIncomingCall` on Kotlin — the call rings on-device despite the master disable. Adding the same early-exit pattern used in `__incoming_link_established` closes this window.

```suggestion
    def __caller_identified(self, link, identity):
        """Handle caller identity verification."""
        with self._call_handler_lock:
            if self._incoming_disabled:
                RNS.log("Caller identified 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:
```

### Issue 4 of 4
python/lxst_modules/call_manager.py:972-975
**`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()
```

Reviews (1): Last reviewed commit: "fix(voice): signal :reticulum via Intent..." | Re-trigger Greptile

Comment on lines +930 to +958
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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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 == NoneFalse, 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.

Fix in Claude Code

Comment on lines +933 to +937
# 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
# 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.

Fix in Claude Code

Comment on lines +972 to +975
# 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()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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().

Suggested change
# 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.

Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add possibility to block voice call spammers

1 participant