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

@ -181,33 +181,10 @@ def main():
('SYSTEM_IMAGE=/installed/tundra/images/'+sha+'.ext4\nSYSTEM_IMAGE_SHA='+sha+'\n# A kernel fallback').encode())
# Bundle UI changes with the boot trial rather than retransferring
# the entire immutable system image for each CSS/JS adjustment.
buffer = io.BytesIO()
selected = [*sorted((ROOT.parent/'host').rglob('*')),
*sorted((ROOT.parent/'services').rglob('*.mjs')),
*sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
ROOT.parent/'tools/installed_apps.py',
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
*sorted((ROOT.parent/'overrides').rglob('*')),
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/activities.js',
ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
ROOT.parent/'assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/js/tone_player.js',
ROOT.parent/'tools/project.py',
ROOT.parent/'tools/serve-gaia.py',
ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs',
ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py']
with tarfile.open(fileobj=buffer, mode='w') as archive:
for path in selected:
if not path.is_file(): continue
relative = str(path.relative_to(ROOT.parent))
target = 'trial/'+path.name if path in (ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py') else relative
archive.add(path, arcname='opt/vulpes/'+target, filter=owned)
shared_ui[relative] = digest(path)
files['shared-ui.tar'] = (0o600, buffer.getvalue())
from shared_ui import build_payload
payload, shared_ui = build_payload()
files['shared-ui.tar'] = (0o600, payload)
sources.append(ROOT/'tools/shared_ui.py')
if state:
config = ('STATE_IMAGE=/installed/tundra/data/'+state['uuid']+'.ext4\n'
'STATE_UUID='+state['uuid']+'\nSTATE_BYTES='+str(state['bytes'])+'\n')

View file

@ -0,0 +1,116 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Report native telephony health without dialing or exposing subscriber data."""
import datetime
import json
from pathlib import Path
import re
import subprocess
FIELDS = {
'Modem': ('Powered', 'Online', 'Emergency', 'Revision'),
'SimManager': ('Present', 'PinRequired', 'LockedPins'),
'NetworkRegistration': ('Status', 'Mode', 'Technology', 'Strength'),
'RadioSettings': ('TechnologyPreference', 'AvailableTechnologies'),
'ConnectionManager': ('Attached', 'Powered', 'RoamingAllowed'),
'IpMultimediaSystem': ('Registered', 'VoiceCapable', 'SmsCapable'),
'CallVolume': ('Muted', 'SpeakerVolume', 'MicrophoneVolume'),
}
def select_properties(interface, properties):
return {key: properties[key].get('data') for key in FIELDS[interface]
if isinstance(properties.get(key), dict) and 'data' in properties[key]}
def registration_events(text):
"""Keep RAT and denial reason; never copy cell location or raw log lines."""
events = []
pattern = re.compile(
r'binder_network_poll_(voice|data)_state_cb\(\).*?slot\d+ '
r'(registered|roaming|searching|unregistered|denied|unknown),'
r'([^,]*),(-?\d+),(-?\d+),(-?\d+),(-?\d+)')
for line in text.splitlines():
match = pattern.search(line)
if not match:
continue
domain, status, technology, rat, _lac, _cell, reason = match.groups()
event = {'domain': domain, 'status': status,
'technology': technology, 'radioTechnology': int(rat),
'denialReason': int(reason)}
if not events or event != events[-1]:
events.append(event)
return events[-40:]
def run(command):
return subprocess.run(command, capture_output=True, text=True, timeout=12)
def modem_crashes(text):
signals = []
for line in text.splitlines():
if 'qcrild' not in line:
continue
match = re.search(r'Fatal signal (\d+) \((SIG[A-Z]+)\)', line)
if match:
event = {'signal': int(match[1]), 'name': match[2]}
timestamp = re.match(r'\s*(\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s', line)
if timestamp:
event['logTime'] = timestamp[1]
signals.append(event)
return {'signals': signals,
'initialAttachTimeoutInBacktrace': 'qcril_qmi_nas_initial_attach_timeout_handler' in text}
def collect(command=run, root=Path('/')):
report = {'recordedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'properties': {}, 'services': {}, 'errors': {}}
for interface in FIELDS:
try:
result = command(['busctl', '--system', '--timeout=8', '--json=short',
'call', 'org.ofono', '/ril_0',
'org.ofono.' + interface, 'GetProperties'])
if result.returncode:
report['errors'][interface] = 'unavailable'
continue
data = json.loads(result.stdout)['data'][0]
report['properties'][interface] = select_properties(interface, data)
except (OSError, subprocess.TimeoutExpired, ValueError, KeyError, IndexError, TypeError):
report['errors'][interface] = 'unavailable'
for service in ('ofono', 'tundra-radio', 'tundra-sms', 'tundra-audio', 'tundra-call-audio'):
try:
result = command(['systemctl', 'is-active', service])
state = result.stdout.strip()
report['services'][service] = state if state in (
'active', 'inactive', 'failed', 'activating', 'deactivating') else 'unknown'
except (OSError, subprocess.TimeoutExpired):
report['services'][service] = 'unknown'
try:
result = command(['journalctl', '-u', 'ofono', '--since', '-10 min',
'--no-pager', '-n', '2000'])
report['registrationEvents'] = registration_events(result.stdout)
report['registrationLogAvailable'] = result.returncode == 0
except (OSError, subprocess.TimeoutExpired):
report['registrationEvents'] = []
report['registrationLogAvailable'] = False
try:
result = command(['lxc-attach', '-n', 'tundra-hal', '--', '/system/bin/logcat',
'-b', 'crash', '-d', '-t', '300'])
report['qualcommCrashes'] = modem_crashes(result.stdout)
report['qualcommCrashes']['logAvailable'] = result.returncode == 0
except (OSError, subprocess.TimeoutExpired):
report['qualcommCrashes'] = {'logAvailable': False}
try:
data = json.loads((root/'run/tundra-call-audio/status.json').read_text())
report['audio'] = {key: data[key] for key in (
'active', 'routed', 'speaker', 'muted', 'volume', 'recordedAt') if key in data}
report['audio']['hasError'] = bool(data.get('error'))
except (OSError, ValueError, TypeError):
report['audio'] = {'available': False}
return report
if __name__ == '__main__':
print(json.dumps(collect(), indent=2))

View file

@ -0,0 +1,117 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Refresh shared Gaia files in a verified private RAM boot, preserving hardware."""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
import time
import io
import tarfile
from ramdisk_reference import read_newc
from shared_ui import build_payload
from tundra import ROOT, checked_source, cpio, digest, save
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--base',type=Path,required=True)
p.add_argument('--expected-sha256',required=True)
p.add_argument('--output',type=Path,required=True)
p.add_argument('--settings-adapters',action='store_true',help='Also replace native Settings bridges and hotspot firewall configuration')
args=p.parse_args();os.umask(0o077)
base=args.base.resolve();out=args.output.resolve()
build=json.loads((base/'build.json').read_text())
image=base/build['image']
if digest(image)!=args.expected_sha256 or build['imageSha256']!=args.expected_sha256:
raise ValueError('Base image hash mismatch')
if not build.get('sourceSnapshot') or not build.get('maintenanceEnabled') or build.get('storageMode')!='raw-userdata-ext4':
raise ValueError('Expected a private standalone build with a source snapshot')
spec=importlib.util.spec_from_file_location('rescue',ROOT/'tools/test-rescue-boot.py')
rescue=importlib.util.module_from_spec(spec);spec.loader.exec_module(rescue)
rescue.verify_sources(base,build)
payload,hashes=build_payload()
packager=checked_source('aosp-mkbootimg')
out.mkdir(parents=True,exist_ok=False)
with tempfile.TemporaryDirectory(dir=out) as temporary:
temp=Path(temporary)
text=subprocess.check_output([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(image),'--out',str(temp/'base'),'--format=mkbootimg'],text=True)
options=shlex.split(text);fields=dict(zip(options[::2],options[1::2]))
if fields['--header_version']!='0' or digest(temp/'base/kernel')!=build['kernelSha256']:
raise ValueError('Unexpected boot format or kernel')
original=read_newc((temp/'base/ramdisk').read_bytes())
if 'shared-ui.tar' not in original or 'standalone-mount.sh' not in original:
raise ValueError('Missing standalone UI overlay')
files={name:{**item} for name,item in original.items()}
files['shared-ui.tar']['data']=payload
native_sources = {}
if args.settings_adapters:
names = ['radio-control.py','wifi-control.py','mobile-settings.py','camera-preview.py']
native_sources = {'etc/tundra/'+name: ROOT/'runtime'/name for name in names}
native_sources['etc/NetworkManager/conf.d/80-vulpes-hotspot.conf']=ROOT/'runtime/hotspot-firewall.conf'
native_sources['etc/systemd/system/tundra-radio.service.d/clock.conf']=ROOT/'runtime/clock-service.conf'
aliases={'usr/sbin/'+name:name+'-legacy' for name in ('iptables','ip6tables')}
buffer = io.BytesIO()
with tarfile.open(fileobj=io.BytesIO(original['native-config.tar']['data'])) as source, tarfile.open(fileobj=buffer,mode='w') as target:
for member in source.getmembers():
if member.name in native_sources or member.name in aliases: continue
target.addfile(member,source.extractfile(member) if member.isfile() else None)
for name,path in native_sources.items():
content=path.read_bytes();info=tarfile.TarInfo(name);info.mode=0o644;info.size=len(content)
info.uid=info.gid=0;target.addfile(info,io.BytesIO(content))
for name,link in aliases.items():
info=tarfile.TarInfo(name);info.type=tarfile.SYMTYPE;info.linkname=link;info.mode=0o777
target.addfile(info)
files['native-config.tar']['data']=buffer.getvalue()
entries=[]
for name,item in sorted(files.items()):
if (stat.S_ISCHR(item['mode']) or stat.S_ISBLK(item['mode'])) and name!='dev/console':
raise ValueError('Unrecognized device node: '+name)
entries.append((name,item['mode'],item['data'],5 if name=='dev/console' else 0,1 if name=='dev/console' else 0))
ramdisk=out/'initramfs.cpio.gz';ramdisk.write_bytes(cpio(entries))
fields['--ramdisk']=str(ramdisk)
command=[sys.executable,str(packager/'mkbootimg.py')]
for k,v in fields.items():command.extend([k,v])
target=out/'boot.img'
subprocess.run(command+['-o',str(target)],check=True)
if target.stat().st_size>67108864:raise ValueError('Boot partition size exceeded')
subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(target),'--out',str(temp/'verify')],check=True,stdout=subprocess.DEVNULL)
if digest(temp/'verify/kernel')!=build['kernelSha256'] or digest(temp/'verify/ramdisk')!=digest(ramdisk):
raise ValueError('Boot roundtrip failed')
verified=read_newc((temp/'verify/ramdisk').read_bytes())
if verified.keys()!=original.keys():raise ValueError('Ramdisk entry list changed')
changed=[name for name in original if (verified[name]['mode'],verified[name]['data'])!=(original[name]['mode'],original[name]['data'])]
permitted={'native-config.tar','shared-ui.tar'} if args.settings_adapters else {'shared-ui.tar'}
if set(changed)-permitted:raise ValueError('Unexpected changes: '+str(changed))
# A native-only fix may reproduce the same shared UI archive exactly.
# Still verify every replacement, including unchanged payloads.
for name in permitted:
if (verified[name]['mode'],verified[name]['data']) != (files[name]['mode'],files[name]['data']):
raise ValueError('Replacement mismatch: '+name)
shutil.copytree(base/'sources',out/'sources')
for name in hashes:
target=out/'sources'/name;target.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(ROOT.parent/name,target)
for path in (Path(__file__).resolve(),ROOT/'tools/shared_ui.py',*native_sources.values(),*([ROOT/'runtime/prepare-native.py'] if args.settings_adapters else [])):
target=out/'sources'/path.relative_to(ROOT.parent)
target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(path,target)
build['sources'][str(path.relative_to(ROOT))]=digest(path)
shutil.copy2(base/'client-key',out/'client-key')
build.update(image='boot.img',imageSha256=digest(out/'boot.img'),ramdiskSha256=digest(ramdisk),
sharedUiHashes=hashes,builtAtUnix=int(time.time()),flashReady=False,
baseImageSha256=args.expected_sha256,uiOnlyRefresh=not args.settings_adapters,changedRamdiskEntries=changed,
scope='Private RAM Settings update; kernel and storage configuration preserved')
save(out/'build.json',build)
rescue.verify_sources(out,build)
print(json.dumps({'image':str(out/'boot.img'),'sha256':build['imageSha256'],
'changed':changed,'sharedFiles':len(hashes),'flashed':False}))
if __name__=='__main__':main()

