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 +14 -0
- apk_patchx/cli.py +273 -0
- apk_patchx/exceptions.py +41 -0
- apk_patchx/services/__init__.py +1 -0
- apk_patchx/services/adb.py +98 -0
- apk_patchx/services/android_sdk.py +145 -0
- apk_patchx/services/apktool.py +150 -0
- apk_patchx/services/frida.py +311 -0
- apk_patchx/services/patch_dex.py +119 -0
- apk_patchx/services/patch_smali.py +145 -0
- apk_patchx/services/signing.py +106 -0
- apk_patchx/services/split_merge.py +146 -0
- apk_patchx/utils/__init__.py +1 -0
- apk_patchx/utils/core.py +77 -0
- apk_patchx/utils/manifest.py +140 -0
- apk_patchx/utils/py.typed +0 -0
- apk_patchx/utils/versions.py +65 -0
- apk_patchx-7.9.2025.1.dist-info/METADATA +110 -0
- apk_patchx-7.9.2025.1.dist-info/RECORD +22 -0
- apk_patchx-7.9.2025.1.dist-info/WHEEL +4 -0
- apk_patchx-7.9.2025.1.dist-info/entry_points.txt +3 -0
- apk_patchx-7.9.2025.1.dist-info/licenses/LICENSE +21 -0
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()
|
apk_patchx/exceptions.py
ADDED
@@ -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}")
|