apk-patchx 7.9.2025.1__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.
apk_patchx/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """APKPatcher - Android APK manipulation toolkit."""
2
+
3
+ __version__ = "7.9.2025.1"
4
+ __author__ = "APKPatcher Contributors"
5
+ __email__ = "contributors@apkpatcher.dev"
6
+ __license__ = "MIT"
7
+
8
+ from .exceptions import APKPatcherError, ToolNotFoundError, ValidationError
9
+
10
+ __all__ = [
11
+ "APKPatcherError",
12
+ "ToolNotFoundError",
13
+ "ValidationError"
14
+ ]
apk_patchx/cli.py ADDED
@@ -0,0 +1,273 @@
1
+ """Command-line interface for APKPatcher."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import click
8
+ import colorama
9
+ from colorama import Fore, Style
10
+
11
+ from . import __version__
12
+ from .exceptions import APKPatcherError
13
+ from .services.adb import ADBService
14
+ from .services.apktool import ApktoolService
15
+ from .services.frida import FridaService
16
+ from .services.signing import SigningService
17
+ from .utils.core import setup_logging, get_apkpatcher_home
18
+
19
+ colorama.init(autoreset=True)
20
+
21
+
22
+ def print_error(message: str) -> None:
23
+ """Print error message in red."""
24
+ click.echo(f"{Fore.RED}[!] {message}{Style.RESET_ALL}", err=True)
25
+
26
+
27
+ def print_success(message: str) -> None:
28
+ """Print success message in green."""
29
+ click.echo(f"{Fore.GREEN}[+] {message}{Style.RESET_ALL}")
30
+
31
+
32
+ def print_info(message: str) -> None:
33
+ """Print info message in blue."""
34
+ click.echo(f"{Fore.BLUE}[*] {message}{Style.RESET_ALL}")
35
+
36
+
37
+ def print_warning(message: str) -> None:
38
+ """Print warning message in yellow."""
39
+ click.echo(f"{Fore.YELLOW}[!] {message}{Style.RESET_ALL}")
40
+
41
+
42
+ @click.group(invoke_without_command=True)
43
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
44
+ @click.option("--version", is_flag=True, help="Show version and exit")
45
+ @click.pass_context
46
+ def cli(ctx: click.Context, verbose: bool, version: bool) -> None:
47
+ """APKPatcher - Android APK manipulation toolkit."""
48
+ if version:
49
+ click.echo(f"APKPatcher {__version__}")
50
+ sys.exit(0)
51
+
52
+ if ctx.invoked_subcommand is None:
53
+ click.echo(ctx.get_help())
54
+ sys.exit(0)
55
+
56
+ ctx.ensure_object(dict)
57
+ ctx.obj["verbose"] = verbose
58
+ setup_logging(verbose)
59
+
60
+ # Initialize APKPatcher home directory
61
+ home = get_apkpatcher_home()
62
+ home.mkdir(parents=True, exist_ok=True)
63
+
64
+ if verbose:
65
+ print_info(f"APKPatcher home: {home}")
66
+
67
+
68
+ @cli.command()
69
+ @click.argument("package_name")
70
+ @click.option("--net", is_flag=True, help="Add permissive network security config")
71
+ @click.pass_context
72
+ def pull(ctx: click.Context, package_name: str, net: bool) -> None:
73
+ """Pull APK from connected device."""
74
+ try:
75
+ verbose = ctx.obj.get("verbose", False)
76
+ adb_service = ADBService(verbose=verbose)
77
+ apktool_service = ApktoolService(verbose=verbose)
78
+ signing_service = SigningService(verbose=verbose)
79
+
80
+ print_info(f"Pulling {package_name} from device...")
81
+ apk_paths = adb_service.pull_package(package_name)
82
+
83
+ if len(apk_paths) > 1:
84
+ from .services.split_merge import SplitMergeService
85
+ merge_service = SplitMergeService(verbose=verbose)
86
+ output_path = merge_service.merge_split_apks(apk_paths, net=net)
87
+ print_success(f"Merged split APKs to: {output_path}")
88
+ else:
89
+ print_success(f"Pulled APK to: {apk_paths[0]}")
90
+
91
+ except APKPatcherError as e:
92
+ print_error(str(e))
93
+ sys.exit(1)
94
+
95
+
96
+ @cli.command()
97
+ @click.argument("apk_file", type=click.Path(exists=True, path_type=Path))
98
+ @click.option("--no-res", "-r", is_flag=True, help="Do not decode resources")
99
+ @click.option("--no-src", "-s", is_flag=True, help="Do not disassemble DEX")
100
+ @click.option("--only-main-classes", is_flag=True, help="Only disassemble main DEX classes")
101
+ @click.option("--apktool-decode-args", help="Additional apktool decode arguments")
102
+ @click.pass_context
103
+ def decode(ctx: click.Context, apk_file: Path, no_res: bool, no_src: bool,
104
+ only_main_classes: bool, apktool_decode_args: Optional[str]) -> None:
105
+ """Decode APK file."""
106
+ try:
107
+ verbose = ctx.obj.get("verbose", False)
108
+ apktool_service = ApktoolService(verbose=verbose)
109
+
110
+ print_info(f"Decoding {apk_file}...")
111
+ output_dir = apktool_service.decode(
112
+ apk_file,
113
+ no_resources=no_res,
114
+ no_sources=no_src,
115
+ only_main_classes=only_main_classes,
116
+ extra_args=apktool_decode_args
117
+ )
118
+ print_success(f"Decoded to: {output_dir}")
119
+
120
+ except APKPatcherError as e:
121
+ print_error(str(e))
122
+ sys.exit(1)
123
+
124
+
125
+ @cli.command()
126
+ @click.argument("apk_dir", type=click.Path(exists=True, path_type=Path))
127
+ @click.option("-o", "--output", help="Output APK path", type=click.Path(path_type=Path))
128
+ @click.option("--net", is_flag=True, help="Add permissive network security config")
129
+ @click.option("--apktool-build-args", help="Additional apktool build arguments")
130
+ @click.pass_context
131
+ def build(ctx: click.Context, apk_dir: Path, output: Optional[Path],
132
+ net: bool, apktool_build_args: Optional[str]) -> None:
133
+ """Build APK from decoded directory."""
134
+ try:
135
+ verbose = ctx.obj.get("verbose", False)
136
+ apktool_service = ApktoolService(verbose=verbose)
137
+ signing_service = SigningService(verbose=verbose)
138
+
139
+ if output is None:
140
+ output = apk_dir.with_suffix(".apk")
141
+
142
+ print_info(f"Building {apk_dir}...")
143
+ apk_path = apktool_service.build(
144
+ apk_dir,
145
+ output,
146
+ add_network_config=net,
147
+ extra_args=apktool_build_args
148
+ )
149
+
150
+ print_info("Signing APK...")
151
+ signed_path = signing_service.sign_apk(apk_path)
152
+ print_success(f"Built and signed: {signed_path}")
153
+
154
+ except APKPatcherError as e:
155
+ print_error(str(e))
156
+ sys.exit(1)
157
+
158
+
159
+ @cli.command()
160
+ @click.argument("apk_file", type=click.Path(exists=True, path_type=Path))
161
+ @click.option("--arch", "-a", required=True,
162
+ type=click.Choice(["arm", "arm64", "x86", "x86_64"]),
163
+ help="Target architecture")
164
+ @click.option("--gadget-conf", "-g", type=click.Path(exists=True, path_type=Path),
165
+ help="Frida gadget configuration file")
166
+ @click.option("--net", is_flag=True, help="Add permissive network security config")
167
+ @click.option("--no-src", "-s", is_flag=True, help="Do not disassemble DEX")
168
+ @click.option("--only-main-classes", is_flag=True, help="Only disassemble main DEX classes")
169
+ @click.option("--frida-version", help="Specific Frida version to use")
170
+ @click.option("--apktool-decode-args", help="Additional apktool decode arguments")
171
+ @click.option("--apktool-build-args", help="Additional apktool build arguments")
172
+ @click.pass_context
173
+ def patch(ctx: click.Context, apk_file: Path, arch: str, gadget_conf: Optional[Path],
174
+ net: bool, no_src: bool, only_main_classes: bool, frida_version: Optional[str],
175
+ apktool_decode_args: Optional[str], apktool_build_args: Optional[str]) -> None:
176
+ """Patch APK with Frida gadget."""
177
+ try:
178
+ verbose = ctx.obj.get("verbose", False)
179
+ frida_service = FridaService(verbose=verbose)
180
+
181
+ print_info(f"Patching {apk_file} with Frida gadget ({arch})...")
182
+ output_path = frida_service.patch_apk(
183
+ apk_file,
184
+ arch,
185
+ gadget_config=gadget_conf,
186
+ add_network_config=net,
187
+ no_sources=no_src,
188
+ only_main_classes=only_main_classes,
189
+ frida_version=frida_version,
190
+ decode_args=apktool_decode_args,
191
+ build_args=apktool_build_args
192
+ )
193
+ print_success(f"Patched APK: {output_path}")
194
+
195
+ except APKPatcherError as e:
196
+ print_error(str(e))
197
+ sys.exit(1)
198
+
199
+
200
+ @cli.command()
201
+ @click.argument("apk_file", type=click.Path(exists=True, path_type=Path))
202
+ @click.argument("new_package")
203
+ @click.option("--net", is_flag=True, help="Add permissive network security config")
204
+ @click.pass_context
205
+ def rename(ctx: click.Context, apk_file: Path, new_package: str, net: bool) -> None:
206
+ """Rename APK package."""
207
+ try:
208
+ verbose = ctx.obj.get("verbose", False)
209
+ apktool_service = ApktoolService(verbose=verbose)
210
+ signing_service = SigningService(verbose=verbose)
211
+
212
+ print_info(f"Renaming {apk_file} to {new_package}...")
213
+
214
+ # Decode APK
215
+ decoded_dir = apktool_service.decode(apk_file, no_resources=False, no_sources=True)
216
+
217
+ # Update apktool.yml with new package name
218
+ apktool_yml = decoded_dir / "apktool.yml"
219
+ content = apktool_yml.read_text()
220
+
221
+ # Add or update renameManifestPackage
222
+ if "renameManifestPackage:" in content:
223
+ import re
224
+ content = re.sub(r"renameManifestPackage:.*", f"renameManifestPackage: {new_package}", content)
225
+ else:
226
+ content += f"\nrenameManifestPackage: {new_package}\n"
227
+
228
+ apktool_yml.write_text(content)
229
+
230
+ # Build and sign
231
+ output_path = apk_file.parent / f"{apk_file.stem}.renamed.apk"
232
+ built_path = apktool_service.build(decoded_dir, output_path, add_network_config=net)
233
+ signed_path = signing_service.sign_apk(built_path)
234
+
235
+ print_success(f"Renamed APK: {signed_path}")
236
+
237
+ except APKPatcherError as e:
238
+ print_error(str(e))
239
+ sys.exit(1)
240
+
241
+
242
+ @cli.command()
243
+ @click.argument("apk_file", type=click.Path(exists=True, path_type=Path))
244
+ @click.pass_context
245
+ def sign(ctx: click.Context, apk_file: Path) -> None:
246
+ """Sign APK file."""
247
+ try:
248
+ verbose = ctx.obj.get("verbose", False)
249
+ signing_service = SigningService(verbose=verbose)
250
+
251
+ print_info(f"Signing {apk_file}...")
252
+ signed_path = signing_service.sign_apk(apk_file)
253
+ print_success(f"Signed APK: {signed_path}")
254
+
255
+ except APKPatcherError as e:
256
+ print_error(str(e))
257
+ sys.exit(1)
258
+
259
+
260
+ def main() -> None:
261
+ """Entry point for the CLI."""
262
+ try:
263
+ cli()
264
+ except KeyboardInterrupt:
265
+ print_error("Operation cancelled by user")
266
+ sys.exit(1)
267
+ except Exception as e:
268
+ print_error(f"Unexpected error: {e}")
269
+ sys.exit(1)
270
+
271
+
272
+ if __name__ == "__main__":
273
+ main()
@@ -0,0 +1,41 @@
1
+ """Custom exceptions for APKPatcher."""
2
+
3
+
4
+ class APKPatcherError(Exception):
5
+ """Base exception for APKPatcher."""
6
+ pass
7
+
8
+
9
+ class ToolNotFoundError(APKPatcherError):
10
+ """Raised when a required tool is not found."""
11
+ pass
12
+
13
+
14
+ class ValidationError(APKPatcherError):
15
+ """Raised when input validation fails."""
16
+ pass
17
+
18
+
19
+ class BuildError(APKPatcherError):
20
+ """Raised when APK build operation fails."""
21
+ pass
22
+
23
+
24
+ class SigningError(APKPatcherError):
25
+ """Raised when APK signing fails."""
26
+ pass
27
+
28
+
29
+ class ADBError(APKPatcherError):
30
+ """Raised when ADB operation fails."""
31
+ pass
32
+
33
+
34
+ class FridaPatchError(APKPatcherError):
35
+ """Raised when Frida patching fails."""
36
+ pass
37
+
38
+
39
+ class NetworkError(APKPatcherError):
40
+ """Raised when network operation fails."""
41
+ pass
@@ -0,0 +1 @@
1
+ """Services package for APKPatcher."""
@@ -0,0 +1,98 @@
1
+ """ADB service for device operations."""
2
+
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import List, Optional
7
+
8
+ from ..exceptions import ADBError, ToolNotFoundError
9
+ from ..utils.core import run_command, get_apkpatcher_home
10
+ from .android_sdk import AndroidSDKService
11
+
12
+
13
+ class ADBService:
14
+ """Service for ADB operations."""
15
+
16
+ def __init__(self, verbose: bool = False):
17
+ self.verbose = verbose
18
+ self.sdk_service = AndroidSDKService(verbose=verbose)
19
+ self._adb_path: Optional[Path] = None
20
+
21
+ @property
22
+ def adb_path(self) -> Path:
23
+ """Get ADB executable path."""
24
+ if self._adb_path is None:
25
+ # First try system ADB
26
+ result = subprocess.run(["which", "adb"], capture_output=True, text=True)
27
+ if result.returncode == 0:
28
+ self._adb_path = Path(result.stdout.strip())
29
+ else:
30
+ # Use SDK ADB
31
+ self.sdk_service.ensure_platform_tools()
32
+ sdk_root = get_apkpatcher_home() / "tools" / "sdk"
33
+ self._adb_path = sdk_root / "platform-tools" / "adb"
34
+
35
+ if not self._adb_path.exists():
36
+ raise ToolNotFoundError("ADB not found and SDK installation failed")
37
+
38
+ return self._adb_path
39
+
40
+ def pull_package(self, package_name: str) -> List[Path]:
41
+ """Pull APK(s) for given package name."""
42
+ # Get package path(s)
43
+ cmd = [str(self.adb_path), "shell", "pm", "path", package_name]
44
+ result = run_command(cmd, capture_output=True, text=True)
45
+
46
+ if result.returncode != 0 or not result.stdout.strip():
47
+ raise ADBError(f"Package {package_name} not found on device")
48
+
49
+ package_paths = []
50
+ for line in result.stdout.strip().split("\n"):
51
+ if line.startswith("package:"):
52
+ package_paths.append(line.replace("package:", ""))
53
+
54
+ if not package_paths:
55
+ raise ADBError(f"No package paths found for {package_name}")
56
+
57
+ # Pull each APK
58
+ pulled_apks = []
59
+ if len(package_paths) > 1:
60
+ # Split APKs
61
+ split_dir = Path(f"{package_name}_split_apks")
62
+ split_dir.mkdir(exist_ok=True)
63
+
64
+ for i, package_path in enumerate(package_paths):
65
+ apk_name = f"split_{i}.apk" if i > 0 else "base.apk"
66
+ local_path = split_dir / apk_name
67
+
68
+ cmd = [str(self.adb_path), "pull", package_path, str(local_path)]
69
+ result = run_command(cmd)
70
+ if result.returncode != 0:
71
+ raise ADBError(f"Failed to pull {package_path}")
72
+
73
+ pulled_apks.append(local_path)
74
+ else:
75
+ # Single APK
76
+ local_path = Path(f"{package_name}.apk")
77
+ cmd = [str(self.adb_path), "pull", package_paths[0], str(local_path)]
78
+ result = run_command(cmd)
79
+ if result.returncode != 0:
80
+ raise ADBError(f"Failed to pull {package_paths[0]}")
81
+
82
+ pulled_apks.append(local_path)
83
+
84
+ return pulled_apks
85
+
86
+ def is_device_connected(self) -> bool:
87
+ """Check if device is connected."""
88
+ try:
89
+ cmd = [str(self.adb_path), "devices"]
90
+ result = run_command(cmd, capture_output=True, text=True)
91
+
92
+ lines = result.stdout.strip().split("\n")[1:] # Skip header
93
+ for line in lines:
94
+ if line.strip() and "\tdevice" in line:
95
+ return True
96
+ return False
97
+ except Exception:
98
+ return False
@@ -0,0 +1,145 @@
1
+ """Android SDK management service."""
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ from urllib.parse import urljoin
8
+
9
+ from ..exceptions import NetworkError, ToolNotFoundError
10
+ from ..utils.core import run_command, get_apkpatcher_home, download_file
11
+
12
+
13
+ class AndroidSDKService:
14
+ """Service for managing Android SDK components."""
15
+
16
+ def __init__(self, verbose: bool = False):
17
+ self.verbose = verbose
18
+ self.sdk_root = get_apkpatcher_home() / "tools" / "sdk"
19
+ self.cmdline_tools_dir = get_apkpatcher_home() / "tools" / "cmdline-tools"
20
+
21
+ def ensure_java(self) -> None:
22
+ """Ensure Java is available."""
23
+ try:
24
+ result = subprocess.run(["java", "-version"],
25
+ capture_output=True, text=True, check=False)
26
+ if result.returncode != 0:
27
+ raise ToolNotFoundError(
28
+ "Java is required but not found. Please install Java 8 or later."
29
+ )
30
+ except FileNotFoundError:
31
+ raise ToolNotFoundError(
32
+ "Java is required but not found. Please install Java 8 or later."
33
+ )
34
+
35
+ def ensure_cmdline_tools(self) -> None:
36
+ """Ensure Android command line tools are installed."""
37
+ if (self.cmdline_tools_dir / "bin" / "sdkmanager").exists():
38
+ return
39
+
40
+ self.cmdline_tools_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+ # Download command line tools
43
+ tools_url = "https://dl.google.com/android/repository/commandlinetools-linux-9123335_latest.zip"
44
+ zip_path = get_apkpatcher_home() / "tools" / "commandlinetools.zip"
45
+
46
+ if self.verbose:
47
+ print(f"Downloading Android command line tools...")
48
+
49
+ download_file(tools_url, zip_path)
50
+
51
+ # Extract
52
+ import zipfile
53
+ with zipfile.ZipFile(zip_path, 'r') as zip_ref:
54
+ zip_ref.extractall(get_apkpatcher_home() / "tools")
55
+
56
+ zip_path.unlink()
57
+
58
+ if self.verbose:
59
+ print("Android command line tools installed")
60
+
61
+ def ensure_build_tools(self, version: str = "33.0.1") -> None:
62
+ """Ensure Android build tools are installed."""
63
+ build_tools_dir = self.sdk_root / "build-tools" / version
64
+ if build_tools_dir.exists():
65
+ return
66
+
67
+ self.ensure_cmdline_tools()
68
+ self.sdk_root.mkdir(parents=True, exist_ok=True)
69
+
70
+ sdkmanager = self.cmdline_tools_dir / "bin" / "sdkmanager"
71
+
72
+ if self.verbose:
73
+ print(f"Installing build-tools {version}...")
74
+
75
+ cmd = [
76
+ str(sdkmanager),
77
+ f"build-tools;{version}",
78
+ f"--sdk_root={self.sdk_root}"
79
+ ]
80
+
81
+ # Accept licenses automatically
82
+ process = subprocess.Popen(
83
+ cmd,
84
+ stdin=subprocess.PIPE,
85
+ stdout=subprocess.PIPE if not self.verbose else None,
86
+ stderr=subprocess.PIPE if not self.verbose else None,
87
+ text=True
88
+ )
89
+
90
+ # Send 'y' for license acceptance
91
+ stdout, stderr = process.communicate(input="y\n" * 10)
92
+
93
+ if process.returncode != 0:
94
+ raise ToolNotFoundError(f"Failed to install build-tools: {stderr}")
95
+
96
+ if self.verbose:
97
+ print(f"Build-tools {version} installed")
98
+
99
+ def ensure_platform_tools(self) -> None:
100
+ """Ensure Android platform tools are installed."""
101
+ platform_tools_dir = self.sdk_root / "platform-tools"
102
+ if platform_tools_dir.exists():
103
+ return
104
+
105
+ self.ensure_cmdline_tools()
106
+ self.sdk_root.mkdir(parents=True, exist_ok=True)
107
+
108
+ sdkmanager = self.cmdline_tools_dir / "bin" / "sdkmanager"
109
+
110
+ if self.verbose:
111
+ print("Installing platform-tools...")
112
+
113
+ cmd = [
114
+ str(sdkmanager),
115
+ "platform-tools",
116
+ f"--sdk_root={self.sdk_root}"
117
+ ]
118
+
119
+ # Accept licenses automatically
120
+ process = subprocess.Popen(
121
+ cmd,
122
+ stdin=subprocess.PIPE,
123
+ stdout=subprocess.PIPE if not self.verbose else None,
124
+ stderr=subprocess.PIPE if not self.verbose else None,
125
+ text=True
126
+ )
127
+
128
+ stdout, stderr = process.communicate(input="y\n" * 10)
129
+
130
+ if process.returncode != 0:
131
+ raise ToolNotFoundError(f"Failed to install platform-tools: {stderr}")
132
+
133
+ if self.verbose:
134
+ print("Platform-tools installed")
135
+
136
+ def get_tool_path(self, tool: str) -> Path:
137
+ """Get path to SDK tool."""
138
+ if tool in ["aapt", "aapt2", "zipalign", "apksigner"]:
139
+ self.ensure_build_tools()
140
+ return self.sdk_root / "build-tools" / "33.0.1" / tool
141
+ elif tool == "adb":
142
+ self.ensure_platform_tools()
143
+ return self.sdk_root / "platform-tools" / tool
144
+ else:
145
+ raise ValueError(f"Unknown tool: {tool}")