51
tundra/tools/shared_ui.py Normal file
View file

@ -0,0 +1,51 @@
# SPDX-License-Identifier: MPL-2.0
"""Shared Gaia payload used by full builds and private UI-only RAM updates."""
import io
import tarfile
from tundra import ROOT, digest
def selected_files():
return [*sorted((ROOT.parent/'host').rglob('*')),
*sorted((ROOT.parent/'services').rglob('*.mjs')),
*sorted((ROOT/'mms-codec').glob('*')),
*sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
ROOT.parent/'tools/installed_apps.py',
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
*sorted((ROOT.parent/'overrides').rglob('*')),
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/activities.js',
ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
ROOT.parent/'assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js',
ROOT.parent/'assets/webapps/settings.gaiamobile.org/js/panels/app_permissions_list/panel.js',
ROOT.parent/'assets/webapps/settings.gaiamobile.org/js/panels/screen_lock_passcode/panel.js',
ROOT.parent/'assets/webapps/verticalhome.gaiamobile.org/gaia_build_defer_index.js',
ROOT.parent/'assets/webapps/wallpaper.gaiamobile.org/style/wallpaper.css',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/js/tone_player.js',
ROOT.parent/'gaia/apps/communications/dialer/js/telephony_helper.js',
ROOT.parent/'tools/project.py',
ROOT.parent/'tools/serve-gaia.py',
ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs',
ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py']
def build_payload():
buffer = io.BytesIO()
hashes = {}
def owned(info):
info.uid = info.gid = 0
info.uname = info.gname = 'root'
info.mtime = 0
return info
with tarfile.open(fileobj=buffer, mode='w') as archive:
for path in selected_files():
if not path.is_file():
continue
relative = str(path.relative_to(ROOT.parent))
target = 'trial/' + path.name if path in (ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py') else relative
archive.add(path, arcname='opt/vulpes/' + target, filter=owned)
hashes[relative] = digest(path)
return buffer.getvalue(), hashes