docs: add software-vision and android-backup-restore skills with android scripts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a16a879b1e
commit
5e6abadb95
12 files changed
+1261
-1
No files matched your search
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reinstall each restored app with its original installer (pm set-installer refuses root).
|
||||
|
||||
Same APKs, `install-multiple -r -i <installer>` keeps data. The device comes from ANDROID_SERIAL, as for adb. Usage: fix-installers.py BACKUP_DIR WORK_DIR
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
backup, work = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
log_file = (work / "restore.log").open("a")
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
line = f"{time.strftime('%H:%M:%S')} {msg}"
|
||||
print(line, flush=True)
|
||||
log_file.write(line + "\n")
|
||||
log_file.flush()
|
||||
|
||||
|
||||
def adb(*args: str, timeout: int = 900) -> str:
|
||||
r = subprocess.run(["adb", *args], capture_output=True, text=True, timeout=timeout)
|
||||
return (r.stdout + r.stderr).strip()
|
||||
|
||||
|
||||
def installers(text: str) -> dict:
|
||||
# `pm list packages -i` prints "package:PKG", with -f "package:PATH=PKG".
|
||||
return dict(re.findall(r"[:=]([\w.]+)\s+installer=(\S+)", text))
|
||||
|
||||
|
||||
want = installers((backup / "rooted-fresh/packages-3.txt").read_text())
|
||||
now = installers(adb("shell", "pm list packages -3 -i"))
|
||||
disabled = set(re.findall(r"package:([\w.]+)", adb("shell", "pm list packages -d")))
|
||||
log("=== installer fix-up start")
|
||||
for pkg, inst in sorted(want.items()):
|
||||
if inst == "null" or pkg not in now or now[pkg] == inst or inst not in now | {"com.android.vending": 1}:
|
||||
continue
|
||||
apks = sorted(str(p) for p in (backup / "apks" / pkg).glob("*.apk"))
|
||||
if not apks:
|
||||
continue
|
||||
# Reinstalling re-enables a frozen app; disable it again afterwards.
|
||||
out = adb("install-multiple", "-r", "-d", "-i", inst, "--bypass-low-target-sdk-block", *apks)
|
||||
if pkg in disabled:
|
||||
adb("shell", f"su -c 'pm disable-user --user 0 {pkg}'")
|
||||
log(f"{pkg}: installer {now[pkg]} -> {inst}: {'OK' if 'Success' in out else 'FAILED ' + out[-200:]}")
|
||||
log("=== installer fix-up done")
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Emit SQL that copies apps' keystore2 keys from an old ROM's DB into the live one, under the new UIDs.
|
||||
|
||||
Only works when both ROMs use the same TEE (compare keyentry.km_uuid). Keys super-encrypted with the
|
||||
old lock-screen credential (blobmetadata tag 0, EncryptedBy) cannot be moved and are skipped.
|
||||
|
||||
Usage: keystore-transplant.py OLD_DB [--replace] OLD_UID:NEW_UID [...] > transplant.sql
|
||||
--replace first delete the app's keys that already exist on the new ROM (needed when it regenerated them)
|
||||
Apply with the apps force-stopped: `su -c 'sqlite3 /data/misc/keystore/persistent.sqlite < transplant.sql'`, reboot.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
DOMAIN_APP = 0
|
||||
TAG_ENCRYPTED_BY = 0
|
||||
|
||||
|
||||
def lit(v: object) -> str:
|
||||
if v is None:
|
||||
return "NULL"
|
||||
if isinstance(v, bytes):
|
||||
return f"X'{v.hex()}'"
|
||||
if isinstance(v, str):
|
||||
return "'" + v.replace("'", "''") + "'"
|
||||
return str(v)
|
||||
|
||||
|
||||
def delete_keys(where: str) -> list[str]:
|
||||
ids = f"(SELECT id FROM keyentry WHERE {where})"
|
||||
return [f"DELETE FROM blobmetadata WHERE blobentryid IN (SELECT id FROM blobentry WHERE keyentryid IN {ids});",
|
||||
f"DELETE FROM blobentry WHERE keyentryid IN {ids};",
|
||||
f"DELETE FROM keyparameter WHERE keyentryid IN {ids};",
|
||||
f"DELETE FROM keymetadata WHERE keyentryid IN {ids};",
|
||||
f"DELETE FROM grant WHERE keyentryid IN {ids};",
|
||||
f"DELETE FROM keyentry WHERE {where};"]
|
||||
|
||||
|
||||
args = sys.argv[1:]
|
||||
old = sqlite3.connect(f"file:{args.pop(0)}?mode=ro", uri=True)
|
||||
replace = "--replace" in args
|
||||
pairs = [tuple(map(int, a.split(":"))) for a in args if a != "--replace"]
|
||||
# Older keystore2 schemas have no blobentry.state; every blob there is current (0).
|
||||
blob_state = "state" if any(c[1] == "state" for c in old.execute("PRAGMA table_info(blobentry)")) else "0"
|
||||
|
||||
out = ["BEGIN;"]
|
||||
for old_ns, new_ns in pairs:
|
||||
if replace:
|
||||
out += delete_keys(f"domain={DOMAIN_APP} AND namespace={new_ns}")
|
||||
keys = old.execute("SELECT id, key_type, alias, state, km_uuid FROM keyentry WHERE domain=? AND namespace=?",
|
||||
(DOMAIN_APP, old_ns)).fetchall()
|
||||
for kid, key_type, alias, state, km_uuid in keys:
|
||||
blobs = old.execute(f"SELECT id, subcomponent_type, blob, {blob_state} FROM blobentry WHERE keyentryid=?", (kid,)).fetchall()
|
||||
if any(old.execute("SELECT 1 FROM blobmetadata WHERE blobentryid=? AND tag=?", (b[0], TAG_ENCRYPTED_BY)).fetchone()
|
||||
for b in blobs):
|
||||
print(f"-- skip {old_ns} {alias!r}: super-encrypted with the old lock-screen credential", file=sys.stderr)
|
||||
continue
|
||||
new_id = secrets.randbits(63) * (1 if secrets.randbits(1) else -1)
|
||||
# The old key wins over one the app generated under the same alias on the new ROM.
|
||||
out += delete_keys(f"domain={DOMAIN_APP} AND namespace={new_ns} AND alias={lit(alias)}")
|
||||
out.append(f"INSERT INTO keyentry (id,key_type,domain,namespace,alias,state,km_uuid) VALUES "
|
||||
f"({new_id},{key_type},{DOMAIN_APP},{new_ns},{lit(alias)},{state},{lit(km_uuid)});")
|
||||
for bid, sub, blob, bstate in blobs:
|
||||
out.append(f"INSERT INTO blobentry (subcomponent_type,keyentryid,blob,state) VALUES ({sub},{new_id},{lit(blob)},{bstate});")
|
||||
for tag, data in old.execute("SELECT tag, data FROM blobmetadata WHERE blobentryid=?", (bid,)):
|
||||
out.append(f"INSERT INTO blobmetadata (blobentryid,tag,data) VALUES ((SELECT max(id) FROM blobentry WHERE keyentryid={new_id}),{tag},{lit(data)});")
|
||||
for tag, data, level in old.execute("SELECT tag, data, security_level FROM keyparameter WHERE keyentryid=?", (kid,)):
|
||||
out.append(f"INSERT INTO keyparameter (keyentryid,tag,data,security_level) VALUES ({new_id},{tag},{lit(data)},{level});")
|
||||
for tag, data in old.execute("SELECT tag, data FROM keymetadata WHERE keyentryid=?", (kid,)):
|
||||
out.append(f"INSERT INTO keymetadata (keyentryid,tag,data) VALUES ({new_id},{tag},{lit(data)});")
|
||||
print(f"-- {old_ns}->{new_ns} {alias!r}: {len(blobs)} blobs", file=sys.stderr)
|
||||
out.append("COMMIT;")
|
||||
print("\n".join(out))
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cold-start an app's launcher activity the way Android 17 accepts from the shell. Usage: launch.sh PKG [WAIT] [SHOT.png]
|
||||
p=$1 w=${2:-6} shot=${3:-}
|
||||
act=$(adb shell "cmd package resolve-activity --brief -c android.intent.category.LAUNCHER $p" </dev/null | tr -d '\r' | tail -1)
|
||||
adb shell "am start -S -a android.intent.action.MAIN -c android.intent.category.LAUNCHER -n '$act'" </dev/null >/dev/null
|
||||
sleep "$w"
|
||||
[ -n "$shot" ] && adb exec-out screencap -p > "$shot" </dev/null
|
||||
true
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Snapshot phone health: memory, load, crashes/ANRs since boot, low-memory kills, log errors, cold launches.
|
||||
# Usage: measure.sh OUT_FILE [launch-package ...]
|
||||
set -uo pipefail
|
||||
out=$1; shift
|
||||
a() { adb shell "$@" </dev/null; }
|
||||
|
||||
{
|
||||
echo "# $(date '+%F %T') $(a getprop ro.build.display.id) / Android $(a getprop ro.build.version.release)"
|
||||
echo "## system"
|
||||
a 'uptime; cat /proc/loadavg'
|
||||
a 'grep -E "MemTotal|MemAvailable|Cached:|SwapTotal|SwapFree" /proc/meminfo'
|
||||
a 'su -c "cat /proc/sys/vm/swappiness; cat /sys/block/zram0/mm_stat 2>/dev/null"'
|
||||
echo "procs: $(a 'ls -d /proc/[0-9]* | wc -l')"
|
||||
echo "## top cpu"
|
||||
a 'top -b -n 1 -m 12 -o PID,USER,%CPU,%MEM,NAME' | tail -13
|
||||
echo "## dropbox since boot"
|
||||
boot=$(a 'date -d @$(( $(date +%s) - $(cut -d. -f1 /proc/uptime) )) "+%Y-%m-%d %H:%M:%S"')
|
||||
a "dumpsys dropbox" | awk -v b="$boot" '$1" "$2 >= b && $3 ~ /anr|crash|watchdog|tombstone|wtf|lowmem|native/ {print $3}' | sort | uniq -c
|
||||
echo "## crash buffer (FATAL / ANR per process)"
|
||||
a 'logcat -d -b crash' | grep -oE "Process: [^,]+|FATAL EXCEPTION|>>> [^ ]+ <<<" | sort | uniq -c | sort -rn | head -20
|
||||
a 'logcat -d -b events' | grep -oE "am_anr: \[[^]]*" | awk -F, '{print "ANR " $3}' | sort | uniq -c
|
||||
echo "## lmkd / am_kill"
|
||||
a 'logcat -d -b events' | grep -cE "am_kill|lmk_kill" | sed 's/^/kills: /'
|
||||
a 'logcat -d -b events' | grep -oE "am_kill: \[[^]]*" | awk -F, '{print $5}' | sort | uniq -c | sort -rn | head -10
|
||||
echo "## logcat error tags (top 25)"
|
||||
a 'logcat -d *:E' | awk '{print $6}' | sort | uniq -c | sort -rn | head -25
|
||||
echo "## thermal / battery"
|
||||
a 'dumpsys battery | grep -E "level|temperature|status"; for z in /sys/class/thermal/thermal_zone*; do echo "$(cat $z/type) $(cat $z/temp)"; done 2>/dev/null | sort -k2 -n | tail -5'
|
||||
if [ $# -gt 0 ]; then
|
||||
echo "## cold launches (ms, am start -W -S, 3 runs)"
|
||||
for p in "$@"; do
|
||||
t=""
|
||||
for _ in 1 2 3; do
|
||||
act=$(a "cmd package resolve-activity --brief -c android.intent.category.LAUNCHER $p" | tr -d '\r' | tail -1)
|
||||
v=$(a "am start -W -S -a android.intent.action.MAIN -c android.intent.category.LAUNCHER -n '$act'" | awk '/TotalTime/{print $2}')
|
||||
t="$t ${v:-fail}"; a 'input keyevent KEYCODE_HOME'; sleep 2
|
||||
done
|
||||
echo "$p:$t"
|
||||
done
|
||||
fi
|
||||
} 2>&1 | tee "$out"
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild the Nova home screen in Lawnchair, plus the Ice Box shortcuts Nova had pinned.
|
||||
|
||||
Usage: nova-to-lawnchair.py NOVA_DB LAWNCHAIR_DB OLD_ICEBOX_SHORTCUT_XML OUT_DIR
|
||||
|
||||
Writes OUT_DIR/launcher_5_5_5.db (Lawnchair schema, Nova's items), OUT_DIR/icebox.xml
|
||||
(shortcut_service package file with only the pinned shortcuts) and OUT_DIR/lawnchair-pins.xml
|
||||
(shortcut_service launcher file pinning them for Lawnchair).
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
LAUNCHER = "app.lawnchair.nightly"
|
||||
ICEBOX = "com.catchingnow.icebox"
|
||||
# From /data/system/packages.xml on the new ROM: versionCode, lastUpdateTime, SHA-256 of the cert.
|
||||
LAUNCHER_INFO = ("1600020100", "1792700106881", "dHw2RbNXJYsuI+hR5TyWdH/grdAH5bos2X6MhVcuTcU=")
|
||||
ICEBOX_INFO = ("154821395", "1792699567277", "gvW+cC4DIBdGDKg3b22r1yWp5S3SSSDdXl17WF+/4bY=")
|
||||
# Nova-only containers (gesture/dock-swipe slots); Lawnchair has nothing to put them in.
|
||||
SKIP_CONTAINERS = {-207, -208}
|
||||
ITEM_APP, ITEM_FOLDER, ITEM_WIDGET, ITEM_DEEP_SHORTCUT = 0, 2, 4, 6
|
||||
FLAG_ID_NOT_VALID = 1 # Launcher3 restore flag: allocate and bind a fresh widget id on load
|
||||
MISSING_PACKAGES = {"com.dot.gallery"} # not installed on the new ROM
|
||||
# Google's smartspace widget on row 0 of the first page: Lawnchair reserves that row for its own.
|
||||
SKIP_IDS = {56}
|
||||
|
||||
nova_db, lc_db, icebox_xml, out = map(Path, sys.argv[1:5])
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def package_info(version: str, updated: str, sig: str) -> str:
|
||||
return (f'<package-info version="{version}" last_udpate_time="{updated}" shadow="0" allow-backup="1" '
|
||||
f'allow-backup-initialized="1" bk_src_version="-1" bk_src_backup-allowed="0">\n'
|
||||
f'<signature hash="{sig}" />\n</package-info>\n')
|
||||
|
||||
|
||||
nova = sqlite3.connect(nova_db)
|
||||
nova.row_factory = sqlite3.Row
|
||||
rows = [r for r in nova.execute("select * from favorites where _id >= 0 order by _id")
|
||||
if r["container"] not in SKIP_CONTAINERS and r["_id"] not in SKIP_IDS]
|
||||
folders = {r["_id"] for r in rows if r["itemType"] == ITEM_FOLDER}
|
||||
kept = []
|
||||
for r in rows:
|
||||
pkg = re.search(r"component=([\w.]+)/", r["intent"] or "")
|
||||
if pkg and pkg.group(1) in MISSING_PACKAGES:
|
||||
print(f"skip {r['title']}: {pkg.group(1)} not installed")
|
||||
continue
|
||||
if r["container"] >= 0 and r["container"] not in folders:
|
||||
continue
|
||||
kept.append(r)
|
||||
|
||||
db = out / "launcher_5_5_5.db"
|
||||
shutil.copy(lc_db, db)
|
||||
lc = sqlite3.connect(db)
|
||||
lc.execute("delete from favorites")
|
||||
pins = []
|
||||
for r in kept:
|
||||
widget = r["itemType"] == ITEM_WIDGET
|
||||
hotseat = r["container"] == -101
|
||||
in_folder = r["container"] >= 0
|
||||
x, y = int(max(r["cellX"] or 0, 0)), int(max(r["cellY"] or 0, 0))
|
||||
lc.execute(
|
||||
"insert into favorites (_id,title,intent,container,screen,cellX,cellY,spanX,spanY,itemType,"
|
||||
"appWidgetId,appWidgetProvider,restored,profileId,rank,options) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(r["_id"], r["title"], r["intent"], r["container"],
|
||||
x if hotseat else (0 if in_folder else r["screen"]),
|
||||
x, 0 if hotseat else y, int(r["spanX"]), int(r["spanY"]), r["itemType"],
|
||||
r["appWidgetId"] if widget else -1, r["appWidgetProvider"],
|
||||
FLAG_ID_NOT_VALID if widget else 0, 0, r["rank"], 0))
|
||||
if r["itemType"] == ITEM_DEEP_SHORTCUT:
|
||||
pins.append(re.search(r"S\.shortcut_id=([^;]+)", r["intent"]).group(1))
|
||||
lc.commit()
|
||||
print(f"{len(kept)} items written, pins: {pins}")
|
||||
|
||||
old = Path(icebox_xml).read_text()
|
||||
blocks = [m.group(0) for m in re.finditer(r'<shortcut id="([^"]+)".*?</shortcut>\n', old, re.S) if m.group(1) in pins]
|
||||
assert len(blocks) == len(pins), f"found {len(blocks)} of {len(pins)} shortcuts"
|
||||
head = re.search(r"<package name=[^>]*>\n", old).group(0)
|
||||
(out / "icebox.xml").write_text("<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n" + head
|
||||
+ package_info(*ICEBOX_INFO) + "".join(blocks) + "</package>\n")
|
||||
(out / "lawnchair-pins.xml").write_text(
|
||||
"<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n"
|
||||
f'<launcher-pins package-name="{LAUNCHER}" launcher-user="0">\n' + package_info(*LAUNCHER_INFO)
|
||||
+ f'<package package-name="{ICEBOX}" package-user="0">\n'
|
||||
+ "".join(f'<pin value="{p}" />\n' for p in pins) + "</package>\n</launcher-pins>\n")
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Replace an app with a differently signed build while keeping its data, permissions,
|
||||
# installer and frozen state. Data is also saved to BACKUP_DIR before the uninstall.
|
||||
# Usage: reinstall-keep-data.sh PACKAGE BACKUP_DIR APK [APK ...]
|
||||
set -euo pipefail
|
||||
pkg=$1 bak=$2; shift 2
|
||||
a() { adb shell "$@" </dev/null; }
|
||||
su_() { a "su -c '$1'"; }
|
||||
|
||||
mkdir -p "$bak"
|
||||
installer=$(a "pm list packages -3 -i $pkg" | tr -d '\r' | grep "package:$pkg " | sed 's/.*installer=//')
|
||||
disabled=$(a "pm list packages -d $pkg" | tr -d '\r' | grep -cx "package:$pkg" || true)
|
||||
perms=$(a "dumpsys package $pkg" | sed -n '/runtime permissions:/,/^ *[a-z]* .*:$/p' | grep -oE '[a-zA-Z0-9_.]+: granted=true' | cut -d: -f1 | sort -u | tr '\n' ' ')
|
||||
echo "$pkg: installer=$installer disabled=$disabled perms=[$perms]"
|
||||
|
||||
dirs="/data/data/$pkg /data/user_de/0/$pkg"
|
||||
[ "$(su_ "[ -d /data/media/0/Android/data/$pkg ] && echo y")" = y ] && dirs="$dirs /data/media/0/Android/data/$pkg"
|
||||
su_ "am force-stop $pkg"
|
||||
# The phone writes the tar to stdout; exec-out keeps it binary-safe.
|
||||
adb exec-out "su -c 'tar -cf - --exclude=$pkg/cache --exclude=$pkg/code_cache $dirs 2>/dev/null'" > "$bak/$pkg.tar"
|
||||
echo "saved $(stat -c %s "$bak/$pkg.tar") bytes to $bak/$pkg.tar"
|
||||
[ "$(stat -c %s "$bak/$pkg.tar")" -gt 10240 ] || { echo "backup suspiciously small, aborting"; exit 1; }
|
||||
|
||||
a "pm uninstall $pkg"
|
||||
iopt=(); [ -n "$installer" ] && [ "$installer" != null ] && iopt=(-i "$installer")
|
||||
adb install-multiple -r "${iopt[@]}" --bypass-low-target-sdk-block "$@"
|
||||
|
||||
uid=$(su_ "stat -c %u /data/data/$pkg")
|
||||
cache_gid=$((uid - 10000 + 20000))
|
||||
adb exec-in "su -c 'tar -xf - -C /'" < "$bak/$pkg.tar"
|
||||
su_ "while pgrep -x tar >/dev/null; do sleep 0.3; done"
|
||||
for d in /data/data/$pkg /data/user_de/0/$pkg; do
|
||||
su_ "chown -R $uid:$uid $d; for c in cache code_cache; do mkdir -p $d/\$c; chown $uid:$cache_gid $d/\$c; chmod 2771 $d/\$c; done; chcon -hR \"\$(stat -c %C $d)\" $d"
|
||||
done
|
||||
case " $dirs " in *Android/data*)
|
||||
su_ "d=/data/media/0/Android/data/$pkg; chown -R $uid:1078 \$d; find \$d -type d -exec chmod 2770 {} +; find \$d -type f -exec chmod 660 {} +; chcon -R u:object_r:media_rw_data_file:s0 \$d" ;;
|
||||
esac
|
||||
for p in $perms; do a "pm grant $pkg $p" 2>/dev/null || true; done
|
||||
[ "$disabled" = 1 ] && su_ "pm disable-user --user 0 $pkg"
|
||||
echo "$pkg: reinstalled as uid $uid, $(a "dumpsys package $pkg" | grep -m1 versionName | xargs)"
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Restore user apps, app data and permissions onto a freshly flashed, rooted phone.
|
||||
|
||||
Sources (from the backup folder):
|
||||
apks/<pkg>/*.apk APK + splits
|
||||
rooted-fresh/dumpsys-package.txt installer, granted runtime permissions
|
||||
rooted-fresh/packages-3.txt which packages are user apps
|
||||
rooted-fresh/packages-disabled.txt frozen/disabled state
|
||||
rooted/apps-manifest.json special app-ops (overlay, all-files, ...)
|
||||
<work>/data/<pkg>, <work>/user_de/0/<pkg> unpacked data-fresh.tar
|
||||
|
||||
The device comes from ANDROID_SERIAL, as for adb. Usage: restore.py BACKUP_DIR WORK_DIR [--data-only] [pkg ...] (no pkgs = all user apps)
|
||||
--data-only only put the data back (apps already installed and updated): keeps the current APK,
|
||||
installer, permissions and frozen state. Never re-run a full restore over updated apps.
|
||||
Every action and its result is appended to WORK_DIR/restore.log.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SKIP = {"com.topjohnwu.magisk"} # installed fresh, its data must not be reused
|
||||
# Dropped on restore: regenerated by the app, and the bulk of most backups.
|
||||
VOLATILE = ["cache", "code_cache", "app_webview/Default/Cache", "app_webview/Default/Code Cache"]
|
||||
|
||||
backup, work = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
data_only = "--data-only" in sys.argv[3:]
|
||||
only = set(sys.argv[3:]) - {"--data-only"}
|
||||
log_file = (work / "restore.log").open("a")
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
line = f"{time.strftime('%H:%M:%S')} {msg}"
|
||||
print(line, flush=True)
|
||||
log_file.write(line + "\n")
|
||||
log_file.flush()
|
||||
|
||||
|
||||
def adb(*args: str, timeout: int = 300, stdin=None) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["adb", *args], capture_output=True, text=stdin is None,
|
||||
timeout=timeout, stdin=stdin)
|
||||
|
||||
|
||||
def su(cmd: str, timeout: int = 300) -> str:
|
||||
r = adb("shell", f"su -c {shlex.quote(cmd)}", timeout=timeout)
|
||||
return (r.stdout + r.stderr).strip()
|
||||
|
||||
|
||||
def parse_dumpsys(text: str) -> dict:
|
||||
"""installer + granted runtime permissions per package."""
|
||||
out = {}
|
||||
for block in re.split(r"\n Package \[", text)[1:]:
|
||||
name = block.split("]", 1)[0]
|
||||
installer = re.search(r"\n installerPackageName=(\S+)", block)
|
||||
runtime = block.split("runtime permissions:", 1)
|
||||
perms = re.findall(r"\n ([\w.]+): granted=true", runtime[1]) if len(runtime) > 1 else []
|
||||
out[name] = {"installer": installer.group(1) if installer else None, "perms": sorted(set(perms))}
|
||||
return out
|
||||
|
||||
|
||||
fresh = backup / "rooted-fresh"
|
||||
meta = parse_dumpsys((fresh / "dumpsys-package.txt").read_text(errors="replace"))
|
||||
manifest = json.loads((backup / "rooted/apps-manifest.json").read_text())["apps"]
|
||||
user_pkgs = sorted(set(re.findall(r"=([\w.]+)\s+installer=", (fresh / "packages-3.txt").read_text())) - SKIP)
|
||||
disabled = set(re.findall(r"package:([\w.]+)", (fresh / "packages-disabled.txt").read_text()))
|
||||
targets = [p for p in user_pkgs if not only or p in only]
|
||||
log(f"=== restore start: {len(targets)} packages")
|
||||
|
||||
# Pass 1: install every APK set, so installers like Obtainium exist before pass 2 names them.
|
||||
for pkg in [] if data_only else targets:
|
||||
apks = sorted(str(p) for p in (backup / "apks" / pkg).glob("*.apk"))
|
||||
if not apks:
|
||||
log(f"{pkg}: NO APK, skipped")
|
||||
continue
|
||||
# --bypass-low-target-sdk-block: Android 14+ refuses targetSdk < 24 (Automagic workarounds).
|
||||
r = subprocess.run(["adb", "install-multiple", "-r", "-d", "-g",
|
||||
"--bypass-low-target-sdk-block", *apks],
|
||||
capture_output=True, text=True, timeout=900)
|
||||
log(f"{pkg}: install {'OK' if 'Success' in r.stdout else 'FAILED ' + (r.stdout + r.stderr).strip()[-200:]}")
|
||||
|
||||
installed = set(re.findall(r"package:([\w.]+)", adb("shell", "pm list packages").stdout))
|
||||
|
||||
# Pass 2: data, ownership, SELinux labels, installer, permissions, disabled state.
|
||||
for pkg in targets:
|
||||
if pkg not in installed:
|
||||
continue
|
||||
info = meta.get(pkg, {})
|
||||
su(f"am force-stop {pkg}")
|
||||
uid = su(f"stat -c %u /data/data/{pkg}")
|
||||
if not uid.isdigit():
|
||||
log(f"{pkg}: no data dir after install ({uid}), data skipped")
|
||||
continue
|
||||
cache_gid = int(uid) - 10000 + 20000 # u0_aNNN_cache
|
||||
|
||||
members = [m for m in (f"data/{pkg}", f"user_de/0/{pkg}") if (work / m).is_dir()]
|
||||
if members:
|
||||
for m in members:
|
||||
su(f"find /data/{m} -mindepth 1 -maxdepth 1 ! -name lib -exec rm -rf {{}} +")
|
||||
excludes = [f"--exclude={m}/{v}" for m in members for v in VOLATILE]
|
||||
tar = subprocess.Popen(["tar", "-cf", "-", "-C", str(work), *excludes, *members],
|
||||
stdout=subprocess.PIPE)
|
||||
r = adb("exec-in", "su -c 'tar -xf - -C /data'", stdin=tar.stdout, timeout=1800)
|
||||
tar.wait()
|
||||
# exec-in returns once our side is sent; the phone's tar may still be writing.
|
||||
su("while pgrep -x tar >/dev/null; do sleep 0.3; done", timeout=600)
|
||||
for m in members:
|
||||
su(f"chown -R {uid}:{uid} /data/{m}; "
|
||||
f"for d in cache code_cache; do mkdir -p /data/{m}/$d; chown {uid}:{cache_gid} /data/{m}/$d; "
|
||||
# restorecon gives only app_data_file:s0; the per-app MLS categories that
|
||||
# installd put on the top directory must be copied onto its contents.
|
||||
f"chmod 2771 /data/{m}/$d; done; chcon -hR \"$(stat -c %C /data/{m})\" /data/{m}",
|
||||
timeout=900)
|
||||
wrong = su(f"find {' '.join('/data/' + m for m in members)} ! -user {uid} | wc -l")
|
||||
log(f"{pkg}: data {'+'.join(members)} rc={r.returncode} uid={uid} wrong_owner={wrong}")
|
||||
else:
|
||||
log(f"{pkg}: no data in backup")
|
||||
if data_only:
|
||||
continue
|
||||
|
||||
inst = info.get("installer")
|
||||
if inst and inst != "null" and inst in installed | {"com.android.vending"}:
|
||||
log(f"{pkg}: installer {inst} -> {su(f'pm set-installer {pkg} {inst}') or 'OK'}")
|
||||
|
||||
# -g granted everything at install; revoke what was not granted before.
|
||||
wanted = set(info.get("perms", []))
|
||||
now = re.findall(r"\n\s+([\w.]+): granted=true", su(f"dumpsys package {pkg} | sed -n '/runtime permissions:/,$p'"))
|
||||
revoked = [p for p in set(now) - wanted]
|
||||
for p in revoked:
|
||||
su(f"pm revoke {pkg} {p}")
|
||||
for p in wanted - set(now):
|
||||
su(f"pm grant {pkg} {p}")
|
||||
ops = manifest.get(pkg, {}).get("specialAppOps", {})
|
||||
for op, mode in ops.items():
|
||||
su(f"appops set {pkg} {op} {mode}")
|
||||
log(f"{pkg}: perms granted={len(wanted)} revoked={len(revoked)} appops={','.join(ops) or '-'}")
|
||||
|
||||
if pkg in disabled:
|
||||
log(f"{pkg}: disable-user -> {su(f'pm disable-user --user 0 {pkg}')}")
|
||||
|
||||
log("=== restore done")
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch every user app with a launcher activity, one by one: unfreeze if frozen, cold start,
|
||||
# wait, screenshot, collect its crash/ANR/keystore log lines, check it is alive and in front,
|
||||
# then stop it and re-freeze. Writes OUT_DIR/results.tsv, OUT_DIR/<pkg>.png, OUT_DIR/<pkg>.log.
|
||||
# Usage: test-all-apps.sh OUT_DIR [pkg ...]
|
||||
set -uo pipefail
|
||||
out=$1; shift
|
||||
mkdir -p "$out"
|
||||
a() { adb shell "$@" </dev/null; }
|
||||
WAIT=${WAIT:-8}
|
||||
|
||||
if [ $# -gt 0 ]; then pkgs=("$@"); else
|
||||
mapfile -t pkgs < <(a 'pm list packages -3' | tr -d '\r' | sed 's/package://' | sort)
|
||||
fi
|
||||
frozen=$(a 'pm list packages -d' | tr -d '\r' | sed 's/package://')
|
||||
printf 'pkg\twas_frozen\tlaunch_ms\talive\tforeground\tcrash\tanr\tkeystore\tnote\n' > "$out/results.tsv"
|
||||
|
||||
for p in "${pkgs[@]}"; do
|
||||
wf=0; grep -qx "$p" <<<"$frozen" && wf=1
|
||||
[ $wf = 1 ] && a "su -c 'pm enable --user 0 $p'" >/dev/null
|
||||
act=$(a "cmd package resolve-activity --brief -c android.intent.category.LAUNCHER $p" | tr -d '\r' | tail -1)
|
||||
if [[ "$act" != */* ]]; then
|
||||
[ $wf = 1 ] && a "su -c 'pm disable-user --user 0 $p'" >/dev/null
|
||||
printf '%s\t%s\t-\t-\t-\t-\t-\t-\tno launcher activity\n' "$p" "$wf" >> "$out/results.tsv"; continue
|
||||
fi
|
||||
a 'logcat -c'
|
||||
ms=$(a "am start -W -S -a android.intent.action.MAIN -c android.intent.category.LAUNCHER -n '$act'" | awk '/TotalTime/{print $2}')
|
||||
sleep "$WAIT"
|
||||
adb exec-out screencap -p > "$out/$p.png" </dev/null
|
||||
pid=$(a "pidof $p" | tr -d '\r')
|
||||
fg=$(a 'dumpsys activity activities' | grep -m1 -E 'topResumedActivity|mResumedActivity' | grep -c "$p/")
|
||||
a 'logcat -d' > "$out/$p.log"
|
||||
crash=$(grep -cE "FATAL EXCEPTION|Process: $p|Fatal signal" "$out/$p.log")
|
||||
anr=$(grep -cE "ANR in $p" "$out/$p.log")
|
||||
ks=$(grep -ciE "AEADBadTag|KeyStoreException|VERIFICATION_FAILED|KeyPermanentlyInvalidated|UnrecoverableKey|BadPaddingException|file is not a database|SQLiteDatabaseCorrupt" "$out/$p.log")
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t\n' "$p" "$wf" "${ms:-?}" "${pid:+yes}" "$fg" "$crash" "$anr" "$ks" >> "$out/results.tsv"
|
||||
echo "$p frozen=$wf ms=${ms:-?} alive=${pid:+yes} fg=$fg crash=$crash anr=$anr ks=$ks"
|
||||
a "am force-stop $p; input keyevent KEYCODE_HOME"
|
||||
[ $wf = 1 ] && a "su -c 'pm disable-user --user 0 $p'" >/dev/null
|
||||
done
|
||||
Reference in new issue
Block a user