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/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()