diff --git a/.gitignore b/.gitignore
index c5ba58a..76b0820 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,10 @@ ATK_*.log
# Temp
*.tmp
/tmp/
+
+# ── PROPRIETARY — Logcat visual map. NEVER publish to GitHub. ──
+# (Builds into the shipped binary as obfuscated JS + native Go; source stays private.)
+/proprietary/
+/backend_logcatpatterns.go
+/frontend/src/lib/logcatgraph.ts
+/frontend/src/components/views/LogcatMap.tsx
diff --git a/README.md b/README.md
index b708b56..3495a0a 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,14 @@
-
ATK · Android Tool kit
+
ATK · Android Toolkit
An all-in-one Android command centre with a real-time system-map debugging engine.
+
@@ -53,11 +54,11 @@ ATK builds on these open-source projects. Go star them:
- **[PixelFlasher](https://github.com/badabing2005/PixelFlasher)** (badabing2005): Pixel flash-sequence reference.
- **[Wails](https://wails.io)**: Go and Web application framework.
- **[Lucide](https://lucide.dev)**: icon set.
-- **[adb-gui-kit](https://github.com/Drenzzz/adb-gui-kit)** (Drenzzz): early base ADB GUI groundwork this project started from.
+- **[adb-gui-kit](https://github.com/Drenzzz/adb-gui-kit)** (Drenzzz): early ADB GUI groundwork this project started from.
### 🗺️ See the Live System Map in action
-Real-time demos of the map engine showing live device telemetry: You may have seen this viral on X, it is getting around ! Yes it came from here, This is the original implementation.
+Real-time demos of the map engine showing live device telemetry:
**▶️ Demo 1**
@@ -105,6 +106,10 @@ https://github.com/user-attachments/assets/47a3590a-11f8-416f-b972-0e89d933419c
+> [!NOTE]
+> The four demos above are hosted on GitHub's attachment CDN, so they play inline
+> here. The copies in `screenshot/*.mp4` are no longer needed for playback and you
+> can delete them to keep the repo small.
---
@@ -326,6 +331,11 @@ wails build -tags webkit2_41
**Dev mode (hot reload):** `wails dev -tags webkit2_41`
+> [!NOTE]
+> **About the Live System Map.** The map engine is the one closed-source part of
+> ATK, and its sources are not in this public repo. The **pre-built releases ship
+> the complete app**, map included, and that is the supported way to run ATK with
+> the map. Building from this repo gives you the full toolkit minus the map module.
**Package as .deb**
```bash
diff --git a/backend_gsi.go b/backend_gsi.go
new file mode 100644
index 0000000..59cce14
--- /dev/null
+++ b/backend_gsi.go
@@ -0,0 +1,302 @@
+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 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
+}
diff --git a/backend_intent.go b/backend_intent.go
new file mode 100644
index 0000000..f7fa816
--- /dev/null
+++ b/backend_intent.go
@@ -0,0 +1,123 @@
+package main
+
+// Intent Lab — list an app's launchable (exported) activities and start them via
+// `am start`, plus a free-form implicit-intent launcher. Lets a user reach hidden
+// settings menus / internal screens that aren't on the launcher.
+//
+// Activity discovery uses `dumpsys package `: components that appear in the
+// Activity Resolver Table have an intent filter, so they're launchable by the
+// shell user. Non-filtered/exported=false activities generally can't be started
+// from adb; the launcher surfaces the real `am start` result either way.
+
+import (
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+type IntentActivity struct {
+ Name string `json:"name"` // activity class, relative to the package
+ Component string `json:"component"` // full "package/activity" target for am start
+ Exported bool `json:"exported"` // appears in the resolver table (has an intent filter)
+}
+
+// componentRe guards the `am start -n ` target against shell injection
+// on the device side (adb shell reparses the command).
+var componentRe = regexp.MustCompile(`^[A-Za-z0-9_.]+/[A-Za-z0-9_.$]+$`)
+var actionRe = regexp.MustCompile(`^[A-Za-z0-9_.]+$`)
+
+// ListActivities returns the launchable activities of an installed package.
+func (a *App) ListActivities(packageName string) ([]IntentActivity, error) {
+ if err := validatePackageName(packageName); err != nil {
+ return nil, err
+ }
+ dump, err := a.runAdbShellTimeout(30*1e9, "dumpsys", "package", packageName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query package: %w", err)
+ }
+
+ prefix := packageName + "/"
+ seen := map[string]bool{}
+ var acts []IntentActivity
+ inActivities := false
+
+ for _, line := range strings.Split(dump, "\n") {
+ t := strings.TrimSpace(line)
+ switch {
+ case strings.Contains(t, "Activity Resolver Table"):
+ inActivities = true
+ continue
+ case strings.Contains(t, "Receiver Resolver Table"),
+ strings.Contains(t, "Service Resolver Table"),
+ strings.Contains(t, "Provider Resolver Table"),
+ strings.Contains(t, "Preferred Activities"),
+ strings.Contains(t, "Key Set Manager"):
+ inActivities = false
+ }
+ if !inActivities {
+ continue
+ }
+ for _, tok := range strings.Fields(t) {
+ if strings.HasPrefix(tok, prefix) && len(tok) > len(prefix) && !seen[tok] {
+ seen[tok] = true
+ acts = append(acts, IntentActivity{
+ Name: strings.TrimPrefix(tok, prefix),
+ Component: tok,
+ Exported: true,
+ })
+ }
+ }
+ }
+
+ sort.Slice(acts, func(i, j int) bool { return acts[i].Name < acts[j].Name })
+ return acts, nil
+}
+
+// StartActivity launches an explicit component ("package/activity").
+func (a *App) StartActivity(component string) (string, error) {
+ component = strings.TrimSpace(component)
+ if !componentRe.MatchString(component) {
+ return "", fmt.Errorf("invalid component, expected package/activity: %s", component)
+ }
+ out, err := a.runAdbShell("am", "start", "-n", component)
+ return interpretAmResult(out, err)
+}
+
+// StartIntentAction launches an implicit intent by action, with an optional data URI.
+func (a *App) StartIntentAction(action, data string) (string, error) {
+ action = strings.TrimSpace(action)
+ if action == "" || !actionRe.MatchString(action) {
+ return "", fmt.Errorf("invalid or empty action")
+ }
+ args := []string{"am", "start", "-a", action}
+ if data = strings.TrimSpace(data); data != "" {
+ // Reject shell metacharacters — adb shell reparses this on the device.
+ if strings.ContainsAny(data, " \t\n\r;&|`$<>()\"'\\") {
+ return "", fmt.Errorf("data URI contains disallowed characters")
+ }
+ args = append(args, "-d", data)
+ }
+ out, err := a.runAdbShell(args...)
+ return interpretAmResult(out, err)
+}
+
+// interpretAmResult normalises `am start` output — it often prints errors to
+// stdout with a zero exit, so inspect the text as well as err.
+func interpretAmResult(out string, err error) (string, error) {
+ out = strings.TrimSpace(out)
+ if err != nil {
+ if out != "" {
+ return "", fmt.Errorf("%s", firstLine(out))
+ }
+ return "", err
+ }
+ if strings.Contains(out, "Error:") || strings.Contains(out, "Exception") ||
+ strings.Contains(out, "does not exist") || strings.Contains(out, "Permission Denial") {
+ return "", fmt.Errorf("%s", firstLine(out))
+ }
+ if out == "" {
+ out = "Started."
+ }
+ return out, nil
+}
diff --git a/backend_privacy.go b/backend_privacy.go
new file mode 100644
index 0000000..952a636
--- /dev/null
+++ b/backend_privacy.go
@@ -0,0 +1,263 @@
+package main
+
+// Privacy & Tracker Scanner.
+//
+// Pulls an installed app's base APK, scans its DEX bytecode for known
+// third-party tracker/analytics/ad SDK signatures, cross-references declared
+// dangerous permissions, and derives a 0-100 Privacy Score (A-F grade). This
+// reuses the APK auditor's DEX tracker matcher (matchTrackers /
+// trackerSignatures) and the shared dangerousPermissions set, and additionally
+// enriches the shared tracker DB below (which also improves the full auditor).
+//
+// Data source: a bundled static signature list (Exodus-Privacy-style code
+// signatures = Java package prefixes as they appear in classes*.dex). No runtime
+// network — works fully offline on any device.
+
+import (
+ "archive/zip"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// extraTrackerSignatures widens the built-in trackerSignatures set with more
+// well-known Exodus-catalogued SDKs. Merged into the shared maps at init so both
+// the Privacy Scanner and the APK auditor see them. Signatures are conservative
+// package prefixes chosen to avoid false positives.
+var extraTrackerSignatures = map[string][]string{
+ "Google Tag Manager": {"com/google/android/gms/tagmanager"},
+ "Amazon Mobile Ads": {"com/amazon/device/ads", "com/amazon/aps"},
+ "AdColony": {"com/adcolony"},
+ "Startapp": {"com/startapp"},
+ "Mintegral": {"com/mbridge", "com/mintegral"},
+ "Pangle (ByteDance)": {"com/bytedance/sdk/openadsdk", "com/bytedance/pangle"},
+ "ByteDance AppLog": {"com/bytedance/applog"},
+ "PubMatic": {"com/pubmatic"},
+ "Criteo": {"com/criteo"},
+ "Smaato": {"com/smaato"},
+ "Fyber": {"com/fyber"},
+ "Taboola": {"com/taboola"},
+ "Outbrain": {"com/outbrain"},
+ "CleverTap": {"com/clevertap"},
+ "MoEngage": {"com/moengage"},
+ "Airship": {"com/urbanairship"},
+ "Leanplum": {"com/leanplum"},
+ "Batch": {"com/batch/android"},
+ "Iterable": {"com/iterable"},
+ "Pushwoosh": {"com/pushwoosh"},
+ "Swrve": {"com/swrve"},
+ "Optimizely": {"com/optimizely"},
+ "Adobe Experience": {"com/adobe/marketing/mobile", "com/adobe/mobile"},
+ "New Relic": {"com/newrelic"},
+ "Datadog": {"com/datadog/android"},
+ "Instabug": {"com/instabug"},
+ "Embrace": {"io/embrace/android"},
+ "Countly": {"ly/count/android"},
+ "Matomo": {"org/matomo", "org/piwik"},
+ "Snowplow": {"com/snowplowanalytics"},
+ "Smartlook": {"com/smartlook"},
+ "Nielsen": {"com/nielsen/app"},
+ "Mapbox Telemetry": {"com/mapbox/android/telemetry"},
+ "Foursquare": {"com/foursquare"},
+ "Gimbal": {"com/gimbal"},
+ "Radar": {"io/radar/sdk"},
+}
+
+var extraTrackerCategory = map[string]string{
+ "Google Tag Manager": "Analytics", "Amazon Mobile Ads": "Advertising", "AdColony": "Advertising",
+ "Startapp": "Advertising", "Mintegral": "Advertising", "Pangle (ByteDance)": "Advertising",
+ "ByteDance AppLog": "Analytics", "PubMatic": "Advertising", "Criteo": "Advertising",
+ "Smaato": "Advertising", "Fyber": "Advertising", "Taboola": "Advertising", "Outbrain": "Advertising",
+ "CleverTap": "Analytics", "MoEngage": "Marketing", "Airship": "Marketing", "Leanplum": "Marketing",
+ "Batch": "Marketing", "Iterable": "Marketing", "Pushwoosh": "Push/Analytics", "Swrve": "Marketing",
+ "Optimizely": "Analytics", "Adobe Experience": "Analytics", "New Relic": "Analytics",
+ "Datadog": "Analytics", "Instabug": "Crash reporting", "Embrace": "Crash reporting",
+ "Countly": "Analytics", "Matomo": "Analytics", "Snowplow": "Analytics", "Smartlook": "Analytics",
+ "Nielsen": "Analytics", "Mapbox Telemetry": "Location", "Foursquare": "Location",
+ "Gimbal": "Location", "Radar": "Location",
+}
+
+func init() {
+ for name, sigs := range extraTrackerSignatures {
+ if _, exists := trackerSignatures[name]; !exists {
+ trackerSignatures[name] = sigs
+ }
+ }
+ for name, cat := range extraTrackerCategory {
+ if _, exists := trackerCategory[name]; !exists {
+ trackerCategory[name] = cat
+ }
+ }
+}
+
+// privacyCategoryWeight is the score penalty per unique tracker of a category.
+// Advertising / attribution / location are weighted heaviest (most invasive);
+// crash reporting is light (usually operational, not surveillance).
+var privacyCategoryWeight = map[string]int{
+ "Advertising": 12,
+ "Location": 12,
+ "Attribution": 8,
+ "Marketing": 8,
+ "Analytics": 7,
+ "Push/Analytics": 6,
+ "Crash reporting": 3,
+}
+
+const defaultTrackerWeight = 6
+
+type PrivacyTracker struct {
+ Name string `json:"name"`
+ Category string `json:"category"`
+ Matches int `json:"matches"`
+}
+
+type PrivacyReport struct {
+ PackageName string `json:"packageName"`
+ Score int `json:"score"` // 0-100, higher = more private
+ Grade string `json:"grade"` // A-F
+ TrackerCount int `json:"trackerCount"`
+ Trackers []PrivacyTracker `json:"trackers"`
+ DangerousPermissions []string `json:"dangerousPermissions"`
+ ApkSize int64 `json:"apkSize"`
+}
+
+// ScanAppPrivacy pulls the base APK of an installed package, scans it for
+// tracker SDKs, collects its declared dangerous permissions, and scores it.
+func (a *App) ScanAppPrivacy(packageName string) (PrivacyReport, error) {
+ if err := validatePackageName(packageName); err != nil {
+ return PrivacyReport{}, err
+ }
+ report := PrivacyReport{PackageName: packageName}
+
+ // Locate the base APK on the device.
+ out, err := a.runAdbShell("pm", "path", packageName)
+ if err != nil {
+ return report, fmt.Errorf("could not locate package on device: %w", err)
+ }
+ var remote string
+ for _, line := range strings.Split(out, "\n") {
+ p := strings.TrimPrefix(strings.TrimSpace(line), "package:")
+ if strings.HasSuffix(p, "base.apk") {
+ remote = p
+ break
+ }
+ if remote == "" && strings.HasSuffix(p, ".apk") {
+ remote = p
+ }
+ }
+ if remote == "" {
+ return report, fmt.Errorf("no APK path found for %s", packageName)
+ }
+
+ // Pull to a temp file; unlike the auditor we don't need to keep it around.
+ tmp := filepath.Join(os.TempDir(), "atk-privacy-"+sanitizeFileToken(packageName)+".apk")
+ if _, err := a.runCommandTimeout(auditCommandTimeout, "adb", "pull", remote, tmp); err != nil {
+ return report, fmt.Errorf("failed to pull APK: %w", err)
+ }
+ defer os.Remove(tmp)
+ if info, statErr := os.Stat(tmp); statErr == nil {
+ report.ApkSize = info.Size()
+ }
+
+ // Scan DEX bytecode for tracker signatures.
+ trackerHits := map[string]int{}
+ if zr, zerr := zip.OpenReader(tmp); zerr == nil {
+ for _, f := range zr.File {
+ if !strings.HasPrefix(f.Name, "classes") || !strings.HasSuffix(f.Name, ".dex") {
+ continue
+ }
+ if f.UncompressedSize64 > maxDexBytes {
+ continue
+ }
+ if data := readZipEntry(f); data != nil {
+ matchTrackers(data, trackerHits)
+ }
+ }
+ zr.Close()
+ } else {
+ return report, fmt.Errorf("could not open pulled APK: %w", zerr)
+ }
+ for name, n := range trackerHits {
+ report.Trackers = append(report.Trackers, PrivacyTracker{
+ Name: name, Category: trackerCategory[name], Matches: n,
+ })
+ }
+ // Heaviest categories first, then alphabetical.
+ sort.Slice(report.Trackers, func(i, j int) bool {
+ wi, wj := privacyCategoryWeight[report.Trackers[i].Category], privacyCategoryWeight[report.Trackers[j].Category]
+ if wi != wj {
+ return wi > wj
+ }
+ return report.Trackers[i].Name < report.Trackers[j].Name
+ })
+ report.TrackerCount = len(report.Trackers)
+
+ // Declared dangerous permissions (a permission named anywhere in the package
+ // dump is declared/involved for this package).
+ report.DangerousPermissions = a.declaredDangerousPermissions(packageName)
+
+ report.Score, report.Grade = computePrivacyScore(report.Trackers, report.DangerousPermissions)
+ return report, nil
+}
+
+// declaredDangerousPermissions returns the dangerous permissions the package
+// declares, read from its dumpsys output.
+func (a *App) declaredDangerousPermissions(packageName string) []string {
+ dump, err := a.runAdbShellTimeout(30*1e9, "dumpsys", "package", packageName)
+ if err != nil || dump == "" {
+ return nil
+ }
+ var found []string
+ for perm := range dangerousPermissions {
+ if strings.Contains(dump, perm) {
+ found = append(found, perm)
+ }
+ }
+ sort.Strings(found)
+ return found
+}
+
+// computePrivacyScore derives a 0-100 score (higher = more private) and an A-F
+// grade from the detected trackers and declared dangerous permissions. Trackers
+// dominate; permissions are a secondary, capped penalty.
+func computePrivacyScore(trackers []PrivacyTracker, dangerousPerms []string) (int, string) {
+ score := 100
+ for _, t := range trackers {
+ w, ok := privacyCategoryWeight[t.Category]
+ if !ok {
+ w = defaultTrackerWeight
+ }
+ score -= w
+ }
+ // Permissions: -2 each, capped at -24 so a permission-heavy but tracker-free
+ // app (e.g. a camera app) isn't punished as hard as a tracker-laden one.
+ permPenalty := len(dangerousPerms) * 2
+ if permPenalty > 24 {
+ permPenalty = 24
+ }
+ score -= permPenalty
+
+ if score < 0 {
+ score = 0
+ }
+ if score > 100 {
+ score = 100
+ }
+
+ var grade string
+ switch {
+ case score >= 85:
+ grade = "A"
+ case score >= 70:
+ grade = "B"
+ case score >= 55:
+ grade = "C"
+ case score >= 40:
+ grade = "D"
+ default:
+ grade = "F"
+ }
+ return score, grade
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 73668ce..8ee6283 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -13,11 +13,13 @@ import ViewDebloater from './components/views/ViewDebloater'
import ViewShell from './components/views/ViewShell'
import ViewLogcat from './components/views/ViewLogcat'
import ViewAppInspect from './components/views/ViewAppInspect'
+import ViewIntentLab from './components/views/ViewIntentLab'
import ViewApkAudit from './components/views/ViewApkAudit'
import ViewCerts from './components/views/ViewCerts'
import ViewBackup from './components/views/ViewBackup'
import ViewProps from './components/views/ViewProps'
import ViewFlasher from './components/views/ViewFlasher'
+import ViewGsiLoader from './components/views/ViewGsiLoader'
import ViewUtilities from './components/views/ViewUtilities'
import ViewSettings from './components/views/ViewSettings'
import { CheckSystemRequirements } from './lib/wails'
@@ -57,11 +59,13 @@ export default function App() {
case 'shell': return
case 'logcat': return
case 'appinspect': return
+ case 'intentlab': return
case 'apkaudit': return
case 'certs': return
case 'backup': return
case 'props': return
case 'flasher': return
+ case 'gsiloader': return
case 'utilities': return
case 'settings': return
default: return
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
index 9a79be7..24caea1 100644
--- a/frontend/src/components/layout/Sidebar.tsx
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -1,7 +1,7 @@
import {
LayoutDashboard, FolderOpen, Package, Terminal,
Zap, Wrench, Settings, Shield,
- ScrollText, Search, Lock, Archive, SlidersHorizontal, ScanSearch, MonitorSmartphone
+ ScrollText, Search, Lock, Archive, SlidersHorizontal, ScanSearch, MonitorSmartphone, Rocket, HardDriveDownload
} from 'lucide-react'
import { useState, useEffect, useMemo, useRef } from 'react'
import type { View } from '../../lib/types'
@@ -26,12 +26,14 @@ const navItems: NavItem[] = [
{ view: 'shell', icon: , label: 'Shell' },
{ view: 'logcat', icon: , label: 'Logcat', dividerBefore: true },
{ view: 'appinspect', icon: , label: 'App Inspector' },
+ { view: 'intentlab', icon: , label: 'Intent Lab' },
{ view: 'apkaudit', icon: , label: 'APK Audit' },
{ view: 'certs', icon: , label: 'Certificates' },
{ view: 'backup', icon: , label: 'Backup' },
{ view: 'props', icon: , label: 'Prop Editor' },
{ view: 'utilities', icon: , label: 'Utilities', dividerBefore: true },
{ view: 'flasher', icon: , label: 'Flasher' },
+ { view: 'gsiloader', icon: , label: 'GSI Loader' },
]
interface DragProps {
diff --git a/frontend/src/components/views/ViewApkAudit.tsx b/frontend/src/components/views/ViewApkAudit.tsx
index 4344af0..a178f58 100644
--- a/frontend/src/components/views/ViewApkAudit.tsx
+++ b/frontend/src/components/views/ViewApkAudit.tsx
@@ -9,6 +9,7 @@ import {
ReadAPKEntry, ExportAudit,
} from '../../lib/wails'
import { notify } from '../../lib/notify'
+import { CodeView, detectLang } from '../../lib/syntax'
import type { APKAudit, APKAuditFinding, APKEntryContent, PackageInfo } from '../../lib/types'
type Tab = 'overview' | 'findings' | 'manifest' | 'components' | 'cert' | 'explorer'
@@ -531,7 +532,11 @@ export default function ViewApkAudit() {
)}
{entry?.kind === 'text' && (
-
{entry.text}
+
)}
{entry?.kind === 'binary' && (
{entry.hex}
diff --git a/frontend/src/components/views/ViewAppInspect.tsx b/frontend/src/components/views/ViewAppInspect.tsx
index 3bdb123..0edd004 100644
--- a/frontend/src/components/views/ViewAppInspect.tsx
+++ b/frontend/src/components/views/ViewAppInspect.tsx
@@ -1,8 +1,14 @@
-import { useState } from 'react'
-import { Search, Package, Shield, Activity, Server, Database, Cpu, FileCode, AlertTriangle } from 'lucide-react'
-import { InspectApp, CheckPinning, ListPackages } from '../../lib/wails'
+import { useState, useEffect } from 'react'
+import { Search, Package, Shield, ShieldCheck, Activity, Server, Database, Cpu, FileCode, AlertTriangle, Radar } from 'lucide-react'
+import { InspectApp, CheckPinning, ListPackages, ScanAppPrivacy } from '../../lib/wails'
import { notify } from '../../lib/notify'
-import type { AppInspection, PackageInfo } from '../../lib/types'
+import { CodeView } from '../../lib/syntax'
+import type { AppInspection, PackageInfo, PrivacyReport } from '../../lib/types'
+
+// Privacy grade -> tailwind text/badge colour.
+const GRADE_COLOR: Record = {
+ A: 'text-accent-green', B: 'text-accent-green', C: 'text-warn', D: 'text-warn', F: 'text-danger',
+}
export default function ViewAppInspect() {
const [search, setSearch] = useState('')
@@ -13,6 +19,8 @@ export default function ViewAppInspect() {
const [pinning, setPinning] = useState('')
const [activeTab, setActiveTab] = useState('overview')
const [showManifest, setShowManifest] = useState(false)
+ const [privacy, setPrivacy] = useState(null)
+ const [privacyLoading, setPrivacyLoading] = useState(false)
// Width of the package picker rail. Draggable so long package names (which
// truncate at the old fixed 256px) can be read in full. Persisted.
const [panelW, setPanelW] = useState(() => {
@@ -49,11 +57,16 @@ export default function ViewAppInspect() {
} catch {}
}
+ // Populate the picker as soon as the view opens (so it isn't empty until the
+ // search box is focused). Safe with no device — it just stays empty.
+ useEffect(() => { loadPackages() }, [])
+
const inspect = async (pkg: string) => {
if (!pkg.trim()) return
setLoading(true)
setResult(null)
setPinning('')
+ setPrivacy(null)
setActiveTab('overview')
try {
const data = await InspectApp(pkg.trim())
@@ -65,6 +78,19 @@ export default function ViewAppInspect() {
}
}
+ const scanPrivacy = async () => {
+ if (!result) return
+ setPrivacyLoading(true)
+ try {
+ const rep = await ScanAppPrivacy(result.packageName)
+ setPrivacy(rep)
+ } catch (e: any) {
+ notify.error(e)
+ } finally {
+ setPrivacyLoading(false)
+ }
+ }
+
const checkPinning = async () => {
if (!result) return
try {
@@ -81,6 +107,7 @@ export default function ViewAppInspect() {
const tabs = [
{ id: 'overview', label: 'Overview', icon: },
+ { id: 'privacy', label: privacy ? `Privacy (${privacy.grade})` : 'Privacy', icon: },
{ id: 'permissions', label: `Permissions (${result?.permissions?.length || 0})`, icon: },
{ id: 'components', label: 'Components', icon: },
{ id: 'libs', label: 'Native Libs', icon: },
@@ -218,6 +245,100 @@ export default function ViewAppInspect() {
)}
+ {activeTab === 'privacy' && (
+
+ {!privacy && (
+
+
+
+ Scans the app's bytecode for known tracker / analytics / ad SDKs and
+ cross-references dangerous permissions to compute a privacy score.
+ Pulls the APK off the device — may take a few seconds.
+
+ Heuristic: matches known SDK package signatures in DEX bytecode (string
+ constants aren't decrypted, so obfuscated/encrypted trackers may be missed)
+ and counts declared Android "dangerous" permissions. A lower score means more
+ trackers / invasive permissions.
+
+ Installs a GSI as a temporary guest OS via Dynamic System Updates — no unlock,
+ no data wipe. Pick a raw (unsparsed) GSI system.img or a
+ .gz you made from one. After install, tap Restart in the device notification to boot it.
+
System size (bytes) — auto for raw .img, required for .gz
+ setSystemSize(Math.max(0, Number(e.target.value)))} placeholder="0 = auto (raw .img)" />
+
+
+
+ {pushPct >= 0 && (
+
+
+
+
+ {pushPct}%
+
+ )}
+
+
+
+ {installing ? 'Installing…' : 'Install DSU'}
+
+ Reboot('')} className="btn-ghost text-xs" title="Cold reboot — boots the GSI if just installed, or back to the host OS"> Reboot
+
+ Destructive & permanent. Erases the system partition, wipes userdata, and needs an
+ unlocked bootloader. An incompatible GSI can leave the device unbootable — keep the stock factory image to recover. GSIs don't support rollback.
+
+ No launchable activities{activities.length > 0 ? ' match the filter' : ' — this app exports none, or requires root to reach its internal screens'}.
+
+ )}
+
{/* Visual map — kept mounted so it keeps ingesting the stream; hidden in text mode */}
@@ -343,22 +464,25 @@ export default function ViewLogcat() {
{running ? 'Waiting for log output...' : 'Press Start to begin streaming logcat'}