feat: 保存当前使用的 Android 设备 (#22)

This commit is contained in:
chengma
2026-08-07 14:13:40 +08:00
parent d2de7331bb
commit 27a09e524c
4 changed files with 558 additions and 17 deletions
@@ -0,0 +1,50 @@
"""当前使用 Android 设备的本地设置服务。"""
from .settings_repository import SettingsRepository
SELECTED_ANDROID_SERIAL_KEY = "android.selected_serial"
MAX_ANDROID_SERIAL_LENGTH = 255
class SelectedAndroidDeviceService:
"""只保存自动化应使用的设备号,不缓存 ADB 搜索结果。"""
def __init__(self, repository: SettingsRepository):
self._repository = repository
def load(self) -> str:
"""读取已保存设备号;缺失或损坏时返回空字符串。"""
value = self._repository.get(SELECTED_ANDROID_SERIAL_KEY, "")
if not isinstance(value, str):
return ""
return value.strip()
def save(self, serial: str) -> str:
"""校验并保存设备号,返回规范化后的值。"""
normalized = self._normalize_serial(serial)
self._repository.set(SELECTED_ANDROID_SERIAL_KEY, normalized)
return normalized
def delete(self) -> bool:
"""清除当前设备配置;配置不存在时也视为安全完成。"""
return self._repository.delete(SELECTED_ANDROID_SERIAL_KEY)
@staticmethod
def _normalize_serial(serial: object) -> str:
if not isinstance(serial, str):
raise ValueError("Android 设备号必须是文本")
normalized = serial.strip()
if not normalized:
raise ValueError("Android 设备号不能为空")
if len(normalized) > MAX_ANDROID_SERIAL_LENGTH:
raise ValueError(
f"Android 设备号最多 {MAX_ANDROID_SERIAL_LENGTH} 个字符"
)
if any(character.isspace() for character in normalized):
raise ValueError("Android 设备号不能包含空白字符")
return normalized
+238 -16
View File
@@ -1,7 +1,7 @@
"""设置页事件、ADB 搜索、当前 Client 本地保存和后台登记。
本文件不直接写 SQL。ADB、SQLite 保存和 Admin HTTP 请求由 Worker 在线程中
执行,后台结果只通过信号返回主线程更新页面。
本文件不直接写 SQL。ADB 搜索、SQLite 写入和 Admin HTTP 请求由 Worker 在线程中
执行;页面启动时只同步读取少量索引设置,后台结果通过信号返回主线程更新页面。
"""
from typing import Optional
@@ -24,6 +24,7 @@ from .current_client_service import (
generate_client_device_id,
)
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
from .selected_android_device_service import SelectedAndroidDeviceService
from .settings_repository import SettingsRepository
from .settings_ui import AndroidDeviceRow
@@ -129,6 +130,54 @@ class AndroidDeviceSearchWorker(QObject):
self.completed.emit()
class AndroidDeviceSettingWorker(QObject):
"""在后台保存或删除当前使用的 Android 设备号。"""
succeeded = pyqtSignal(str, str)
failed = pyqtSignal(str, str)
completed = pyqtSignal()
def __init__(
self,
service: SelectedAndroidDeviceService,
action: str,
serial: str = "",
):
super().__init__()
self._service = service
self._action = action
self._serial = serial
self._cancelled = False
def cancel(self) -> None:
"""写入无法中断;完成后不再向已关闭页面返回结果。"""
self._cancelled = True
@pyqtSlot()
def run(self) -> None:
try:
try:
if self._action == "save":
serial = self._service.save(self._serial)
elif self._action == "delete":
self._service.delete()
serial = ""
else:
raise ValueError("未知的 Android 设备设置操作")
except Exception as exc:
if not self._cancelled:
self.failed.emit(
self._action,
str(exc) or "无法写入本地数据库",
)
else:
if not self._cancelled:
self.succeeded.emit(self._action, serial)
finally:
self.completed.emit()
class SettingsPageEventBinder(QObject):
"""绑定设备管理控件,并向应用层发出稳定事件。"""
@@ -155,12 +204,19 @@ class SettingsPageEventBinder(QObject):
self._search_busy = False
self._search_thread: Optional[QThread] = None
self._search_worker: Optional[AndroidDeviceSearchWorker] = None
self._android_setting_busy = False
self._android_setting_thread: Optional[QThread] = None
self._android_setting_worker: Optional[AndroidDeviceSettingWorker] = None
self._saved_android_serial = ""
self._android_device_service = (
android_device_service or AndroidDeviceService()
)
repository = settings_repository or SettingsRepository()
self._client_service = CurrentClientService(repository)
self._selected_android_device_service = SelectedAndroidDeviceService(
repository
)
self._admin_gateway = admin_gateway
self._gateway_error = ""
if self._admin_gateway is None:
@@ -185,7 +241,9 @@ class SettingsPageEventBinder(QObject):
self.searchRequested.connect(self._start_search)
page.connectButton.clicked.connect(self._request_connect)
page.saveButton.clicked.connect(self._request_save)
self.saveRequested.connect(self._start_save_android_device)
page.deleteButton.clicked.connect(self._request_delete)
self.deleteRequested.connect(self._start_delete_android_device)
page.addressInput.textChanged.connect(self._sync_button_state)
page.deviceTableModel.checkedDeviceChanged.connect(self._sync_button_state)
page.destroyed.connect(self.shutdown)
@@ -194,11 +252,17 @@ class SettingsPageEventBinder(QObject):
application.aboutToQuit.connect(self.shutdown)
self._load_current_client()
self._load_selected_android_device()
self._sync_button_state()
@pyqtSlot()
def _request_save_current_device(self) -> None:
if self._busy or self._current_device_busy or self._search_busy:
if (
self._busy
or self._current_device_busy
or self._search_busy
or self._android_setting_busy
):
return
device_id = self._page.deviceIdInput.text().strip()
@@ -210,7 +274,12 @@ 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 or self._search_busy:
if (
self._closing
or self._current_device_busy
or self._search_busy
or self._android_setting_busy
):
return
try:
@@ -250,7 +319,12 @@ class SettingsPageEventBinder(QObject):
@pyqtSlot()
def _request_search(self) -> None:
if self._busy or self._current_device_busy or self._search_busy:
if (
self._busy
or self._current_device_busy
or self._search_busy
or self._android_setting_busy
):
return
self.searchRequested.emit()
@@ -261,6 +335,7 @@ class SettingsPageEventBinder(QObject):
or self._busy
or self._current_device_busy
or self._search_busy
or self._android_setting_busy
):
return
@@ -300,7 +375,7 @@ class SettingsPageEventBinder(QObject):
@pyqtSlot()
def _request_save(self) -> None:
if self._busy:
if self._busy or self._search_busy or self._android_setting_busy:
return
serial = self._page.deviceTableModel.checked_serial
@@ -309,21 +384,69 @@ class SettingsPageEventBinder(QObject):
self._page.deviceTable.setFocus()
return
device_name = self._page.deviceNameInput.text().strip()
self.saveRequested.emit(device_name, serial)
self.saveRequested.emit("", serial)
@pyqtSlot(str, str)
def _start_save_android_device(
self, _unused_device_name: str, serial: str
) -> None:
self._start_android_device_setting("save", serial)
@pyqtSlot()
def _request_delete(self) -> None:
if self._busy:
if self._busy or self._search_busy or self._android_setting_busy:
return
serial = self._page.deviceTableModel.checked_serial
serial = self._saved_android_serial
if not serial:
self._page.deviceStatusLabel.setText("删除失败:请先勾选一台设备")
self._page.deviceTable.setFocus()
self._page.deviceStatusLabel.setText("删除失败:当前没有已保存设备")
return
self.deleteRequested.emit(serial)
@pyqtSlot(str)
def _start_delete_android_device(self, _serial: str) -> None:
self._start_android_device_setting("delete")
def _start_android_device_setting(
self, action: str, serial: str = ""
) -> None:
if (
self._closing
or self._busy
or self._search_busy
or self._android_setting_busy
):
return
self._android_setting_busy = True
self._sync_button_state()
message = (
"正在保存当前使用设备…"
if action == "save"
else "正在删除已保存设备配置…"
)
self._page.deviceStatusLabel.setText(message)
thread = QThread(self)
worker = AndroidDeviceSettingWorker(
self._selected_android_device_service,
action,
serial,
)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.succeeded.connect(self._on_android_device_setting_succeeded)
worker.failed.connect(self._on_android_device_setting_failed)
worker.completed.connect(thread.quit)
worker.completed.connect(worker.deleteLater)
thread.finished.connect(self._on_android_device_setting_finished)
thread.finished.connect(thread.deleteLater)
self._android_setting_thread = thread
self._android_setting_worker = worker
thread.start()
@pyqtSlot()
def _sync_button_state(self, *_args) -> None:
serial = self._page.deviceTableModel.checked_serial
@@ -332,8 +455,13 @@ class SettingsPageEventBinder(QObject):
not self._busy
and not self._current_device_busy
and not self._search_busy
and not self._android_setting_busy
)
device_commands_enabled = (
not self._busy
and not self._search_busy
and not self._android_setting_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
)
@@ -344,7 +472,7 @@ class SettingsPageEventBinder(QObject):
device_commands_enabled and bool(serial)
)
self._page.deleteButton.setEnabled(
device_commands_enabled and bool(serial)
device_commands_enabled and bool(self._saved_android_serial)
)
def set_busy(self, busy: bool, message: str = "") -> None:
@@ -370,6 +498,23 @@ class SettingsPageEventBinder(QObject):
if saved.client_id:
self._page.set_current_device_status("本地设备信息已加载")
def _load_selected_android_device(self) -> None:
"""页面创建时恢复自动化应使用的 Android 设备号。"""
try:
serial = self._selected_android_device_service.load()
except Exception as exc:
self._page.deviceStatusLabel.setText(
f"读取已保存 Android 设备失败:{str(exc) or '数据库不可用'}"
)
return
self._saved_android_serial = serial
if serial:
self._page.deviceStatusLabel.setText(
f"当前使用设备:{serial}(等待搜索确认连接)"
)
def _selected_android_device(self) -> Optional[AndroidDeviceInfo]:
serial = self._page.deviceTableModel.checked_serial.strip()
return AndroidDeviceInfo(serial) if serial else None
@@ -390,7 +535,23 @@ class SettingsPageEventBinder(QObject):
for device in devices
]
self._page.set_android_devices(rows)
if rows:
saved_device = self._page.deviceTableModel.device_for_serial(
self._saved_android_serial
)
if saved_device is not None and saved_device.status == "device":
self._page.deviceTableModel.set_checked_serial(
self._saved_android_serial
)
self._page.deviceStatusLabel.setText(
f"搜索完成:找到 {len(rows)} 台 Android 设备;"
"已自动选择当前使用设备"
)
elif self._saved_android_serial:
self._page.deviceStatusLabel.setText(
"已保存设备当前未连接:"
f"{self._saved_android_serial};配置已保留"
)
elif rows:
self._page.deviceStatusLabel.setText(
f"搜索完成:找到 {len(rows)} 台 Android 设备"
)
@@ -404,7 +565,7 @@ class SettingsPageEventBinder(QObject):
if self._closing:
return
self._page.deviceStatusLabel.setText(
f"搜索失败:{message};设备列表未更改"
f"搜索失败:{message};设备列表和已保存配置未更改"
)
@pyqtSlot()
@@ -415,6 +576,44 @@ class SettingsPageEventBinder(QObject):
if not self._closing:
self._sync_button_state()
@pyqtSlot(str, str)
def _on_android_device_setting_succeeded(
self, action: str, serial: str
) -> None:
if self._closing:
return
if action == "save":
self._saved_android_serial = serial
self._page.deviceStatusLabel.setText(
f"当前使用设备:{serial}(已保存)"
)
else:
self._saved_android_serial = ""
self._page.deviceTableModel.set_checked_serial("")
self._page.deviceStatusLabel.setText(
"当前使用设备:未选择(本地配置已删除)"
)
@pyqtSlot(str, str)
def _on_android_device_setting_failed(
self, action: str, message: str
) -> None:
if self._closing:
return
action_text = "保存" if action == "save" else "删除"
self._page.deviceStatusLabel.setText(
f"{action_text}失败:{message};原设备配置未更改,可重试"
)
@pyqtSlot()
def _on_android_device_setting_finished(self) -> None:
self._android_setting_worker = None
self._android_setting_thread = None
self._android_setting_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:
@@ -495,3 +694,26 @@ class SettingsPageEventBinder(QObject):
if search_thread is not None and search_thread.isRunning():
search_thread.quit()
search_thread.wait(6000)
setting_worker = self._android_setting_worker
setting_thread = self._android_setting_thread
if setting_worker is not None:
setting_worker.cancel()
for signal, slot in (
(
setting_worker.succeeded,
self._on_android_device_setting_succeeded,
),
(
setting_worker.failed,
self._on_android_device_setting_failed,
),
):
try:
signal.disconnect(slot)
except TypeError:
pass
if setting_thread is not None and setting_thread.isRunning():
setting_thread.quit()
setting_thread.wait(4000)
@@ -0,0 +1,60 @@
"""当前使用 Android 设备设置服务测试。"""
import tempfile
import unittest
from pathlib import Path
from src.selected_android_device_service import (
SELECTED_ANDROID_SERIAL_KEY,
SelectedAndroidDeviceService,
)
from src.settings_repository import SettingsRepository
class SelectedAndroidDeviceServiceTest(unittest.TestCase):
def setUp(self):
self.temp_directory = tempfile.TemporaryDirectory()
db_path = Path(self.temp_directory.name) / "client.db"
self.repository = SettingsRepository(db_path)
self.service = SelectedAndroidDeviceService(self.repository)
def tearDown(self):
self.temp_directory.cleanup()
def test_missing_setting_loads_as_empty(self):
self.assertEqual(self.service.load(), "")
def test_save_normalizes_and_persists_serial(self):
serial = self.service.save(" 192.168.0.173:5555 ")
self.assertEqual(serial, "192.168.0.173:5555")
self.assertEqual(
self.repository.get(SELECTED_ANDROID_SERIAL_KEY), serial
)
def test_invalid_serial_is_rejected(self):
for serial in ("", "USB 001", None):
with self.subTest(serial=serial):
with self.assertRaises(ValueError):
self.service.save(serial)
def test_corrupted_setting_loads_as_empty(self):
self.repository.set(SELECTED_ANDROID_SERIAL_KEY, {"serial": "bad"})
self.assertEqual(self.service.load(), "")
def test_delete_removes_only_selected_serial(self):
self.repository.set("android.pdd_package", "com.xunmeng.pinduoduo")
self.service.save("USB-001")
self.assertTrue(self.service.delete())
self.assertIsNone(self.repository.get(SELECTED_ANDROID_SERIAL_KEY))
self.assertEqual(
self.repository.get("android.pdd_package"),
"com.xunmeng.pinduoduo",
)
if __name__ == "__main__":
unittest.main()
+210 -1
View File
@@ -17,6 +17,7 @@ from src.android_device_service import (
AndroidDeviceSearchError,
)
from src.mock_admin_gateway import MockAdminGateway
from src.selected_android_device_service import SELECTED_ANDROID_SERIAL_KEY
from src.settings_repository import SettingsRepository
from src.settings_ui import AndroidDeviceRow, SettingsPage
@@ -57,6 +58,34 @@ class SlowAndroidDeviceService:
return [AndroidDevice("USB-001", "USB", "Pixel 8", "14")]
class ControlledSettingsRepository(SettingsRepository):
"""控制 Android 设备设置写入速度和失败,其他设置保持正常。"""
def __init__(self, db_path, delay=0.0):
super().__init__(db_path)
self.delay = delay
self.fail_selected_write = False
self.selected_write_count = 0
def set(self, setting_key, value, updated_at=None):
if setting_key == SELECTED_ANDROID_SERIAL_KEY:
self.selected_write_count += 1
if self.delay:
time.sleep(self.delay)
if self.fail_selected_write:
raise OSError("模拟数据库写入失败")
return super().set(setting_key, value, updated_at)
def delete(self, setting_key):
if setting_key == SELECTED_ANDROID_SERIAL_KEY:
self.selected_write_count += 1
if self.delay:
time.sleep(self.delay)
if self.fail_selected_write:
raise OSError("模拟数据库删除失败")
return super().delete(setting_key)
class SettingsPageEventTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -254,7 +283,7 @@ class SettingsPageEventTest(unittest.TestCase):
page.deviceTableModel.device_for_serial("USB-OLD")
)
self.assertIn("搜索失败:未找到 adb", page.deviceStatusLabel.text())
self.assertIn("设备列表未更改", page.deviceStatusLabel.text())
self.assertIn("已保存配置未更改", page.deviceStatusLabel.text())
page.eventBinder.shutdown()
page.deleteLater()
@@ -321,6 +350,186 @@ class SettingsPageEventTest(unittest.TestCase):
)
page.deleteLater()
def test_selected_android_device_is_saved_to_sqlite(self):
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
)
page.set_android_devices(
[AndroidDeviceRow("USB-001", "USB", "Pixel 8", "14")]
)
page.deviceTableModel.set_checked_serial("USB-001")
page.saveButton.click()
self.assertFalse(page.saveButton.isEnabled())
self._wait_until(
lambda: page.eventBinder._android_setting_thread is None
)
self.assertEqual(
self.repository.get(SELECTED_ANDROID_SERIAL_KEY), "USB-001"
)
self.assertEqual(
page.deviceStatusLabel.text(),
"当前使用设备:USB-001(已保存)",
)
self.assertTrue(page.deleteButton.isEnabled())
page.eventBinder.shutdown()
page.deleteLater()
def test_saved_android_device_is_restored_before_search(self):
self.repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-001")
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
)
self.assertEqual(page.eventBinder._saved_android_serial, "USB-001")
self.assertEqual(page.deviceTableModel.checked_serial, "")
self.assertIn("等待搜索确认连接", page.deviceStatusLabel.text())
self.assertTrue(page.deleteButton.isEnabled())
page.eventBinder.shutdown()
page.deleteLater()
def test_search_auto_selects_saved_online_device(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,
)
page.searchButton.click()
self._wait_until(lambda: page.eventBinder._search_thread is None)
self.assertEqual(page.deviceTableModel.checked_serial, "USB-001")
self.assertIn("已自动选择", page.deviceStatusLabel.text())
page.eventBinder.shutdown()
page.deleteLater()
def test_search_keeps_saved_device_when_it_is_offline(self):
self.repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-001")
service = SequenceAndroidDeviceService([[]])
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
android_device_service=service,
)
page.searchButton.click()
self._wait_until(lambda: page.eventBinder._search_thread is None)
self.assertEqual(
self.repository.get(SELECTED_ANDROID_SERIAL_KEY), "USB-001"
)
self.assertEqual(page.deviceTableModel.checked_serial, "")
self.assertIn("已保存设备当前未连接", page.deviceStatusLabel.text())
self.assertIn("配置已保留", page.deviceStatusLabel.text())
page.eventBinder.shutdown()
page.deleteLater()
def test_selected_android_device_save_failure_keeps_old_configuration(self):
repository = ControlledSettingsRepository(self.db_path)
repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-OLD")
repository.fail_selected_write = True
page = SettingsPage(
settings_repository=repository,
admin_gateway=MockAdminGateway(),
)
page.set_android_devices(
[
AndroidDeviceRow("USB-OLD", "USB"),
AndroidDeviceRow("USB-NEW", "USB"),
]
)
page.deviceTableModel.set_checked_serial("USB-NEW")
page.saveButton.click()
self._wait_until(
lambda: page.eventBinder._android_setting_thread is None
)
self.assertEqual(
repository.get(SELECTED_ANDROID_SERIAL_KEY), "USB-OLD"
)
self.assertEqual(page.deviceTableModel.checked_serial, "USB-NEW")
self.assertIn("原设备配置未更改", page.deviceStatusLabel.text())
self.assertTrue(page.saveButton.isEnabled())
page.eventBinder.shutdown()
page.deleteLater()
def test_delete_clears_saved_offline_device_only(self):
self.repository.set(SELECTED_ANDROID_SERIAL_KEY, "USB-OFFLINE")
self.repository.set("android.pdd_package", "com.xunmeng.pinduoduo")
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
)
page.deleteButton.click()
self._wait_until(
lambda: page.eventBinder._android_setting_thread is None
)
self.assertIsNone(
self.repository.get(SELECTED_ANDROID_SERIAL_KEY)
)
self.assertEqual(
self.repository.get("android.pdd_package"),
"com.xunmeng.pinduoduo",
)
self.assertIn("本地配置已删除", page.deviceStatusLabel.text())
self.assertFalse(page.deleteButton.isEnabled())
page.eventBinder.shutdown()
page.deleteLater()
def test_slow_android_device_save_does_not_block_or_start_twice(self):
repository = ControlledSettingsRepository(self.db_path, delay=0.08)
page = SettingsPage(
settings_repository=repository,
admin_gateway=MockAdminGateway(),
)
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.saveButton.click()
page.eventBinder._request_save()
self._wait_until(lambda: bool(timer_fired), timeout_ms=500)
self._wait_until(
lambda: page.eventBinder._android_setting_thread is None
)
self.assertTrue(timer_fired)
self.assertEqual(repository.selected_write_count, 1)
page.eventBinder.shutdown()
page.deleteLater()
def test_shutdown_ignores_late_android_device_save_result(self):
repository = ControlledSettingsRepository(self.db_path, delay=0.08)
page = SettingsPage(
settings_repository=repository,
admin_gateway=MockAdminGateway(),
)
page.set_android_devices([AndroidDeviceRow("USB-001", "USB")])
page.deviceTableModel.set_checked_serial("USB-001")
page.saveButton.click()
self._wait_until(
lambda: page.eventBinder._android_setting_thread is not None
)
status_before_close = page.deviceStatusLabel.text()
page.eventBinder.shutdown()
QTest.qWait(120)
self.assertEqual(page.deviceStatusLabel.text(), status_before_close)
page.deleteLater()
if __name__ == "__main__":
unittest.main()