apkdev 2.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.
- apkdev/__init__.py +13 -0
- apkdev/__main__.py +5 -0
- apkdev/apkfile.py +168 -0
- apkdev/builder.py +671 -0
- apkdev/cli.py +925 -0
- apkdev/completion.py +14 -0
- apkdev/device.py +161 -0
- apkdev/inspector.py +291 -0
- apkdev/optimizer.py +58 -0
- apkdev/reverser.py +62 -0
- apkdev/sdk.py +526 -0
- apkdev/utils.py +74 -0
- apkdev-2.0.0.dist-info/METADATA +159 -0
- apkdev-2.0.0.dist-info/RECORD +17 -0
- apkdev-2.0.0.dist-info/WHEEL +5 -0
- apkdev-2.0.0.dist-info/entry_points.txt +2 -0
- apkdev-2.0.0.dist-info/top_level.txt +1 -0
apkdev/cli.py
ADDED
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
"""CLI entry point — click-based command interface."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .builder import create_project, build_apk, sign_apk
|
|
10
|
+
from .device import devices, install_apk, uninstall, pull_apk, list_packages
|
|
11
|
+
from .inspector import inspect, manifest, permissions, strings, resources, analyze
|
|
12
|
+
from .optimizer import optimize
|
|
13
|
+
from .apkfile import list_contents, extract_apk, certificate
|
|
14
|
+
from .reverser import (
|
|
15
|
+
apktool_decode,
|
|
16
|
+
apktool_rebuild,
|
|
17
|
+
jadx_decompile,
|
|
18
|
+
smali_assemble,
|
|
19
|
+
baksmali_disassemble,
|
|
20
|
+
dex2jar,
|
|
21
|
+
apkeditor,
|
|
22
|
+
)
|
|
23
|
+
from .sdk import info, setup, detect_platform
|
|
24
|
+
from .utils import banner
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AliasedGroup(click.Group):
|
|
31
|
+
"""Allow abbreviated commands (e.g. 'b' for 'build')."""
|
|
32
|
+
|
|
33
|
+
def get_command(self, ctx, cmd_name):
|
|
34
|
+
rv = click.Group.get_command(self, ctx, cmd_name)
|
|
35
|
+
if rv is not None:
|
|
36
|
+
return rv
|
|
37
|
+
matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
|
|
38
|
+
if len(matches) == 1:
|
|
39
|
+
return click.Group.get_command(self, ctx, matches[0])
|
|
40
|
+
if len(matches) > 1:
|
|
41
|
+
ctx.fail(f"'{cmd_name}' is ambiguous: {', '.join(matches)}")
|
|
42
|
+
ctx.fail(f"No such command '{cmd_name}'.")
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _version_cb(ctx, param, value):
|
|
47
|
+
if not value or ctx.resilient_parsing:
|
|
48
|
+
return
|
|
49
|
+
click.echo(f"apkdev, version {__version__}")
|
|
50
|
+
ctx.exit()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@click.group(cls=AliasedGroup, context_settings=CONTEXT_SETTINGS)
|
|
54
|
+
@click.option("--version", is_flag=True, callback=_version_cb, expose_value=False, help="Show version")
|
|
55
|
+
def cli():
|
|
56
|
+
"""📱 APKDev — The complete Android APK toolkit.
|
|
57
|
+
|
|
58
|
+
Build APKs from scratch, reverse engineer any APK, extract metadata,
|
|
59
|
+
manage devices, and more — all from one command.
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
apkdev new MyApp Create new project
|
|
63
|
+
apkdev build Build APK
|
|
64
|
+
apkdev decode app.apk Decompile with apktool
|
|
65
|
+
apkdev decompile app.apk Decompile to Java with jadx
|
|
66
|
+
apkdev inspect app.apk Show full APK metadata
|
|
67
|
+
apkdev install app.apk Install to device
|
|
68
|
+
"""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ═══════════════════════════════════════════════════════════════
|
|
73
|
+
# BUILD COMMANDS
|
|
74
|
+
# ═══════════════════════════════════════════════════════════════
|
|
75
|
+
|
|
76
|
+
@cli.command("new", help="Create a new Kotlin APK project")
|
|
77
|
+
@click.argument("name", default="MyApp")
|
|
78
|
+
def new(name):
|
|
79
|
+
"""Scaffold a new Android project with Kotlin + Gradle."""
|
|
80
|
+
console.print(banner())
|
|
81
|
+
proj = create_project(name)
|
|
82
|
+
console.print(f"\n[green]✅ Project created:[/] {proj}")
|
|
83
|
+
console.print(f" [dim]cd {name} && apkdev build[/]")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@cli.command("build", help="Build APK (gradle assembleDebug)")
|
|
87
|
+
@click.argument("directory", required=False, default=None)
|
|
88
|
+
def build(directory):
|
|
89
|
+
"""Compile APK via Gradle."""
|
|
90
|
+
build_apk(directory)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@cli.command("sign", help="Sign an APK with debug keystore")
|
|
94
|
+
@click.argument("apk_path")
|
|
95
|
+
def sign(apk_path):
|
|
96
|
+
"""Sign an unsigned APK with the auto-generated debug key."""
|
|
97
|
+
sign_apk(apk_path)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@cli.command("optimize", help="Zipalign + sign APK")
|
|
101
|
+
@click.argument("apk_path")
|
|
102
|
+
def _optimize(apk_path):
|
|
103
|
+
"""Optimize APK: zipalign (4-byte alignment) then sign."""
|
|
104
|
+
optimize(apk_path)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ═══════════════════════════════════════════════════════════════
|
|
108
|
+
# DEVICE COMMANDS (ADB)
|
|
109
|
+
# ═══════════════════════════════════════════════════════════════
|
|
110
|
+
|
|
111
|
+
@cli.command("devices", help="List connected Android devices")
|
|
112
|
+
def _devices():
|
|
113
|
+
"""Show connected ADB devices."""
|
|
114
|
+
devices()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@cli.command("install", help="Install APK to device via ADB")
|
|
118
|
+
@click.argument("apk_path")
|
|
119
|
+
@click.option("-s", "--serial", help="Device serial number")
|
|
120
|
+
def _install(apk_path, serial):
|
|
121
|
+
"""Install an APK to a connected device (adb install -r -t)."""
|
|
122
|
+
install_apk(apk_path, serial)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@cli.command("uninstall", help="Remove app from device via ADB")
|
|
126
|
+
@click.argument("package")
|
|
127
|
+
@click.option("-s", "--serial", help="Device serial number")
|
|
128
|
+
def _uninstall(package, serial):
|
|
129
|
+
"""Uninstall a package from a connected device."""
|
|
130
|
+
uninstall(package, serial)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@cli.command("pull", help="Pull APK from device by package name")
|
|
134
|
+
@click.argument("package")
|
|
135
|
+
@click.argument("output", required=False, default=None)
|
|
136
|
+
@click.option("-s", "--serial", help="Device serial number")
|
|
137
|
+
def _pull(package, output, serial):
|
|
138
|
+
"""Pull an installed app's APK from a connected device."""
|
|
139
|
+
pull_apk(package, output, serial)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@cli.command("packages", help="List installed packages on device")
|
|
143
|
+
@click.argument("filter", required=False, default=None)
|
|
144
|
+
@click.option("-s", "--serial", help="Device serial number")
|
|
145
|
+
def _packages(filter, serial):
|
|
146
|
+
"""List installed packages on a connected device."""
|
|
147
|
+
list_packages(serial, filter)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ═══════════════════════════════════════════════════════════════
|
|
151
|
+
# REVERSE ENGINEERING COMMANDS
|
|
152
|
+
# ═══════════════════════════════════════════════════════════════
|
|
153
|
+
|
|
154
|
+
@cli.command("decode", help="Decode APK with apktool")
|
|
155
|
+
@click.argument("apk_file")
|
|
156
|
+
@click.argument("output", required=False, default=None)
|
|
157
|
+
def decode(apk_file, output):
|
|
158
|
+
"""Decompile an APK to smali/resources with Apktool."""
|
|
159
|
+
apktool_decode(apk_file, output)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@cli.command("rebuild", help="Rebuild APK from decoded directory")
|
|
163
|
+
@click.argument("directory")
|
|
164
|
+
@click.argument("output", required=False, default=None)
|
|
165
|
+
def rebuild(directory, output):
|
|
166
|
+
"""Rebuild an APK from an apktool-decoded directory."""
|
|
167
|
+
apktool_rebuild(directory, output)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@cli.command("decompile", help="Decompile DEX/APK to Java with jadx")
|
|
171
|
+
@click.argument("target")
|
|
172
|
+
@click.argument("output", required=False, default=None)
|
|
173
|
+
@click.option("--show-bad-code", is_flag=True, help="Show code even with errors")
|
|
174
|
+
def decompile(target, output, show_bad_code):
|
|
175
|
+
"""Decompile DEX or APK to readable Java source using jadx."""
|
|
176
|
+
jadx_decompile(target, output)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@cli.command("smali", help="Assemble smali → dex")
|
|
180
|
+
@click.argument("smali_dir")
|
|
181
|
+
@click.argument("output", required=False, default=None)
|
|
182
|
+
def smali(smali_dir, output):
|
|
183
|
+
"""Assemble smali source files into a DEX file."""
|
|
184
|
+
smali_assemble(smali_dir, output)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@cli.command("baksmali", help="Disassemble dex → smali")
|
|
188
|
+
@click.argument("dex_file")
|
|
189
|
+
@click.argument("output", required=False, default=None)
|
|
190
|
+
def baksmali(dex_file, output):
|
|
191
|
+
"""Disassemble a DEX file into human-readable smali."""
|
|
192
|
+
baksmali_disassemble(dex_file, output)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@cli.command("jar", help="Convert DEX/APK to JAR")
|
|
196
|
+
@click.argument("dex_file")
|
|
197
|
+
@click.argument("output", required=False, default=None)
|
|
198
|
+
def jar(dex_file, output):
|
|
199
|
+
"""Convert DEX or APK to a JAR file using dex2jar."""
|
|
200
|
+
dex2jar(dex_file, output)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@cli.command("apkeditor", help="Run APKEditor (merge/split/modify APK)")
|
|
204
|
+
@click.argument("args", nargs=-1, required=True)
|
|
205
|
+
def apkeditor_cmd(args):
|
|
206
|
+
"""Pass arguments directly to APKEditor."""
|
|
207
|
+
apkeditor(*args)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ═══════════════════════════════════════════════════════════════
|
|
211
|
+
# APK INSPECTION COMMANDS
|
|
212
|
+
# ═══════════════════════════════════════════════════════════════
|
|
213
|
+
|
|
214
|
+
@cli.command("inspect", help="Show all APK metadata")
|
|
215
|
+
@click.argument("apk_path")
|
|
216
|
+
def _inspect(apk_path):
|
|
217
|
+
"""Full APK analysis: package, version, permissions, activities, services, etc."""
|
|
218
|
+
inspect(apk_path)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@cli.command("manifest", help="Extract AndroidManifest.xml")
|
|
222
|
+
@click.argument("apk_path")
|
|
223
|
+
def _manifest(apk_path):
|
|
224
|
+
"""Dump the full AndroidManifest.xml in readable format."""
|
|
225
|
+
manifest(apk_path)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@cli.command("permissions", help="List APK permissions")
|
|
229
|
+
@click.argument("apk_path")
|
|
230
|
+
def _permissions(apk_path):
|
|
231
|
+
"""List all permissions requested by the APK."""
|
|
232
|
+
permissions(apk_path)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@cli.command("strings", help="Extract resource strings from APK")
|
|
236
|
+
@click.argument("apk_path")
|
|
237
|
+
def _strings(apk_path):
|
|
238
|
+
"""Dump the resource string pool from the APK."""
|
|
239
|
+
strings(apk_path)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@cli.command("resources", help="List APK resource table")
|
|
243
|
+
@click.argument("apk_path")
|
|
244
|
+
def _resources(apk_path):
|
|
245
|
+
"""Dump the full resource table from the APK."""
|
|
246
|
+
resources(apk_path)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@cli.command("analyze", help="Deep analysis with androguard")
|
|
250
|
+
@click.argument("apk_path")
|
|
251
|
+
def _analyze(apk_path):
|
|
252
|
+
"""Deep static analysis using androguard (permissions, components, etc.)."""
|
|
253
|
+
analyze(apk_path)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ═══════════════════════════════════════════════════════════════
|
|
257
|
+
# FILE COMMANDS
|
|
258
|
+
# ═══════════════════════════════════════════════════════════════
|
|
259
|
+
|
|
260
|
+
@cli.command("ls", help="List APK contents (files inside)")
|
|
261
|
+
@click.argument("apk_path")
|
|
262
|
+
def _ls(apk_path):
|
|
263
|
+
"""List all files inside an APK archive."""
|
|
264
|
+
list_contents(apk_path)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@cli.command("extract", help="Extract APK contents to directory")
|
|
268
|
+
@click.argument("apk_path")
|
|
269
|
+
@click.argument("output", required=False, default=None)
|
|
270
|
+
def _extract(apk_path, output):
|
|
271
|
+
"""Extract all files from an APK (like unzip)."""
|
|
272
|
+
extract_apk(apk_path, output)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@cli.command("cert", help="Show APK signing certificate info")
|
|
276
|
+
@click.argument("apk_path")
|
|
277
|
+
def _cert(apk_path):
|
|
278
|
+
"""Display signing certificate information for an APK."""
|
|
279
|
+
certificate(apk_path)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ═══════════════════════════════════════════════════════════════
|
|
283
|
+
# NATIVE BINARY RE COMMANDS (for .so / ELF / PE binaries)
|
|
284
|
+
# ═══════════════════════════════════════════════════════════════
|
|
285
|
+
|
|
286
|
+
@cli.command("elf", help="Analyze ELF binary (headers, sections, symbols)")
|
|
287
|
+
@click.argument("binary", required=True)
|
|
288
|
+
def _elf(binary):
|
|
289
|
+
"""Analyze an ELF binary (.so shared library or executable)."""
|
|
290
|
+
import subprocess
|
|
291
|
+
from pathlib import Path
|
|
292
|
+
|
|
293
|
+
path = Path(binary)
|
|
294
|
+
if not path.exists():
|
|
295
|
+
console.print(f"[red]❌ File not found: {binary}[/]")
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
console.print(f"[bold cyan]📄 ELF Analysis: {path.name}[/]")
|
|
299
|
+
console.print()
|
|
300
|
+
|
|
301
|
+
# File info
|
|
302
|
+
r = subprocess.run(["file", str(path)], capture_output=True, text=True, timeout=5)
|
|
303
|
+
console.print(f"[bold]File:[/] {r.stdout.strip()}")
|
|
304
|
+
console.print()
|
|
305
|
+
|
|
306
|
+
# ELF header
|
|
307
|
+
console.print("[bold]ELF Header:[/]")
|
|
308
|
+
r = subprocess.run(["readelf", "-h", str(path)], capture_output=True, text=True, timeout=5)
|
|
309
|
+
console.print(r.stdout)
|
|
310
|
+
|
|
311
|
+
# Sections
|
|
312
|
+
console.print("[bold]Sections:[/]")
|
|
313
|
+
r = subprocess.run(["readelf", "-S", str(path)], capture_output=True, text=True, timeout=5)
|
|
314
|
+
for line in r.stdout.split(chr(10)):
|
|
315
|
+
if "Name" in line or ".text" in line or ".data" in line or ".rodata" in line or ".bss" in line or "PROGBITS" in line or "NOBITS" in line:
|
|
316
|
+
console.print(f" {line}")
|
|
317
|
+
console.print()
|
|
318
|
+
|
|
319
|
+
# Dynamic symbols (functions)
|
|
320
|
+
console.print("[bold]Exported Functions (dynamic symbols):[/]")
|
|
321
|
+
r = subprocess.run(["readelf", "--dyn-syms", "--wide", str(path)], capture_output=True, text=True, timeout=5)
|
|
322
|
+
lines = [l for l in r.stdout.split(chr(10)) if " FUNC " in l]
|
|
323
|
+
for line in lines[:30]:
|
|
324
|
+
console.print(f" {line}")
|
|
325
|
+
if len(lines) > 30:
|
|
326
|
+
console.print(f" ... and {len(lines)-30} more")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@cli.command("disasm", help="Disassemble native binary with Capstone (ARM64/ARM/x86)")
|
|
330
|
+
@click.argument("binary", required=True)
|
|
331
|
+
@click.option("--arch", default="auto", help="Architecture: auto, arm64, arm, x86_64, x86")
|
|
332
|
+
@click.option("--count", default=60, help="Lines of disassembly to show")
|
|
333
|
+
@click.option("--func", "-f", default=None, help="Function address (hex) to focus on")
|
|
334
|
+
def _disasm(binary, arch, count, func):
|
|
335
|
+
"""Disassemble a native binary (.so / ELF) using Capstone engine.
|
|
336
|
+
Shows ARM64/ARM/x86 disassembly with function boundaries highlighted.
|
|
337
|
+
"""
|
|
338
|
+
import struct, subprocess
|
|
339
|
+
from pathlib import Path
|
|
340
|
+
|
|
341
|
+
path = Path(binary)
|
|
342
|
+
if not path.exists():
|
|
343
|
+
console.print(f"[red]File not found: {binary}[/]")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM, CS_ARCH_ARM, CS_ARCH_X86, CS_MODE_64, CS_MODE_32
|
|
348
|
+
except ImportError:
|
|
349
|
+
console.print("[yellow]Capstone not installed, falling back to objdump...[/]")
|
|
350
|
+
r = subprocess.run(["objdump", "-d", str(path)], capture_output=True, text=True, timeout=30)
|
|
351
|
+
for line in r.stdout.split(chr(10))[:count]:
|
|
352
|
+
console.print(line)
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
data = path.read_bytes()
|
|
356
|
+
if data[:4] != b'\x7fELF':
|
|
357
|
+
console.print("[red]Not an ELF file[/]")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
# Parse ELF section headers
|
|
361
|
+
e_shoff = struct.unpack_from('<Q', data, 40)[0]
|
|
362
|
+
e_shentsize = struct.unpack_from('<H', data, 58)[0]
|
|
363
|
+
e_shnum = struct.unpack_from('<H', data, 60)[0]
|
|
364
|
+
e_shstrndx = struct.unpack_from('<H', data, 62)[0]
|
|
365
|
+
strtab_off = struct.unpack_from('<Q', data[e_shoff + e_shstrndx * e_shentsize + 24:], 0)[0]
|
|
366
|
+
|
|
367
|
+
text_addr = text_off = text_sz = None
|
|
368
|
+
for i in range(e_shnum):
|
|
369
|
+
shdr = data[e_shoff + i * e_shentsize:]
|
|
370
|
+
name_off = struct.unpack_from('<I', shdr, 0)[0]
|
|
371
|
+
sh_addr = struct.unpack_from('<Q', shdr, 16)[0]
|
|
372
|
+
sh_off = struct.unpack_from('<Q', shdr, 24)[0]
|
|
373
|
+
sh_sz = struct.unpack_from('<Q', shdr, 32)[0]
|
|
374
|
+
name = data[strtab_off + name_off:].split(b'\x00')[0].decode(errors='replace')
|
|
375
|
+
if name == '.text':
|
|
376
|
+
text_addr, text_off, text_sz = sh_addr, sh_off, sh_sz
|
|
377
|
+
break
|
|
378
|
+
|
|
379
|
+
if text_addr is None:
|
|
380
|
+
console.print("[red]Could not find .text section[/]")
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# Set up Capstone architecture
|
|
384
|
+
if arch == "auto":
|
|
385
|
+
fi = subprocess.run(["file", str(path)], capture_output=True, text=True, timeout=5).stdout
|
|
386
|
+
if "aarch64" in fi:
|
|
387
|
+
cs_arch, cs_mode = CS_ARCH_ARM64, CS_MODE_ARM
|
|
388
|
+
elif "ARM" in fi:
|
|
389
|
+
cs_arch, cs_mode = CS_ARCH_ARM, CS_MODE_ARM
|
|
390
|
+
elif "x86-64" in fi:
|
|
391
|
+
cs_arch, cs_mode = CS_ARCH_X86, CS_MODE_64
|
|
392
|
+
else:
|
|
393
|
+
cs_arch, cs_mode = CS_ARCH_ARM64, CS_MODE_ARM
|
|
394
|
+
elif arch in ("arm64", "aarch64"):
|
|
395
|
+
cs_arch, cs_mode = CS_ARCH_ARM64, CS_MODE_ARM
|
|
396
|
+
elif arch == "arm":
|
|
397
|
+
cs_arch, cs_mode = CS_ARCH_ARM, CS_MODE_ARM
|
|
398
|
+
elif arch == "x86_64":
|
|
399
|
+
cs_arch, cs_mode = CS_ARCH_X86, CS_MODE_64
|
|
400
|
+
elif arch == "x86":
|
|
401
|
+
cs_arch, cs_mode = CS_ARCH_X86, CS_MODE_32
|
|
402
|
+
else:
|
|
403
|
+
cs_arch, cs_mode = CS_ARCH_ARM64, CS_MODE_ARM
|
|
404
|
+
|
|
405
|
+
md = Cs(cs_arch, cs_mode)
|
|
406
|
+
arch_name = {CS_ARCH_ARM64: "ARM64", CS_ARCH_ARM: "ARM", CS_ARCH_X86: "x86_64"}.get(cs_arch, "?")
|
|
407
|
+
|
|
408
|
+
if func is not None:
|
|
409
|
+
func_addr = int(func, 16) if func.startswith('0x') else int(func)
|
|
410
|
+
code_off = func_addr - text_addr + text_off
|
|
411
|
+
code = data[code_off:code_off + 0x200]
|
|
412
|
+
base = func_addr
|
|
413
|
+
console.print(f"[bold cyan]Function at 0x{func_addr:x} ({arch_name})[/]")
|
|
414
|
+
else:
|
|
415
|
+
code = data[text_off:text_off + min(text_sz, 0x200)]
|
|
416
|
+
base = text_addr
|
|
417
|
+
console.print(f"[bold cyan]Disassembly: {path.name} ({arch_name})[/]")
|
|
418
|
+
console.print()
|
|
419
|
+
|
|
420
|
+
insns = list(md.disasm(code, base))
|
|
421
|
+
|
|
422
|
+
# Find function boundaries by scanning for BL instruction bytes
|
|
423
|
+
bl_targets = set()
|
|
424
|
+
for i, byte in enumerate(code):
|
|
425
|
+
if byte == 0x94 and i + 4 <= len(code): # BL in ARM64
|
|
426
|
+
# Extract the target from the BL instruction
|
|
427
|
+
bl_insn = int.from_bytes(code[i:i+4], 'little')
|
|
428
|
+
offset = (bl_insn & 0x3FFFFFF) << 2
|
|
429
|
+
if bl_insn & 0x2000000: # Sign extend
|
|
430
|
+
offset = offset - 0x100000000
|
|
431
|
+
target = base + i + offset
|
|
432
|
+
bl_targets.add(target)
|
|
433
|
+
|
|
434
|
+
displayed = 0
|
|
435
|
+
for insn in insns:
|
|
436
|
+
if displayed >= count:
|
|
437
|
+
console.print(f"... ({len(insns)-displayed} more — use --count to show more)")
|
|
438
|
+
break
|
|
439
|
+
if insn.address in bl_targets and insn.address != base:
|
|
440
|
+
console.print(f"[bold cyan]--- Function at 0x{insn.address:x} ---[/]")
|
|
441
|
+
if insn.mnemonic in ('bl', 'blr'):
|
|
442
|
+
console.print(f" [yellow]0x{insn.address:x}: {insn.mnemonic:10s} {insn.op_str}[/]")
|
|
443
|
+
elif insn.mnemonic in ('ret',):
|
|
444
|
+
console.print(f" [green]0x{insn.address:x}: {insn.mnemonic:10s} {insn.op_str}[/]")
|
|
445
|
+
elif insn.mnemonic.startswith('b.'):
|
|
446
|
+
console.print(f" [magenta]0x{insn.address:x}: {insn.mnemonic:10s} {insn.op_str}[/]")
|
|
447
|
+
elif insn.mnemonic in ('b', 'cbz', 'cbnz', 'tbz', 'tbnz'):
|
|
448
|
+
console.print(f" [magenta]0x{insn.address:x}: {insn.mnemonic:10s} {insn.op_str}[/]")
|
|
449
|
+
else:
|
|
450
|
+
console.print(f" 0x{insn.address:x}: {insn.mnemonic:10s} {insn.op_str}")
|
|
451
|
+
displayed += 1
|
|
452
|
+
|
|
453
|
+
if not insns:
|
|
454
|
+
console.print("[red]No instructions disassembled. Try --arch arm or x86_64[/]")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@cli.command("strings", help="Extract strings from any binary")
|
|
458
|
+
@click.argument("binary", required=True)
|
|
459
|
+
@click.option("--min", default=6, help="Minimum string length")
|
|
460
|
+
@click.option("--max", default=100, help="Maximum strings to show")
|
|
461
|
+
def _nstrings(binary, min, max):
|
|
462
|
+
"""Extract readable strings from a binary (.so, APK, DEX, etc.).
|
|
463
|
+
Pure Python implementation — no external tools needed."""
|
|
464
|
+
from pathlib import Path
|
|
465
|
+
|
|
466
|
+
path = Path(binary)
|
|
467
|
+
if not path.exists():
|
|
468
|
+
console.print(f"[red]❌ File not found: {binary}[/]")
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
data = path.read_bytes()
|
|
472
|
+
strings_found = []
|
|
473
|
+
current = []
|
|
474
|
+
|
|
475
|
+
for byte in data:
|
|
476
|
+
if 32 <= byte <= 126:
|
|
477
|
+
current.append(chr(byte))
|
|
478
|
+
else:
|
|
479
|
+
if len(current) >= min:
|
|
480
|
+
strings_found.append("".join(current))
|
|
481
|
+
current = []
|
|
482
|
+
if len(current) >= min:
|
|
483
|
+
strings_found.append("".join(current))
|
|
484
|
+
|
|
485
|
+
console.print(f"[bold cyan]📝 Strings in {path.name}[/] ({len(strings_found)} found)")
|
|
486
|
+
console.print()
|
|
487
|
+
for s in strings_found[:max]:
|
|
488
|
+
console.print(f" {s}")
|
|
489
|
+
if len(strings_found) > max:
|
|
490
|
+
console.print(f" ... and {len(strings_found)-max} more")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@cli.command("debug", help="Debug a native process with GDB")
|
|
494
|
+
@click.argument("target", required=True)
|
|
495
|
+
@click.option("--args", default="", help="Arguments for the target")
|
|
496
|
+
def _debug(target, args):
|
|
497
|
+
"""Start GDB for debugging a native binary or attach to a PID."""
|
|
498
|
+
import subprocess, os, signal
|
|
499
|
+
|
|
500
|
+
gdb = shutil.which("gdb")
|
|
501
|
+
if not gdb:
|
|
502
|
+
console.print("[red]❌ GDB not found. Install: pkg install gdb[/]")
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
console.print(f"[bold cyan]🐛 GDB Debug: {target}[/]")
|
|
506
|
+
console.print("[yellow]Starting GDB... (type 'quit' to exit)[/]")
|
|
507
|
+
console.print()
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
if target.isdigit():
|
|
511
|
+
# Attach to PID
|
|
512
|
+
subprocess.run([gdb, "-p", target])
|
|
513
|
+
else:
|
|
514
|
+
# Run binary
|
|
515
|
+
cmd = [gdb, "--args", target] + args.split()
|
|
516
|
+
subprocess.run(cmd)
|
|
517
|
+
except KeyboardInterrupt:
|
|
518
|
+
pass
|
|
519
|
+
except Exception as e:
|
|
520
|
+
console.print(f"[red]❌ GDB failed: {e}[/]")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@cli.command("patch", help="Patch bytes in a native binary (.so / ELF)")
|
|
525
|
+
@click.argument("binary")
|
|
526
|
+
@click.option("--offset", "-o", default=None, help="Hex offset to patch (e.g. 0x1234)")
|
|
527
|
+
@click.option("--bytes", "-b", default=None, help="Bytes to write (hex, e.g. 1F2003D5)")
|
|
528
|
+
@click.option("--string", "-s", default=None, help="String to write")
|
|
529
|
+
@click.option("--nop", "-n", default=None, type=int, help="NOP out N instructions at offset")
|
|
530
|
+
@click.option("--find", "-f", default=None, help="Find string/hex in binary")
|
|
531
|
+
@click.option("--replace", "-r", default=None, help="Replace string in binary")
|
|
532
|
+
@click.option("--hexdump", "-x", default=None, type=int, help="Show hex dump N lines at offset")
|
|
533
|
+
def _patch(binary, offset, bytes, string, nop, find, replace, hexdump):
|
|
534
|
+
"""Patch a native binary (.so, ELF, or any file).
|
|
535
|
+
|
|
536
|
+
Examples:
|
|
537
|
+
apkdev patch lib.so --offset 0x1234 --bytes 1F2003D5
|
|
538
|
+
apkdev patch lib.so --offset 0x5678 --nop 4
|
|
539
|
+
apkdev patch lib.so --string "old" --replace "new"
|
|
540
|
+
apkdev patch lib.so --find "check" --replace "allow"
|
|
541
|
+
apkdev patch lib.so --hexdump 20
|
|
542
|
+
"""
|
|
543
|
+
from pathlib import Path
|
|
544
|
+
|
|
545
|
+
path = Path(binary)
|
|
546
|
+
if not path.exists():
|
|
547
|
+
console.print(f"[red]❌ File not found: {binary}[/]")
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
data = bytearray(path.read_bytes())
|
|
551
|
+
|
|
552
|
+
# Parse offset
|
|
553
|
+
off = None
|
|
554
|
+
if offset is not None:
|
|
555
|
+
off = int(offset, 16) if offset.startswith("0x") else int(offset)
|
|
556
|
+
|
|
557
|
+
# Hex dump
|
|
558
|
+
if hexdump is not None and off is not None:
|
|
559
|
+
console.print(f"[bold cyan]📋 Hex dump at 0x{off:x}:[/]")
|
|
560
|
+
start = max(0, off - 8)
|
|
561
|
+
for i in range(hexdump):
|
|
562
|
+
addr = start + i * 16
|
|
563
|
+
if addr >= len(data):
|
|
564
|
+
break
|
|
565
|
+
hex_part = " ".join(f"{data[j]:02x}" for j in range(addr, min(addr+16, len(data))))
|
|
566
|
+
ascii_part = "".join(chr(data[j]) if 32 <= data[j] <= 126 else "." for j in range(addr, min(addr+16, len(data))))
|
|
567
|
+
marker = " →" if addr <= off < addr+16 else " "
|
|
568
|
+
console.print(f" {marker} 0x{addr:08x}: {hex_part:<48} {ascii_part}")
|
|
569
|
+
return
|
|
570
|
+
|
|
571
|
+
# Find and replace
|
|
572
|
+
if find is not None:
|
|
573
|
+
if replace is not None:
|
|
574
|
+
# String replace
|
|
575
|
+
# Auto-detect: if input is pure hex (no spaces, even length), treat as hex
|
|
576
|
+
pure_hex = all(c in '0123456789abcdefABCDEF' for c in find) and len(find) % 2 == 0
|
|
577
|
+
find_bytes = bytes.fromhex(find) if pure_hex else find.encode()
|
|
578
|
+
pure_hex_r = all(c in '0123456789abcdefABCDEF' for c in replace) and len(replace) % 2 == 0
|
|
579
|
+
replace_bytes = bytes.fromhex(replace) if pure_hex_r else replace.encode()
|
|
580
|
+
count = 0
|
|
581
|
+
idx = data.find(find_bytes)
|
|
582
|
+
while idx >= 0:
|
|
583
|
+
for j, b in enumerate(replace_bytes):
|
|
584
|
+
data[idx + j] = b
|
|
585
|
+
count += 1
|
|
586
|
+
idx = data.find(find_bytes, idx + len(replace_bytes))
|
|
587
|
+
console.print(f"[green]✅ Replaced {count} occurrence(s) of '{find}' → '{replace}'[/]")
|
|
588
|
+
path.write_bytes(data)
|
|
589
|
+
return
|
|
590
|
+
else:
|
|
591
|
+
# Find only
|
|
592
|
+
# Check if input looks like hex (only hex chars and spaces)
|
|
593
|
+
is_hex = all(c in '0123456789abcdefABCDEF ' for c in find)
|
|
594
|
+
find_bytes = bytes.fromhex(find.replace(" ", "")) if is_hex else find.encode()
|
|
595
|
+
idx = data.find(find_bytes)
|
|
596
|
+
if idx >= 0:
|
|
597
|
+
console.print(f"[green]✅ Found at offset 0x{idx:x}[/]")
|
|
598
|
+
# Show context
|
|
599
|
+
start = max(0, idx - 4)
|
|
600
|
+
end = min(len(data), idx + len(find_bytes) + 16)
|
|
601
|
+
console.print(f" Context: {' '.join(f'{data[j]:02x}' for j in range(start, end))}")
|
|
602
|
+
else:
|
|
603
|
+
console.print("[yellow]⚠️ Not found[/]")
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
# NOP patching (ARM64 NOP = 1F 20 03 D5)
|
|
607
|
+
if nop is not None and off is not None:
|
|
608
|
+
for i in range(nop):
|
|
609
|
+
data[off + i*4] = 0x1F
|
|
610
|
+
data[off + i*4 + 1] = 0x20
|
|
611
|
+
data[off + i*4 + 2] = 0x03
|
|
612
|
+
data[off + i*4 + 3] = 0xD5
|
|
613
|
+
console.print(f"[green]✅ NOP'd {nop} instruction(s) at 0x{off:x}[/]")
|
|
614
|
+
path.write_bytes(data)
|
|
615
|
+
return
|
|
616
|
+
|
|
617
|
+
# Byte patching
|
|
618
|
+
if bytes is not None and off is not None:
|
|
619
|
+
patch_bytes = bytes.fromhex(bytes.replace(" ", ""))
|
|
620
|
+
for i, b in enumerate(patch_bytes):
|
|
621
|
+
if off + i < len(data):
|
|
622
|
+
data[off + i] = b
|
|
623
|
+
console.print(f"[green]✅ Patched {len(patch_bytes)} byte(s) at 0x{off:x}[/]")
|
|
624
|
+
# Show before/after
|
|
625
|
+
console.print(f" Old: {' '.join(f'{data[j]:02x}' for j in range(off, min(off+len(patch_bytes), len(data))))}")
|
|
626
|
+
path.write_bytes(data)
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
# String patch at offset
|
|
630
|
+
if string is not None and off is not None:
|
|
631
|
+
str_bytes = string.encode()
|
|
632
|
+
for i, b in enumerate(str_bytes):
|
|
633
|
+
if off + i < len(data):
|
|
634
|
+
data[off + i] = b
|
|
635
|
+
# Null terminate
|
|
636
|
+
if off + len(str_bytes) < len(data):
|
|
637
|
+
data[off + len(str_bytes)] = 0
|
|
638
|
+
console.print(f"[green]✅ Wrote string '{string}' at 0x{off:x}[/]")
|
|
639
|
+
path.write_bytes(data)
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
console.print("[yellow]Usage: apkdev patch binary.so --options ...[/]")
|
|
643
|
+
console.print(" apkdev patch --help for details")
|
|
644
|
+
# ═══════════════════════════════════════════════════════════════
|
|
645
|
+
# SYSTEM COMMANDS
|
|
646
|
+
# ═══════════════════════════════════════════════════════════════
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@cli.command("dart", help="Analyze Dart AOT snapshot in Flutter libapp.so")
|
|
650
|
+
@click.argument("binary")
|
|
651
|
+
@click.option("--classes", "-c", is_flag=True, help="Extract class/method names")
|
|
652
|
+
@click.option("--strings", "-s", is_flag=True, help="Extract Dart strings")
|
|
653
|
+
@click.option("--count", default=40, help="Max items to show")
|
|
654
|
+
def _dart(binary, classes, strings, count):
|
|
655
|
+
"""Analyze a Flutter app's Dart AOT snapshot (libapp.so).
|
|
656
|
+
Extracts class names, method names, and strings from Dart compiled code.
|
|
657
|
+
"""
|
|
658
|
+
from pathlib import Path
|
|
659
|
+
import re
|
|
660
|
+
|
|
661
|
+
path = Path(binary)
|
|
662
|
+
if not path.exists():
|
|
663
|
+
console.print(f"[red]File not found: {binary}[/]")
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
data = path.read_bytes()
|
|
667
|
+
console.print(f"[bold cyan]Dart AOT Analysis: {path.name}[/]")
|
|
668
|
+
console.print(f" Size: {len(data):,} bytes")
|
|
669
|
+
console.print()
|
|
670
|
+
|
|
671
|
+
# Find Dart snapshot sections
|
|
672
|
+
markers = {
|
|
673
|
+
b'_kDartVmSnapshotData': 'VM Snapshot Data',
|
|
674
|
+
b'_kDartVmSnapshotInstructions': 'VM Snapshot Instructions',
|
|
675
|
+
b'_kDartIsolateSnapshotData': 'Isolate Snapshot Data',
|
|
676
|
+
b'_kDartIsolateSnapshotInstructions': 'Isolate Snapshot Instructions',
|
|
677
|
+
b'_kDartSnapshotBuildId': 'Build ID',
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
found_any = False
|
|
681
|
+
for marker, desc in markers.items():
|
|
682
|
+
idx = data.find(marker)
|
|
683
|
+
if idx >= 0:
|
|
684
|
+
found_any = True
|
|
685
|
+
# Try to find the value after the marker
|
|
686
|
+
val_start = idx + len(marker)
|
|
687
|
+
# Look for null-terminated string or address
|
|
688
|
+
end = data.find(b'\x00', val_start, val_start + 200)
|
|
689
|
+
if end > 0:
|
|
690
|
+
val = data[val_start:end]
|
|
691
|
+
try:
|
|
692
|
+
val_str = val.decode('ascii', errors='replace')
|
|
693
|
+
console.print(f" [green]{desc}:[/] {val_str}")
|
|
694
|
+
except:
|
|
695
|
+
console.print(f" [green]{desc}:[/] (binary data, {len(val)} bytes)")
|
|
696
|
+
|
|
697
|
+
if not found_any:
|
|
698
|
+
console.print(" [yellow]No standard Dart snapshot markers found[/]")
|
|
699
|
+
|
|
700
|
+
# Extract class names
|
|
701
|
+
if classes or not strings:
|
|
702
|
+
console.print()
|
|
703
|
+
console.print("[bold]Potential Dart identifiers:[/]")
|
|
704
|
+
# Find CamelCase identifiers
|
|
705
|
+
# In AOT snapshots, class/method names are often stored with length prefix
|
|
706
|
+
dart_ids = set()
|
|
707
|
+
# Pattern: look for strings that look like Dart names (CamelCase with underscores)
|
|
708
|
+
for m in re.finditer(rb'[A-Z][a-z]{1,10}[A-Z][a-zA-Z0-9_]{2,80}', data[:min(len(data), 2000000)]):
|
|
709
|
+
try:
|
|
710
|
+
s = m.group().decode('ascii')
|
|
711
|
+
if any(c.islower() for c in s[1:]):
|
|
712
|
+
dart_ids.add(s)
|
|
713
|
+
except:
|
|
714
|
+
pass
|
|
715
|
+
|
|
716
|
+
for name in sorted(dart_ids)[:count]:
|
|
717
|
+
console.print(f" {name}")
|
|
718
|
+
if len(dart_ids) > count:
|
|
719
|
+
console.print(f" ... and {len(dart_ids) - count} more")
|
|
720
|
+
|
|
721
|
+
# Extract strings
|
|
722
|
+
if strings:
|
|
723
|
+
console.print()
|
|
724
|
+
console.print("[bold]Dart strings (readable):[/]")
|
|
725
|
+
dart_strs = set()
|
|
726
|
+
for m in re.finditer(rb'[\x20-\x7e]{8,}', data):
|
|
727
|
+
try:
|
|
728
|
+
s = m.group().decode('ascii')
|
|
729
|
+
if any(c.isupper() for c in s) and any(c.islower() for c in s):
|
|
730
|
+
dart_strs.add(s)
|
|
731
|
+
except:
|
|
732
|
+
pass
|
|
733
|
+
|
|
734
|
+
for s in sorted(dart_strs)[:count]:
|
|
735
|
+
console.print(f" {s}")
|
|
736
|
+
if len(dart_strs) > count:
|
|
737
|
+
console.print(f" ... and {len(dart_strs) - count} more")
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@cli.command("go", help="Analyze Go native library (.so)")
|
|
741
|
+
@click.argument("binary")
|
|
742
|
+
@click.option("--funcs", "-f", is_flag=True, help="Show exported Go functions")
|
|
743
|
+
@click.option("--strings", "-s", is_flag=True, help="Extract Go strings")
|
|
744
|
+
@click.option("--count", default=40, help="Max items to show")
|
|
745
|
+
def _golang(binary, funcs, strings, count):
|
|
746
|
+
"""Analyze a Go-compiled native library (libgojni.so, etc.).
|
|
747
|
+
Extracts Go function names, package paths, and strings.
|
|
748
|
+
"""
|
|
749
|
+
from pathlib import Path
|
|
750
|
+
import subprocess, re
|
|
751
|
+
|
|
752
|
+
path = Path(binary)
|
|
753
|
+
if not path.exists():
|
|
754
|
+
console.print(f"[red]File not found: {binary}[/]")
|
|
755
|
+
return
|
|
756
|
+
|
|
757
|
+
data = path.read_bytes()
|
|
758
|
+
|
|
759
|
+
# Check if it's Go by looking for Go runtime strings
|
|
760
|
+
is_go = b'Go' in data[:50] and b'gobackend' in data or b'go1.' in data or b'go.build' in data
|
|
761
|
+
go_hints = [b'Go build', b'go1.', b'gobackend', b'goid', b'goroutine']
|
|
762
|
+
go_score = sum(1 for h in go_hints if h in data)
|
|
763
|
+
|
|
764
|
+
console.print(f"[bold cyan]Go Analysis: {path.name}[/]")
|
|
765
|
+
console.print(f" Size: {len(data):,} bytes")
|
|
766
|
+
console.print(f" Go confidence: {'✅ High' if go_score >= 3 else '⚠️ Low'} (score={go_score}/5)")
|
|
767
|
+
console.print()
|
|
768
|
+
|
|
769
|
+
# Try to get exported functions from ELF
|
|
770
|
+
try:
|
|
771
|
+
r = subprocess.run(["readelf", "--dyn-syms", "--wide", str(path)],
|
|
772
|
+
capture_output=True, text=True, timeout=10)
|
|
773
|
+
go_funcs = [l for l in r.stdout.split(chr(10)) if ' FUNC ' in l and 'gobackend' in l.lower()]
|
|
774
|
+
|
|
775
|
+
if funcs or not strings:
|
|
776
|
+
console.print("[bold]Go functions (gobackend.*):[/]")
|
|
777
|
+
if go_funcs:
|
|
778
|
+
for line in go_funcs[:count]:
|
|
779
|
+
# Extract the function name
|
|
780
|
+
parts = line.split()
|
|
781
|
+
if len(parts) >= 8:
|
|
782
|
+
name = parts[-1]
|
|
783
|
+
# Try to clean up the name
|
|
784
|
+
if name.startswith('_cgoexp_'):
|
|
785
|
+
# Extract the readable part
|
|
786
|
+
readable = name.split('_proxy', 1)[-1] if '_proxy' in name else name
|
|
787
|
+
console.print(f" {readable}")
|
|
788
|
+
else:
|
|
789
|
+
console.print(f" {name}")
|
|
790
|
+
else:
|
|
791
|
+
console.print(" [yellow]No gobackend functions found in dynamic symbols[/]")
|
|
792
|
+
# Show any function symbols
|
|
793
|
+
all_funcs = [l for l in r.stdout.split(chr(10)) if ' FUNC ' in l]
|
|
794
|
+
console.print(f" Total function symbols: {len(all_funcs)}")
|
|
795
|
+
for line in all_funcs[:10]:
|
|
796
|
+
parts = line.split()
|
|
797
|
+
if len(parts) >= 8:
|
|
798
|
+
console.print(f" {parts[-1]}")
|
|
799
|
+
|
|
800
|
+
except Exception as e:
|
|
801
|
+
console.print(f" [red]Error: {e}[/]")
|
|
802
|
+
|
|
803
|
+
# Extract Go strings
|
|
804
|
+
if strings:
|
|
805
|
+
console.print()
|
|
806
|
+
console.print("[bold]Go strings:[/]")
|
|
807
|
+
# Go strings are typically null-terminated ASCII
|
|
808
|
+
go_strs = set()
|
|
809
|
+
for m in re.finditer(rb'[\x20-\x7e]{6,}', data):
|
|
810
|
+
try:
|
|
811
|
+
s = m.group().decode('ascii')
|
|
812
|
+
if any(c.isupper() for c in s) and any(c.islower() for c in s):
|
|
813
|
+
go_strs.add(s)
|
|
814
|
+
except:
|
|
815
|
+
pass
|
|
816
|
+
|
|
817
|
+
# Filter to more interesting ones
|
|
818
|
+
interesting = [s for s in go_strs if any(k in s for k in ['download', 'quality', 'error', 'token', 'url', 'api', 'http'])]
|
|
819
|
+
for s in interesting[:count]:
|
|
820
|
+
console.print(f" {s}")
|
|
821
|
+
if len(interesting) > count:
|
|
822
|
+
console.print(f" ... and {len(interesting) - count} more")
|
|
823
|
+
@cli.command("info", help="Show installed tool versions")
|
|
824
|
+
def _info():
|
|
825
|
+
"""Display all installed tool versions and SDK status."""
|
|
826
|
+
console.print(banner())
|
|
827
|
+
info()
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@cli.command("setup", help="Full one-command environment setup")
|
|
831
|
+
def _setup():
|
|
832
|
+
"""Auto-install ALL dependencies: tools, SDK, keystore, Python modules."""
|
|
833
|
+
console.print(banner())
|
|
834
|
+
setup()
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
@cli.command("doctor", help="Check environment for missing dependencies")
|
|
838
|
+
def doctor():
|
|
839
|
+
"""Check what's installed and what's missing (cross-platform)."""
|
|
840
|
+
console.print(banner())
|
|
841
|
+
|
|
842
|
+
import shutil
|
|
843
|
+
|
|
844
|
+
plat = detect_platform()
|
|
845
|
+
console.print(f"[yellow]🔍 Checking environment... ({plat['platform_name']})[/]\n")
|
|
846
|
+
|
|
847
|
+
# Platform-specific install hints
|
|
848
|
+
install_cmd = "apkdev setup"
|
|
849
|
+
if plat["pkg_install_cmd"]:
|
|
850
|
+
pm = " ".join(plat["pkg_install_cmd"])
|
|
851
|
+
else:
|
|
852
|
+
pm = "pkg install" if plat["is_termux"] else "apt-get install"
|
|
853
|
+
|
|
854
|
+
all_checks = [
|
|
855
|
+
("Build", [
|
|
856
|
+
("java", f"{pm} {plat['java_pkg']}"),
|
|
857
|
+
("kotlin", f"{pm} kotlin"),
|
|
858
|
+
("gradle", f"{pm} gradle"),
|
|
859
|
+
("aapt2", f"{pm} aapt2" if not plat["is_android"] else f"{pm} aapt2"),
|
|
860
|
+
("apksigner", f"{pm} apksigner"),
|
|
861
|
+
("zipalign", f"{pm} zipalign"),
|
|
862
|
+
("adb", f"{pm} android-tools" if plat["is_termux"] else f"{pm} adb"),
|
|
863
|
+
]),
|
|
864
|
+
("Reverse Engineering", [
|
|
865
|
+
("apktool", f"{pm} apktool"),
|
|
866
|
+
("jadx", f"{pm} jadx"),
|
|
867
|
+
("d2j-dex2jar", f"{pm} dex2jar" if plat["is_termux"] else "pip3 install pydex2"),
|
|
868
|
+
("smali", install_cmd),
|
|
869
|
+
("baksmali", install_cmd),
|
|
870
|
+
("APKEditor", install_cmd),
|
|
871
|
+
]),
|
|
872
|
+
("Python", [
|
|
873
|
+
("androguard", "pip3 install androguard"),
|
|
874
|
+
]),
|
|
875
|
+
]
|
|
876
|
+
|
|
877
|
+
issues = []
|
|
878
|
+
|
|
879
|
+
for group_name, tools in all_checks:
|
|
880
|
+
console.print(f"[bold]{group_name}[/]")
|
|
881
|
+
for cmd, hint in tools:
|
|
882
|
+
if shutil.which(cmd):
|
|
883
|
+
console.print(f" [green]✅[/] {cmd}")
|
|
884
|
+
else:
|
|
885
|
+
console.print(f" [red]❌[/] {cmd}")
|
|
886
|
+
issues.append(hint)
|
|
887
|
+
console.print()
|
|
888
|
+
|
|
889
|
+
# SDK check
|
|
890
|
+
sdk = Path.home() / "android-sdk"
|
|
891
|
+
if sdk.exists() and (sdk / "platforms").exists():
|
|
892
|
+
plats = [p.name for p in (sdk / "platforms").iterdir()]
|
|
893
|
+
console.print(f" [green]✅[/] Android SDK ({', '.join(plats)})")
|
|
894
|
+
else:
|
|
895
|
+
console.print(f" [red]❌[/] Android SDK")
|
|
896
|
+
issues.append(install_cmd)
|
|
897
|
+
|
|
898
|
+
# Summary
|
|
899
|
+
console.print()
|
|
900
|
+
if issues:
|
|
901
|
+
console.print(f"[yellow]⚠️ {len(issues)} issues to fix:[/]")
|
|
902
|
+
for issue in issues[:5]:
|
|
903
|
+
console.print(f" • {issue}")
|
|
904
|
+
if len(issues) > 5:
|
|
905
|
+
console.print(f" • ... and {len(issues)-5} more")
|
|
906
|
+
console.print(f"\n Run: [bold cyan]apkdev setup[/] — auto-fix everything")
|
|
907
|
+
else:
|
|
908
|
+
console.print("[green]✅ Everything is ready![/]")
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
@cli.command("completion", help="Generate shell completion script")
|
|
912
|
+
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]), default="bash")
|
|
913
|
+
def _completion(shell):
|
|
914
|
+
"""Generate and print shell completion script."""
|
|
915
|
+
scripts = {
|
|
916
|
+
"bash": 'eval "$(_APKDEV_COMPLETE=bash_source apkdev)"',
|
|
917
|
+
"zsh": 'eval "$(_APKDEV_COMPLETE=zsh_source apkdev)"',
|
|
918
|
+
"fish": 'eval (env _APKDEV_COMPLETE=fish_source apkdev)',
|
|
919
|
+
}
|
|
920
|
+
print(f"# Add to your ~/.{shell}rc:")
|
|
921
|
+
print(scripts.get(shell, scripts["bash"]))
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
if __name__ == "__main__":
|
|
925
|
+
cli()
|