VulpesOS/tundra/runtime/mobile-settings.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

316 lines
16 KiB
Python

# SPDX-License-Identifier: MPL-2.0
"""Settings-only oFono queries. Never return IMSI, PIN retries or device serials."""
import subprocess
import json
import re
import time
from pathlib import Path
CLOCK_CONFIG = Path("/var/lib/tundra/clock/settings.json")
def clock_automatic():
try:
return json.loads(CLOCK_CONFIG.read_text())["automatic"] is True
except FileNotFoundError:
return True
def save_clock_automatic(enabled):
CLOCK_CONFIG.parent.mkdir(parents=True, exist_ok=True)
temporary = CLOCK_CONFIG.with_suffix(".tmp")
temporary.write_text(json.dumps({"automatic": enabled}) + "\n")
temporary.chmod(0o600)
temporary.replace(CLOCK_CONFIG)
def network_time():
result = subprocess.run(["/usr/bin/busctl", "--system", "--timeout=5", "--json=short", "call",
"org.ofono", "/ril_0", "org.ofono.NetworkTime", "GetNetworkTime"], capture_output=True, text=True, timeout=7)
if result.returncode:
raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
values = {key: value.get("data") for key, value in json.loads(result.stdout)["data"][0].items()}
utc, received = values.get("UTC"), values.get("Received")
now = time.monotonic()
if type(utc) is not int or type(received) is not int or not 0 <= received <= now:
raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
seconds = utc + now - received
if not 946684800 <= seconds <= 4102444800:
raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
return seconds
def sync_network_clock():
if not clock_automatic():
return
seconds = network_time()
if abs(time.time() - seconds) > 2:
# oFono timestamps use CLOCK_MONOTONIC, not wall time or boot time.
time.clock_settime(time.CLOCK_REALTIME, seconds)
def properties(interface, timeout=5):
result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout='+str(timeout), '--json=short',
'call', 'org.ofono', '/ril_0', 'org.ofono.' + interface,
'GetProperties'], capture_output=True, text=True, timeout=timeout+2)
if result.returncode:
raise RuntimeError('RADIO_NOT_READY')
return {key: value.get('data') for key, value in json.loads(result.stdout)['data'][0].items()}
def request(value):
if value.get('action') in ('calls','setCallWaiting','setCallerId','forwarding','setForwarding'):
return supplementary_request(value)
if value.get('action') in ('radioMode', 'setRadioMode', 'apns', 'editApn'):
return connection_request(value)
if set(value) - {'version', 'operation', 'action', 'enabled'}:
raise ValueError('INVALID_REQUEST')
action = value.get('action')
if action not in ('deliveryReports','dataEnabled','roaming') and 'enabled' in value:
raise ValueError('INVALID_REQUEST')
if action in ('network', 'dataEnabled', 'roaming'):
if action != 'network':
if type(value.get('enabled')) is not bool:
raise ValueError('INVALID_REQUEST')
name = 'Powered' if action == 'dataEnabled' else 'RoamingAllowed'
set_property('ConnectionManager', name, 'b', value['enabled'])
data = properties('ConnectionManager')
if action != 'network' and data.get(name) is not value['enabled']:
raise RuntimeError('RADIO_REQUEST_FAILED')
network = properties('NetworkRegistration')
return {'enabled':data.get('Powered'), 'roaming':data.get('RoamingAllowed'),
'attached':data.get('Attached'), 'status':network.get('Status'),
'operator':network.get('Name'), 'technology':network.get('Technology'), 'mode':network.get('Mode')}
if action == 'sim':
sim = properties('SimManager')
modem = properties('Modem')
# The slot key stays private to Gaia Settings; subscriber identity is not needed.
return {'radioEnabled':modem.get('Powered') is True and modem.get('Online') is True,
'present': sim.get('Present') is True, 'id': sim.get('CardIdentifier'),
'operator': sim.get('ServiceProviderName', ''), 'pin': sim.get('PinRequired'),
'fixedDialing': sim.get('FixedDialing'), 'pinEnabled':'pin' in sim.get('LockedPins', []), 'numbers': sim.get('SubscriberNumbers', [])}
if action in ('smsc', 'messages'):
props = properties('MessageManager')
return {'address': props.get('ServiceCenterAddress'), 'deliveryReports': props.get('UseDeliveryReports')}
if action == 'deliveryReports':
if type(value.get('enabled')) is not bool:
raise ValueError('INVALID_REQUEST')
result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout=5', 'call',
'org.ofono', '/ril_0', 'org.ofono.MessageManager', 'SetProperty',
'sv', 'UseDeliveryReports', 'b', str(value['enabled']).lower()],
capture_output=True, text=True, timeout=7)
if result.returncode:
raise RuntimeError('RADIO_REQUEST_FAILED')
props = properties('MessageManager')
if props.get('UseDeliveryReports') is not value['enabled']:
raise RuntimeError('RADIO_REQUEST_FAILED')
return {'deliveryReports': props['UseDeliveryReports']}
raise ValueError('NOT_SUPPORTED')
def set_property(interface, name, signature, value, timeout=5):
result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout='+str(timeout), 'call',
'org.ofono', '/ril_0', 'org.ofono.' + interface,
'SetProperty', 'sv', name, signature, str(value).lower() if signature == 'b' else value],
capture_output=True, text=True, timeout=timeout+2)
if result.returncode:
raise RuntimeError('RADIO_REQUEST_FAILED')
def device_request(value):
"""Clock service only; callers cannot supply commands, paths or D-Bus members."""
import math
from pathlib import Path
allowed = {'version', 'operation', 'action', 'timezone', 'milliseconds', 'enabled'}
if set(value) - allowed:
raise ValueError('INVALID_REQUEST')
action = value.get('action')
extras = {'setTimezone': {'timezone'}, 'setTime': {'milliseconds'}, 'clock': set(), 'setAutomatic': {'enabled'}}
if action in extras and set(value) - {'version', 'operation', 'action'} - extras[action]:
raise ValueError('INVALID_REQUEST')
if action == 'setTimezone':
zone = value.get('timezone')
if not isinstance(zone, str) or zone.startswith('/') or '..' in zone.split('/') or len(zone) > 80:
raise ValueError('INVALID_REQUEST')
root = Path('/usr/share/zoneinfo').resolve()
target = (root / zone).resolve()
if not target.is_relative_to(root) or not target.is_file() or target.read_bytes()[:4] != b'TZif':
raise ValueError('INVALID_REQUEST')
args = ['set-timezone', zone]
elif action == 'setAutomatic':
if type(value.get('enabled')) is not bool:
raise ValueError('INVALID_REQUEST')
save_clock_automatic(value['enabled'])
if value['enabled']:
try:
sync_network_clock()
except RuntimeError:
pass # Keep the requested mode while waiting for the network.
args = None
elif action == 'setTime':
ms = value.get('milliseconds')
if type(ms) not in (int, float) or not math.isfinite(ms) or not 946684800000 <= ms <= 4102444800000:
raise ValueError('INVALID_REQUEST')
if clock_automatic():
raise RuntimeError('AUTOMATIC_TIME_ENABLED')
args = ['set-time', '@' + str(int(ms / 1000))]
elif action == 'clock':
args = None
else:
raise ValueError('NOT_SUPPORTED')
if args:
result = subprocess.run(['/usr/bin/timedatectl', *args], capture_output=True, text=True, timeout=10)
if result.returncode:
raise RuntimeError('CLOCK_REQUEST_FAILED')
result = subprocess.run(['/usr/bin/timedatectl', 'show', '-p', 'Timezone', '-p', 'NTP', '-p', 'CanNTP'],
capture_output=True, text=True, timeout=5)
if result.returncode:
raise RuntimeError('CLOCK_UNAVAILABLE')
state = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
if action == 'setTimezone' and state.get('Timezone') != value['timezone']:
raise RuntimeError('CLOCK_REQUEST_FAILED')
return {'timezone':state.get('Timezone'), 'automatic':clock_automatic(), 'automaticAvailable':True}
# Only existing user-facing Internet/MMS contexts are editable. IMS is owned by
# the telephony stack and must never be rewritten by the Settings form.
APN_FIELDS = {'apn':'AccessPointName', 'user':'Username', 'password':'Password',
'protocol':'Protocol', 'authtype':'AuthenticationMethod',
'mmsc':'MessageCenter', 'mmsproxy':'MessageProxy'}
def contexts():
result = subprocess.run(['/usr/bin/busctl','--system','--timeout=5','--json=short',
'call','org.ofono','/ril_0','org.ofono.ConnectionManager','GetContexts'],
capture_output=True,text=True,timeout=7)
if result.returncode:
raise RuntimeError('RADIO_NOT_READY')
return {path: {key:item['data'] for key,item in props.items()}
for path,props in json.loads(result.stdout)['data'][0]
if re.fullmatch(r'/ril_0/context[0-9]+',path)
and props.get('Type',{}).get('data') in ('internet','mms')}
def apn_view(path, props):
fields={key:str(props.get(name,'')) for key,name in APN_FIELDS.items() if key!='password'}
return {'id':path,'type':'default' if props['Type']=='internet' else 'mms',
'active':props.get('Active') is True, 'hasPassword':bool(props.get('Password')),
'fields':fields}
def set_context(path, name, value):
result = subprocess.run(['/usr/bin/busctl','--system','--timeout=3','call','org.ofono',path,
'org.ofono.ConnectionContext','SetProperty','sv',name,'s',value],
capture_output=True,text=True,timeout=4)
if result.returncode:
raise RuntimeError('APN_WRITE_FAILED')
def connection_request(value):
action=value.get('action')
extras={'radioMode':set(),'setRadioMode':{'mode'},'apns':set(),'editApn':{'id','fields'}}
if action not in extras or set(value)-{'version','operation','action'}-extras[action]:
raise ValueError('INVALID_REQUEST')
if action in ('radioMode','setRadioMode'):
before=properties('RadioSettings')
available=[mode for mode in before.get('AvailableTechnologies',[]) if mode in ('gsm','umts','lte')]
if action=='setRadioMode':
mode=value.get('mode')
if mode not in ['any',*available]:
raise ValueError('INVALID_REQUEST')
if mode != before.get('TechnologyPreference'):
set_property('RadioSettings','TechnologyPreference','s',mode)
before=properties('RadioSettings')
if before.get('TechnologyPreference')!=mode:
raise RuntimeError('RADIO_REQUEST_FAILED')
return {'mode':before.get('TechnologyPreference'),'available':['any',*available]}
if action=='apns':
return [apn_view(path,props) for path,props in contexts().items()]
path=value.get('id'); fields=value.get('fields')
if not isinstance(path,str) or not re.fullmatch(r'/ril_0/context[0-9]+',path):
raise ValueError('INVALID_REQUEST')
if not isinstance(fields,dict) or not fields or set(fields)-set(APN_FIELDS):
raise ValueError('INVALID_REQUEST')
for key,field in fields.items():
if not isinstance(field,str) or len(field.encode())>512 or any(ord(c)<32 for c in field):
raise ValueError('INVALID_REQUEST')
if key=='apn' and not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,99}',field):
raise ValueError('INVALID_REQUEST')
if key=='protocol' and field not in ('ip','ipv6','dual'):
raise ValueError('INVALID_REQUEST')
if key=='authtype' and field not in ('none','pap','chap','any'):
raise ValueError('INVALID_REQUEST')
if key=='mmsc' and field:
from urllib.parse import urlsplit
url=urlsplit(field)
if url.scheme not in ('http','https') or not url.hostname or url.username or url.password:
raise ValueError('INVALID_REQUEST')
if key=='mmsproxy' and field and not re.fullmatch(r'[A-Za-z0-9.\[\]:-]{1,255}',field):
raise ValueError('INVALID_REQUEST')
before=contexts().get(path)
if before is None:
raise ValueError('APN_NOT_FOUND')
if before['Type']!='mms' and any(k in fields for k in ('mmsc','mmsproxy')):
raise ValueError('INVALID_REQUEST')
changes={APN_FIELDS[key]:field for key,field in fields.items() if before.get(APN_FIELDS[key],'')!=field}
if not changes:
return apn_view(path,before)
if before.get('Active'):
raise RuntimeError('APN_IN_USE')
changed=[]
try:
for name,field in changes.items():
# Include the attempted property: a D-Bus timeout may arrive after
# the modem has already applied it.
changed.append(name)
set_context(path,name,field)
after=contexts().get(path)
if after is None or any(after.get(name,'')!=field for name,field in changes.items()):
raise RuntimeError('APN_WRITE_FAILED')
return apn_view(path,after)
except (RuntimeError,OSError,subprocess.TimeoutExpired):
failed=False
for name in reversed(changed):
try:set_context(path,name,str(before.get(name,'')))
except (RuntimeError,OSError,subprocess.TimeoutExpired):failed=True
try:restored=contexts().get(path)
except (RuntimeError,OSError,subprocess.TimeoutExpired):restored=None
if failed or restored is None or any(restored.get(name,'')!=before.get(name,'') for name in changed):
raise RuntimeError('APN_ROLLBACK_FAILED') from None
raise RuntimeError('APN_WRITE_FAILED') from None
FORWARDING = {'unconditional':'VoiceUnconditional', 'busy':'VoiceBusy',
'noReply':'VoiceNoReply', 'notReachable':'VoiceNotReachable'}
def supplementary_request(value):
action=value.get('action')
extras={'calls':set(), 'setCallWaiting':{'enabled'}, 'setCallerId':{'mode'},
'forwarding':set(), 'setForwarding':{'reason','enabled','number'}}
if action not in extras or set(value)-{'version','operation','action'}-extras[action]:
raise ValueError('INVALID_REQUEST')
if action in ('calls','setCallWaiting','setCallerId'):
name=None
if action=='setCallWaiting':
if type(value.get('enabled')) is not bool:raise ValueError('INVALID_REQUEST')
name='VoiceCallWaiting'; desired='enabled' if value['enabled'] else 'disabled'
elif action=='setCallerId':
desired=value.get('mode')
if desired not in ('default','enabled','disabled'):raise ValueError('INVALID_REQUEST')
name='HideCallerId'
if name:set_property('CallSettings',name,'s',desired,timeout=20)
# Network supplementary-service queries take several seconds on this RIL.
props=properties('CallSettings',timeout=20)
if name and props.get(name)!=desired:raise RuntimeError('RADIO_READBACK_FAILED')
return {key:props.get(key) for key in ('HideCallerId','VoiceCallWaiting')}
if action=='setForwarding':
name=FORWARDING.get(value.get('reason'))
number=value.get('number','');enabled=value.get('enabled')
if name is None or type(enabled) is not bool or not isinstance(number,str):raise ValueError('INVALID_REQUEST')
if enabled and not re.fullmatch(r'\+?[0-9]{1,20}',number):raise ValueError('INVALID_REQUEST')
if not enabled and number:raise ValueError('INVALID_REQUEST')
desired=number if enabled else ''
before=properties('CallForwarding',timeout=20)
if name not in before:raise RuntimeError('RADIO_NOT_READY')
if before[name]!=desired:set_property('CallForwarding',name,'s',desired,timeout=20)
props=properties('CallForwarding',timeout=20)
if action=='setForwarding' and props.get(name)!=desired:raise RuntimeError('RADIO_READBACK_FAILED')
return {key:props.get(member) for key,member in FORWARDING.items()}