From 4e2af1373bd24f4f86c47c284a334314450d65d9 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 20:30:28 -0400 Subject: [PATCH 1/2] fix(callkit): add voip background mode + mic permission so incoming calls work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incoming voice calls were unusable: the CallKit screen flashed the caller's name, auto-dismissed, reappeared as 'Unknown', and couldn't be answered. Root cause: the app's UIBackgroundModes was missing 'voip', so callservicesd DENIED creation of the CallKit call source and reset the provider (providerDidReset → CallManager.handleCallKitReset → currentCallUUID=nil + callState=.ended). Found via the simulator's com.apple.calls.callkit unified log ('Denying creation of CXXPCCallSource … Not accepting connection'); the device diag.log only showed the effect (providerDidReset). - Info.plist: add 'voip' (+ 'audio') to UIBackgroundModes; add NSMicrophoneUsageDescription (the 'no audio' half — mic capture needs it). 'voip' is just an Info.plist key, no provisioning capability required. - Tests/interop/voice_caller.py: headless LXST caller (Sideband's ReticulumTelephone, wire-identical) that dials the sim's telephony dest to reproduce the incoming-call/CallKit path on the simulator — where CallKit runs, so the bug + the framework deny reason are observable (unlike on device). Verified on sim: with the fix, reportIncomingCall succeeds + callservicesd 'Created CXXPCCallSource' — no deny, no providerDidReset. Co-Authored-By: Claude Opus 4.8 --- Sources/ColumbaApp/Resources/Info.plist | 4 ++ Tests/interop/voice_caller.py | 76 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 Tests/interop/voice_caller.py diff --git a/Sources/ColumbaApp/Resources/Info.plist b/Sources/ColumbaApp/Resources/Info.plist index 37ebab2e..6d8b7663 100644 --- a/Sources/ColumbaApp/Resources/Info.plist +++ b/Sources/ColumbaApp/Resources/Info.plist @@ -29,6 +29,8 @@ Columba shares your location with contacts you explicitly choose, so they can see where you are during an active sharing session. NSLocationAlwaysAndWhenInUseUsageDescription Columba keeps sharing your location with the contacts you chose even when the app is in the background, so a sharing session you started doesn't silently stop. You can stop sharing at any time from the conversation or from Settings. + NSMicrophoneUsageDescription + Columba uses the microphone for encrypted voice calls over the mesh network. UIAppFonts materialdesignicons.ttf @@ -42,9 +44,11 @@ UIBackgroundModes + audio bluetooth-central bluetooth-peripheral location + voip UILaunchScreen diff --git a/Tests/interop/voice_caller.py b/Tests/interop/voice_caller.py new file mode 100644 index 00000000..dc323a86 --- /dev/null +++ b/Tests/interop/voice_caller.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Headless LXST voice caller — dials the iOS Columba sim's telephony +destination so its incoming-call / CallKit path fires, for debugging the +"call screen dismisses + reappears as Unknown" bug on the sim (full log access). + +Uses Sideband's own ReticulumTelephone (which wraps LXST.Telephone), so it's +wire-identical to a Sideband user placing the call. Joins the host's shared +RNS instance (lxmd) exactly like the interop peer, so it shares Columba's +transport. + +Usage: + PYTHONPATH unneeded — paths injected below. + python3 voice_caller.py [ring_seconds] +""" +import sys, os, time, threading + +sys.path.insert(0, os.path.expanduser("~/repos/LXST")) +sys.path.insert(0, os.path.expanduser("~/repos/Sideband")) + +import RNS # noqa: E402 +from sbapp.sideband.voice import ReticulumTelephone # noqa: E402 + +COLUMBA_ID_HEX = sys.argv[1] if len(sys.argv) > 1 else "695f23533fe3547183f1b550d8552ae8" +RING_SECONDS = int(sys.argv[2]) if len(sys.argv) > 2 else 25 + + +def main(): + RNS.Reticulum() # connect to the shared lxmd instance (share_instance=Yes) + identity = RNS.Identity() + print(f"[caller] my identity={RNS.prettyhexrep(identity.hash)}", flush=True) + + phone = ReticulumTelephone(identity, owner=None) + # NB: don't call phone.start() — its run() loop is Sideband hw-state polling + # and isn't present on this build; the wrapped LXST.Telephone drives the + # call protocol itself. We just need to keep the process alive. + time.sleep(1.0) + + phone.announce() # so Columba can resolve/path us (matches the 'correct name' case) + print("[caller] announced own telephony", flush=True) + + id_bytes = bytes.fromhex(COLUMBA_ID_HEX) + tel_dest = RNS.Destination.hash_from_name_and_identity("lxst.telephony", id_bytes) + print(f"[caller] Columba telephony dest = {tel_dest.hex()}", flush=True) + + if not RNS.Transport.has_path(tel_dest): + RNS.Transport.request_path(tel_dest) + deadline = time.time() + 30 + while not RNS.Transport.has_path(tel_dest) and time.time() < deadline: + time.sleep(0.5) + if not RNS.Transport.has_path(tel_dest): + print("[caller] NO PATH to Columba telephony after 30s — is the sim " + "announcing lxst.telephony + bridged through lxmd? aborting.", flush=True) + return 1 + print(f"[caller] path to telephony resolved ({RNS.Transport.hops_to(tel_dest)} hops); DIALING", flush=True) + + result = phone.dial(id_bytes) + print(f"[caller] dial() -> {result}", flush=True) + + # state: 0=AVAILABLE 1=CONNECTING 2=RINGING 3=IN_CALL (is_* are @property, no parens) + _names = {0: "AVAILABLE", 1: "CONNECTING", 2: "RINGING", 3: "IN_CALL"} + for i in range(RING_SECONDS): + st = phone.state + print(f"[caller] t={i:>2}s state={st}({_names.get(st, '?')})", flush=True) + time.sleep(1.0) + + print("[caller] hanging up", flush=True) + try: + phone.hangup() + except Exception as e: + print(f"[caller] hangup error: {e}", flush=True) + time.sleep(2.0) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From a89228acd6e5a0d7bc5ae962892626ebc4ec0633 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 21:07:49 -0400 Subject: [PATCH 2/2] address greptile review feedback (greploop iteration 1) - voice_caller.py: drop unused 'threading' import; make LXST/Sideband checkout paths overridable via LXST_SRC/SIDEBAND_SRC (SIDEBAND_SRC matches conftest) with a clear error if missing, instead of hardcoded ~/repos paths. Co-Authored-By: Claude Opus 4.8 --- Tests/interop/voice_caller.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Tests/interop/voice_caller.py b/Tests/interop/voice_caller.py index dc323a86..cbc5f185 100644 --- a/Tests/interop/voice_caller.py +++ b/Tests/interop/voice_caller.py @@ -12,10 +12,18 @@ PYTHONPATH unneeded — paths injected below. python3 voice_caller.py [ring_seconds] """ -import sys, os, time, threading - -sys.path.insert(0, os.path.expanduser("~/repos/LXST")) -sys.path.insert(0, os.path.expanduser("~/repos/Sideband")) +import sys, os, time + +# LXST + Sideband are sibling checkouts; override with LXST_SRC / SIDEBAND_SRC +# (SIDEBAND_SRC matches the interop conftest). Default to ~/repos/. +for _name, _env, _default in ( + ("LXST", "LXST_SRC", "~/repos/LXST"), + ("Sideband", "SIDEBAND_SRC", "~/repos/Sideband"), +): + _path = os.environ.get(_env, os.path.expanduser(_default)) + if not os.path.isdir(_path): + sys.exit(f"{_name} checkout not found at {_path} — set {_env} to its path.") + sys.path.insert(0, _path) import RNS # noqa: E402 from sbapp.sideband.voice import ReticulumTelephone # noqa: E402