apk-patchx 7.9.2025.1__tar.gz

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.
@@ -0,0 +1,3 @@
1
+ __pycache__
2
+ .env
3
+ .venv
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 APKPatcher Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: apk-patchx
3
+ Version: 7.9.2025.1
4
+ Summary: Android APK manipulation toolkit with Frida gadget injection support
5
+ Keywords: android,apk,reverse-engineering,frida,patching
6
+ Author: APKPatcher Contributors
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Security
23
+ Classifier: Topic :: Software Development :: Build Tools
24
+ Classifier: Topic :: System :: Software Distribution
25
+ License-File: LICENSE
26
+ Requires-Dist: click>=8.0.0
27
+ Requires-Dist: requests>=2.25.0
28
+ Requires-Dist: tqdm>=4.60.0
29
+ Requires-Dist: colorama>=0.4.4
30
+ Project-URL: Homepage, https://github.com/kaifcodec/apk-patchx
31
+ Project-URL: Issues, https://github.com/kaifcodec/apk-patchx/issues
32
+ Project-URL: Repository, https://github.com/kaifcodec/apk-patchx.git
33
+
34
+ # APKPatcher
35
+
36
+ A powerful command-line tool for Android APK manipulation, including Frida gadget injection, APK decoding/building, and package management.
37
+
38
+ ## Features
39
+
40
+ - **APK Management**: Pull, decode, build, and sign APK files
41
+ - **Frida Integration**: Inject Frida gadgets for runtime manipulation
42
+ - **Split APK Support**: Automatically merge split APKs into single files
43
+ - **Package Renaming**: Change APK package names
44
+ - **Auto-bootstrap**: Automatically downloads and manages required tools
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install apkpatcher
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ### Pull APK from device
55
+ ```bash
56
+ apkpatcher pull com.example.app
57
+ ```
58
+
59
+ ### Decode APK
60
+ ```bash
61
+ apkpatcher decode app.apk
62
+ ```
63
+
64
+ ### Build APK from source
65
+ ```bash
66
+ apkpatcher build app_src/
67
+ ```
68
+
69
+ ### Patch APK with Frida gadget
70
+ ```bash
71
+ apkpatcher patch app.apk --arch arm64
72
+ ```
73
+
74
+ ### Rename APK package
75
+ ```bash
76
+ apkpatcher rename app.apk com.newpackage.name
77
+ ```
78
+
79
+ ### Sign APK
80
+ ```bash
81
+ apkpatcher sign app.apk
82
+ ```
83
+
84
+ ## Architecture Support
85
+
86
+ - ARM (`arm`)
87
+ - ARM64 (`arm64`)
88
+ - x86 (`x86`)
89
+ - x86_64 (`x86_64`)
90
+
91
+ ## Requirements
92
+
93
+ - Python 3.8+
94
+ - Java Runtime Environment (JRE 8+)
95
+ - ADB (for device operations)
96
+
97
+ ## Tool Management
98
+
99
+ APKPatcher automatically downloads and manages required tools in `~/.apkpatcher/tools/`:
100
+
101
+ - apktool
102
+ - Android SDK build-tools
103
+ - Platform tools (adb)
104
+ - dexpatch
105
+ - Frida gadgets
106
+
107
+ ## License
108
+
109
+ MIT License - see LICENSE file for details.
110
+
@@ -0,0 +1,76 @@
1
+ # APKPatcher
2
+
3
+ A powerful command-line tool for Android APK manipulation, including Frida gadget injection, APK decoding/building, and package management.
4
+
5
+ ## Features
6
+
7
+ - **APK Management**: Pull, decode, build, and sign APK files
8
+ - **Frida Integration**: Inject Frida gadgets for runtime manipulation
9
+ - **Split APK Support**: Automatically merge split APKs into single files
10
+ - **Package Renaming**: Change APK package names
11
+ - **Auto-bootstrap**: Automatically downloads and manages required tools
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install apkpatcher
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### Pull APK from device
22
+ ```bash
23
+ apkpatcher pull com.example.app
24
+ ```
25
+
26
+ ### Decode APK
27
+ ```bash
28
+ apkpatcher decode app.apk
29
+ ```
30
+
31
+ ### Build APK from source
32
+ ```bash
33
+ apkpatcher build app_src/
34
+ ```
35
+
36
+ ### Patch APK with Frida gadget
37
+ ```bash
38
+ apkpatcher patch app.apk --arch arm64
39
+ ```
40
+
41
+ ### Rename APK package
42
+ ```bash
43
+ apkpatcher rename app.apk com.newpackage.name
44
+ ```
45
+
46
+ ### Sign APK
47
+ ```bash
48
+ apkpatcher sign app.apk
49
+ ```
50
+
51
+ ## Architecture Support
52
+
53
+ - ARM (`arm`)
54
+ - ARM64 (`arm64`)
55
+ - x86 (`x86`)
56
+ - x86_64 (`x86_64`)
57
+
58
+ ## Requirements
59
+
60
+ - Python 3.8+
61
+ - Java Runtime Environment (JRE 8+)
62
+ - ADB (for device operations)
63
+
64
+ ## Tool Management
65
+
66
+ APKPatcher automatically downloads and manages required tools in `~/.apkpatcher/tools/`:
67
+
68
+ - apktool
69
+ - Android SDK build-tools
70
+ - Platform tools (adb)
71
+ - dexpatch
72
+ - Frida gadgets
73
+
74
+ ## License
75
+
76
+ MIT License - see LICENSE file for details.
@@ -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
+ ]
@@ -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