feat: 支持 USB 设备转 Wi-Fi ADB (#25)

This commit is contained in:
chengma
2026-08-07 14:42:00 +08:00
parent bf81bad741
commit d478e690b6
5 changed files with 741 additions and 3 deletions
+211
View File
@@ -1,17 +1,25 @@
"""通过 ADB 查询当前电脑已经识别的 Android 设备。""" """通过 ADB 查询当前电脑已经识别的 Android 设备。"""
from dataclasses import dataclass from dataclasses import dataclass
import ipaddress
import subprocess import subprocess
import time
from typing import Callable, List, Optional, Sequence from typing import Callable, List, Optional, Sequence
DEFAULT_ADB_TIMEOUT_SECONDS = 5.0 DEFAULT_ADB_TIMEOUT_SECONDS = 5.0
DEFAULT_USB_DISCONNECT_TIMEOUT_SECONDS = 60.0
DEFAULT_USB_POLL_INTERVAL_SECONDS = 0.5
class AndroidDeviceSearchError(RuntimeError): class AndroidDeviceSearchError(RuntimeError):
"""ADB 无法完成设备搜索时抛出的可读错误。""" """ADB 无法完成设备搜索时抛出的可读错误。"""
class AndroidDeviceConversionCancelled(RuntimeError):
"""页面关闭后停止 USB 转 Wi-Fi 的后续步骤。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class AndroidDevice: class AndroidDevice:
"""一台由 ``adb devices -l`` 返回的设备。""" """一台由 ``adb devices -l`` 返回的设备。"""
@@ -23,7 +31,17 @@ class AndroidDevice:
status: str = "device" status: str = "device"
@dataclass(frozen=True)
class AndroidWifiConversionResult:
"""USB 转 Wi-Fi 成功后的目标设备号和最新设备列表。"""
wifi_serial: str
devices: List[AndroidDevice]
CommandRunner = Callable[[Sequence[str], float], subprocess.CompletedProcess] CommandRunner = Callable[[Sequence[str], float], subprocess.CompletedProcess]
Sleeper = Callable[[float], None]
ProgressCallback = Callable[[str], None]
def run_adb_command( def run_adb_command(
@@ -50,11 +68,23 @@ class AndroidDeviceService:
self, self,
command_runner: CommandRunner = run_adb_command, command_runner: CommandRunner = run_adb_command,
timeout_seconds: float = DEFAULT_ADB_TIMEOUT_SECONDS, timeout_seconds: float = DEFAULT_ADB_TIMEOUT_SECONDS,
usb_disconnect_timeout_seconds: float = (
DEFAULT_USB_DISCONNECT_TIMEOUT_SECONDS
),
usb_poll_interval_seconds: float = DEFAULT_USB_POLL_INTERVAL_SECONDS,
sleeper: Sleeper = time.sleep,
): ):
if timeout_seconds <= 0: if timeout_seconds <= 0:
raise ValueError("ADB 超时时间必须大于 0") raise ValueError("ADB 超时时间必须大于 0")
if usb_disconnect_timeout_seconds <= 0:
raise ValueError("等待拔出 USB 的超时时间必须大于 0")
if usb_poll_interval_seconds <= 0:
raise ValueError("USB 检查间隔必须大于 0")
self._run_command = command_runner self._run_command = command_runner
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
self._usb_disconnect_timeout_seconds = usb_disconnect_timeout_seconds
self._usb_poll_interval_seconds = usb_poll_interval_seconds
self._sleep = sleeper
def search( def search(
self, is_cancelled: Optional[Callable[[], bool]] = None self, is_cancelled: Optional[Callable[[], bool]] = None
@@ -91,6 +121,110 @@ class AndroidDeviceService:
) )
return enriched return enriched
def convert_usb_to_wifi(
self,
usb_serial: str,
is_cancelled: Optional[Callable[[], bool]] = None,
on_progress: Optional[ProgressCallback] = None,
) -> AndroidWifiConversionResult:
"""开启 USB 设备的 5555 端口,等拔线后重连并返回最新列表。"""
self._validate_usb_serial(usb_serial)
self._check_cancelled(is_cancelled)
self._report(on_progress, "正在读取勾选设备的 Wi-Fi 地址…")
route = self._run(
["adb", "-s", usb_serial, "shell", "ip", "route"],
"读取 Wi-Fi 地址",
)
ip_address = self.parse_wifi_ipv4(route.stdout or "")
wifi_serial = f"{ip_address}:5555"
self._check_cancelled(is_cancelled)
self._report(on_progress, "正在开启 ADB 5555 端口…")
self._run(
["adb", "-s", usb_serial, "tcpip", "5555"],
"开启 Wi-Fi 调试",
)
# 部分手机插线时就能建立 Wi-Fi 通道;该连接仅用于提前准备,
# 真正的结果仍以拔线后再次连接和验证为准。
try:
self._connect_wifi(wifi_serial)
except AndroidDeviceSearchError:
pass
self._report(
on_progress,
f"已开启 {wifi_serial},请拔掉 USB 数据线…",
)
self._wait_until_usb_disconnected(usb_serial, is_cancelled)
self._check_cancelled(is_cancelled)
self._report(on_progress, "已检测到 USB 拔出,正在重新连接 Wi-Fi…")
self._connect_wifi(wifi_serial)
self._check_cancelled(is_cancelled)
devices = self._list_devices()
wifi_device = next(
(device for device in devices if device.serial == wifi_serial),
None,
)
if wifi_device is None or wifi_device.status != "device":
status = wifi_device.status if wifi_device is not None else "未发现"
raise AndroidDeviceSearchError(
f"Wi-Fi 连接验证失败:{wifi_serial} 状态为 {status};"
"请确认手机和电脑在同一网络后重试"
)
self._report(on_progress, "Wi-Fi ADB 已连接,正在刷新设备列表…")
return AndroidWifiConversionResult(wifi_serial, self.search(is_cancelled))
@staticmethod
def parse_wifi_ipv4(route_output: str) -> str:
"""从 Android ``ip route`` 输出的 ``src`` 字段读取有效 IPv4。"""
route_tokens = [line.split() for line in route_output.splitlines()]
default_interfaces = []
for tokens in route_tokens:
if not tokens or tokens[0] != "default" or "dev" not in tokens:
continue
device_index = tokens.index("dev")
if device_index + 1 < len(tokens):
default_interfaces.append(tokens[device_index + 1])
preferred_routes = []
for interface in default_interfaces:
preferred_routes.extend(
tokens
for tokens in route_tokens
if "dev" in tokens
and tokens.index("dev") + 1 < len(tokens)
and tokens[tokens.index("dev") + 1] == interface
)
preferred_routes.extend(
tokens for tokens in route_tokens if tokens not in preferred_routes
)
for tokens in preferred_routes:
for index, token in enumerate(tokens[:-1]):
if token != "src":
continue
candidate = tokens[index + 1]
try:
address = ipaddress.ip_address(candidate)
except ValueError:
continue
if (
address.version == 4
and not address.is_loopback
and not address.is_unspecified
and not address.is_multicast
):
return str(address)
raise AndroidDeviceSearchError(
"无法读取手机 Wi-Fi IPv4;请先让手机连接与电脑相同的 Wi-Fi"
)
@staticmethod @staticmethod
def parse_devices(output: str) -> List[AndroidDevice]: def parse_devices(output: str) -> List[AndroidDevice]:
"""解析 ``adb devices -l`` 输出,不依赖操作系统换行格式。""" """解析 ``adb devices -l`` 输出,不依赖操作系统换行格式。"""
@@ -137,6 +271,83 @@ class AndroidDeviceService:
value = (result.stdout or "").strip() value = (result.stdout or "").strip()
return value or "—" return value or "—"
def _wait_until_usb_disconnected(
self,
usb_serial: str,
is_cancelled: Optional[Callable[[], bool]],
) -> None:
attempts = max(
1,
int(
self._usb_disconnect_timeout_seconds
/ self._usb_poll_interval_seconds
),
)
missing_checks = 0
for attempt in range(attempts):
self._check_cancelled(is_cancelled)
devices = self._list_devices()
if all(device.serial != usb_serial for device in devices):
missing_checks += 1
if missing_checks >= 2:
return
else:
missing_checks = 0
if attempt < attempts - 1:
self._sleep(self._usb_poll_interval_seconds)
raise AndroidDeviceSearchError(
"等待拔出 USB 超时;5555 已开启,请拔线后重新点击转换"
)
def _list_devices(self) -> List[AndroidDevice]:
result = self._run(["adb", "devices", "-l"], "读取设备列表")
return self.parse_devices(result.stdout or "")
def _connect_wifi(self, wifi_serial: str) -> None:
result = self._run(
["adb", "connect", wifi_serial],
"连接 Wi-Fi 设备",
)
detail = " ".join(
part.strip() for part in (result.stdout, result.stderr) if part
)
normalized_detail = detail.lower()
if (
"connected to" not in normalized_detail
and "already connected to" not in normalized_detail
):
raise AndroidDeviceSearchError(
f"ADB 连接 {wifi_serial} 失败:"
f"{detail or '没有返回成功信息'}"
)
@staticmethod
def _validate_usb_serial(serial: str) -> None:
if not isinstance(serial, str) or not serial.strip():
raise AndroidDeviceSearchError("请先勾选一台已连接的 USB 设备")
contains_whitespace = any(
character.isspace() for character in serial
)
if serial != serial.strip() or contains_whitespace:
raise AndroidDeviceSearchError("USB 设备号格式不正确")
if ":" in serial:
raise AndroidDeviceSearchError("当前设备已经是 Wi-Fi 连接")
@staticmethod
def _check_cancelled(
is_cancelled: Optional[Callable[[], bool]],
) -> None:
if is_cancelled is not None and is_cancelled():
raise AndroidDeviceConversionCancelled()
@staticmethod
def _report(
callback: Optional[ProgressCallback], message: str
) -> None:
if callback is not None:
callback(message)
def _run(self, command: Sequence[str], action: str): def _run(self, command: Sequence[str], action: str):
try: try:
result = self._run_command(command, self._timeout_seconds) result = self._run_command(command, self._timeout_seconds)
+9
View File
@@ -250,8 +250,16 @@ class SettingsPage(QWidget):
self.searchButton = PushButton(FIF.SEARCH, "搜索", self) self.searchButton = PushButton(FIF.SEARCH, "搜索", self)
self.connectButton = PushButton(FIF.LINK, "连接", self) self.connectButton = PushButton(FIF.LINK, "连接", self)
self.convertWifiButton = PushButton(FIF.WIFI, "转为 Wi-Fi", self)
self.convertWifiButton.setAccessibleName(
"将勾选的 USB Android 设备转为 Wi-Fi 连接"
)
self.convertWifiButton.setToolTip(
"开启 ADB 5555,并在拔掉 USB 后重新连接"
)
self.saveButton = PushButton(FIF.SAVE, "保存", self) self.saveButton = PushButton(FIF.SAVE, "保存", self)
self.deleteButton = PushButton(FIF.DELETE, "删除", self) self.deleteButton = PushButton(FIF.DELETE, "删除", self)
self.convertWifiButton.setEnabled(False)
self.saveButton.setEnabled(False) self.saveButton.setEnabled(False)
self.deleteButton.setEnabled(False) self.deleteButton.setEnabled(False)
@@ -360,6 +368,7 @@ class SettingsPage(QWidget):
commandLayout = QHBoxLayout() commandLayout = QHBoxLayout()
commandLayout.setSpacing(10) commandLayout.setSpacing(10)
commandLayout.addWidget(self.searchButton) commandLayout.addWidget(self.searchButton)
commandLayout.addWidget(self.convertWifiButton)
commandLayout.addStretch(1) commandLayout.addStretch(1)
commandLayout.addWidget(self.saveButton) commandLayout.addWidget(self.saveButton)
commandLayout.addWidget(self.deleteButton) commandLayout.addWidget(self.deleteButton)
+218 -3
View File
@@ -9,6 +9,7 @@ from typing import Optional
from PyQt5.QtCore import QCoreApplication, QObject, QThread, pyqtSignal, pyqtSlot from PyQt5.QtCore import QCoreApplication, QObject, QThread, pyqtSignal, pyqtSlot
from .android_device_service import ( from .android_device_service import (
AndroidDeviceConversionCancelled,
AndroidDeviceSearchError, AndroidDeviceSearchError,
AndroidDeviceService, AndroidDeviceService,
) )
@@ -130,6 +131,49 @@ class AndroidDeviceSearchWorker(QObject):
self.completed.emit() self.completed.emit()
class AndroidWifiConversionWorker(QObject):
"""在后台引导一台 USB 设备切换到 Wi-Fi ADB。"""
progressChanged = pyqtSignal(str)
succeeded = pyqtSignal(object)
failed = pyqtSignal(str)
completed = pyqtSignal()
def __init__(self, service: AndroidDeviceService, usb_serial: str):
super().__init__()
self._service = service
self._usb_serial = usb_serial
self._cancelled = False
def cancel(self) -> None:
"""停止轮询和后续命令;正在执行的 ADB 命令等待自身超时。"""
self._cancelled = True
@pyqtSlot()
def run(self) -> None:
try:
try:
result = self._service.convert_usb_to_wifi(
self._usb_serial,
lambda: self._cancelled,
self.progressChanged.emit,
)
except AndroidDeviceConversionCancelled:
return
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 "无法将 USB 设备转为 Wi-Fi")
else:
if not self._cancelled:
self.succeeded.emit(result)
finally:
self.completed.emit()
class AndroidDeviceSettingWorker(QObject): class AndroidDeviceSettingWorker(QObject):
"""在后台保存或删除当前使用的 Android 设备号。""" """在后台保存或删除当前使用的 Android 设备号。"""
@@ -183,6 +227,7 @@ class SettingsPageEventBinder(QObject):
searchRequested = pyqtSignal() searchRequested = pyqtSignal()
connectRequested = pyqtSignal(str) connectRequested = pyqtSignal(str)
convertWifiRequested = pyqtSignal(str)
currentDeviceSaveRequested = pyqtSignal(str, str) currentDeviceSaveRequested = pyqtSignal(str, str)
saveRequested = pyqtSignal(str, str) saveRequested = pyqtSignal(str, str)
deleteRequested = pyqtSignal(str) deleteRequested = pyqtSignal(str)
@@ -204,6 +249,11 @@ class SettingsPageEventBinder(QObject):
self._search_busy = False self._search_busy = False
self._search_thread: Optional[QThread] = None self._search_thread: Optional[QThread] = None
self._search_worker: Optional[AndroidDeviceSearchWorker] = None self._search_worker: Optional[AndroidDeviceSearchWorker] = None
self._wifi_conversion_busy = False
self._wifi_conversion_thread: Optional[QThread] = None
self._wifi_conversion_worker: Optional[
AndroidWifiConversionWorker
] = None
self._android_setting_busy = False self._android_setting_busy = False
self._android_setting_thread: Optional[QThread] = None self._android_setting_thread: Optional[QThread] = None
self._android_setting_worker: Optional[AndroidDeviceSettingWorker] = None self._android_setting_worker: Optional[AndroidDeviceSettingWorker] = None
@@ -240,6 +290,8 @@ class SettingsPageEventBinder(QObject):
page.searchButton.clicked.connect(self._request_search) page.searchButton.clicked.connect(self._request_search)
self.searchRequested.connect(self._start_search) self.searchRequested.connect(self._start_search)
page.connectButton.clicked.connect(self._request_connect) page.connectButton.clicked.connect(self._request_connect)
page.convertWifiButton.clicked.connect(self._request_convert_wifi)
self.convertWifiRequested.connect(self._start_convert_wifi)
page.saveButton.clicked.connect(self._request_save) page.saveButton.clicked.connect(self._request_save)
self.saveRequested.connect(self._start_save_android_device) self.saveRequested.connect(self._start_save_android_device)
page.deleteButton.clicked.connect(self._request_delete) page.deleteButton.clicked.connect(self._request_delete)
@@ -261,6 +313,7 @@ class SettingsPageEventBinder(QObject):
self._busy self._busy
or self._current_device_busy or self._current_device_busy
or self._search_busy or self._search_busy
or self._wifi_conversion_busy
or self._android_setting_busy or self._android_setting_busy
): ):
return return
@@ -278,6 +331,7 @@ class SettingsPageEventBinder(QObject):
self._closing self._closing
or self._current_device_busy or self._current_device_busy
or self._search_busy or self._search_busy
or self._wifi_conversion_busy
or self._android_setting_busy or self._android_setting_busy
): ):
return return
@@ -323,6 +377,7 @@ class SettingsPageEventBinder(QObject):
self._busy self._busy
or self._current_device_busy or self._current_device_busy
or self._search_busy or self._search_busy
or self._wifi_conversion_busy
or self._android_setting_busy or self._android_setting_busy
): ):
return return
@@ -335,6 +390,7 @@ class SettingsPageEventBinder(QObject):
or self._busy or self._busy
or self._current_device_busy or self._current_device_busy
or self._search_busy or self._search_busy
or self._wifi_conversion_busy
or self._android_setting_busy or self._android_setting_busy
): ):
return return
@@ -361,7 +417,13 @@ class SettingsPageEventBinder(QObject):
@pyqtSlot() @pyqtSlot()
def _request_connect(self) -> None: def _request_connect(self) -> None:
if self._busy: if (
self._busy
or self._current_device_busy
or self._search_busy
or self._wifi_conversion_busy
or self._android_setting_busy
):
return return
address = self._page.addressInput.text().strip() address = self._page.addressInput.text().strip()
@@ -373,9 +435,76 @@ class SettingsPageEventBinder(QObject):
return return
self.connectRequested.emit(address) self.connectRequested.emit(address)
@pyqtSlot()
def _request_convert_wifi(self) -> None:
if (
self._busy
or self._current_device_busy
or self._search_busy
or self._wifi_conversion_busy
or self._android_setting_busy
):
return
serial = self._page.deviceTableModel.checked_serial
device = self._page.deviceTableModel.device_for_serial(serial)
if device is None or device.status != "device":
self._page.deviceStatusLabel.setText(
"转换失败:请先勾选一台已连接的 USB 设备"
)
self._page.deviceTable.setFocus()
return
if device.connection_type != "USB":
self._page.deviceStatusLabel.setText(
"转换失败:当前设备已经是 Wi-Fi 连接"
)
return
self.convertWifiRequested.emit(serial)
@pyqtSlot(str)
def _start_convert_wifi(self, usb_serial: str) -> None:
if (
self._closing
or self._busy
or self._current_device_busy
or self._search_busy
or self._wifi_conversion_busy
or self._android_setting_busy
):
return
self._wifi_conversion_busy = True
self._sync_button_state()
self._page.deviceStatusLabel.setText("正在准备 USB 转 Wi-Fi…")
thread = QThread(self)
worker = AndroidWifiConversionWorker(
self._android_device_service,
usb_serial,
)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.progressChanged.connect(self._on_wifi_conversion_progress)
worker.succeeded.connect(self._on_wifi_conversion_succeeded)
worker.failed.connect(self._on_wifi_conversion_failed)
worker.completed.connect(thread.quit)
worker.completed.connect(worker.deleteLater)
thread.finished.connect(self._on_wifi_conversion_finished)
thread.finished.connect(thread.deleteLater)
self._wifi_conversion_thread = thread
self._wifi_conversion_worker = worker
thread.start()
@pyqtSlot() @pyqtSlot()
def _request_save(self) -> None: def _request_save(self) -> None:
if self._busy or self._search_busy or self._android_setting_busy: if (
self._busy
or self._search_busy
or self._wifi_conversion_busy
or self._android_setting_busy
):
return return
serial = self._page.deviceTableModel.checked_serial serial = self._page.deviceTableModel.checked_serial
@@ -394,7 +523,12 @@ class SettingsPageEventBinder(QObject):
@pyqtSlot() @pyqtSlot()
def _request_delete(self) -> None: def _request_delete(self) -> None:
if self._busy or self._search_busy or self._android_setting_busy: if (
self._busy
or self._search_busy
or self._wifi_conversion_busy
or self._android_setting_busy
):
return return
serial = self._saved_android_serial serial = self._saved_android_serial
@@ -414,6 +548,7 @@ class SettingsPageEventBinder(QObject):
self._closing self._closing
or self._busy or self._busy
or self._search_busy or self._search_busy
or self._wifi_conversion_busy
or self._android_setting_busy or self._android_setting_busy
): ):
return return
@@ -450,16 +585,19 @@ class SettingsPageEventBinder(QObject):
@pyqtSlot() @pyqtSlot()
def _sync_button_state(self, *_args) -> None: def _sync_button_state(self, *_args) -> None:
serial = self._page.deviceTableModel.checked_serial serial = self._page.deviceTableModel.checked_serial
selected_device = self._page.deviceTableModel.device_for_serial(serial)
has_address = bool(self._page.addressInput.text().strip()) has_address = bool(self._page.addressInput.text().strip())
self._page.currentDeviceSaveButton.setEnabled( self._page.currentDeviceSaveButton.setEnabled(
not self._busy not self._busy
and not self._current_device_busy and not self._current_device_busy
and not self._search_busy and not self._search_busy
and not self._wifi_conversion_busy
and not self._android_setting_busy and not self._android_setting_busy
) )
device_commands_enabled = ( device_commands_enabled = (
not self._busy not self._busy
and not self._search_busy and not self._search_busy
and not self._wifi_conversion_busy
and not self._android_setting_busy and not self._android_setting_busy
) )
self._page.searchButton.setEnabled( self._page.searchButton.setEnabled(
@@ -471,6 +609,13 @@ class SettingsPageEventBinder(QObject):
self._page.saveButton.setEnabled( self._page.saveButton.setEnabled(
device_commands_enabled and bool(serial) device_commands_enabled and bool(serial)
) )
self._page.convertWifiButton.setEnabled(
device_commands_enabled
and not self._current_device_busy
and selected_device is not None
and selected_device.status == "device"
and selected_device.connection_type == "USB"
)
self._page.deleteButton.setEnabled( self._page.deleteButton.setEnabled(
device_commands_enabled and bool(self._saved_android_serial) device_commands_enabled and bool(self._saved_android_serial)
) )
@@ -576,6 +721,49 @@ class SettingsPageEventBinder(QObject):
if not self._closing: if not self._closing:
self._sync_button_state() self._sync_button_state()
@pyqtSlot(str)
def _on_wifi_conversion_progress(self, message: str) -> None:
if not self._closing:
self._page.deviceStatusLabel.setText(message)
@pyqtSlot(object)
def _on_wifi_conversion_succeeded(self, result) -> 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 result.devices
]
self._page.set_android_devices(rows)
self._page.deviceTableModel.set_checked_serial(result.wifi_serial)
self._page.deviceStatusLabel.setText(
f"Wi-Fi 设备 {result.wifi_serial} 已连接并勾选;"
"请确认后点击保存,仅在可信网络使用"
)
@pyqtSlot(str)
def _on_wifi_conversion_failed(self, message: str) -> None:
if self._closing:
return
self._page.deviceStatusLabel.setText(
f"转换失败:{message};设备列表和已保存配置未更改,可重试"
)
@pyqtSlot()
def _on_wifi_conversion_finished(self) -> None:
self._wifi_conversion_worker = None
self._wifi_conversion_thread = None
self._wifi_conversion_busy = False
if not self._closing:
self._sync_button_state()
@pyqtSlot(str, str) @pyqtSlot(str, str)
def _on_android_device_setting_succeeded( def _on_android_device_setting_succeeded(
self, action: str, serial: str self, action: str, serial: str
@@ -695,6 +883,33 @@ class SettingsPageEventBinder(QObject):
search_thread.quit() search_thread.quit()
search_thread.wait(6000) search_thread.wait(6000)
conversion_worker = self._wifi_conversion_worker
conversion_thread = self._wifi_conversion_thread
if conversion_worker is not None:
conversion_worker.cancel()
for signal, slot in (
(
conversion_worker.progressChanged,
self._on_wifi_conversion_progress,
),
(
conversion_worker.succeeded,
self._on_wifi_conversion_succeeded,
),
(
conversion_worker.failed,
self._on_wifi_conversion_failed,
),
):
try:
signal.disconnect(slot)
except TypeError:
pass
if conversion_thread is not None and conversion_thread.isRunning():
conversion_thread.quit()
conversion_thread.wait(6000)
setting_worker = self._android_setting_worker setting_worker = self._android_setting_worker
setting_thread = self._android_setting_thread setting_thread = self._android_setting_thread
if setting_worker is not None: if setting_worker is not None:
+132
View File
@@ -16,6 +16,25 @@ def completed(command, stdout="", stderr="", returncode=0):
class AndroidDeviceServiceTest(unittest.TestCase): class AndroidDeviceServiceTest(unittest.TestCase):
def test_parse_wifi_ipv4_from_route(self):
output = (
"10.20.0.0/16 dev rmnet_data0 scope link src 10.20.1.2\n"
"default via 192.168.0.1 dev wlan0 proto dhcp\n"
"192.168.0.0/24 dev wlan0 proto kernel scope link "
"src 192.168.0.173\n"
)
self.assertEqual(
AndroidDeviceService.parse_wifi_ipv4(output),
"192.168.0.173",
)
def test_missing_wifi_ipv4_has_recovery_message(self):
with self.assertRaisesRegex(AndroidDeviceSearchError, "连接.*Wi-Fi"):
AndroidDeviceService.parse_wifi_ipv4(
"default via 10.0.0.1 dev wlan0\n"
)
def test_parse_usb_wifi_and_unavailable_devices(self): def test_parse_usb_wifi_and_unavailable_devices(self):
output = """List of devices attached output = """List of devices attached
USB-001 device product:p model:Pixel_8 device:husky transport_id:1 USB-001 device product:p model:Pixel_8 device:husky transport_id:1
@@ -104,6 +123,119 @@ USB-002 unauthorized usb:1-2 transport_id:3
with self.assertRaisesRegex(AndroidDeviceSearchError, "didn't ACK"): with self.assertRaisesRegex(AndroidDeviceSearchError, "didn't ACK"):
AndroidDeviceService(runner).search() AndroidDeviceService(runner).search()
def test_convert_usb_to_wifi_reconnects_after_usb_is_removed(self):
commands = []
device_list_count = 0
def runner(command, _timeout):
nonlocal device_list_count
command = list(command)
commands.append(command)
if command[-3:] == ["shell", "ip", "route"]:
return completed(
command,
"192.168.0.0/24 dev wlan0 scope link "
"src 192.168.0.173\n",
)
if command[:2] == ["adb", "connect"]:
return completed(command, "connected to 192.168.0.173:5555\n")
if command == ["adb", "devices", "-l"]:
device_list_count += 1
usb_line = (
"USB-001 device model:Phone\n"
if device_list_count == 1
else ""
)
return completed(
command,
"List of devices attached\n"
f"{usb_line}"
"192.168.0.173:5555 device model:Phone\n",
)
if command[-1] == "ro.build.version.release":
return completed(command, "14\n")
return completed(command, "restarting in TCP mode port: 5555\n")
progress = []
service = AndroidDeviceService(
runner,
usb_disconnect_timeout_seconds=0.03,
usb_poll_interval_seconds=0.01,
sleeper=lambda _seconds: None,
)
result = service.convert_usb_to_wifi(
"USB-001",
on_progress=progress.append,
)
self.assertEqual(result.wifi_serial, "192.168.0.173:5555")
self.assertEqual(result.devices[0].connection_type, "Wi-Fi")
self.assertIn(
["adb", "-s", "USB-001", "tcpip", "5555"],
commands,
)
self.assertEqual(
commands.count(["adb", "connect", "192.168.0.173:5555"]),
2,
)
self.assertTrue(any("拔掉 USB" in message for message in progress))
def test_convert_usb_to_wifi_times_out_without_unplug(self):
def runner(command, _timeout):
command = list(command)
if command[-3:] == ["shell", "ip", "route"]:
return completed(command, "local src 192.168.0.173\n")
if command[:2] == ["adb", "connect"]:
return completed(command, "connected to 192.168.0.173:5555\n")
if command == ["adb", "devices", "-l"]:
return completed(
command,
"List of devices attached\nUSB-001 device\n",
)
return completed(command, "restarting in TCP mode port: 5555\n")
service = AndroidDeviceService(
runner,
usb_disconnect_timeout_seconds=0.03,
usb_poll_interval_seconds=0.01,
sleeper=lambda _seconds: None,
)
with self.assertRaisesRegex(AndroidDeviceSearchError, "拔出 USB 超时"):
service.convert_usb_to_wifi("USB-001")
def test_convert_usb_to_wifi_rejects_wifi_serial(self):
with self.assertRaisesRegex(AndroidDeviceSearchError, "已经是 Wi-Fi"):
AndroidDeviceService().convert_usb_to_wifi(
"192.168.0.173:5555"
)
def test_convert_usb_to_wifi_requires_connected_wifi_status(self):
def runner(command, _timeout):
command = list(command)
if command[-3:] == ["shell", "ip", "route"]:
return completed(command, "local src 192.168.0.173\n")
if command[:2] == ["adb", "connect"]:
return completed(command, "connected to 192.168.0.173:5555\n")
if command == ["adb", "devices", "-l"]:
return completed(
command,
"List of devices attached\n"
"192.168.0.173:5555 offline\n",
)
return completed(command, "restarting in TCP mode port: 5555\n")
service = AndroidDeviceService(
runner,
usb_disconnect_timeout_seconds=0.03,
usb_poll_interval_seconds=0.01,
sleeper=lambda _seconds: None,
)
with self.assertRaisesRegex(AndroidDeviceSearchError, "状态为 offline"):
service.convert_usb_to_wifi("USB-001")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+171
View File
@@ -14,7 +14,9 @@ from PyQt5.QtWidgets import QApplication
from src.android_device_service import ( from src.android_device_service import (
AndroidDevice, AndroidDevice,
AndroidDeviceConversionCancelled,
AndroidDeviceSearchError, AndroidDeviceSearchError,
AndroidWifiConversionResult,
) )
from src.mock_admin_gateway import MockAdminGateway from src.mock_admin_gateway import MockAdminGateway
from src.selected_android_device_service import SELECTED_ANDROID_SERIAL_KEY from src.selected_android_device_service import SELECTED_ANDROID_SERIAL_KEY
@@ -58,6 +60,40 @@ class SlowAndroidDeviceService:
return [AndroidDevice("USB-001", "USB", "Pixel 8", "14")] return [AndroidDevice("USB-001", "USB", "Pixel 8", "14")]
class ControlledWifiConversionService:
"""模拟 USB 转 Wi-Fi 的进度、成功、失败和延迟。"""
def __init__(self, delay=0.0, error=None):
self.delay = delay
self.error = error
self.call_count = 0
def search(self, _is_cancelled=None):
return []
def convert_usb_to_wifi(
self,
usb_serial,
is_cancelled=None,
on_progress=None,
):
self.call_count += 1
if on_progress is not None:
on_progress("已开启 192.168.0.173:5555,请拔掉 USB 数据线…")
if self.delay:
time.sleep(self.delay)
if is_cancelled is not None and is_cancelled():
raise AndroidDeviceConversionCancelled()
if self.error is not None:
raise self.error
self.last_usb_serial = usb_serial
wifi_serial = "192.168.0.173:5555"
return AndroidWifiConversionResult(
wifi_serial,
[AndroidDevice(wifi_serial, "Wi-Fi", "Phone", "14")],
)
class ControlledSettingsRepository(SettingsRepository): class ControlledSettingsRepository(SettingsRepository):
"""控制 Android 设备设置写入速度和失败,其他设置保持正常。""" """控制 Android 设备设置写入速度和失败,其他设置保持正常。"""
@@ -530,6 +566,141 @@ class SettingsPageEventTest(unittest.TestCase):
self.assertEqual(page.deviceStatusLabel.text(), status_before_close) self.assertEqual(page.deviceStatusLabel.text(), status_before_close)
page.deleteLater() page.deleteLater()
def test_convert_wifi_button_requires_connected_usb_device(self):
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
)
self.assertFalse(page.convertWifiButton.isEnabled())
page.set_android_devices([AndroidDeviceRow("USB-001", "USB")])
page.deviceTableModel.set_checked_serial("USB-001")
self.assertTrue(page.convertWifiButton.isEnabled())
page.set_android_devices(
[AndroidDeviceRow("192.168.0.173:5555", "Wi-Fi")]
)
page.deviceTableModel.set_checked_serial("192.168.0.173:5555")
self.assertFalse(page.convertWifiButton.isEnabled())
page.set_android_devices(
[AndroidDeviceRow("USB-OFFLINE", "USB", status="offline")]
)
page.deviceTableModel.set_checked_serial("USB-OFFLINE")
self.assertFalse(page.convertWifiButton.isEnabled())
page.eventBinder.shutdown()
page.deleteLater()
def test_convert_wifi_refreshes_and_checks_wifi_without_saving(self):
service = ControlledWifiConversionService()
self.repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-OLD")
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
android_device_service=service,
)
page.set_android_devices([AndroidDeviceRow("USB-001", "USB")])
page.deviceTableModel.set_checked_serial("USB-001")
page.convertWifiButton.click()
self._wait_until(
lambda: page.eventBinder._wifi_conversion_thread is None
)
self.assertEqual(service.last_usb_serial, "USB-001")
self.assertEqual(
page.deviceTableModel.checked_serial,
"192.168.0.173:5555",
)
self.assertEqual(
self.repository.get(SELECTED_ANDROID_SERIAL_KEY),
"USB-OLD",
)
self.assertIn("已连接并勾选", page.deviceStatusLabel.text())
self.assertTrue(page.saveButton.isEnabled())
self.assertFalse(page.convertWifiButton.isEnabled())
page.eventBinder.shutdown()
page.deleteLater()
def test_convert_wifi_failure_preserves_list_and_saved_configuration(self):
service = ControlledWifiConversionService(
error=AndroidDeviceSearchError("等待拔出 USB 超时")
)
self.repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-OLD")
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
android_device_service=service,
)
page.set_android_devices([AndroidDeviceRow("USB-001", "USB")])
page.deviceTableModel.set_checked_serial("USB-001")
page.convertWifiButton.click()
self._wait_until(
lambda: page.eventBinder._wifi_conversion_thread is None
)
self.assertIsNotNone(
page.deviceTableModel.device_for_serial("USB-001")
)
self.assertEqual(page.deviceTableModel.checked_serial, "USB-001")
self.assertEqual(
self.repository.get(SELECTED_ANDROID_SERIAL_KEY),
"USB-OLD",
)
self.assertIn("转换失败", page.deviceStatusLabel.text())
self.assertIn("可重试", page.deviceStatusLabel.text())
self.assertTrue(page.convertWifiButton.isEnabled())
page.eventBinder.shutdown()
page.deleteLater()
def test_slow_convert_wifi_does_not_block_or_start_twice(self):
service = ControlledWifiConversionService(delay=0.08)
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
android_device_service=service,
)
page.set_android_devices([AndroidDeviceRow("USB-001", "USB")])
page.deviceTableModel.set_checked_serial("USB-001")
timer_fired = []
QTimer.singleShot(10, lambda: timer_fired.append(True))
page.convertWifiButton.click()
page.eventBinder._request_convert_wifi()
self._wait_until(lambda: bool(timer_fired), timeout_ms=500)
self._wait_until(
lambda: page.eventBinder._wifi_conversion_thread is None
)
self.assertTrue(timer_fired)
self.assertEqual(service.call_count, 1)
page.eventBinder.shutdown()
page.deleteLater()
def test_shutdown_ignores_late_wifi_conversion_result(self):
service = ControlledWifiConversionService(delay=0.08)
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
android_device_service=service,
)
page.set_android_devices([AndroidDeviceRow("USB-001", "USB")])
page.deviceTableModel.set_checked_serial("USB-001")
page.convertWifiButton.click()
self._wait_until(
lambda: page.eventBinder._wifi_conversion_thread is not None
)
status_before_close = page.deviceStatusLabel.text()
page.eventBinder.shutdown()
QTest.qWait(120)
self.assertEqual(page.deviceStatusLabel.text(), status_before_close)
self.assertEqual(page.deviceTableModel.checked_serial, "USB-001")
page.deleteLater()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()