damon-devtools 0.1.0__tar.gz → 0.2.0__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.
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/PKG-INFO +1 -1
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/damon_devtools.egg-info/PKG-INFO +1 -1
- damon_devtools-0.2.0/devtools/tools/apkpull/core.py +303 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/apkpull/ui.py +27 -14
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/pyproject.toml +1 -1
- damon_devtools-0.1.0/devtools/tools/apkpull/core.py +0 -123
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/README.md +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/damon_devtools.egg-info/SOURCES.txt +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/damon_devtools.egg-info/dependency_links.txt +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/damon_devtools.egg-info/entry_points.txt +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/damon_devtools.egg-info/requires.txt +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/damon_devtools.egg-info/top_level.txt +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/__init__.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/__main__.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/launcher.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/__init__.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/apkpull/__init__.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/apkpull/__main__.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/apkpull/models.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/portkill/__init__.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/portkill/__main__.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/portkill/core.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/portkill/models.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/portkill/ui.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/removebg/__init__.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/removebg/__main__.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/removebg/cli.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/tools/removebg/core.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/devtools/types.py +0 -0
- {damon_devtools-0.1.0 → damon_devtools-0.2.0}/setup.cfg +0 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import re
|
|
4
|
+
import tempfile
|
|
5
|
+
import urllib.request
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
# Local aapt2 binary directory
|
|
9
|
+
AAPT2_LOCAL_DIR = os.path.join(
|
|
10
|
+
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
|
11
|
+
"deps", "android-tools", "build"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Remote aapt2 download base URL (android-11.0.0_r33)
|
|
15
|
+
AAPT2_REMOTE_URL = "https://github.com/JonForShort/android-tools/raw/refs/heads/master/build/android-11.0.0_r33/aapt2/{arch}/bin/aapt2"
|
|
16
|
+
|
|
17
|
+
# Device-side aapt2 path
|
|
18
|
+
AAPT2_DEVICE_PATH = "/data/local/tmp/aapt2"
|
|
19
|
+
|
|
20
|
+
# App name cache
|
|
21
|
+
LABEL_CACHE_FILE = os.path.expanduser("~/.devtools/cache/apkpull/label_cache.json")
|
|
22
|
+
|
|
23
|
+
def load_label_cache() -> dict[str, str]:
|
|
24
|
+
"""Load cached app labels from file"""
|
|
25
|
+
if os.path.isfile(LABEL_CACHE_FILE):
|
|
26
|
+
try:
|
|
27
|
+
with open(LABEL_CACHE_FILE, "r", encoding="utf-8") as f:
|
|
28
|
+
return json.load(f)
|
|
29
|
+
except Exception:
|
|
30
|
+
return {}
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
def save_label_cache(cache: dict[str, str]) -> None:
|
|
34
|
+
"""Save app labels cache to file"""
|
|
35
|
+
try:
|
|
36
|
+
os.makedirs(os.path.dirname(LABEL_CACHE_FILE), exist_ok=True)
|
|
37
|
+
with open(LABEL_CACHE_FILE, "w", encoding="utf-8") as f:
|
|
38
|
+
json.dump(cache, f, ensure_ascii=False, indent=2)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
# Global cache
|
|
43
|
+
_label_cache: dict[str, str] | None = None
|
|
44
|
+
|
|
45
|
+
def get_label_cache() -> dict[str, str]:
|
|
46
|
+
"""Get or initialize the global label cache"""
|
|
47
|
+
global _label_cache
|
|
48
|
+
if _label_cache is None:
|
|
49
|
+
_label_cache = load_label_cache()
|
|
50
|
+
return _label_cache
|
|
51
|
+
|
|
52
|
+
def check_adb() -> bool:
|
|
53
|
+
"""检查 ADB 是否可用"""
|
|
54
|
+
try:
|
|
55
|
+
subprocess.run(["adb", "version"], capture_output=True, check=True)
|
|
56
|
+
return True
|
|
57
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def get_device_serial() -> str | None:
|
|
61
|
+
"""获取第一个已连接的设备序列号,多设备时返回第一个"""
|
|
62
|
+
try:
|
|
63
|
+
result = subprocess.run(["adb", "devices"], capture_output=True, text=True, encoding="utf-8", errors="replace", check=True)
|
|
64
|
+
lines = result.stdout.strip().split("\n")
|
|
65
|
+
for line in lines[1:]:
|
|
66
|
+
if line.strip() and not line.startswith("*"):
|
|
67
|
+
parts = line.split()
|
|
68
|
+
if len(parts) >= 2 and parts[1] == "device":
|
|
69
|
+
return parts[0]
|
|
70
|
+
return None
|
|
71
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def check_device() -> bool:
|
|
75
|
+
"""检查设备是否连接"""
|
|
76
|
+
return get_device_serial() is not None
|
|
77
|
+
|
|
78
|
+
def adb_cmd(args: list[str], serial: str | None = None) -> list[str]:
|
|
79
|
+
"""构建带 -s 参数的 adb 命令"""
|
|
80
|
+
if serial:
|
|
81
|
+
return ["adb", "-s", serial] + args
|
|
82
|
+
return ["adb"] + args
|
|
83
|
+
|
|
84
|
+
def check_aapt2_on_device() -> bool:
|
|
85
|
+
"""Check if /data/local/tmp/aapt2 exists on device"""
|
|
86
|
+
serial = get_device_serial()
|
|
87
|
+
try:
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
adb_cmd(["shell", "test", "-f", AAPT2_DEVICE_PATH], serial),
|
|
90
|
+
capture_output=True, check=False
|
|
91
|
+
)
|
|
92
|
+
return result.returncode == 0
|
|
93
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def detect_device_arch() -> str | None:
|
|
97
|
+
"""Get device CPU architecture via getprop"""
|
|
98
|
+
serial = get_device_serial()
|
|
99
|
+
try:
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
adb_cmd(["shell", "getprop", "ro.product.cpu.abi"], serial),
|
|
102
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
|
|
103
|
+
)
|
|
104
|
+
arch = result.stdout.strip()
|
|
105
|
+
return arch if arch else None
|
|
106
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
# Local aapt2 dir name -> device arch values
|
|
110
|
+
ARCH_MAP = {
|
|
111
|
+
"arm64-v8a": ["arm64-v8a", "aarch64", "arm64"],
|
|
112
|
+
"armeabi-v7a": ["armeabi-v7a", "armeabi", "arm"],
|
|
113
|
+
"x86": ["x86", "i386", "i686"],
|
|
114
|
+
"x86_64": ["x86_64", "x64", "amd64"],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
def find_local_aapt2(arch: str) -> str | None:
|
|
118
|
+
"""Find local aapt2 binary matching device arch"""
|
|
119
|
+
if not os.path.isdir(AAPT2_LOCAL_DIR):
|
|
120
|
+
return None
|
|
121
|
+
for version in sorted(os.listdir(AAPT2_LOCAL_DIR), reverse=True):
|
|
122
|
+
version_path = os.path.join(AAPT2_LOCAL_DIR, version, "aapt2")
|
|
123
|
+
if not os.path.isdir(version_path):
|
|
124
|
+
continue
|
|
125
|
+
for arch_dir, aliases in ARCH_MAP.items():
|
|
126
|
+
if arch in aliases:
|
|
127
|
+
aapt2_path = os.path.join(version_path, arch_dir, "bin", "aapt2")
|
|
128
|
+
if os.path.isfile(aapt2_path):
|
|
129
|
+
return aapt2_path
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def download_aapt2(arch: str) -> str | None:
|
|
133
|
+
"""Download aapt2 from GitHub and cache locally. Returns local path or None on failure."""
|
|
134
|
+
# Map device arch to download arch
|
|
135
|
+
arch_to_url_arch = {
|
|
136
|
+
"arm64-v8a": "arm64-v8a",
|
|
137
|
+
"aarch64": "arm64-v8a",
|
|
138
|
+
"arm64": "arm64-v8a",
|
|
139
|
+
"armeabi-v7a": "armeabi-v7a",
|
|
140
|
+
"armeabi": "armeabi-v7a",
|
|
141
|
+
"arm": "armeabi-v7a",
|
|
142
|
+
"x86": "x86",
|
|
143
|
+
"i386": "x86",
|
|
144
|
+
"i686": "x86",
|
|
145
|
+
"x86_64": "x86_64",
|
|
146
|
+
"x64": "x86_64",
|
|
147
|
+
"amd64": "x86_64",
|
|
148
|
+
}
|
|
149
|
+
url_arch = arch_to_url_arch.get(arch, arch)
|
|
150
|
+
url = AAPT2_REMOTE_URL.format(arch=url_arch)
|
|
151
|
+
|
|
152
|
+
# Cache path: AAPT2_LOCAL_DIR/android-11.0.0_r33/aapt2/{arch}/bin/aapt2
|
|
153
|
+
cache_dir = os.path.join(AAPT2_LOCAL_DIR, "android-11.0.0_r33", "aapt2", url_arch, "bin")
|
|
154
|
+
cache_path = os.path.join(cache_dir, "aapt2")
|
|
155
|
+
|
|
156
|
+
if os.path.isfile(cache_path):
|
|
157
|
+
return cache_path
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
161
|
+
print(f"正在下载 aapt2 ({arch})...")
|
|
162
|
+
urllib.request.urlretrieve(url, cache_path)
|
|
163
|
+
# Make executable
|
|
164
|
+
os.chmod(cache_path, 0o755)
|
|
165
|
+
return cache_path
|
|
166
|
+
except Exception as e:
|
|
167
|
+
print(f"下载失败: {e}")
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def ensure_aapt2_on_device() -> bool:
|
|
171
|
+
"""Push local aapt2 to device if missing; return success"""
|
|
172
|
+
if check_aapt2_on_device():
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
serial = get_device_serial()
|
|
176
|
+
arch = detect_device_arch()
|
|
177
|
+
if not arch:
|
|
178
|
+
print("警告: 无法获取设备架构,使用 arm64-v8a")
|
|
179
|
+
arch = "arm64-v8a"
|
|
180
|
+
|
|
181
|
+
local_aapt2 = find_local_aapt2(arch)
|
|
182
|
+
if not local_aapt2:
|
|
183
|
+
print(f"本地未找到 aapt2 ({arch}),尝试下载...")
|
|
184
|
+
local_aapt2 = download_aapt2(arch)
|
|
185
|
+
if not local_aapt2:
|
|
186
|
+
print(f"警告: 下载失败,回退到 pm dump 方式")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
print(f"正在推送 aapt2 ({arch}) 到设备...")
|
|
191
|
+
subprocess.run(
|
|
192
|
+
adb_cmd(["push", local_aapt2, AAPT2_DEVICE_PATH], serial),
|
|
193
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
|
|
194
|
+
)
|
|
195
|
+
subprocess.run(
|
|
196
|
+
adb_cmd(["shell", "chmod", "0755", AAPT2_DEVICE_PATH], serial),
|
|
197
|
+
capture_output=True, check=True
|
|
198
|
+
)
|
|
199
|
+
return True
|
|
200
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
def scan_packages() -> list[str]:
|
|
204
|
+
"""执行 adb shell pm list packages -3,返回包名列表"""
|
|
205
|
+
serial = get_device_serial()
|
|
206
|
+
result = subprocess.run(
|
|
207
|
+
adb_cmd(["shell", "pm", "list", "packages", "-3"], serial),
|
|
208
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
|
|
209
|
+
)
|
|
210
|
+
pkgs = []
|
|
211
|
+
for line in result.stdout.strip().split("\n"):
|
|
212
|
+
line = line.strip()
|
|
213
|
+
if line.startswith("package:"):
|
|
214
|
+
pkgs.append(line[8:])
|
|
215
|
+
return pkgs
|
|
216
|
+
|
|
217
|
+
def get_apk_path(pkg: str) -> str | None:
|
|
218
|
+
"""执行 pm path,返回 APK 物理路径"""
|
|
219
|
+
serial = get_device_serial()
|
|
220
|
+
try:
|
|
221
|
+
result = subprocess.run(
|
|
222
|
+
adb_cmd(["shell", "pm", "path", pkg], serial),
|
|
223
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
|
|
224
|
+
)
|
|
225
|
+
for line in result.stdout.strip().split("\n"):
|
|
226
|
+
if line.startswith("package:"):
|
|
227
|
+
return line[8:].strip()
|
|
228
|
+
return None
|
|
229
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def get_app_name_pm_dump(pkg: str) -> str:
|
|
233
|
+
"""通过设备端 pm dump 提取 application label(失败时回退包名)"""
|
|
234
|
+
serial = get_device_serial()
|
|
235
|
+
try:
|
|
236
|
+
result = subprocess.run(
|
|
237
|
+
adb_cmd(["shell", "pm", "dump", pkg], serial),
|
|
238
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace"
|
|
239
|
+
)
|
|
240
|
+
if result.returncode != 0:
|
|
241
|
+
return pkg
|
|
242
|
+
for line in result.stdout.splitlines():
|
|
243
|
+
# application-label:'Facebook'
|
|
244
|
+
# application-label-zh-CN:'微信'
|
|
245
|
+
m = re.search(r"application-label(?:-[^:]+)?:'(.+?)'", line)
|
|
246
|
+
if m:
|
|
247
|
+
return m.group(1)
|
|
248
|
+
return pkg
|
|
249
|
+
except Exception:
|
|
250
|
+
return pkg
|
|
251
|
+
|
|
252
|
+
def get_app_name_aapt2(pkg: str) -> str | None:
|
|
253
|
+
"""Use device-side aapt2 dump badging to get app label. Returns None on failure."""
|
|
254
|
+
apk_path = get_apk_path(pkg)
|
|
255
|
+
if not apk_path:
|
|
256
|
+
return None
|
|
257
|
+
serial = get_device_serial()
|
|
258
|
+
try:
|
|
259
|
+
cmd = f'/data/local/tmp/aapt2 dump badging "{apk_path}"'
|
|
260
|
+
result = subprocess.run(
|
|
261
|
+
adb_cmd(["shell", cmd], serial),
|
|
262
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace"
|
|
263
|
+
)
|
|
264
|
+
if result.returncode != 0:
|
|
265
|
+
return None
|
|
266
|
+
for line in result.stdout.splitlines():
|
|
267
|
+
m = re.search(r"^application-label:'(.+?)'", line)
|
|
268
|
+
if m:
|
|
269
|
+
return m.group(1)
|
|
270
|
+
return None
|
|
271
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def get_app_name(pkg: str) -> str:
|
|
275
|
+
"""Get app label: check cache first, then fetch and cache"""
|
|
276
|
+
cache = get_label_cache()
|
|
277
|
+
if pkg in cache:
|
|
278
|
+
return cache[pkg]
|
|
279
|
+
|
|
280
|
+
label = get_app_name_aapt2(pkg)
|
|
281
|
+
if not label:
|
|
282
|
+
label = get_app_name_pm_dump(pkg)
|
|
283
|
+
|
|
284
|
+
cache[pkg] = label
|
|
285
|
+
save_label_cache(cache)
|
|
286
|
+
return label
|
|
287
|
+
|
|
288
|
+
def sanitize_filename(name: str) -> str:
|
|
289
|
+
"""Sanitize filename for cross-platform compatibility."""
|
|
290
|
+
sanitized = re.sub(r'[\\/:*?"<>|]+', "_", name).strip().rstrip(".")
|
|
291
|
+
return sanitized or "unknown"
|
|
292
|
+
|
|
293
|
+
def pull_apk(apk_path: str, dest_path: str) -> bool:
|
|
294
|
+
"""执行 adb pull,拉取 APK 到目标文件路径"""
|
|
295
|
+
serial = get_device_serial()
|
|
296
|
+
try:
|
|
297
|
+
subprocess.run(
|
|
298
|
+
adb_cmd(["pull", apk_path, dest_path], serial),
|
|
299
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
|
|
300
|
+
)
|
|
301
|
+
return True
|
|
302
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
303
|
+
return False
|
|
@@ -3,12 +3,13 @@ if sys.platform == "win32":
|
|
|
3
3
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
+
import argparse
|
|
6
7
|
import threading
|
|
7
8
|
from devtools.tools.apkpull.models import PackageInfo
|
|
8
9
|
|
|
9
10
|
def scan_packages_with_labels() -> list[PackageInfo]:
|
|
10
11
|
"""Scan packages and fetch labels in parallel threads (max 10 concurrent)"""
|
|
11
|
-
from devtools.tools.apkpull.core import scan_packages, check_adb, check_device, get_app_name
|
|
12
|
+
from devtools.tools.apkpull.core import scan_packages, check_adb, check_device, get_app_name, ensure_aapt2_on_device
|
|
12
13
|
|
|
13
14
|
if not check_adb():
|
|
14
15
|
print("错误: 未找到 adb 命令,请确保 Android SDK platform-tools 已安装并加入 PATH")
|
|
@@ -17,6 +18,7 @@ def scan_packages_with_labels() -> list[PackageInfo]:
|
|
|
17
18
|
print("错误: 未检测到设备,请确保手机已通过 USB 连接并开启了 USB 调试模式")
|
|
18
19
|
return []
|
|
19
20
|
|
|
21
|
+
ensure_aapt2_on_device()
|
|
20
22
|
print("正在获取应用列表...")
|
|
21
23
|
pkgs = scan_packages()
|
|
22
24
|
if not pkgs:
|
|
@@ -53,12 +55,12 @@ def scan_packages_with_labels() -> list[PackageInfo]:
|
|
|
53
55
|
|
|
54
56
|
def show_packages(packages: list[PackageInfo], short: bool = True) -> None:
|
|
55
57
|
print()
|
|
56
|
-
print(f"{'#':<5} {'
|
|
58
|
+
print(f"{'#':<5} {'包名':<50} {'应用名'}")
|
|
57
59
|
print("-" * 90)
|
|
58
60
|
for i, p in enumerate(packages):
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
print(f"{i+1:<5} {
|
|
61
|
+
pkg = p.pkg[:48] + ".." if short and len(p.pkg) > 50 else p.pkg
|
|
62
|
+
label = p.label[:38] + ".." if short and len(p.label) > 40 else p.label
|
|
63
|
+
print(f"{i+1:<5} {pkg:<50} {label}")
|
|
62
64
|
|
|
63
65
|
def select_packages(packages: list[PackageInfo], user_input: str) -> list[PackageInfo]:
|
|
64
66
|
"""Parse user input: keyword search or numeric selection (1,3-5, 1,3,5)"""
|
|
@@ -108,17 +110,26 @@ def run(args: list[str] | None = None):
|
|
|
108
110
|
if args is None:
|
|
109
111
|
args = []
|
|
110
112
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
parser = argparse.ArgumentParser(prog="apkpull", description="Pull APK from Android device")
|
|
114
|
+
parser.add_argument("dest_dir", nargs="?", default=os.getcwd(), help="目标目录 (默认当前目录)")
|
|
115
|
+
parser.add_argument("-s", "--search", metavar="KEYWORD", help="模糊搜索应用名/包名并退出")
|
|
116
|
+
|
|
117
|
+
parsed = parser.parse_args(args)
|
|
116
118
|
|
|
117
119
|
packages = scan_packages_with_labels()
|
|
118
120
|
if not packages:
|
|
119
121
|
return
|
|
120
122
|
|
|
121
|
-
|
|
123
|
+
# Fuzzy search mode: pre-filter packages and enter selection loop
|
|
124
|
+
if parsed.search:
|
|
125
|
+
keyword = parsed.search.lower()
|
|
126
|
+
packages = [p for p in packages if keyword in p.label.lower() or keyword in p.pkg.lower()]
|
|
127
|
+
if not packages:
|
|
128
|
+
print(f"未找到包含 '{parsed.search}' 的应用")
|
|
129
|
+
return
|
|
130
|
+
show_packages(packages)
|
|
131
|
+
else:
|
|
132
|
+
show_packages(packages)
|
|
122
133
|
|
|
123
134
|
while True:
|
|
124
135
|
print()
|
|
@@ -142,12 +153,12 @@ def run(args: list[str] | None = None):
|
|
|
142
153
|
|
|
143
154
|
show_packages(selected, short=False)
|
|
144
155
|
|
|
145
|
-
if not confirm_pull(selected, dest_dir):
|
|
156
|
+
if not confirm_pull(selected, parsed.dest_dir):
|
|
146
157
|
print("取消拉取")
|
|
147
158
|
continue
|
|
148
159
|
|
|
149
160
|
# Pull APKs
|
|
150
|
-
from devtools.tools.apkpull.core import get_apk_path, pull_apk
|
|
161
|
+
from devtools.tools.apkpull.core import get_apk_path, pull_apk, sanitize_filename
|
|
151
162
|
succeeded: list[str] = []
|
|
152
163
|
failed: list[tuple] = []
|
|
153
164
|
|
|
@@ -156,7 +167,9 @@ def run(args: list[str] | None = None):
|
|
|
156
167
|
if not apk_path:
|
|
157
168
|
failed.append((p.label, "未找到 APK 路径"))
|
|
158
169
|
continue
|
|
159
|
-
if
|
|
170
|
+
file_base = p.label if p.label and p.label != p.pkg else p.pkg
|
|
171
|
+
dest_apk = os.path.join(parsed.dest_dir, f"{sanitize_filename(file_base)}.apk")
|
|
172
|
+
if pull_apk(apk_path, dest_apk):
|
|
160
173
|
succeeded.append(p.label)
|
|
161
174
|
else:
|
|
162
175
|
failed.append((p.label, "拉取失败"))
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import subprocess
|
|
2
|
-
import re
|
|
3
|
-
import os
|
|
4
|
-
import tempfile
|
|
5
|
-
|
|
6
|
-
def _find_aapt() -> str | None:
|
|
7
|
-
"""Find aapt.exe — checks PATH first, then Android SDK build-tools"""
|
|
8
|
-
# 1. Check PATH
|
|
9
|
-
import shutil as _shutil
|
|
10
|
-
path_aapt = _shutil.which("aapt")
|
|
11
|
-
if path_aapt and os.path.isfile(path_aapt):
|
|
12
|
-
return path_aapt
|
|
13
|
-
path_aapt2 = _shutil.which("aapt2")
|
|
14
|
-
if path_aapt2 and os.path.isfile(path_aapt2):
|
|
15
|
-
return path_aapt2
|
|
16
|
-
# 2. Check Android SDK build-tools
|
|
17
|
-
sdk_base = os.environ.get("LOCALAPPDATA", "") + "/Android/Sdk"
|
|
18
|
-
build_tools = os.path.join(sdk_base, "build-tools")
|
|
19
|
-
if os.path.isdir(build_tools):
|
|
20
|
-
versions = sorted(os.listdir(build_tools), reverse=True)
|
|
21
|
-
for v in versions:
|
|
22
|
-
for binary in ("aapt.exe", "aapt2.exe"):
|
|
23
|
-
aapt = os.path.join(build_tools, v, binary)
|
|
24
|
-
if os.path.isfile(aapt):
|
|
25
|
-
return aapt
|
|
26
|
-
return None
|
|
27
|
-
|
|
28
|
-
AAPT_PATH = _find_aapt()
|
|
29
|
-
|
|
30
|
-
def check_adb() -> bool:
|
|
31
|
-
"""检查 ADB 是否可用"""
|
|
32
|
-
try:
|
|
33
|
-
subprocess.run(["adb", "version"], capture_output=True, check=True)
|
|
34
|
-
return True
|
|
35
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
36
|
-
return False
|
|
37
|
-
|
|
38
|
-
def check_device() -> bool:
|
|
39
|
-
"""检查设备是否连接"""
|
|
40
|
-
try:
|
|
41
|
-
result = subprocess.run(["adb", "devices"], capture_output=True, text=True, encoding="utf-8", errors="replace", check=True)
|
|
42
|
-
lines = result.stdout.strip().split("\n")
|
|
43
|
-
for line in lines[1:]:
|
|
44
|
-
if line.strip() and not line.startswith("*"):
|
|
45
|
-
parts = line.split()
|
|
46
|
-
if len(parts) >= 2 and parts[1] == "device":
|
|
47
|
-
return True
|
|
48
|
-
return False
|
|
49
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
50
|
-
return False
|
|
51
|
-
|
|
52
|
-
def scan_packages() -> list[str]:
|
|
53
|
-
"""执行 adb shell pm list packages -3,返回包名列表"""
|
|
54
|
-
result = subprocess.run(
|
|
55
|
-
["adb", "shell", "pm", "list", "packages", "-3"],
|
|
56
|
-
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
|
|
57
|
-
)
|
|
58
|
-
pkgs = []
|
|
59
|
-
for line in result.stdout.strip().split("\n"):
|
|
60
|
-
line = line.strip()
|
|
61
|
-
if line.startswith("package:"):
|
|
62
|
-
pkgs.append(line[8:])
|
|
63
|
-
return pkgs
|
|
64
|
-
|
|
65
|
-
def get_apk_path(pkg: str) -> str | None:
|
|
66
|
-
"""执行 pm path,返回 APK 物理路径"""
|
|
67
|
-
try:
|
|
68
|
-
result = subprocess.run(
|
|
69
|
-
["adb", "shell", "pm", "path", pkg],
|
|
70
|
-
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
|
|
71
|
-
)
|
|
72
|
-
for line in result.stdout.strip().split("\n"):
|
|
73
|
-
if line.startswith("package:"):
|
|
74
|
-
return line[8:].strip()
|
|
75
|
-
return None
|
|
76
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
77
|
-
return None
|
|
78
|
-
|
|
79
|
-
def get_app_name(pkg: str) -> str:
|
|
80
|
-
"""通过 aapt 提取 APK 的 application label"""
|
|
81
|
-
if not AAPT_PATH:
|
|
82
|
-
return pkg
|
|
83
|
-
apk_path = get_apk_path(pkg)
|
|
84
|
-
if not apk_path:
|
|
85
|
-
return pkg
|
|
86
|
-
tmp = os.path.join(tempfile.gettempdir(), f"apkpull_{os.getpid()}_{pkg.replace('.', '_')}.apk")
|
|
87
|
-
try:
|
|
88
|
-
# pull APK to temp file
|
|
89
|
-
pull_ok = subprocess.run(
|
|
90
|
-
["adb", "pull", apk_path, tmp],
|
|
91
|
-
capture_output=True, encoding="utf-8", errors="replace"
|
|
92
|
-
).returncode == 0
|
|
93
|
-
if not pull_ok or not os.path.exists(tmp):
|
|
94
|
-
return pkg
|
|
95
|
-
# extract label via aapt
|
|
96
|
-
result = subprocess.run(
|
|
97
|
-
[AAPT_PATH, "dump", "badging", tmp],
|
|
98
|
-
capture_output=True, encoding="utf-8", errors="replace"
|
|
99
|
-
)
|
|
100
|
-
for line in result.stdout.split("\n"):
|
|
101
|
-
m = re.search(r"application-label:'(.+?)'", line)
|
|
102
|
-
if m:
|
|
103
|
-
return m.group(1)
|
|
104
|
-
return pkg
|
|
105
|
-
except Exception:
|
|
106
|
-
return pkg
|
|
107
|
-
finally:
|
|
108
|
-
if os.path.exists(tmp):
|
|
109
|
-
try:
|
|
110
|
-
os.remove(tmp)
|
|
111
|
-
except OSError:
|
|
112
|
-
pass
|
|
113
|
-
|
|
114
|
-
def pull_apk(apk_path: str, dest_dir: str) -> bool:
|
|
115
|
-
"""执行 adb pull,拉取 APK 到目标目录"""
|
|
116
|
-
try:
|
|
117
|
-
subprocess.run(
|
|
118
|
-
["adb", "pull", apk_path, dest_dir],
|
|
119
|
-
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
|
|
120
|
-
)
|
|
121
|
-
return True
|
|
122
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
123
|
-
return False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|