feat: 实现 Android 设备搜索 (#21)

This commit is contained in:
chengma
2026-08-07 11:53:03 +08:00
parent 4975c6898d
commit 45fde81b8b
5 changed files with 608 additions and 13 deletions
+159
View File
@@ -0,0 +1,159 @@
"""通过 ADB 查询当前电脑已经识别的 Android 设备。"""
from dataclasses import dataclass
import subprocess
from typing import Callable, List, Optional, Sequence
DEFAULT_ADB_TIMEOUT_SECONDS = 5.0
class AndroidDeviceSearchError(RuntimeError):
"""ADB 无法完成设备搜索时抛出的可读错误。"""
@dataclass(frozen=True)
class AndroidDevice:
"""一台由 ``adb devices -l`` 返回的设备。"""
serial: str
connection_type: str
model: str = "—"
android_version: str = "—"
status: str = "device"
CommandRunner = Callable[[Sequence[str], float], subprocess.CompletedProcess]
def run_adb_command(
command: Sequence[str], timeout_seconds: float
) -> subprocess.CompletedProcess:
"""执行一条隐藏窗口的 ADB 命令,返回标准输出和错误输出。"""
return subprocess.run(
list(command),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_seconds,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
class AndroidDeviceService:
"""查询 ADB 设备列表;本类不依赖 Qt,也不创建 uiautomator2 设备。"""
def __init__(
self,
command_runner: CommandRunner = run_adb_command,
timeout_seconds: float = DEFAULT_ADB_TIMEOUT_SECONDS,
):
if timeout_seconds <= 0:
raise ValueError("ADB 超时时间必须大于 0")
self._run_command = command_runner
self._timeout_seconds = timeout_seconds
def search(
self, is_cancelled: Optional[Callable[[], bool]] = None
) -> List[AndroidDevice]:
"""返回当前 ADB 设备;取消后停止补充设备属性。"""
result = self._run(["adb", "devices", "-l"], "搜索设备")
devices = self.parse_devices(result.stdout or "")
enriched = []
for device in devices:
if is_cancelled is not None and is_cancelled():
break
if device.status != "device":
enriched.append(device)
continue
model = device.model
if model == "—":
model = self._read_property(device.serial, "ro.product.model")
if is_cancelled is not None and is_cancelled():
break
android_version = self._read_property(
device.serial, "ro.build.version.release"
)
enriched.append(
AndroidDevice(
serial=device.serial,
connection_type=device.connection_type,
model=model,
android_version=android_version,
status=device.status,
)
)
return enriched
@staticmethod
def parse_devices(output: str) -> List[AndroidDevice]:
"""解析 ``adb devices -l`` 输出,不依赖操作系统换行格式。"""
devices = []
for raw_line in output.splitlines():
line = raw_line.strip()
if not line or line.startswith("List of devices") or line.startswith("*"):
continue
parts = line.split()
if len(parts) < 2:
continue
serial, status = parts[0], parts[1]
fields = {}
for token in parts[2:]:
if ":" not in token:
continue
key, value = token.split(":", 1)
fields[key] = value
model = fields.get("model", "—").replace("_", " ") or "—"
devices.append(
AndroidDevice(
serial=serial,
connection_type="Wi-Fi" if ":" in serial else "USB",
model=model,
status=status,
)
)
return devices
def _read_property(self, serial: str, property_name: str) -> str:
"""读取单个属性;失败只返回占位符,不丢掉已经发现的设备。"""
try:
result = self._run(
["adb", "-s", serial, "shell", "getprop", property_name],
"读取设备信息",
)
except AndroidDeviceSearchError:
return "—"
value = (result.stdout or "").strip()
return value or "—"
def _run(self, command: Sequence[str], action: str):
try:
result = self._run_command(command, self._timeout_seconds)
except FileNotFoundError as exc:
raise AndroidDeviceSearchError(
"未找到 adb,请安装 Android platform-tools 并把 adb 加入 PATH 后重试"
) from exc
except subprocess.TimeoutExpired as exc:
raise AndroidDeviceSearchError(
f"ADB {action}超时,请检查设备连接或重启 ADB 后重试"
) from exc
except OSError as exc:
raise AndroidDeviceSearchError(
f"无法启动 adb:{str(exc) or '系统拒绝执行'}"
) from exc
if result.returncode != 0:
detail = (result.stderr or result.stdout or "未知错误").strip()
raise AndroidDeviceSearchError(f"ADB {action}失败:{detail}")
return result
+3
View File
@@ -206,6 +206,7 @@ class SettingsPage(QWidget):
parent=None,
settings_repository=None,
admin_gateway=None,
android_device_service=None,
):
super().__init__(parent)
self.setObjectName("settingsPage")
@@ -218,6 +219,7 @@ class SettingsPage(QWidget):
self,
settings_repository=settings_repository,
admin_gateway=admin_gateway,
android_device_service=android_device_service,
)
def _build_ui(self) -> None:
@@ -277,6 +279,7 @@ class SettingsPage(QWidget):
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
self.deviceStatusLabel = CaptionLabel("当前使用设备:未选择", self)
self.deviceStatusLabel.setAccessibleName("Android 设备搜索和选择状态")
self.androidDeviceCard = self._build_android_device_card()
content = QWidget(self)
+158 -11
View File
@@ -1,13 +1,17 @@
"""设置页事件、当前 Client 本地保存和后台登记。
"""设置页事件、ADB 搜索、当前 Client 本地保存和后台登记。
本文件不直接写 SQL。SQLite 保存和 Admin HTTP 请求由 Worker 在线程中执行,
后台结果只通过信号返回主线程更新页面。
本文件不直接写 SQL。ADB、SQLite 保存和 Admin HTTP 请求由 Worker 在线程中
执行,后台结果只通过信号返回主线程更新页面。
"""
from typing import Optional
from PyQt5.QtCore import QCoreApplication, QObject, QThread, pyqtSignal, pyqtSlot
from .android_device_service import (
AndroidDeviceSearchError,
AndroidDeviceService,
)
from .admin_gateway import (
AdminGatewayError,
AndroidDeviceInfo,
@@ -21,6 +25,7 @@ from .current_client_service import (
)
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
from .settings_repository import SettingsRepository
from .settings_ui import AndroidDeviceRow
DEVICE_ID_PLACEHOLDER = "待生成"
@@ -89,6 +94,41 @@ class CurrentClientSaveWorker(QObject):
self.completed.emit()
class AndroidDeviceSearchWorker(QObject):
"""在后台线程查询 ADB,结果只通过信号返回主线程。"""
succeeded = pyqtSignal(object)
failed = pyqtSignal(str)
completed = pyqtSignal()
def __init__(self, service: AndroidDeviceService):
super().__init__()
self._service = service
self._cancelled = False
def cancel(self) -> None:
"""停止后续属性查询;正在执行的命令等待自身超时结束。"""
self._cancelled = True
@pyqtSlot()
def run(self) -> None:
try:
try:
devices = self._service.search(lambda: self._cancelled)
except AndroidDeviceSearchError as exc:
if not self._cancelled:
self.failed.emit(str(exc))
except Exception as exc:
if not self._cancelled:
self.failed.emit(str(exc) or "无法搜索 Android 设备")
else:
if not self._cancelled:
self.succeeded.emit(devices)
finally:
self.completed.emit()
class SettingsPageEventBinder(QObject):
"""绑定设备管理控件,并向应用层发出稳定事件。"""
@@ -103,6 +143,7 @@ class SettingsPageEventBinder(QObject):
page,
settings_repository: Optional[SettingsRepository] = None,
admin_gateway: Optional[ClientRegistrationGateway] = None,
android_device_service: Optional[AndroidDeviceService] = None,
):
super().__init__(page)
self._page = page
@@ -111,6 +152,12 @@ class SettingsPageEventBinder(QObject):
self._closing = False
self._thread: Optional[QThread] = None
self._worker: Optional[CurrentClientSaveWorker] = None
self._search_busy = False
self._search_thread: Optional[QThread] = None
self._search_worker: Optional[AndroidDeviceSearchWorker] = None
self._android_device_service = (
android_device_service or AndroidDeviceService()
)
repository = settings_repository or SettingsRepository()
self._client_service = CurrentClientService(repository)
@@ -135,6 +182,7 @@ class SettingsPageEventBinder(QObject):
self._start_save_current_device
)
page.searchButton.clicked.connect(self._request_search)
self.searchRequested.connect(self._start_search)
page.connectButton.clicked.connect(self._request_connect)
page.saveButton.clicked.connect(self._request_save)
page.deleteButton.clicked.connect(self._request_delete)
@@ -150,7 +198,7 @@ class SettingsPageEventBinder(QObject):
@pyqtSlot()
def _request_save_current_device(self) -> None:
if self._busy or self._current_device_busy:
if self._busy or self._current_device_busy or self._search_busy:
return
device_id = self._page.deviceIdInput.text().strip()
@@ -162,7 +210,7 @@ class SettingsPageEventBinder(QObject):
def _start_save_current_device(
self, _displayed_device_id: str, device_name: str
) -> None:
if self._closing or self._current_device_busy:
if self._closing or self._current_device_busy or self._search_busy:
return
try:
@@ -202,10 +250,40 @@ class SettingsPageEventBinder(QObject):
@pyqtSlot()
def _request_search(self) -> None:
if self._busy:
if self._busy or self._current_device_busy or self._search_busy:
return
self.searchRequested.emit()
@pyqtSlot()
def _start_search(self) -> None:
if (
self._closing
or self._busy
or self._current_device_busy
or self._search_busy
):
return
self._search_busy = True
self._sync_button_state()
self._page.deviceStatusLabel.setText("正在搜索 Android 设备…")
thread = QThread(self)
worker = AndroidDeviceSearchWorker(self._android_device_service)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.succeeded.connect(self._on_search_succeeded)
worker.failed.connect(self._on_search_failed)
worker.completed.connect(thread.quit)
worker.completed.connect(worker.deleteLater)
thread.finished.connect(self._on_search_thread_finished)
thread.finished.connect(thread.deleteLater)
self._search_thread = thread
self._search_worker = worker
thread.start()
@pyqtSlot()
def _request_connect(self) -> None:
if self._busy:
@@ -251,12 +329,23 @@ class SettingsPageEventBinder(QObject):
serial = self._page.deviceTableModel.checked_serial
has_address = bool(self._page.addressInput.text().strip())
self._page.currentDeviceSaveButton.setEnabled(
not self._busy and not self._current_device_busy
not self._busy
and not self._current_device_busy
and not self._search_busy
)
device_commands_enabled = not self._busy and not self._search_busy
self._page.searchButton.setEnabled(
device_commands_enabled and not self._current_device_busy
)
self._page.connectButton.setEnabled(
device_commands_enabled and has_address
)
self._page.saveButton.setEnabled(
device_commands_enabled and bool(serial)
)
self._page.deleteButton.setEnabled(
device_commands_enabled and bool(serial)
)
self._page.searchButton.setEnabled(not self._busy)
self._page.connectButton.setEnabled(not self._busy and has_address)
self._page.saveButton.setEnabled(not self._busy and bool(serial))
self._page.deleteButton.setEnabled(not self._busy and bool(serial))
def set_busy(self, busy: bool, message: str = "") -> None:
"""切换界面忙碌状态,防止用户重复提交设备命令。"""
@@ -285,6 +374,47 @@ class SettingsPageEventBinder(QObject):
serial = self._page.deviceTableModel.checked_serial.strip()
return AndroidDeviceInfo(serial) if serial else None
@pyqtSlot(object)
def _on_search_succeeded(self, devices) -> None:
if self._closing:
return
rows = [
AndroidDeviceRow(
serial=device.serial,
connection_type=device.connection_type,
model=device.model,
android_version=device.android_version,
status=device.status,
)
for device in devices
]
self._page.set_android_devices(rows)
if rows:
self._page.deviceStatusLabel.setText(
f"搜索完成:找到 {len(rows)} 台 Android 设备"
)
else:
self._page.deviceStatusLabel.setText(
"未找到 Android 设备,请检查 USB 调试或无线连接后重试"
)
@pyqtSlot(str)
def _on_search_failed(self, message: str) -> None:
if self._closing:
return
self._page.deviceStatusLabel.setText(
f"搜索失败:{message};设备列表未更改"
)
@pyqtSlot()
def _on_search_thread_finished(self) -> None:
self._search_worker = None
self._search_thread = None
self._search_busy = False
if not self._closing:
self._sync_button_state()
@pyqtSlot(str, str)
def _on_local_saved(self, client_id: str, client_name: str) -> None:
if self._closing:
@@ -348,3 +478,20 @@ class SettingsPageEventBinder(QObject):
if thread is not None and thread.isRunning():
thread.quit()
thread.wait(4000)
search_worker = self._search_worker
search_thread = self._search_thread
if search_worker is not None:
search_worker.cancel()
for signal, slot in (
(search_worker.succeeded, self._on_search_succeeded),
(search_worker.failed, self._on_search_failed),
):
try:
signal.disconnect(slot)
except TypeError:
pass
if search_thread is not None and search_thread.isRunning():
search_thread.quit()
search_thread.wait(6000)