#!/usr/bin/env python3 # SPDX-License-Identifier: MPL-2.0 """Install a standalone Sargo package using Fastboot only (Python 3, any host OS). Default: verify files and display the destructive installation plan. --install --erase-userdata replaces the old OS and ALL files in userdata. Keep a separate backup. The unlocked bootloader and device calibration remain. """ import argparse import hashlib import json from pathlib import Path import re import shutil import struct import subprocess import sys PARTITIONS=('userdata','dtbo_a','vbmeta_a','boot_a') NAMES={'userdata':'userdata.img','dtbo_a':'dtbo.img','vbmeta_a':'vbmeta.img','boot_a':'boot.img'} def digest(path): sha=hashlib.sha256() with path.open('rb') as source: while block:=source.read(1024*1024):sha.update(block) return sha.hexdigest() def sparse_size(path): with path.open('rb') as source:header=source.read(28) if len(header)!=28:raise ValueError('Truncated userdata image') magic,major,minor,file_header,chunk_header,block_size,blocks,chunks,crc=struct.unpack('size:raise ValueError('Image exceeds partition: '+partition) return info def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--package',type=Path,default=Path(__file__).resolve().parent) parser.add_argument('--serial') parser.add_argument('--install',action='store_true') parser.add_argument('--erase-userdata',action='store_true') parser.add_argument('--experimental',action='store_true') parser.add_argument('--reboot',action='store_true') args=parser.parse_args();value=package(args.package) print('Pixel 3a / Sargo: replace userdata, dtbo_a, vbmeta_a and boot_a; activate slot A.') print('ALL existing user data and the old OS in userdata will be lost. Bootloader remains unlocked.') print('No existing Android or Droidian installation is required.') if not args.install: print('Review only; no device command sent.');return if not args.erase_userdata:parser.error('--install requires --erase-userdata') if not value.get('flashQualified') and not args.experimental: parser.error('This candidate is not qualified; private tests require --experimental') fastboot=shutil.which('fastboot') if not fastboot:raise ValueError('Install Android platform-tools (fastboot) on the host computer') devices=subprocess.check_output([fastboot,'devices'],text=True,timeout=10).splitlines() serials=[line.split()[0] for line in devices if len(line.split())==2 and line.split()[1]=='fastboot'] serial=args.serial if serial is None: if len(serials)!=1:raise ValueError('Connect exactly one Pixel or specify --serial') serial=serials[0] if serial not in serials:raise ValueError('Selected device not in Fastboot') info=preflight(fastboot,serial,value) report={'deviceSha256':hashlib.sha256(serial.encode()).hexdigest(),'preflight':info,'completed':[],'passed':False} path=args.package/'install-report.json' def save():path.write_text(json.dumps(report,indent=2)+'\n') save() subprocess.run([fastboot,'-s',serial,'erase','userdata'],check=True,timeout=300) report['completed'].append('erase:userdata');save() for partition in PARTITIONS: subprocess.run([fastboot,'-s',serial,'flash',partition,str((args.package/NAMES[partition]).resolve())],check=True,timeout=1200) report['completed'].append(partition);save() subprocess.run([fastboot,'-s',serial,'set_active','a'],check=True,timeout=30) report['passed']=True;save() if args.reboot:subprocess.run([fastboot,'-s',serial,'reboot'],check=True,timeout=30) print('Images written. Keep the bootloader unlocked. Select Start to launch Vulpes.') if __name__=='__main__':main()