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.
This commit is contained in:
OverSu 2026-10-13 00:19:45 +02:00
commit 1311bed5e9
170 changed files with 19698 additions and 2495 deletions

View file

@ -0,0 +1,326 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Durable MMS queue, oFono bearer and WAP-push agent. No Wi-Fi fallback."""
import base64
import contextlib
import hashlib
import http.client
import ipaddress
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
import threading
import time
from urllib.parse import urlsplit
import uuid
ROOT = Path('/var/lib/tundra/mms')
SOCKET = Path('/run/tundra-mms/mms.sock')
CODEC = '/usr/libexec/tundra-mms-codec'
LIMIT = 600 * 1024
WIRE_LIMIT = 2 * LIMIT
LOCK = threading.RLock()
MODEM = '/ril_0'
def save(path, data):
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
temp = path.with_suffix('.tmp')
with open(temp, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as stream:
json.dump(data, stream); stream.flush(); os.fsync(stream.fileno())
temp.replace(path)
def records():
path = ROOT/'queue.json'
return json.loads(path.read_text()) if path.exists() else []
def codec(operation, **data):
result = subprocess.run([CODEC], input=json.dumps({'operation': operation, **data}),
text=True, capture_output=True, timeout=10, env={**os.environ, 'GOMEMLIMIT': '64MiB'})
if result.returncode: raise ValueError('MMS_CODEC_ERROR')
value = json.loads(result.stdout)
if 'error' in value: raise ValueError(value['error'])
return value
def validate_send(value):
recipients = value.get('recipients')
if not isinstance(recipients,list) or not 1 <= len(recipients) <= 10 or any(
not isinstance(n,str) or not re.fullmatch(r'\+?[0-9]{3,20}',n) for n in recipients):
raise ValueError('INVALID_NUMBER')
parts = value.get('parts')
if not isinstance(parts,list) or not 1 <= len(parts) <= 20: raise ValueError('INVALID_MMS')
total = 0
for part in parts:
if not isinstance(part,dict): raise ValueError('INVALID_MMS')
if not isinstance(part.get('id'),str) or not re.fullmatch(r'[\w .@+-]{1,100}',part['id']): raise ValueError('INVALID_MMS')
if not isinstance(part.get('type'),str) or not re.fullmatch(r'[a-zA-Z0-9.+-]+/[a-zA-Z0-9.+-]+',part['type']): raise ValueError('INVALID_MMS')
try: total += len(base64.b64decode(part['data'],validate=True))
except (ValueError,TypeError,KeyError): raise ValueError('INVALID_MMS')
if total > LIMIT: raise ValueError('MMS_TOO_LARGE')
subject=value.get('subject','')
if not isinstance(subject,str) or len(subject)>200: raise ValueError('INVALID_MMS')
return {'recipients':recipients,'parts':parts,'subject':subject}
def request(value):
if not isinstance(value,dict) or value.get('version')!=1: raise ValueError('INVALID_REQUEST')
op=value.get('operation')
with LOCK:
rows=records()
if op=='send':
message=validate_send(value)
# Encode before accepting the queue entry so malformed content isn't queued.
encoded=codec('encode',**message)
identity=str(uuid.uuid4())
rows.append({'id':identity,'direction':'outgoing','state':'pending','encoded':encoded,'created':time.time()})
save(ROOT/'queue.json',rows)
return {'path':identity,'state':'pending'}
if op=='state':
row=next((r for r in rows if r['id']==value.get('path')),None)
return {k:row[k] for k in ['state','error'] if k in row} if row else {'state':'unknown'}
if op=='incoming':
return [r['message'] for r in rows if r['direction']=='incoming' and r['state']=='received'][:1]
if op=='ack':
ids=value.get('ids')
if not isinstance(ids,list) or len(ids)>100: raise ValueError('INVALID_REQUEST')
for row in rows:
if row['direction']=='incoming' and row['id'] in ids and row['state']=='received':
row['state']='stored';row.pop('message',None)
save(ROOT/'queue.json',rows);return True
raise ValueError('NOT_SUPPORTED')
@contextlib.contextmanager
def bearer():
import dbus
bus=dbus.SystemBus(private=True)
# This short-lived connection is closed after each transfer. With GLib,
# the bus default would terminate the whole daemon on that disconnect.
bus.set_exit_on_disconnect(False)
try:
conn=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.ConnectionManager')
contexts=[(str(p),v) for p,v in conn.GetContexts() if str(v.get('Type'))=='mms']
if not contexts: raise ValueError('MMS_APN_MISSING')
path,props=contexts[0]
# Only provision an incomplete default context, never replace user APNs.
if not props.get('MessageCenter'):
sim=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.SimManager').GetProperties()
imsi=str(sim.get('SubscriberIdentity',''))
if imsi.startswith('20801') and str(props.get('AccessPointName')) in ('','mms'):
cfg={'AccessPointName':'orange.acte','Username':'orange','Password':'orange',
'AuthenticationMethod':'pap','Protocol':'ip','MessageCenter':'http://mms.orange.fr',
'MessageProxy':'192.168.10.200:8080'}
ctx=dbus.Interface(bus.get_object('org.ofono',path),'org.ofono.ConnectionContext')
for key,val in cfg.items():ctx.SetProperty(key,dbus.String(val,variant_level=1))
props=ctx.GetProperties()
else: raise ValueError('MMS_APN_MISSING')
ctx=dbus.Interface(bus.get_object('org.ofono',path),'org.ofono.ConnectionContext')
activated=False;manager=None;previous=None;cleanup=[]
try:
try:
manager=dbus.Interface(bus.get_object('org.ofono','/'),'org.nemomobile.ofono.ModemManager')
previous=manager.GetMmsSim()
sim=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.SimManager').GetProperties()
manager.SetMmsSim(sim['SubscriberIdentity'])
except dbus.DBusException:manager=None
if not props.get('Active'):
try:ctx.SetProperty('Active',dbus.Boolean(True,variant_level=1),timeout=45)
except dbus.DBusException:raise ValueError('MMS_DATA_UNAVAILABLE')
activated=True
props=ctx.GetProperties();settings=props.get('Settings',{})
interface=str(settings.get('Interface',''));address=str(settings.get('Address',''))
if not re.fullmatch(r'[\w.-]{1,15}',interface) or not address:raise ValueError('MMS_DATA_UNAVAILABLE')
ip=ipaddress.ip_address(address)
if ip.version!=4:raise ValueError('MMS_DATA_UNAVAILABLE')
proxy=str(props.get('MessageProxy',''));url=str(props.get('MessageCenter',''))
parsed=urlsplit(url)
if parsed.scheme!='http' or not parsed.hostname or parsed.username:raise ValueError('MMS_APN_MISSING')
proxyurl=urlsplit(proxy if '://' in proxy else '//'+proxy)
host=proxyurl.hostname or parsed.hostname;port=proxyurl.port or (8080 if proxy else parsed.port or 80)
# Operator MMS profiles normally expose an IPv4 proxy. Avoid DNS via Wi-Fi.
try: destination=str(ipaddress.IPv4Address(host))
except ValueError:raise ValueError('MMS_PROXY_REQUIRED')
subprocess.run(['ip','link','set',interface,'up'],check=True,stdout=subprocess.DEVNULL)
prefix=ipaddress.IPv4Network('0.0.0.0/'+str(settings.get('Netmask','255.255.255.255'))).prefixlen
current=json.loads(subprocess.check_output(['ip','-j','addr','show','dev',interface]))
if not any(a.get('local')==address for link in current for a in link.get('addr_info',[])):
address_args=[address+'/'+str(prefix),'dev',interface]
subprocess.run(['ip','addr','add',*address_args],check=True,stdout=subprocess.DEVNULL)
cleanup.append(['ip','addr','del',*address_args])
routes=json.loads(subprocess.check_output(['ip','-j','route','show',destination+'/32']))
if routes and not any(r.get('dev')==interface for r in routes):raise ValueError('MMS_ROUTE_CONFLICT')
if not routes:
route_args=[destination+'/32','dev',interface,'src',address]
subprocess.run(['ip','route','add',*route_args],check=True,stdout=subprocess.DEVNULL)
cleanup.append(['ip','route','del',*route_args])
yield {'interface':interface,'address':address,'host':destination,'port':port,'url':url,'proxy':bool(proxy)}
finally:
for command in reversed(cleanup):
subprocess.run(command,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
if activated:
try:ctx.SetProperty('Active',dbus.Boolean(False,variant_level=1),timeout=20)
except dbus.DBusException:pass
if manager is not None and previous is not None:
try:manager.SetMmsSim(previous)
except dbus.DBusException:pass
finally:
bus.close()
def transfer(connection, url, body=None):
parsed=urlsplit(url)
if parsed.scheme!='http' or not parsed.hostname or parsed.username or parsed.fragment:raise ValueError('MMS_INVALID_URL')
if not connection['proxy'] and parsed.hostname!=urlsplit(connection['url']).hostname:raise ValueError('MMS_INVALID_URL')
client=http.client.HTTPConnection(connection['host'],connection['port'],timeout=35)
sock=socket.socket(socket.AF_INET,socket.SOCK_STREAM);client.sock=sock
target=url if connection['proxy'] else (parsed.path or '/')+('?' + parsed.query if parsed.query else '')
try:
sock.settimeout(35)
sock.setsockopt(socket.SOL_SOCKET,socket.SO_BINDTODEVICE,connection['interface'].encode()+b'\0')
sock.bind((connection['address'],0));sock.connect((connection['host'],connection['port']))
client.request('POST' if body is not None else 'GET',target,body,
{'Content-Type':'application/vnd.wap.mms-message','Accept':'application/vnd.wap.mms-message',
'User-Agent':'VulpesOS/2.7 MMS/1.1','Host':parsed.netloc})
response=client.getresponse();data=response.read(LIMIT+1)
if response.status!=200:raise ValueError('MMS_HTTP_ERROR')
if len(data)>LIMIT:raise ValueError('MMS_TOO_LARGE')
return data
finally:client.close()
def process(row):
with bearer() as connection:
if row['direction']=='outgoing':
encoded=row['encoded'];data=transfer(connection,connection['url'],base64.b64decode(encoded['data']))
response=codec('response',data=base64.b64encode(data).decode(),transaction=encoded['transaction'])
if not response['accepted']:raise ValueError('MMS_REJECTED')
return {'state':'sent','encoded':None,'acceptedByMMSC':True}
notification=row['notification']
if row.get('ackPending'):
ack=codec('ack',transaction=notification['transaction'])
transfer(connection,connection['url'],base64.b64decode(ack['data']))
return {'ackPending':False}
if notification['expires'] and notification['expires']<time.time():raise ValueError('MMS_EXPIRED')
data=transfer(connection,notification['url']);message=codec('retrieve',data=base64.b64encode(data).decode())
message.update({'receipt':row['id'],'type':'mms'})
# Persist before acknowledging download to the operator.
with LOCK:
rows=records();target=next(r for r in rows if r['id']==row['id']);target.update({'message':message,'state':'received','ackPending':True});save(ROOT/'queue.json',rows)
ack=codec('ack',transaction=notification['transaction'])
transfer(connection,connection['url'],base64.b64decode(ack['data']))
return {'ackPending':False}
def process_once():
with LOCK:
pending=next((r.copy() for r in records() if
(r['state']=='pending' or r.get('ackPending')) and r.get('retryAt',0)<=time.time()),None)
if not pending:return False
if pending['direction']=='outgoing':
rows=records();next(r for r in rows if r['id']==pending['id'])['state']='submitting';save(ROOT/'queue.json',rows)
try:result=process(pending)
except Exception as e:
code=str(e) if re.fullmatch(r'MMS_[A-Z_]+',str(e)) else 'MMS_TRANSPORT_ERROR'
result={'error':code}
if pending['direction']=='outgoing':result['state']='failed'
else:
attempts=pending.get('attempts',0)+1
result.update(attempts=attempts,retryAt=time.time()+min(3600,30*2**min(attempts,7)))
if code=='MMS_EXPIRED':result.update(state='failed',ackPending=False)
with LOCK:
rows=records();row=next((r for r in rows if r['id']==pending['id']),None)
if row:
row.update(result)
if 'error' not in result:row.pop('error',None);row.pop('retryAt',None)
save(ROOT/'queue.json',rows)
return True
def worker():
while True:
if not process_once():time.sleep(1)
def serve():
SOCKET.parent.mkdir(parents=True,exist_ok=True);SOCKET.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX) as server:
server.bind(str(SOCKET));os.chmod(SOCKET,0o660)
import pwd
os.chown(SOCKET,0,pwd.getpwnam('droidian').pw_gid)
server.listen(4)
while True:
conn,_=server.accept()
with conn:
conn.settimeout(15);data=b''
try:
while not data.endswith(b'\n') and len(data)<=WIRE_LIMIT:
chunk=conn.recv(min(65536,WIRE_LIMIT+1-len(data)))
if not chunk:break
data+=chunk
if len(data)>WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
result={'ok':True,'result':request(json.loads(data))}
except Exception as e:
code=str(e) if re.fullmatch(r'[A-Z_]+',str(e)) else 'INVALID_REQUEST'
result={'ok':False,'error':code}
try:conn.sendall(json.dumps(result).encode()+b'\n')
except OSError:pass
def agent():
import dbus,dbus.service
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
DBusGMainLoop(set_as_default=True);bus=dbus.SystemBus();path='/org/vulpes/MmsPush'
class Push(dbus.service.Object):
@dbus.service.method('org.ofono.PushNotificationAgent',in_signature='aya{sv}',out_signature='',sender_keyword='sender')
def ReceiveNotification(self,data,info,sender=None):
if sender!=bus.get_name_owner('org.ofono'):raise dbus.DBusException('Denied')
try:notification=codec('notification',data=base64.b64encode(bytes(data)).decode())
except Exception:return
identity=hashlib.sha256((notification['transaction']+'\n'+notification['url']).encode()).hexdigest()
with LOCK:
rows=records()
if not any(r['id']==identity for r in rows):
rows.append({'id':identity,'direction':'incoming','state':'pending','notification':notification});save(ROOT/'queue.json',rows)
@dbus.service.method('org.ofono.PushNotificationAgent',in_signature='',out_signature='',sender_keyword='sender')
def Release(self,sender=None):
if sender==bus.get_name_owner('org.ofono'):GLib.timeout_add_seconds(3,register)
push=Push(bus,path)
def register():
try:dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.PushNotification').RegisterAgent(dbus.ObjectPath(path));return False
except dbus.DBusException:return True
if register():GLib.timeout_add_seconds(10,register)
GLib.MainLoop().run()
def client():
raw=sys.stdin.read(WIRE_LIMIT+1)
if len(raw)>WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(20);sock.connect(str(SOCKET));sock.sendall(raw.encode()+b'\n');data=b''
while not data.endswith(b'\n') and len(data)<=WIRE_LIMIT:
chunk=sock.recv(65536)
if not chunk:break
data+=chunk
if len(data)>WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
print(data.decode())
if __name__=='__main__':
if '--client' in sys.argv:client()
else:
os.umask(0o077);ROOT.mkdir(parents=True,exist_ok=True)
rows=records()
for row in rows:
if row['state']=='submitting':row.update({'state':'failed','error':'MMS_DELIVERY_UNKNOWN'})
save(ROOT/'queue.json',rows)
threading.Thread(target=worker,daemon=True).start()
threading.Thread(target=serve,daemon=True).start()
agent()