getapk 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- getapk-1.0.0.dist-info/METADATA +60 -0
- getapk-1.0.0.dist-info/RECORD +8 -0
- getapk-1.0.0.dist-info/WHEEL +5 -0
- getapk-1.0.0.dist-info/entry_points.txt +2 -0
- getapk-1.0.0.dist-info/licenses/LICENSE +21 -0
- getapk-1.0.0.dist-info/top_level.txt +1 -0
- getapk_cli/__init__.py +1 -0
- getapk_cli/cli.py +251 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: getapk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Pull an installed app from an Android device over adb as an APK (or an APKS bundle for split APKs)
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://rudranetra.com/tools
|
|
7
|
+
Project-URL: Source, https://github.com/RansomWhisker/getAPK
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# getAPK
|
|
14
|
+
|
|
15
|
+
**Pull an installed app off your Android phone as an `.apk` (or an `.apks` bundle for split apps) — in one command.**
|
|
16
|
+
|
|
17
|
+
`getapk` talks to your device through `adb`, finds where an app is installed, copies every APK file it consists of, checks each one is a real APK, and writes a single ready-to-analyse file with its SHA-256. It is the Android counterpart of [getIPA](https://github.com/RansomWhisker/getIPA).
|
|
18
|
+
|
|
19
|
+
> A tool by [**rudranetra.com**](https://rudranetra.com) · more at [rudranetra.com/tools](https://rudranetra.com/tools)
|
|
20
|
+
|
|
21
|
+
## Why not just `adb pull`?
|
|
22
|
+
|
|
23
|
+
Most Play Store apps are installed as *split APKs*: `base.apk` plus one file per CPU architecture, language and screen density. Pulling only `base.apk` gives you something that will not install and is missing native libraries and resources. getapk pulls them all and bundles them, base first, into one `.apks` file that [AppSleuth](https://github.com/RansomWhisker/AppSleuth) and bundletool-style tools understand.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
pipx install getapk
|
|
28
|
+
|
|
29
|
+
Requires Python 3.9+ and Android platform-tools (`adb`) on your PATH. Works on macOS, Linux and Windows. No Python dependencies.
|
|
30
|
+
|
|
31
|
+
On the phone: enable Developer options → USB debugging, connect, and accept the authorisation prompt. No root needed.
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
getapk --list # user-installed apps with versions and installer
|
|
36
|
+
getapk --list -m bank # filter by text
|
|
37
|
+
getapk com.example.app -o ./apps # one app
|
|
38
|
+
getapk com.a.app com.b.app # several
|
|
39
|
+
getapk --all -o ./apps # every user-installed app
|
|
40
|
+
getapk --all --include-system # include preinstalled apps
|
|
41
|
+
getapk -s SERIAL com.example.app # pick a device when several are connected
|
|
42
|
+
|
|
43
|
+
Single-APK apps are written as `<package>-<version>.apk`; split apps as `<package>-<version>.apks`. Use `--format apks` to always bundle. Then:
|
|
44
|
+
|
|
45
|
+
appsleuth scan com.example.app-1.2.3.apks
|
|
46
|
+
|
|
47
|
+
## Security
|
|
48
|
+
|
|
49
|
+
* Package names, device serials and every path reported by the device are validated before use; nothing is passed through a shell on your computer, and the one command run on the phone only ever receives a validated package name.
|
|
50
|
+
* Output file names are sanitised, so a hostile device cannot write outside the output directory.
|
|
51
|
+
* Each pulled file must be a valid APK (a zip containing `AndroidManifest.xml`).
|
|
52
|
+
* Files are written to a temporary directory and moved into place only when complete.
|
|
53
|
+
|
|
54
|
+
Only use getapk on devices and apps you own or are authorised to analyse. Apps may be copyrighted; extraction is for your own security analysis.
|
|
55
|
+
|
|
56
|
+
## Tests
|
|
57
|
+
|
|
58
|
+
python -m unittest discover -s tests -t .
|
|
59
|
+
|
|
60
|
+
MIT licence · © Rudranetra India Private Limited
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
getapk-1.0.0.dist-info/licenses/LICENSE,sha256=OvAvc5n2ihAoXNUuv2V5A4VDTsZ7rNCgGwVT8GNt35U,1114
|
|
2
|
+
getapk_cli/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
|
|
3
|
+
getapk_cli/cli.py,sha256=hlTygkXD3Q63u1T5wOOEzpnIbAkSpHufVN065H2DiX0,10658
|
|
4
|
+
getapk-1.0.0.dist-info/METADATA,sha256=5M2ZG42dKtlWtxHGASjwPrCgzfVC1VgMt7Zc0riYiOk,3113
|
|
5
|
+
getapk-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
getapk-1.0.0.dist-info/entry_points.txt,sha256=jMkZYXAYnXnqVm5rnB3y_x-97uIeY4mG8bfRH-aWSRE,47
|
|
7
|
+
getapk-1.0.0.dist-info/top_level.txt,sha256=yXpADZI-s30nmYV36JeRx6CWwOP2tYm3ZpNBt6bKziQ,11
|
|
8
|
+
getapk-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rudranetra India Private Limited (https://rudranetra.com)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
getapk_cli
|
getapk_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
getapk_cli/cli.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""getapk: pull installed apps from an Android device over adb.
|
|
2
|
+
|
|
3
|
+
Single-APK apps become `<package>-<version>.apk`. Apps installed as split APKs (most Play Store
|
|
4
|
+
apps today) become `<package>-<version>.apks`, a zip holding base.apk and every split, which
|
|
5
|
+
AppSleuth and bundletool-style tools understand. Only use it on devices and apps you are
|
|
6
|
+
authorised to analyse.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import zipfile
|
|
19
|
+
from typing import Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
|
|
23
|
+
PKG_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$")
|
|
24
|
+
SERIAL_RE = re.compile(r"^[A-Za-z0-9._:\-]{1,100}$")
|
|
25
|
+
DEVICE_PATH_RE = re.compile(r"^/[A-Za-z0-9_./=~+@\-]+\.apk$")
|
|
26
|
+
ADB_TIMEOUT = 120
|
|
27
|
+
PULL_TIMEOUT = 900
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AdbError(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def die(msg: str, code: int = 1):
|
|
35
|
+
print(f"error: {msg}", file=sys.stderr)
|
|
36
|
+
sys.exit(code)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Adb:
|
|
40
|
+
def __init__(self, serial: Optional[str] = None, exe: Optional[str] = None):
|
|
41
|
+
self.exe = exe or shutil.which("adb")
|
|
42
|
+
if not self.exe:
|
|
43
|
+
raise AdbError("adb not found. Install Android platform-tools and put adb on your PATH "
|
|
44
|
+
"(or pass --adb /path/to/adb).")
|
|
45
|
+
if serial is not None and not SERIAL_RE.match(serial):
|
|
46
|
+
raise AdbError("invalid device serial")
|
|
47
|
+
self.serial = serial
|
|
48
|
+
|
|
49
|
+
def _base(self) -> List[str]:
|
|
50
|
+
return [self.exe] + (["-s", self.serial] if self.serial else [])
|
|
51
|
+
|
|
52
|
+
def run(self, *args: str, timeout: int = ADB_TIMEOUT) -> str:
|
|
53
|
+
try:
|
|
54
|
+
r = subprocess.run(self._base() + list(args), capture_output=True, text=True,
|
|
55
|
+
encoding="utf-8", errors="replace", timeout=timeout)
|
|
56
|
+
except subprocess.TimeoutExpired:
|
|
57
|
+
raise AdbError(f"adb {' '.join(args[:2])} timed out")
|
|
58
|
+
if r.returncode != 0:
|
|
59
|
+
raise AdbError((r.stderr or r.stdout).strip()[:300] or f"adb exited {r.returncode}")
|
|
60
|
+
return r.stdout
|
|
61
|
+
|
|
62
|
+
def devices(self) -> List[Dict[str, str]]:
|
|
63
|
+
out = subprocess.run([self.exe, "devices", "-l"], capture_output=True, text=True,
|
|
64
|
+
encoding="utf-8", errors="replace", timeout=ADB_TIMEOUT).stdout
|
|
65
|
+
rows = []
|
|
66
|
+
for line in out.splitlines()[1:]:
|
|
67
|
+
parts = line.split()
|
|
68
|
+
if len(parts) >= 2:
|
|
69
|
+
model = next((p.split(":", 1)[1] for p in parts[2:] if p.startswith("model:")), "")
|
|
70
|
+
rows.append({"serial": parts[0], "state": parts[1], "model": model})
|
|
71
|
+
return rows
|
|
72
|
+
|
|
73
|
+
def check_device(self):
|
|
74
|
+
ds = self.devices()
|
|
75
|
+
ready = [d for d in ds if d["state"] == "device"]
|
|
76
|
+
if self.serial:
|
|
77
|
+
if not any(d["serial"] == self.serial and d["state"] == "device" for d in ds):
|
|
78
|
+
raise AdbError(f"device {self.serial} not found or not authorised (check the phone for the USB debugging prompt)")
|
|
79
|
+
elif not ready:
|
|
80
|
+
bad = [d for d in ds if d["state"] != "device"]
|
|
81
|
+
raise AdbError("no authorised device. " + (f"{bad[0]['serial']} is '{bad[0]['state']}': "
|
|
82
|
+
"unlock the phone and accept the USB debugging prompt." if bad else
|
|
83
|
+
"Connect a device with USB debugging enabled."))
|
|
84
|
+
elif len(ready) > 1:
|
|
85
|
+
raise AdbError("several devices connected; choose one with -s SERIAL: " +
|
|
86
|
+
", ".join(f"{d['serial']} ({d['model']})" for d in ready))
|
|
87
|
+
|
|
88
|
+
def packages(self, include_system: bool = False) -> List[str]:
|
|
89
|
+
out = self.run("shell", "pm", "list", "packages", *([] if include_system else ["-3"]))
|
|
90
|
+
pk = sorted({l.split(":", 1)[1].strip() for l in out.splitlines() if l.startswith("package:")})
|
|
91
|
+
return [p for p in pk if PKG_RE.match(p)]
|
|
92
|
+
|
|
93
|
+
def apk_paths(self, package: str) -> List[str]:
|
|
94
|
+
if not PKG_RE.match(package):
|
|
95
|
+
raise AdbError(f"invalid package name: {package!r}")
|
|
96
|
+
out = self.run("shell", "pm", "path", package)
|
|
97
|
+
paths = [l.split(":", 1)[1].strip() for l in out.splitlines() if l.startswith("package:")]
|
|
98
|
+
bad = [p for p in paths if not DEVICE_PATH_RE.match(p)]
|
|
99
|
+
if bad:
|
|
100
|
+
raise AdbError(f"unexpected path reported by the device: {bad[0][:80]!r}")
|
|
101
|
+
# base first, then splits, in a stable order
|
|
102
|
+
return sorted(paths, key=lambda p: (os.path.basename(p) != "base.apk", p))
|
|
103
|
+
|
|
104
|
+
def version(self, package: str) -> Dict[str, str]:
|
|
105
|
+
info = {"name": "", "code": "", "installer": ""}
|
|
106
|
+
try:
|
|
107
|
+
out = self.run("shell", "dumpsys", "package", package)
|
|
108
|
+
except AdbError:
|
|
109
|
+
return info
|
|
110
|
+
for line in out.splitlines():
|
|
111
|
+
line = line.strip()
|
|
112
|
+
if line.startswith("versionName=") and not info["name"]:
|
|
113
|
+
info["name"] = line.split("=", 1)[1].strip()
|
|
114
|
+
elif line.startswith("versionCode=") and not info["code"]:
|
|
115
|
+
info["code"] = line.split("=", 1)[1].split()[0]
|
|
116
|
+
elif line.startswith("installerPackageName=") and not info["installer"]:
|
|
117
|
+
info["installer"] = line.split("=", 1)[1].strip()
|
|
118
|
+
return info
|
|
119
|
+
|
|
120
|
+
def pull(self, remote: str, local: str):
|
|
121
|
+
self.run("pull", remote, local, timeout=PULL_TIMEOUT)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def safe_part(s: str) -> str:
|
|
125
|
+
return re.sub(r"[^A-Za-z0-9._+-]", "_", s)[:80] or "unknown"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def sha256_file(path: str) -> str:
|
|
129
|
+
h = hashlib.sha256()
|
|
130
|
+
with open(path, "rb") as fh:
|
|
131
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
132
|
+
h.update(chunk)
|
|
133
|
+
return h.hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def looks_like_apk(path: str) -> bool:
|
|
137
|
+
try:
|
|
138
|
+
with zipfile.ZipFile(path) as z:
|
|
139
|
+
return "AndroidManifest.xml" in z.namelist()
|
|
140
|
+
except (zipfile.BadZipFile, OSError):
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def pull_app(adb: Adb, package: str, outdir: str, fmt: str = "auto", quiet: bool = False) -> Dict[str, object]:
|
|
145
|
+
paths = adb.apk_paths(package)
|
|
146
|
+
if not paths:
|
|
147
|
+
raise AdbError(f"{package} is not installed")
|
|
148
|
+
ver = adb.version(package)
|
|
149
|
+
stem = safe_part(package) + (f"-{safe_part(ver['name'])}" if ver["name"] else "")
|
|
150
|
+
os.makedirs(outdir, exist_ok=True)
|
|
151
|
+
work = tempfile.mkdtemp(prefix="getapk_")
|
|
152
|
+
try:
|
|
153
|
+
local = []
|
|
154
|
+
for i, remote in enumerate(paths):
|
|
155
|
+
name = safe_part(os.path.basename(remote))
|
|
156
|
+
if name in [os.path.basename(x) for x in local]:
|
|
157
|
+
name = f"{i}_{name}"
|
|
158
|
+
dst = os.path.join(work, name)
|
|
159
|
+
adb.pull(remote, dst)
|
|
160
|
+
if not looks_like_apk(dst):
|
|
161
|
+
raise AdbError(f"{name} did not pull as a valid APK")
|
|
162
|
+
local.append(dst)
|
|
163
|
+
split = len(local) > 1
|
|
164
|
+
if fmt == "apk" and split:
|
|
165
|
+
raise AdbError(f"{package} is a split-APK app ({len(local)} files); use --format auto or apks")
|
|
166
|
+
if split or fmt == "apks":
|
|
167
|
+
final = os.path.join(outdir, stem + ".apks")
|
|
168
|
+
tmp_out = final + ".part"
|
|
169
|
+
with zipfile.ZipFile(tmp_out, "w", zipfile.ZIP_STORED) as z: # APKs are already compressed
|
|
170
|
+
for f in local:
|
|
171
|
+
z.write(f, os.path.basename(f))
|
|
172
|
+
os.replace(tmp_out, final)
|
|
173
|
+
else:
|
|
174
|
+
final = os.path.join(outdir, stem + ".apk")
|
|
175
|
+
shutil.copyfile(local[0], final)
|
|
176
|
+
return {"package": package, "path": final, "splits": len(local), "version": ver["name"],
|
|
177
|
+
"size": os.path.getsize(final), "sha256": sha256_file(final), "installer": ver["installer"]}
|
|
178
|
+
finally:
|
|
179
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def fmt_size(n: int) -> str:
|
|
183
|
+
return f"{n / 1e6:.1f} MB" if n >= 1e6 else f"{n / 1e3:.0f} KB"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cmd_list(adb: Adb, a) -> int:
|
|
187
|
+
pk = adb.packages(a.include_system)
|
|
188
|
+
if a.match:
|
|
189
|
+
pk = [p for p in pk if a.match.lower() in p.lower()]
|
|
190
|
+
if not pk:
|
|
191
|
+
print("No matching apps.")
|
|
192
|
+
return 1
|
|
193
|
+
for i, p in enumerate(pk, 1):
|
|
194
|
+
v = adb.version(p)
|
|
195
|
+
print(f"{i:3}. {p:<55} {v['name'] or '?':<14} {v['installer'] or ''}")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main(argv: Optional[List[str]] = None):
|
|
200
|
+
for s in (sys.stdout, sys.stderr):
|
|
201
|
+
try:
|
|
202
|
+
s.reconfigure(encoding="utf-8", errors="replace")
|
|
203
|
+
except (AttributeError, ValueError):
|
|
204
|
+
pass
|
|
205
|
+
ap = argparse.ArgumentParser(prog="getapk", description=__doc__,
|
|
206
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
207
|
+
ap.add_argument("package", nargs="*", help="package name(s) to pull, e.g. com.example.app")
|
|
208
|
+
ap.add_argument("-l", "--list", action="store_true", help="list installed apps")
|
|
209
|
+
ap.add_argument("--all", action="store_true", help="pull every (user-installed) app")
|
|
210
|
+
ap.add_argument("-m", "--match", help="with --list/--all: only packages containing this text")
|
|
211
|
+
ap.add_argument("--include-system", action="store_true", help="include system/preinstalled apps")
|
|
212
|
+
ap.add_argument("-s", "--serial", help="device serial (needed when several devices are connected)")
|
|
213
|
+
ap.add_argument("-o", "--out", default=".", help="output directory (default: current)")
|
|
214
|
+
ap.add_argument("--format", choices=["auto", "apk", "apks"], default="auto",
|
|
215
|
+
help="auto: .apk for single-APK apps, .apks bundle for split apps")
|
|
216
|
+
ap.add_argument("--adb", help="path to the adb executable")
|
|
217
|
+
ap.add_argument("--version", action="version", version=f"getapk {__version__}")
|
|
218
|
+
a = ap.parse_args(argv)
|
|
219
|
+
try:
|
|
220
|
+
adb = Adb(a.serial, a.adb)
|
|
221
|
+
adb.check_device()
|
|
222
|
+
if a.list:
|
|
223
|
+
sys.exit(cmd_list(adb, a))
|
|
224
|
+
targets = list(a.package)
|
|
225
|
+
if a.all:
|
|
226
|
+
targets += [p for p in adb.packages(a.include_system)
|
|
227
|
+
if (not a.match or a.match.lower() in p.lower()) and p not in targets]
|
|
228
|
+
if not targets:
|
|
229
|
+
ap.print_usage(sys.stderr)
|
|
230
|
+
die("give a package name, or use --list / --all", 2)
|
|
231
|
+
failures = 0
|
|
232
|
+
for p in targets:
|
|
233
|
+
try:
|
|
234
|
+
r = pull_app(adb, p, a.out, a.format)
|
|
235
|
+
kind = f"{r['splits']} splits" if r["splits"] > 1 else "single APK"
|
|
236
|
+
print(f"[+] {r['package']} {r['version'] or ''} -> {r['path']} ({kind}, {fmt_size(r['size'])})")
|
|
237
|
+
print(f" sha256 {r['sha256']}")
|
|
238
|
+
except AdbError as e:
|
|
239
|
+
failures += 1
|
|
240
|
+
print(f"[-] {p}: {e}", file=sys.stderr)
|
|
241
|
+
if failures:
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
except AdbError as e:
|
|
244
|
+
die(str(e))
|
|
245
|
+
except KeyboardInterrupt:
|
|
246
|
+
print("\ninterrupted", file=sys.stderr)
|
|
247
|
+
sys.exit(130)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
main()
|