diff --git a/client/src/android_device_service.py b/client/src/android_device_service.py index efd3911..820039f 100644 --- a/client/src/android_device_service.py +++ b/client/src/android_device_service.py @@ -8,8 +8,8 @@ from typing import Callable, List, Optional, Sequence DEFAULT_ADB_TIMEOUT_SECONDS = 5.0 -DEFAULT_USB_DISCONNECT_TIMEOUT_SECONDS = 60.0 -DEFAULT_USB_POLL_INTERVAL_SECONDS = 0.5 +DEFAULT_DEVICE_READY_CHECKS = 8 +DEFAULT_DEVICE_READY_INTERVAL_SECONDS = 0.25 class AndroidDeviceSearchError(RuntimeError): @@ -40,8 +40,8 @@ class AndroidWifiConversionResult: CommandRunner = Callable[[Sequence[str], float], subprocess.CompletedProcess] -Sleeper = Callable[[float], None] ProgressCallback = Callable[[str], None] +Sleeper = Callable[[float], None] def run_adb_command( @@ -68,22 +68,12 @@ class AndroidDeviceService: self, command_runner: CommandRunner = run_adb_command, 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: 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._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( @@ -132,6 +122,7 @@ class AndroidDeviceService: self._check_cancelled(is_cancelled) if ":" in serial: self._connect_wifi(serial) + self._wait_until_device_ready(serial, is_cancelled) self._check_cancelled(is_cancelled) return self.search(is_cancelled) @@ -141,7 +132,7 @@ class AndroidDeviceService: is_cancelled: Optional[Callable[[], bool]] = None, on_progress: Optional[ProgressCallback] = None, ) -> AndroidWifiConversionResult: - """开启 USB 设备的 5555 端口,等拔线后重连并返回最新列表。""" + """插着数据线开启 5555,连接 Wi-Fi 后返回最新设备列表。""" self._validate_usb_serial(usb_serial) self._check_cancelled(is_cancelled) @@ -160,25 +151,14 @@ class AndroidDeviceService: "开启 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._report(on_progress, f"正在连接 Wi-Fi 设备 {wifi_serial}…") self._connect_wifi(wifi_serial) + self._wait_until_device_ready(wifi_serial, is_cancelled) + self._wait_until_device_ready(usb_serial, is_cancelled) self._check_cancelled(is_cancelled) - devices = self._list_devices() + devices = self.search(is_cancelled) wifi_device = next( (device for device in devices if device.serial == wifi_serial), None, @@ -190,8 +170,8 @@ class AndroidDeviceService: "请确认手机和电脑在同一网络后重试" ) - self._report(on_progress, "Wi-Fi ADB 已连接,正在刷新设备列表…") - return AndroidWifiConversionResult(wifi_serial, self.search(is_cancelled)) + self._report(on_progress, "Wi-Fi ADB 已连接,可以保存后拔掉 USB") + return AndroidWifiConversionResult(wifi_serial, devices) @staticmethod def parse_wifi_ipv4(route_output: str) -> str: @@ -285,34 +265,27 @@ class AndroidDeviceService: value = (result.stdout or "").strip() return value or "—" - def _wait_until_usb_disconnected( + def _wait_until_device_ready( self, - usb_serial: str, + 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) + """短暂等待 ADB transport 完成重启或授权握手。""" - raise AndroidDeviceSearchError( - "等待拔出 USB 超时;5555 已开启,请拔线后重新点击转换" - ) + for attempt in range(DEFAULT_DEVICE_READY_CHECKS): + self._check_cancelled(is_cancelled) + device = next( + ( + item + for item in self._list_devices() + if item.serial == serial + ), + None, + ) + if device is not None and device.status == "device": + return + if attempt < DEFAULT_DEVICE_READY_CHECKS - 1: + self._sleep(DEFAULT_DEVICE_READY_INTERVAL_SECONDS) def _list_devices(self) -> List[AndroidDevice]: result = self._run(["adb", "devices", "-l"], "读取设备列表") diff --git a/client/src/settings_ui_event.py b/client/src/settings_ui_event.py index eff0916..595c2a5 100644 --- a/client/src/settings_ui_event.py +++ b/client/src/settings_ui_event.py @@ -266,6 +266,7 @@ class SettingsPageEventBinder(QObject): self._worker: Optional[CurrentClientSaveWorker] = None self._search_busy = False self._search_restore_serial = "" + self._search_is_startup_restore = False self._search_thread: Optional[QThread] = None self._search_worker: Optional[AndroidDeviceSearchWorker] = None self._wifi_conversion_busy = False @@ -406,15 +407,27 @@ class SettingsPageEventBinder(QObject): @pyqtSlot() def _start_search(self) -> None: - self._start_search_worker() + restore_serial = ( + self._saved_android_serial + if ":" in self._saved_android_serial + else "" + ) + self._start_search_worker(restore_serial) @pyqtSlot() def _request_restore_saved_android_device(self) -> None: if not self._saved_android_serial: return - self._start_search_worker(self._saved_android_serial) + self._start_search_worker( + self._saved_android_serial, + is_startup_restore=True, + ) - def _start_search_worker(self, restore_serial: str = "") -> None: + def _start_search_worker( + self, + restore_serial: str = "", + is_startup_restore: bool = False, + ) -> None: if ( self._closing or self._busy @@ -427,12 +440,14 @@ class SettingsPageEventBinder(QObject): self._search_busy = True self._search_restore_serial = restore_serial + self._search_is_startup_restore = is_startup_restore self._sync_button_state() - status = ( - f"正在恢复已保存设备:{restore_serial}…" - if restore_serial - else "正在搜索 Android 设备…" - ) + if is_startup_restore: + status = f"正在恢复已保存设备:{restore_serial}…" + elif restore_serial: + status = f"正在重新连接已保存 Wi-Fi 设备:{restore_serial}…" + else: + status = "正在搜索 Android 设备…" self._page.deviceStatusLabel.setText(status) thread = QThread(self) @@ -726,11 +741,26 @@ class SettingsPageEventBinder(QObject): self._page.deviceTableModel.set_checked_serial( self._saved_android_serial ) - action = "恢复完成" if self._search_restore_serial else "搜索完成" + action = ( + "恢复完成" + if self._search_is_startup_restore + else "搜索完成" + ) self._page.deviceStatusLabel.setText( f"{action}:找到 {len(rows)} 台 Android 设备;" "已自动选择当前使用设备" ) + elif saved_device is not None and saved_device.status == "unauthorized": + self._page.deviceStatusLabel.setText( + "已重新连接 Wi-Fi 设备,但尚未授权:" + f"{self._saved_android_serial};请在手机上允许调试后重试" + ) + elif saved_device is not None: + self._page.deviceStatusLabel.setText( + "已保存设备当前不可用:" + f"{self._saved_android_serial}({saved_device.status});" + "配置已保留" + ) elif self._saved_android_serial: self._page.deviceStatusLabel.setText( "已保存设备当前未连接:" @@ -749,11 +779,16 @@ class SettingsPageEventBinder(QObject): def _on_search_failed(self, message: str) -> None: if self._closing: return - if self._search_restore_serial: + if self._search_is_startup_restore: self._page.deviceStatusLabel.setText( f"自动恢复失败:{message};已保存配置未更改," "可点击搜索重试" ) + elif self._search_restore_serial: + self._page.deviceStatusLabel.setText( + f"搜索失败:无法重新连接已保存 Wi-Fi 设备;{message};" + "已保存配置未更改,可重试" + ) else: self._page.deviceStatusLabel.setText( f"搜索失败:{message};设备列表和已保存配置未更改" @@ -765,6 +800,7 @@ class SettingsPageEventBinder(QObject): self._search_thread = None self._search_busy = False self._search_restore_serial = "" + self._search_is_startup_restore = False if not self._closing: self._sync_button_state() @@ -792,7 +828,7 @@ class SettingsPageEventBinder(QObject): self._page.deviceTableModel.set_checked_serial(result.wifi_serial) self._page.deviceStatusLabel.setText( f"Wi-Fi 设备 {result.wifi_serial} 已连接并勾选;" - "请确认后点击保存,仅在可信网络使用" + "请确认后点击保存,再拔掉 USB 数据线" ) @pyqtSlot(str) diff --git a/client/test/test_android_device_service.py b/client/test/test_android_device_service.py index f046555..80e182b 100644 --- a/client/test/test_android_device_service.py +++ b/client/test/test_android_device_service.py @@ -101,6 +101,33 @@ USB-002 unauthorized usb:1-2 transport_id:3 ["adb", "connect", "192.168.0.173:5555"], ) + def test_restore_saved_wifi_waits_for_authorization_handshake(self): + device_list_count = 0 + sleeps = [] + + def runner(command, _timeout): + nonlocal device_list_count + command = list(command) + 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 + status = "unauthorized" if device_list_count == 1 else "device" + return completed( + command, + "List of devices attached\n" + f"192.168.0.173:5555 {status} model:Phone\n", + ) + return completed(command, "14\n") + + devices = AndroidDeviceService( + runner, + sleeper=sleeps.append, + ).restore_saved_device("192.168.0.173:5555") + + self.assertEqual(devices[0].status, "device") + self.assertEqual(sleeps, [0.25]) + def test_restore_saved_usb_only_searches(self): commands = [] @@ -175,12 +202,10 @@ USB-002 unauthorized usb:1-2 transport_id:3 with self.assertRaisesRegex(AndroidDeviceSearchError, "didn't ACK"): AndroidDeviceService(runner).search() - def test_convert_usb_to_wifi_reconnects_after_usb_is_removed(self): + def test_convert_usb_to_wifi_connects_while_usb_is_attached(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"]: @@ -192,16 +217,10 @@ USB-002 unauthorized usb:1-2 transport_id:3 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}" + "USB-001 device model:Phone\n" "192.168.0.173:5555 device model:Phone\n", ) if command[-1] == "ro.build.version.release": @@ -209,12 +228,7 @@ USB-002 unauthorized usb:1-2 transport_id:3 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, - ) + service = AndroidDeviceService(runner) result = service.convert_usb_to_wifi( "USB-001", @@ -222,41 +236,23 @@ USB-002 unauthorized usb:1-2 transport_id:3 ) self.assertEqual(result.wifi_serial, "192.168.0.173:5555") - self.assertEqual(result.devices[0].connection_type, "Wi-Fi") + self.assertEqual( + [device.serial for device in result.devices], + ["USB-001", "192.168.0.173:5555"], + ) self.assertIn( ["adb", "-s", "USB-001", "tcpip", "5555"], commands, ) self.assertEqual( commands.count(["adb", "connect", "192.168.0.173:5555"]), - 2, + 1, ) - 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, + self.assertEqual(commands.count(["adb", "devices", "-l"]), 3) + self.assertTrue( + any("保存后拔掉 USB" in message for message in progress) ) - 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( @@ -278,12 +274,7 @@ USB-002 unauthorized usb:1-2 transport_id:3 ) 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, - ) + service = AndroidDeviceService(runner, sleeper=lambda _seconds: None) with self.assertRaisesRegex(AndroidDeviceSearchError, "状态为 offline"): service.convert_usb_to_wifi("USB-001") diff --git a/client/test/test_settings_ui_event.py b/client/test/test_settings_ui_event.py index 9cccc78..ef055d0 100644 --- a/client/test/test_settings_ui_event.py +++ b/client/test/test_settings_ui_event.py @@ -88,7 +88,7 @@ class ControlledWifiConversionService: ): self.call_count += 1 if on_progress is not None: - on_progress("已开启 192.168.0.173:5555,请拔掉 USB 数据线…") + on_progress("正在连接 Wi-Fi 设备 192.168.0.173:5555…") if self.delay: time.sleep(self.delay) if is_cancelled is not None and is_cancelled(): @@ -99,7 +99,10 @@ class ControlledWifiConversionService: wifi_serial = "192.168.0.173:5555" return AndroidWifiConversionResult( wifi_serial, - [AndroidDevice(wifi_serial, "Wi-Fi", "Phone", "14")], + [ + AndroidDevice(usb_serial, "USB", "Phone", "14"), + AndroidDevice(wifi_serial, "Wi-Fi", "Phone", "14"), + ], ) @@ -479,6 +482,64 @@ class SettingsPageEventTest(unittest.TestCase): page.eventBinder.shutdown() page.deleteLater() + def test_manual_search_reconnects_saved_wifi_before_refresh(self): + wifi_serial = "192.168.0.173:5555" + self.repository.set(SELECTED_ANDROID_SERIAL_KEY, wifi_serial) + service = SequenceAndroidDeviceService( + [ + [AndroidDevice(wifi_serial, "Wi-Fi", "Phone", "14")], + [AndroidDevice(wifi_serial, "Wi-Fi", "Phone", "14")], + ] + ) + page = SettingsPage( + settings_repository=self.repository, + admin_gateway=MockAdminGateway(), + android_device_service=service, + ) + self._wait_until(lambda: service.call_count == 1) + self._wait_until(lambda: page.eventBinder._search_thread is None) + + page.searchButton.click() + self._wait_until(lambda: service.call_count == 2) + self._wait_until(lambda: page.eventBinder._search_thread is None) + + self.assertEqual(service.restore_serials, [wifi_serial, wifi_serial]) + self.assertEqual(page.deviceTableModel.checked_serial, wifi_serial) + self.assertIn("搜索完成", page.deviceStatusLabel.text()) + page.eventBinder.shutdown() + page.deleteLater() + + def test_manual_wifi_reconnect_failure_keeps_saved_device(self): + wifi_serial = "192.168.0.173:5555" + self.repository.set(SELECTED_ANDROID_SERIAL_KEY, wifi_serial) + service = SequenceAndroidDeviceService( + [ + [AndroidDevice(wifi_serial, "Wi-Fi", "Phone", "14")], + AndroidDeviceSearchError("目标设备拒绝连接"), + ] + ) + page = SettingsPage( + settings_repository=self.repository, + admin_gateway=MockAdminGateway(), + android_device_service=service, + ) + self._wait_until(lambda: service.call_count == 1) + self._wait_until(lambda: page.eventBinder._search_thread is None) + + page.searchButton.click() + self._wait_until(lambda: service.call_count == 2) + self._wait_until(lambda: page.eventBinder._search_thread is None) + + self.assertEqual( + self.repository.get(SELECTED_ANDROID_SERIAL_KEY), + wifi_serial, + ) + self.assertEqual(page.deviceTableModel.checked_serial, wifi_serial) + self.assertIn("无法重新连接", page.deviceStatusLabel.text()) + self.assertIn("已保存配置未更改", page.deviceStatusLabel.text()) + page.eventBinder.shutdown() + page.deleteLater() + def test_startup_restore_failure_keeps_saved_configuration(self): wifi_serial = "192.168.0.173:5555" self.repository.set(SELECTED_ANDROID_SERIAL_KEY, wifi_serial) @@ -504,6 +565,31 @@ class SettingsPageEventTest(unittest.TestCase): page.eventBinder.shutdown() page.deleteLater() + def test_saved_wifi_waiting_for_authorization_has_clear_message(self): + wifi_serial = "192.168.0.173:5555" + self.repository.set(SELECTED_ANDROID_SERIAL_KEY, wifi_serial) + service = SequenceAndroidDeviceService( + [[AndroidDevice(wifi_serial, "Wi-Fi", status="unauthorized")]] + ) + + page = SettingsPage( + settings_repository=self.repository, + admin_gateway=MockAdminGateway(), + android_device_service=service, + ) + self._wait_until(lambda: service.call_count == 1) + self._wait_until(lambda: page.eventBinder._search_thread is None) + + self.assertEqual(page.deviceTableModel.checked_serial, "") + self.assertIn("尚未授权", page.deviceStatusLabel.text()) + self.assertIn("请在手机上允许调试", page.deviceStatusLabel.text()) + self.assertEqual( + self.repository.get(SELECTED_ANDROID_SERIAL_KEY), + wifi_serial, + ) + page.eventBinder.shutdown() + page.deleteLater() + def test_slow_startup_restore_keeps_main_thread_responsive(self): self.repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-001") service = SlowAndroidDeviceService() @@ -729,11 +815,20 @@ class SettingsPageEventTest(unittest.TestCase): page.deviceTableModel.checked_serial, "192.168.0.173:5555", ) + self.assertIsNotNone( + page.deviceTableModel.device_for_serial("USB-001") + ) + self.assertIsNotNone( + page.deviceTableModel.device_for_serial( + "192.168.0.173:5555" + ) + ) self.assertEqual( self.repository.get(SELECTED_ANDROID_SERIAL_KEY), "USB-OLD", ) self.assertIn("已连接并勾选", page.deviceStatusLabel.text()) + self.assertIn("保存,再拔掉 USB", page.deviceStatusLabel.text()) self.assertTrue(page.saveButton.isEnabled()) self.assertFalse(page.convertWifiButton.isEnabled()) page.eventBinder.shutdown() @@ -741,7 +836,7 @@ class SettingsPageEventTest(unittest.TestCase): def test_convert_wifi_failure_preserves_list_and_saved_configuration(self): service = ControlledWifiConversionService( - error=AndroidDeviceSearchError("等待拔出 USB 超时") + error=AndroidDeviceSearchError("Wi-Fi 连接验证失败") ) self.repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-OLD") page = SettingsPage(