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:
parent
be2cdce823
commit
1311bed5e9
170 changed files with 19698 additions and 2495 deletions
|
|
@ -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')
|
||||
|
|
|
|||
116
tundra/tools/diagnose-telephony.py
Normal file
116
tundra/tools/diagnose-telephony.py
Normal 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))
|
||||
117
tundra/tools/refresh-boot-ui.py
Normal file
117
tundra/tools/refresh-boot-ui.py
Normal 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
51
tundra/tools/shared_ui.py
Normal 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
|
||||
Loading…
Reference in a new issue