VulpesOS/tundra/runtime/radio-control.py
OverSu 1311bed5e9 Release Preview 20 shared desktop, Android and Sargo updates
Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source.

Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
2026-10-13 00:19:45 +02:00

288 lines
12 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Small, allow-listed oFono broker used by the native Tundra session.
The Gaia process never receives access to the system D-Bus modem service. The
root service accepts one JSON request per Unix-socket connection and invokes
only fixed oFono methods with validated values. This keeps the diagnostic
image useful while avoiding a general purpose command bridge.
"""
import json
import importlib.util
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
import time
import threading
from concurrent.futures import ThreadPoolExecutor
SOCKET = Path('/run/tundra-radio/radio.sock')
MODEM = '/ril_0'
NUMBER = re.compile(r'^[0-9+#*(), .-]{3,32}$')
VOICE = re.compile(r'^/ril_0/voicecall[0-9]+$')
def bus(*args):
"""Call one fixed system-bus method and return busctl's JSON data."""
result = subprocess.run(
['/usr/bin/busctl', '--system', '--json=short', 'call', *args],
capture_output=True, text=True, timeout=15, check=False,
)
if result.returncode:
# Keep modem errors distinct from transport failures, without returning
# command lines, phone numbers or subscriber identifiers to Gaia.
detail = result.stderr.lower()
if 'not registered' in detail or 'notregistered' in detail:
raise RuntimeError('RADIO_NOT_REGISTERED')
if 'in progress' in detail or 'inprogress' in detail or 'busy' in detail:
raise RuntimeError('RADIO_BUSY')
if 'not available' in detail or 'notavailable' in detail:
raise RuntimeError('RADIO_NOT_READY')
raise RuntimeError('RADIO_REQUEST_FAILED')
# Successful void D-Bus methods (Hangup, Answer) produce no JSON in busctl.
if not result.stdout.strip():
return None
try:
payload = json.loads(result.stdout)
return payload.get('data')
except (ValueError, AttributeError):
raise RuntimeError('INVALID_OFONO_REPLY')
def request(value):
if not isinstance(value, dict) or value.get('version') != 1:
raise ValueError('INVALID_REQUEST')
operation = value.get('operation')
if operation == 'deviceSettings':
spec = importlib.util.spec_from_file_location('mobile_settings', Path(__file__).with_name('mobile-settings.py'))
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
return module.device_request(value)
if operation == 'mobileSettings':
spec = importlib.util.spec_from_file_location('mobile_settings',Path(__file__).with_name('mobile-settings.py'))
module = importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
return module.request(value)
if operation in ('incomingMessages','ackMessages','messageState'):
spec = importlib.util.spec_from_file_location('inbox',Path(__file__).with_name('sms-inbox.py'))
inbox = importlib.util.module_from_spec(spec);spec.loader.exec_module(inbox)
if operation == 'messageState':
path = value.get('path', '')
if not isinstance(path,str) or not re.fullmatch(r'/ril_0/message[0-9a-fA-F_]+',path):
raise ValueError('INVALID_SMS_PATH')
state = inbox.outgoing_state(path)
if state is None:
try:
props = bus('org.ofono',path,'org.ofono.Message','GetProperties')[0]
state = props.get('State',{}).get('data','unknown')
except RuntimeError: state = 'unknown'
return {'state':state if state in ('pending','sent','failed') else 'unknown'}
return inbox.pending() if operation == 'incomingMessages' else inbox.acknowledge(value.get('ids'))
if operation == 'calls':
data = bus('org.ofono', MODEM, 'org.ofono.VoiceCallManager', 'GetCalls')
calls = []
for path, properties in data[0]:
if not VOICE.fullmatch(path):
continue
def field(name):
item = properties.get(name, {})
return item.get('data') if isinstance(item, dict) else item
state = field('State')
if state not in ('dialing', 'alerting', 'active', 'held', 'incoming', 'waiting', 'disconnected'):
continue
calls.append({'path': path, 'state': state,
'number': str(field('LineIdentification') or '')[:80]})
return calls
if operation == 'status':
return {
'modem': bus('org.ofono', MODEM, 'org.ofono.Modem', 'GetProperties'),
'registration': bus('org.ofono', MODEM, 'org.ofono.NetworkRegistration', 'GetProperties'),
}
if operation == 'dial':
number = str(value.get('number', '')).strip()
if not NUMBER.fullmatch(number):
raise ValueError('INVALID_NUMBER')
path = bus('org.ofono', MODEM, 'org.ofono.VoiceCallManager',
'Dial', 'ss', number, 'default')
return {'path': path[0] if isinstance(path, list) and path else path,
'startedAt': time.time()}
if operation in ('hangup', 'answer'):
path = value.get('path', '')
if not isinstance(path, str) or not VOICE.fullmatch(path):
raise ValueError('INVALID_CALL')
bus('org.ofono', path, 'org.ofono.VoiceCall',
'Hangup' if operation == 'hangup' else 'Answer')
return {'path': path}
if operation == 'sms':
number = str(value.get('number', '')).strip()
body = value.get('body', '')
if not NUMBER.fullmatch(number) or not isinstance(body, str) or not body or len(body) > 1600:
raise ValueError('INVALID_SMS')
registration = bus('org.ofono', MODEM, 'org.ofono.NetworkRegistration', 'GetProperties')[0]
if registration.get('Status',{}).get('data') not in ('registered','roaming'):
raise RuntimeError('RADIO_NOT_REGISTERED')
result = bus('org.ofono', MODEM, 'org.ofono.MessageManager',
'SendMessage', 'ss', number, body)
return {'path': result[0] if isinstance(result, list) and result else result,
'state':'pending', 'number': number, 'bodyLength': len(body), 'submittedAt': time.time()}
raise ValueError('NOT_SUPPORTED')
def send_response(conn, response):
try:
conn.sendall((json.dumps(response, separators=(',', ':')) + '\n').encode())
except OSError:
# Closing an app must not bring down the broker or retry an operation.
pass
def error_response(error):
# TimeoutExpired includes command arguments, potentially an SMS body.
reason = 'RADIO_TIMEOUT' if isinstance(error, subprocess.TimeoutExpired) else (
'RADIO_ERROR' if isinstance(error, OSError) else str(error) or 'RADIO_ERROR')
return {'ok': False, 'error': reason}
def execute_request(conn, value):
try:
response = {'ok': True, 'result': request(value)}
except (ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as error:
response = error_response(error)
send_response(conn, response)
class SettingsWorker:
"""Serialize Settings transactions without blocking the voice/SMS broker."""
def __init__(self):
self.pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix='settings')
self.slots = threading.BoundedSemaphore(8)
def submit(self, conn, value):
if not self.slots.acquire(blocking=False):
return False
try:
self.pool.submit(self.run, conn, value, time.monotonic())
except RuntimeError:
self.slots.release()
return False
return True
def run(self, conn, value, received):
try:
with conn:
# Do not apply a stale preference after its queued client has
# timed out. An already-started APN rollback still runs to completion.
if time.monotonic() - received > 15:
send_response(conn, {'ok':False, 'error':'RADIO_BUSY'})
else:
execute_request(conn, value)
finally:
self.slots.release()
def close(self):
self.pool.shutdown(wait=True)
def handle_connection(conn, settings_worker=None):
conn.settimeout(20)
line = b''
try:
while not line.endswith(b'\n') and len(line) <= 8192:
chunk = conn.recv(min(1024, 8193 - len(line)))
if not chunk:
return False
line += chunk
if len(line) > 8192:
raise ValueError('INVALID_REQUEST')
value = json.loads(line.decode())
except (socket.timeout, ConnectionError):
return False
except (ValueError, OSError) as error:
send_response(conn, error_response(error))
return False
if settings_worker and isinstance(value, dict) and value.get('operation') in ('mobileSettings','deviceSettings'):
if settings_worker.submit(conn, value):
return True # The worker now owns and closes this connection.
send_response(conn, {'ok':False, 'error':'RADIO_BUSY'})
else:
execute_request(conn, value)
return False
def network_clock_loop(stop, worker):
# Use the same executor as manual date/time writes: disabling automatic
# time cannot race a delayed network update. Voice/SMS keep their own loop.
spec = importlib.util.spec_from_file_location('clock_settings', Path(__file__).with_name('mobile-settings.py'))
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
def tick():
try:
module.sync_network_clock()
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
pass # No SIM or no NITZ yet; retry after registration.
while not stop.is_set():
worker.pool.submit(tick)
stop.wait(60)
def serve():
SOCKET.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
try:
SOCKET.unlink()
except FileNotFoundError:
pass
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
server.bind(str(SOCKET))
os.chmod(SOCKET, 0o660)
try:
import grp
os.chown(SOCKET, 0, grp.getgrnam('droidian').gr_gid)
except (KeyError, OSError):
pass
server.listen(8)
worker = SettingsWorker()
clock_stop = threading.Event()
clock_thread = threading.Thread(target=network_clock_loop, args=(clock_stop, worker), daemon=True)
clock_thread.start()
try:
while True:
conn, _ = server.accept()
if not handle_connection(conn, worker):
conn.close()
finally:
clock_stop.set()
clock_thread.join()
worker.close()
def client(envelope=False):
raw = sys.stdin.read(8192)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
value = json.loads(raw)
# Seven bounded writes plus their rollback/readback can exceed 20 s.
slow_settings = isinstance(value, dict) and value.get('operation') == 'mobileSettings' and value.get('action') in ('editApn','calls','setCallWaiting','setCallerId','forwarding','setForwarding')
sock.settimeout(100 if slow_settings else 20)
sock.connect(str(SOCKET))
sock.sendall(raw.encode() + (b'' if raw.endswith('\n') else b'\n'))
data = b''
while not data.endswith(b'\n') and len(data) < 65536:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
result = json.loads(data.decode())
if envelope:
print(json.dumps(result, separators=(',', ':')))
return
if not result.get('ok'):
raise RuntimeError(result.get('error', 'RADIO_ERROR'))
print(json.dumps(result.get('result'), separators=(',', ':')))
if __name__ == '__main__':
if '--server' in sys.argv:
serve()
elif '--client' in sys.argv or '--client-envelope' in sys.argv:
client(envelope='--client-envelope' in sys.argv)
else:
raise SystemExit('use --server or --client')