- New sidebar views: GSI Loader (DSU + permanent fastboot flash), APK Audit, Firmware, Intent Lab, Screen Mirror - New backend modules: backend_gsi.go, backend_apkaudit*.go, backend_firmware.go, backend_intent.go, backend_magisk.go, backend_applock.go, backend_scrcpy.go, backend_payload.go, backend_privacy.go, backend_overview.go, backend_bootinfo.go, backend_flasher.go, backend_transfer.go, backend_filehttp.go - App Lock / danger-gate infrastructure (DangerGate, LockGate, applock.ts) - Privileged uninstall via embedded Android helper dex (android-helper/) - nfpm packaging version bump to 1.2.0
302 lines
10 KiB
Go
302 lines
10 KiB
Go
package main
|
|
|
|
// GSI Loader backend — two ways to run a Generic System Image on the device:
|
|
//
|
|
// 1. DSU (temporary): install a GSI as a guest OS via Dynamic System Updates.
|
|
// Non-destructive, no unlock, no wipe. Follows the exact adb flow from the
|
|
// Android DSU docs (see dsi_info.txt): gzip the raw image, push it to
|
|
// /storage/emulated/0/Download, then fire the START_INSTALL intent at
|
|
// com.android.dynsystem. Managed afterwards with gsi_tool.
|
|
//
|
|
// 2. GSI flash (permanent): fastboot-flash a GSI to the system partition.
|
|
// Destructive; DANGER-gated (App Lock). Sequences the documented fastboot
|
|
// steps with a dry-run preview.
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const dsuDefaultUserdata int64 = 8589934592 // 8 GiB
|
|
const dsuRemoteDir = "/storage/emulated/0/Download"
|
|
|
|
// GsiCompat reports whether the device can run a GSI and which one.
|
|
type GsiCompat struct {
|
|
TrebleEnabled bool `json:"trebleEnabled"`
|
|
Abi string `json:"abi"` // ro.product.cpu.abi, e.g. arm64-v8a
|
|
GsiArch string `json:"gsiArch"` // derived: arm64 / x86_64 / arm / x86
|
|
VndkIsolated bool `json:"vndkIsolated"` // true => any newer GSI ok; false => same-version only
|
|
AndroidRelease string `json:"androidRelease"`
|
|
Sdk string `json:"sdk"`
|
|
DsuStatus string `json:"dsuStatus"`
|
|
}
|
|
|
|
// GsiCompat runs the documented compatibility checks (getprop + ld.config).
|
|
func (a *App) GsiCompat() (GsiCompat, error) {
|
|
c := GsiCompat{}
|
|
if v, err := a.runAdbShell("getprop", "ro.treble.enabled"); err == nil {
|
|
c.TrebleEnabled = strings.TrimSpace(v) == "true"
|
|
}
|
|
if v, err := a.runAdbShell("getprop", "ro.product.cpu.abi"); err == nil {
|
|
c.Abi = strings.TrimSpace(v)
|
|
c.GsiArch = gsiArchForAbi(c.Abi)
|
|
}
|
|
if v, err := a.runAdbShell("getprop", "ro.build.version.release"); err == nil {
|
|
c.AndroidRelease = strings.TrimSpace(v)
|
|
}
|
|
if v, err := a.runAdbShell("getprop", "ro.build.version.sdk"); err == nil {
|
|
c.Sdk = strings.TrimSpace(v)
|
|
}
|
|
if v, err := a.runAdbShell("cat", "/system/etc/ld.config.version_identifier.txt"); err == nil {
|
|
c.VndkIsolated = vendorNamespaceIsolated(v)
|
|
}
|
|
if v, err := a.runAdbShell("gsi_tool", "status"); err == nil {
|
|
c.DsuStatus = strings.TrimSpace(v)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func gsiArchForAbi(abi string) string {
|
|
switch {
|
|
case strings.HasPrefix(abi, "arm64"):
|
|
return "arm64"
|
|
case strings.HasPrefix(abi, "x86_64"):
|
|
return "x86_64"
|
|
case strings.HasPrefix(abi, "x86"):
|
|
return "x86"
|
|
case strings.HasPrefix(abi, "arm"):
|
|
return "arm"
|
|
}
|
|
return abi
|
|
}
|
|
|
|
// vendorNamespaceIsolated parses ld.config for the [vendor] section and reports
|
|
// whether namespace.default.isolated is true (full VNDK => any newer GSI works).
|
|
func vendorNamespaceIsolated(ld string) bool {
|
|
inVendor := false
|
|
for _, line := range strings.Split(ld, "\n") {
|
|
t := strings.TrimSpace(line)
|
|
if strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
|
|
inVendor = t == "[vendor]"
|
|
continue
|
|
}
|
|
if inVendor && strings.Contains(t, "namespace.default.isolated") {
|
|
return strings.Contains(strings.ToLower(t), "true")
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// --- gsi_tool management ---------------------------------------------------
|
|
|
|
func (a *App) GsiDsuStatus() (string, error) { return a.runAdbShell("gsi_tool", "status") }
|
|
func (a *App) DsuEnable() (string, error) { return a.runAdbShell("gsi_tool", "enable") }
|
|
func (a *App) DsuDisable() (string, error) { return a.runAdbShell("gsi_tool", "disable") }
|
|
func (a *App) DsuWipe() (string, error) { return a.runAdbShell("gsi_tool", "wipe") }
|
|
|
|
// --- DSU install -----------------------------------------------------------
|
|
|
|
// InstallDsu prepares and installs a temporary DSU from a GSI image, then fires
|
|
// the DynamicSystemInstallationService intent. `systemSize` is the UNCOMPRESSED
|
|
// raw image size in bytes (auto = file size for a raw .img; REQUIRED for a .gz).
|
|
// `userdataSize` defaults to 8 GiB when <= 0.
|
|
func (a *App) InstallDsu(imagePath string, systemSize int64, userdataSize int64) (string, error) {
|
|
imagePath = strings.TrimSpace(imagePath)
|
|
if imagePath == "" {
|
|
return "", fmt.Errorf("no GSI image selected")
|
|
}
|
|
info, err := os.Stat(imagePath)
|
|
if err != nil || info.IsDir() {
|
|
return "", fmt.Errorf("image not found: %s", imagePath)
|
|
}
|
|
if isSparseImage(imagePath) {
|
|
return "", fmt.Errorf("this looks like a SPARSE image — DSU needs an unsparsed raw image. Convert first:\n simg2img system.img system_raw.img\nthen select the raw .img (or a .gz you made from it).")
|
|
}
|
|
|
|
if userdataSize <= 0 {
|
|
userdataSize = dsuDefaultUserdata
|
|
}
|
|
|
|
var gzPath string
|
|
var sysSize int64
|
|
isGz := strings.HasSuffix(strings.ToLower(imagePath), ".gz")
|
|
if isGz {
|
|
gzPath = imagePath
|
|
if systemSize <= 0 {
|
|
return "", fmt.Errorf("for a .gz image, provide the uncompressed system image size (bytes) — DSU needs KEY_SYSTEM_SIZE")
|
|
}
|
|
sysSize = systemSize
|
|
} else {
|
|
// Raw image: size is exact; gzip it host-side as the docs require.
|
|
sysSize = info.Size()
|
|
if systemSize > 0 {
|
|
sysSize = systemSize
|
|
}
|
|
gzPath = filepath.Join(os.TempDir(), "atk-dsu.gz")
|
|
if err := gzipFile(imagePath, gzPath); err != nil {
|
|
return "", fmt.Errorf("failed to gzip image: %w", err)
|
|
}
|
|
defer os.Remove(gzPath)
|
|
}
|
|
|
|
// Push the gzipped image to the device (progress via transfer:* events).
|
|
if _, err := a.PushWithProgress(gzPath, dsuRemoteDir); err != nil {
|
|
return "", err
|
|
}
|
|
remote := dsuRemoteDir + "/" + filepath.Base(gzPath)
|
|
|
|
// Fire the DSU install intent — verbatim from the Android docs.
|
|
out, err := a.runAdbShell(
|
|
"am", "start-activity",
|
|
"-n", "com.android.dynsystem/com.android.dynsystem.VerificationActivity",
|
|
"-a", "android.os.image.action.START_INSTALL",
|
|
"-d", "file://"+remote,
|
|
"--el", "KEY_SYSTEM_SIZE", strconv.FormatInt(sysSize, 10),
|
|
"--el", "KEY_USERDATA_SIZE", strconv.FormatInt(userdataSize, 10),
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to launch DSU install: %w (%s)", err, strings.TrimSpace(out))
|
|
}
|
|
return fmt.Sprintf("DSU install started (system %s, userdata %s). On the device, tap Restart in the notification to boot the GSI, or Discard to cancel. Use 'gsi_tool enable' for sticky mode.",
|
|
humanBytes(sysSize), humanBytes(userdataSize)), nil
|
|
}
|
|
|
|
// isSparseImage checks the Android sparse-image magic (0xed26ff3a, little-endian).
|
|
func isSparseImage(path string) bool {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer f.Close()
|
|
var b [4]byte
|
|
if _, err := io.ReadFull(f, b[:]); err != nil {
|
|
return false
|
|
}
|
|
return b[0] == 0x3a && b[1] == 0xff && b[2] == 0x26 && b[3] == 0xed
|
|
}
|
|
|
|
func gzipFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
zw := gzip.NewWriter(out)
|
|
if _, err := io.Copy(zw, in); err != nil {
|
|
zw.Close()
|
|
return err
|
|
}
|
|
return zw.Close()
|
|
}
|
|
|
|
func humanBytes(n int64) string {
|
|
const u = 1024
|
|
if n < u {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
div, exp := int64(u), 0
|
|
for x := n / u; x >= u; x /= u {
|
|
div *= u
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
|
}
|
|
|
|
// --- GSI permanent flash (fastboot) ----------------------------------------
|
|
|
|
type GsiFlashOpts struct {
|
|
Fastbootd bool `json:"fastbootd"` // reboot fastboot (fastbootd) first
|
|
WipeData bool `json:"wipeData"` // fastboot -w
|
|
DisableVerity bool `json:"disableVerity"` // flash vbmeta --disable-verification
|
|
DeleteProduct bool `json:"deleteProduct"` // free space: delete product_<slot>
|
|
Slot string `json:"slot"` // "a" / "b" / "" (for delete-logical-partition)
|
|
VbmetaPath string `json:"vbmetaPath"` // required if DisableVerity
|
|
DryRun bool `json:"dryRun"`
|
|
}
|
|
|
|
type gsiStep struct {
|
|
desc string
|
|
args []string
|
|
}
|
|
|
|
func (a *App) gsiFlashSteps(imagePath string, opts GsiFlashOpts) []gsiStep {
|
|
var steps []gsiStep
|
|
if opts.Fastbootd {
|
|
steps = append(steps, gsiStep{"Reboot to fastbootd", []string{"reboot", "fastboot"}})
|
|
}
|
|
if opts.DeleteProduct {
|
|
part := "product"
|
|
if opts.Slot != "" {
|
|
part += "_" + opts.Slot
|
|
}
|
|
steps = append(steps, gsiStep{"Free space: delete " + part, []string{"delete-logical-partition", part}})
|
|
}
|
|
steps = append(steps,
|
|
gsiStep{"Erase system", []string{"erase", "system"}},
|
|
gsiStep{"Flash system", []string{"flash", "system", imagePath}},
|
|
)
|
|
if opts.WipeData {
|
|
steps = append(steps, gsiStep{"Wipe userdata", []string{"-w"}})
|
|
}
|
|
if opts.DisableVerity && strings.TrimSpace(opts.VbmetaPath) != "" {
|
|
steps = append(steps, gsiStep{"Flash vbmeta (disable verification)", []string{"--disable-verification", "flash", "vbmeta", opts.VbmetaPath}})
|
|
}
|
|
steps = append(steps, gsiStep{"Reboot", []string{"reboot"}})
|
|
return steps
|
|
}
|
|
|
|
// FlashGsiSystem flashes a GSI to the system partition via fastboot. With
|
|
// DryRun, it returns the command list without executing. Otherwise it runs each
|
|
// step, gated behind the App Lock danger check.
|
|
func (a *App) FlashGsiSystem(imagePath string, opts GsiFlashOpts) (string, error) {
|
|
imagePath = strings.TrimSpace(imagePath)
|
|
if imagePath == "" {
|
|
return "", fmt.Errorf("no GSI system image selected")
|
|
}
|
|
if !opts.DryRun {
|
|
if info, err := os.Stat(imagePath); err != nil || info.IsDir() {
|
|
return "", fmt.Errorf("image not found: %s", imagePath)
|
|
}
|
|
}
|
|
steps := a.gsiFlashSteps(imagePath, opts)
|
|
|
|
if opts.DryRun {
|
|
var b strings.Builder
|
|
for _, s := range steps {
|
|
b.WriteString("fastboot " + strings.Join(s.args, " ") + "\n")
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
if err := a.requireDangerUnlocked(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var out strings.Builder
|
|
for _, s := range steps {
|
|
out.WriteString("$ fastboot " + strings.Join(s.args, " ") + "\n")
|
|
res, err := a.runCommandTimeout(10*time.Minute, "fastboot", s.args...)
|
|
if strings.TrimSpace(res) != "" {
|
|
out.WriteString(res + "\n")
|
|
}
|
|
if err != nil {
|
|
return out.String(), fmt.Errorf("%s failed: %w", s.desc, err)
|
|
}
|
|
// fastbootd takes a few seconds to come up before it accepts commands.
|
|
if len(s.args) == 2 && s.args[0] == "reboot" && s.args[1] == "fastboot" {
|
|
time.Sleep(8 * time.Second)
|
|
}
|
|
}
|
|
return out.String(), nil
|
|
}
|