From dc9b000a36ec7f544df73f27e43af64c728f0157 Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 7 Aug 2026 14:53:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=90=AF=E5=8A=A8=E6=97=B6=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=B7=B2=E4=BF=9D=E5=AD=98=E7=9A=84=20Android=20?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/android_device_service.py | 24 ++++ client/src/settings_ui_event.py | 67 +++++++++-- client/test/test_android_device_service.py | 52 +++++++++ client/test/test_settings_ui_event.py | 124 ++++++++++++++++++++- 4 files changed, 253 insertions(+), 14 deletions(-) diff --git a/client/src/android_device_service.py b/client/src/android_device_service.py index 7bf8d6f..efd3911 100644 --- a/client/src/android_device_service.py +++ b/client/src/android_device_service.py @@ -121,6 +121,20 @@ class AndroidDeviceService: ) return enriched + def restore_saved_device( + self, + serial: str, + is_cancelled: Optional[Callable[[], bool]] = None, + ) -> List[AndroidDevice]: + """恢复已保存设备;Wi-Fi 先重连,USB 只刷新设备列表。""" + + self._validate_saved_serial(serial) + self._check_cancelled(is_cancelled) + if ":" in serial: + self._connect_wifi(serial) + self._check_cancelled(is_cancelled) + return self.search(is_cancelled) + def convert_usb_to_wifi( self, usb_serial: str, @@ -334,6 +348,16 @@ class AndroidDeviceService: if ":" in serial: raise AndroidDeviceSearchError("当前设备已经是 Wi-Fi 连接") + @staticmethod + def _validate_saved_serial(serial: str) -> None: + if not isinstance(serial, str) or not serial.strip(): + raise AndroidDeviceSearchError("已保存 Android 设备号为空") + contains_whitespace = any( + character.isspace() for character in serial + ) + if serial != serial.strip() or contains_whitespace: + raise AndroidDeviceSearchError("已保存 Android 设备号格式不正确") + @staticmethod def _check_cancelled( is_cancelled: Optional[Callable[[], bool]], diff --git a/client/src/settings_ui_event.py b/client/src/settings_ui_event.py index 63329f3..eff0916 100644 --- a/client/src/settings_ui_event.py +++ b/client/src/settings_ui_event.py @@ -6,7 +6,14 @@ from typing import Optional -from PyQt5.QtCore import QCoreApplication, QObject, QThread, pyqtSignal, pyqtSlot +from PyQt5.QtCore import ( + QCoreApplication, + QObject, + QThread, + QTimer, + pyqtSignal, + pyqtSlot, +) from .android_device_service import ( AndroidDeviceConversionCancelled, @@ -97,15 +104,20 @@ class CurrentClientSaveWorker(QObject): class AndroidDeviceSearchWorker(QObject): - """在后台线程查询 ADB,结果只通过信号返回主线程。""" + """在后台搜索设备,也可先恢复一台已保存设备。""" succeeded = pyqtSignal(object) failed = pyqtSignal(str) completed = pyqtSignal() - def __init__(self, service: AndroidDeviceService): + def __init__( + self, + service: AndroidDeviceService, + restore_serial: str = "", + ): super().__init__() self._service = service + self._restore_serial = restore_serial self._cancelled = False def cancel(self) -> None: @@ -117,7 +129,13 @@ class AndroidDeviceSearchWorker(QObject): def run(self) -> None: try: try: - devices = self._service.search(lambda: self._cancelled) + if self._restore_serial: + devices = self._service.restore_saved_device( + self._restore_serial, + lambda: self._cancelled, + ) + else: + devices = self._service.search(lambda: self._cancelled) except AndroidDeviceSearchError as exc: if not self._cancelled: self.failed.emit(str(exc)) @@ -247,6 +265,7 @@ class SettingsPageEventBinder(QObject): self._thread: Optional[QThread] = None self._worker: Optional[CurrentClientSaveWorker] = None self._search_busy = False + self._search_restore_serial = "" self._search_thread: Optional[QThread] = None self._search_worker: Optional[AndroidDeviceSearchWorker] = None self._wifi_conversion_busy = False @@ -306,6 +325,8 @@ class SettingsPageEventBinder(QObject): self._load_current_client() self._load_selected_android_device() self._sync_button_state() + if self._saved_android_serial: + QTimer.singleShot(0, self._request_restore_saved_android_device) @pyqtSlot() def _request_save_current_device(self) -> None: @@ -385,6 +406,15 @@ class SettingsPageEventBinder(QObject): @pyqtSlot() def _start_search(self) -> None: + self._start_search_worker() + + @pyqtSlot() + def _request_restore_saved_android_device(self) -> None: + if not self._saved_android_serial: + return + self._start_search_worker(self._saved_android_serial) + + def _start_search_worker(self, restore_serial: str = "") -> None: if ( self._closing or self._busy @@ -396,11 +426,20 @@ class SettingsPageEventBinder(QObject): return self._search_busy = True + self._search_restore_serial = restore_serial self._sync_button_state() - self._page.deviceStatusLabel.setText("正在搜索 Android 设备…") + status = ( + f"正在恢复已保存设备:{restore_serial}…" + if restore_serial + else "正在搜索 Android 设备…" + ) + self._page.deviceStatusLabel.setText(status) thread = QThread(self) - worker = AndroidDeviceSearchWorker(self._android_device_service) + worker = AndroidDeviceSearchWorker( + self._android_device_service, + restore_serial, + ) worker.moveToThread(thread) thread.started.connect(worker.run) @@ -687,8 +726,9 @@ class SettingsPageEventBinder(QObject): self._page.deviceTableModel.set_checked_serial( self._saved_android_serial ) + action = "恢复完成" if self._search_restore_serial else "搜索完成" self._page.deviceStatusLabel.setText( - f"搜索完成:找到 {len(rows)} 台 Android 设备;" + f"{action}:找到 {len(rows)} 台 Android 设备;" "已自动选择当前使用设备" ) elif self._saved_android_serial: @@ -709,15 +749,22 @@ class SettingsPageEventBinder(QObject): def _on_search_failed(self, message: str) -> None: if self._closing: return - self._page.deviceStatusLabel.setText( - f"搜索失败:{message};设备列表和已保存配置未更改" - ) + if self._search_restore_serial: + self._page.deviceStatusLabel.setText( + f"自动恢复失败:{message};已保存配置未更改," + "可点击搜索重试" + ) + else: + 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 + self._search_restore_serial = "" if not self._closing: self._sync_button_state() diff --git a/client/test/test_android_device_service.py b/client/test/test_android_device_service.py index 03ecbb8..f046555 100644 --- a/client/test/test_android_device_service.py +++ b/client/test/test_android_device_service.py @@ -72,6 +72,58 @@ USB-002 unauthorized usb:1-2 transport_id:3 self.assertEqual(len(commands), 2) self.assertIn("ro.build.version.release", commands[1]) + def test_restore_saved_wifi_connects_before_search(self): + commands = [] + + def runner(command, _timeout): + command = list(command) + commands.append(command) + if command[:2] == ["adb", "connect"]: + return completed( + command, + "already 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 device model:Phone\n", + ) + return completed(command, "14\n") + + devices = AndroidDeviceService(runner).restore_saved_device( + "192.168.0.173:5555" + ) + + self.assertEqual(devices[0].serial, "192.168.0.173:5555") + self.assertEqual( + commands[0], + ["adb", "connect", "192.168.0.173:5555"], + ) + + def test_restore_saved_usb_only_searches(self): + commands = [] + + def runner(command, _timeout): + command = list(command) + commands.append(command) + if command == ["adb", "devices", "-l"]: + return completed( + command, + "List of devices attached\n" + "USB-001 device model:Phone\n", + ) + return completed(command, "14\n") + + devices = AndroidDeviceService(runner).restore_saved_device( + "USB-001" + ) + + self.assertEqual(devices[0].serial, "USB-001") + self.assertFalse( + any(command[:2] == ["adb", "connect"] for command in commands) + ) + def test_missing_model_is_read_from_device(self): def runner(command, _timeout): if command == ["adb", "devices", "-l"]: diff --git a/client/test/test_settings_ui_event.py b/client/test/test_settings_ui_event.py index e6d66ff..9cccc78 100644 --- a/client/test/test_settings_ui_event.py +++ b/client/test/test_settings_ui_event.py @@ -38,6 +38,7 @@ class SequenceAndroidDeviceService: def __init__(self, results): self._results = list(results) self.call_count = 0 + self.restore_serials = [] def search(self, _is_cancelled=None): result = self._results[min(self.call_count, len(self._results) - 1)] @@ -46,6 +47,10 @@ class SequenceAndroidDeviceService: raise result return result + def restore_saved_device(self, serial, is_cancelled=None): + self.restore_serials.append(serial) + return self.search(is_cancelled) + class SlowAndroidDeviceService: """模拟较慢的 ADB 搜索。""" @@ -59,6 +64,10 @@ class SlowAndroidDeviceService: time.sleep(self.delay) return [AndroidDevice("USB-001", "USB", "Pixel 8", "14")] + def restore_saved_device(self, serial, is_cancelled=None): + self.restore_serial = serial + return self.search(is_cancelled) + class ControlledWifiConversionService: """模拟 USB 转 Wi-Fi 的进度、成功、失败和延迟。""" @@ -413,21 +422,128 @@ class SettingsPageEventTest(unittest.TestCase): page.eventBinder.shutdown() page.deleteLater() - def test_saved_android_device_is_restored_before_search(self): - self.repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-001") + def test_startup_without_saved_device_does_not_search(self): + service = SequenceAndroidDeviceService([[]]) page = SettingsPage( settings_repository=self.repository, admin_gateway=MockAdminGateway(), + android_device_service=service, + ) + QTest.qWait(30) + + self.assertEqual(service.call_count, 0) + page.eventBinder.shutdown() + page.deleteLater() + + def test_saved_usb_device_is_automatically_searched_on_startup(self): + self.repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-001") + service = SequenceAndroidDeviceService( + [[AndroidDevice("USB-001", "USB", "Pixel 8", "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) + self.assertEqual(page.eventBinder._saved_android_serial, "USB-001") - self.assertEqual(page.deviceTableModel.checked_serial, "") - self.assertIn("等待搜索确认连接", page.deviceStatusLabel.text()) + self.assertEqual(service.restore_serials, ["USB-001"]) + self.assertEqual(page.deviceTableModel.checked_serial, "USB-001") + self.assertIn("恢复完成", page.deviceStatusLabel.text()) self.assertTrue(page.deleteButton.isEnabled()) page.eventBinder.shutdown() page.deleteLater() + def test_saved_wifi_device_is_reconnected_and_selected_on_startup(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")]] + ) + + 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(service.restore_serials, [wifi_serial]) + self.assertEqual(page.deviceTableModel.checked_serial, wifi_serial) + 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) + service = SequenceAndroidDeviceService( + [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) + + self.assertEqual( + self.repository.get(SELECTED_ANDROID_SERIAL_KEY), + wifi_serial, + ) + self.assertIn("自动恢复失败", page.deviceStatusLabel.text()) + self.assertIn("可点击搜索重试", page.deviceStatusLabel.text()) + self.assertTrue(page.searchButton.isEnabled()) + 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() + timer_fired = [] + QTimer.singleShot(10, lambda: timer_fired.append(True)) + + page = SettingsPage( + settings_repository=self.repository, + admin_gateway=MockAdminGateway(), + android_device_service=service, + ) + self._wait_until(lambda: bool(timer_fired), timeout_ms=500) + self._wait_until(lambda: page.eventBinder._search_thread is None) + + self.assertTrue(timer_fired) + self.assertEqual(service.restore_serial, "USB-001") + self.assertEqual(page.deviceTableModel.checked_serial, "USB-001") + page.eventBinder.shutdown() + page.deleteLater() + + def test_shutdown_ignores_late_startup_restore_result(self): + self.repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-001") + service = SlowAndroidDeviceService() + page = SettingsPage( + settings_repository=self.repository, + admin_gateway=MockAdminGateway(), + android_device_service=service, + ) + self._wait_until(lambda: page.eventBinder._search_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.assertIsNone( + page.deviceTableModel.device_for_serial("USB-001") + ) + page.deleteLater() + def test_search_auto_selects_saved_online_device(self): self.repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-001") service = SequenceAndroidDeviceService(