diff --git a/AGENTS.md b/AGENTS.md index 5cfe441..36fe5e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,3 +102,9 @@ ## 9. 测试经验与交付反馈 每轮测试完成后,将测试中遇到的问题及已验证解法补到对应可重复测试流程、排障指南或构建文档,防止后续重复试错;单次失败证据、恢复及重测结果留在测试归档。已知问题先查已有经验,不把未证实推测固化成根因。最终反馈说明经验更新位置和仍未解决的边界;没有新增经验时沿用已有记录,不重复复制。文档不得包含真实凭据、测试设备标识或开发机绝对路径。 + +## Android 按影响选测(当前规则) + +每轮先评估直接和共享依赖影响,记录选测理由;测试矩阵不是每轮全量清单。未改且不受影响的页面/设置不跑真机回归。纯逻辑优先 JVM,普通 UI 优先独立模拟器;BLE、实际权限/后台厂商差异等才做必要的定向真机验证。root 授权不代表真机必测;root 输入规则仅适用于登记真机,模拟器使用标准 Compose 测试 API。必要但没做的验证不能写成不适用。切网、长轮询、性能、生命周期和全量验收均需具体影响理由,不能顺带执行。 + +具体触发条件、命令和证据格式见 [按影响选测](移动端相关内容/安卓app/如何安卓测试/按影响选测.md)。该规则替代历史专项“每轮执行”要求,视觉/物理操作、凭据和真机登记门禁不变。 diff --git a/整体开发需求/03_移动端接入需求.md b/整体开发需求/03_移动端接入需求.md index a629a55..1a2cc7e 100644 --- a/整体开发需求/03_移动端接入需求.md +++ b/整体开发需求/03_移动端接入需求.md @@ -30,3 +30,15 @@ DEVICE-MOBILE-SETTINGS:WiFi 激活任务可选 error_code 为 AUTH_FAILED、NE ## 2026-09-27 手机界面测试补充 TEST-MOBILE-INTEGRATION:Android 顶栏溢出循环、扫描条目按认证会话切换断开且重新扫描保留、两处预览居中,按 ANDROID-TEST-STATE/UI 做共享逻辑与 root 真机布局验收。设备接口、广播仲裁与驱动不变,不需要设备部署或实屏视觉检查。 + +## 按变更影响选择验证(当前规则,替代每轮全量要求) + +TEST-MOBILE-INTEGRATION:矩阵是覆盖目录,不是每轮必跑清单。先记录直接和间接影响,再选择最低有效层级;无影响的页面、BLE、WiFi、性能和生命周期不重复执行。必要证据缺失记为未验证,不能写不适用。历史专项验收不构成后续每轮义务。 + +TEST-MOBILE-SELECTION:运行器显式选择场景,支持不访问设备的列表与预览;切网、控制写入、长轮询和蓝牙开关单独选择。模拟器使用测试 APK 内假平台运行实际界面,不连接核桃派;模拟结果不替代真实 BLE、厂商行为或 systemd 验证。 + +## 2026-09-29 内容图库渐进加载 + +DEVICE-MOBILE-LIBRARY-CACHE:BLE 提供轻量目录修订查询及 16×16 静态预览;既有 64×64 缩略图接口保持兼容。设备为预览建立可再生缓存,并在启动与内容修订变化后清理失效项,不删除用户资源。目录修订覆盖条目内容与顺序。 + +TEST-MOBILE-LIBRARY-CACHE:验证两档图尺寸、修订一致性、网页修改后的失效与清理、旧客户端兼容;真实 BLE 对比首次加载和未变化重连的图片请求与字节数。当前帧、显示输出和驱动不变,无需新增实屏视觉验收。 diff --git a/核桃派软件源代码/FEATURE_UPDATED_AT b/核桃派软件源代码/FEATURE_UPDATED_AT index 123b561..d21af30 100644 --- a/核桃派软件源代码/FEATURE_UPDATED_AT +++ b/核桃派软件源代码/FEATURE_UPDATED_AT @@ -1 +1 @@ -2026-09-27T17:51+08:00 +2026-09-29T22:15+08:00 diff --git a/核桃派软件源代码/app/mobile/control.py b/核桃派软件源代码/app/mobile/control.py index 6d987fb..848df38 100644 --- a/核桃派软件源代码/app/mobile/control.py +++ b/核桃派软件源代码/app/mobile/control.py @@ -9,12 +9,14 @@ from io import BytesIO from pathlib import Path from uuid import UUID, uuid4 from contextlib import nullcontext +from PIL import Image from app.persistence import atomic_write_bytes from app.demo_library import demo_template, demo_animation from app.display.service import ANIMATION_PLAYBACK_SPEEDS, AnimationPlaybackConflictError from app.templates.store import TemplateConflictError, TemplateNotFoundError from .session import RpcError, checked_name +from .preview_cache import PreviewCache SETTINGS = ("brightness", "orientation", "matrix_refresh_rate_limit_hz", "performance_mode_enabled") @@ -30,6 +32,7 @@ class MobileControl: self.tasks = {} self.library_order = library_order self.path = Path(control.store.data_dir) / "mobile/identity.json" + self.preview_cache = PreviewCache(control.store.data_dir) if self.path.exists(): identity = json.loads(self.path.read_text(encoding="utf-8")) if set(identity) != {"schema", "device_id", "name"} or identity["schema"] != 1: @@ -40,6 +43,7 @@ class MobileControl: else: self.identity_record = dict(schema=1, device_id=str(uuid4()), name="奇妙小屏幕") self._persist_identity(self.identity_record) + self.preview_cache.reconcile(self._library_items()) def _persist_identity(self, identity): atomic_write_bytes(self.path, (json.dumps(identity, ensure_ascii=False) + "\n").encode("utf-8")) @@ -47,8 +51,8 @@ class MobileControl: def identity(self): record = self.identity_record return dict(device_id=record["device_id"], device_name=record["name"], - short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=0, - capabilities=["status", "settings", "library", "playback", "frame", "device_name", "wifi", "wifi_scan"], + short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=1, + capabilities=["status", "settings", "library", "library_progressive", "playback", "frame", "device_name", "wifi", "wifi_scan"], limits=dict(max_message_bytes=65536, library_page_size=50, preview_interval_ms=2000)) def settings(self): @@ -66,6 +70,42 @@ class MobileControl: raise RpcError("CONFLICT", "内容已改变,请刷新") return resolved + @staticmethod + def _preview_image(content): + if content["type"] == "template": + return content["image"] + frames = content["frames"] + if not frames: + raise RpcError("NOT_FOUND", "内容没有可预览帧") + frame = frames[0] + if "image" in frame: + return frame["image"] + if "path" not in frame: + raise RpcError("NOT_FOUND", "内容没有可预览帧") + try: + with Image.open(frame["path"]) as image: + image.load() + if image.size != (64, 64): + raise ValueError("invalid animation frame size") + return image.convert("RGB") + except (OSError, ValueError): + raise RpcError("NOT_FOUND", "预览文件不可用") from None + + def _library_items(self): + items = [] + for kind, records in (("template", [demo_template()] + self.templates.list()["templates"]), + ("animation", [demo_animation()] + self.animations.list()["animations"])): + for item in records: + items.append(dict(type=kind, id=item["id"], name=item["name"], revision=item["revision"], + is_demo=bool(item.get("is_demo", False)), playable=kind == "template" or item.get("frame_count", 0) > 0, + thumbnail_revision=item["revision"])) + if self.library_order is not None: + user_items = [item for item in items if not item["is_demo"]] + order = self.library_order.get([{"type": item["type"], "id": item["id"]} for item in user_items])["items"] + by_key = {(item["type"], item["id"]): item for item in user_items} + items = [item for item in items if item["is_demo"]] + [by_key[(ref["type"], ref["id"])] for ref in order] + return items + @staticmethod def _png(image): output = BytesIO() @@ -145,20 +185,12 @@ class MobileControl: else: self.control.update_config(changes) return self.settings() - if method == "library.list": - items = [] - for kind, records in (("template", [demo_template()] + self.templates.list()["templates"]), - ("animation", [demo_animation()] + self.animations.list()["animations"])): - for item in records: - items.append(dict(type=kind, id=item["id"], name=item["name"], revision=item["revision"], - is_demo=bool(item.get("is_demo", False)), playable=kind == "template" or item.get("frame_count", 0) > 0, - thumbnail_revision=item["revision"])) - if self.library_order is not None: - user_items = [item for item in items if not item['is_demo']] - order = self.library_order.get([{'type': item['type'], 'id': item['id']} for item in user_items])['items'] - by_key = {(item['type'], item['id']): item for item in user_items} - items = [item for item in items if item['is_demo']] + [by_key[(ref['type'], ref['id'])] for ref in order] + if method in ("library.list", "library.summary"): + items = self._library_items() revision = self.control.revision(items) + self.preview_cache.reconcile(items) + if method == "library.summary": + return dict(library_revision=revision, item_count=len(items)) limit = params.get("limit", 20) if type(limit) is not int or not 1 <= limit <= 50: raise ValueError() @@ -175,7 +207,13 @@ class MobileControl: next_cursor=f"{revision}:{next_offset}" if next_offset < len(items) else None) if method == "library.thumbnail": content = self._content(params) - return self._png(content["image"] if content["type"] == "template" else content["frames"][0]["image"]) + return self._png(self._preview_image(content)) + if method == "library.preview": + content = self._content(params) + image = self._preview_image(content) + item = {key: content[key] for key in ("type", "id", "revision")} + data = self.preview_cache.get(item, image) + return dict(mime="image/png", data_base64=base64.b64encode(data).decode("ascii")) if method in ("content.play", "content.default.set"): content = self._content(params) if method == "content.default.set": diff --git a/核桃派软件源代码/app/mobile/preview_cache.py b/核桃派软件源代码/app/mobile/preview_cache.py new file mode 100644 index 0000000..dbe4c8f --- /dev/null +++ b/核桃派软件源代码/app/mobile/preview_cache.py @@ -0,0 +1,48 @@ +"""Regenerable BLE library previews, isolated from user-owned media.""" +from __future__ import annotations + +import hashlib +import re +from io import BytesIO +from pathlib import Path + +from PIL import Image + +from app.persistence import atomic_write_bytes + + +_PREVIEW_FILE = re.compile(r"[0-9a-f]{64}\.png\Z") + + +class PreviewCache: + def __init__(self, data_dir: Path): + self.root = Path(data_dir) / "mobile" / "previews" + self.root.mkdir(parents=True, exist_ok=True) + + @staticmethod + def name(item: dict) -> str: + identity = "\0".join(str(item[key]) for key in ("type", "id", "revision")) + return hashlib.sha256(identity.encode("utf-8")).hexdigest() + ".png" + + def reconcile(self, items: list[dict]) -> None: + expected = {self.name(item) for item in items} + for path in self.root.iterdir(): + if path.is_file() and _PREVIEW_FILE.fullmatch(path.name) and path.name not in expected: + path.unlink(missing_ok=True) + + def get(self, item: dict, image: Image.Image) -> bytes: + path = self.root / self.name(item) + if path.exists(): + try: + data = path.read_bytes() + with Image.open(BytesIO(data)) as cached: + cached.verify() + if cached.format == "PNG" and cached.size == (16, 16): + return data + except (OSError, ValueError): + pass + output = BytesIO() + image.convert("RGB").resize((16, 16), Image.Resampling.LANCZOS).save(output, format="PNG", optimize=True) + data = output.getvalue() + atomic_write_bytes(path, data) + return data diff --git a/核桃派软件源代码/tests/test_mobile_control.py b/核桃派软件源代码/tests/test_mobile_control.py index 9f34223..d439620 100644 --- a/核桃派软件源代码/tests/test_mobile_control.py +++ b/核桃派软件源代码/tests/test_mobile_control.py @@ -44,6 +44,60 @@ def test_library_and_frame_readback(tmp_path): mobile.dispatch('content.play', {**item, 'revision': 'old'}) +def test_progressive_preview_and_revision_cleanup(tmp_path): + import base64 + from io import BytesIO + from PIL import Image + app = create_app(project_root=tmp_path, driver_kind='mock') + mobile = app.state.mobile_control + assert mobile.identity()['protocol_minor'] == 1 + assert 'library_progressive' in mobile.identity()['capabilities'] + created = mobile.templates.create('预览清理', { + 'version': 1, 'width': 64, 'height': 64, + 'pixelRgb': base64.b64encode(bytes(64 * 64 * 3)).decode('ascii'), 'elements': [], + }) + summary = mobile.dispatch('library.summary', {}) + items = mobile.dispatch('library.list', {})['items'] + assert summary == {'library_revision': mobile.dispatch('library.list', {})['library_revision'], 'item_count': len(items)} + item = next(item for item in items if item['id'] == created['id']) + small = base64.b64decode(mobile.dispatch('library.preview', item)['data_base64']) + large = base64.b64decode(mobile.dispatch('library.thumbnail', item)['data_base64']) + assert len(small) < len(large) + with Image.open(BytesIO(small)) as image: + assert image.format == 'PNG' and image.size == (16, 16) + with Image.open(BytesIO(large)) as image: + assert image.format == 'PNG' and image.size == (64, 64) + old_file = mobile.preview_cache.root / mobile.preview_cache.name(item) + assert old_file.read_bytes() == small + mobile.templates.rename(created['id'], '预览清理新版', created['revision']) + assert mobile.dispatch('library.summary', {})['library_revision'] != summary['library_revision'] + assert not old_file.exists() + with pytest.raises(RpcError, match='CONFLICT'): + mobile.dispatch('library.preview', item) + + empty = mobile.animations.create('空动图') + empty_item = next(row for row in mobile.dispatch('library.list', {})['items'] if row['id'] == empty['id']) + with pytest.raises(RpcError, match='CONFLICT'): + mobile.dispatch('library.preview', empty_item) + + +def test_user_animation_preview_reads_persisted_first_frame(tmp_path): + import base64 + from io import BytesIO + from PIL import Image + mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control + scene = {'version': 1, 'width': 64, 'height': 64, + 'pixelRgb': base64.b64encode(bytes((12, 34, 56)) * (64 * 64)).decode('ascii'), 'elements': []} + created = mobile.animations.create_from_frames('用户动图', [{'scene': scene, 'duration_ms': 100}]) + item = next(row for row in mobile.dispatch('library.list', {})['items'] if row['id'] == created['id']) + small = base64.b64decode(mobile.dispatch('library.preview', item)['data_base64']) + large = base64.b64decode(mobile.dispatch('library.thumbnail', item)['data_base64']) + with Image.open(BytesIO(small)) as image: + assert image.size == (16, 16) and image.getpixel((0, 0)) == (12, 34, 56) + with Image.open(BytesIO(large)) as image: + assert image.size == (64, 64) and image.getpixel((0, 0)) == (12, 34, 56) + + def test_forbidden_operation_and_settings_fields(tmp_path): mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control with pytest.raises(RpcError, match='UNSUPPORTED_CAPABILITY'): diff --git a/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md b/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md index 5a8d585..7428f3c 100644 --- a/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md +++ b/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md @@ -24,6 +24,7 @@ NetworkManager DNS 往返:`tests/test_wifi.py::test_nmcli_multiple_dns_can_rou | `TEST-NATIVE-UNIT` | `DISPLAY-DRIVER`、bitplane、映射、亮度、双缓冲 | 本机,不访问 `/dev/mem` | 否 | | `TEST-REMOTE-HEALTH` | `DEPLOY-*`、系统、网络、磁盘、工具 | 核桃派,无屏 | 否 | | `TEST-MANUAL-UPDATE` | `DEPLOY-UPDATE`、数据副本迁移、程序/数据原子切换、失败回滚和清理 | 核桃派,无屏 | 否 | +| `TEST-MOBILE-LIBRARY-CACHE` | `DEVICE-MOBILE-LIBRARY-CACHE`、两档 PNG、目录修订与预览孤儿清理、旧接口兼容 | 本机 mock + 核桃派 BLE 只读 | 否 | | `TEST-NATIVE-NOLOAD` | H618 寄存器映射、线程、刷新率、统计、安全关闭 | 核桃派,HUB75 未连接 | 否 | | `TEST-API-CONFIG` | REST、schema v10、工作区顺序、性能模式、动图并发、持久化、字体、模板、动图 | mock / 核桃派 | 否 | | `TEST-FRP-MAINTENANCE` | `WEB-FRP-MAINTENANCE`、`CONFIG-FRP`、`DEPLOY-FRP`、多配置、systemd 持久与真实 SSH 穿透 | Windows mock + 核桃派 | 否 | @@ -283,3 +284,7 @@ nmcli -t -f GENERAL.STATE,IP4.ADDRESS,IP4.GATEWAY device show wlan0 ## Android 0.2.1 界面专项映射 TEST-MOBILE-INTEGRATION 增补 MOBILE-STATUS/DISCOVERY/PREVIEW 对应的 ANDROID-TEST-STATE/UI:顶栏溢出循环配置和完整语义、认证连接后的条目断开及扫描保留、两处预览按卡片居中且刷新不重叠。共享逻辑及登记手机root验收入口和合格标准维护在 `移动端相关内容/安卓app/如何安卓测试/README.md`。手机界面及会话显示状态变化没有修改设备服务或驱动,实屏视觉检查不适用,不增加接线、显示图案切换、网络切换或真实systemd停启步骤。 + +## Android 选测入口 + +遵循 [按影响选测](../../移动端相关内容/安卓app/如何安卓测试/按影响选测.md)。日常变更不自动执行模拟器或 root 真机全量矩阵;独立 uiTest 使用真实 Compose 界面与 Controller、测试专用假平台,不替代物理 BLE。历史版本专项不作为每轮门槛。 diff --git a/移动端相关内容/AGENTS.md b/移动端相关内容/AGENTS.md index 292dd72..b1c6234 100644 --- a/移动端相关内容/AGENTS.md +++ b/移动端相关内容/AGENTS.md @@ -50,3 +50,9 @@ ANDROID-TEST-UI 增加四栏、三点菜单、昵称、长按保存设备、WiFi - 每轮测试前读 `安卓app/如何安卓测试/常见问题与排障.md`;遇到已有症状先沿用已验证方法,不从头试错。 - 测试中出现失败、临时修正或恢复操作,完成后必须复盘。可复用的已验证解法更新对应测试流程、排障或构建文档;单次日志、耗时和重测结果写测试归档,未知根因明确保留,不把重测通过当作根因证明。 - 最终反馈须说明经验已写入哪些文档及未解决项;无新增经验时不重复造条目。不仅在聊天里解释,也不能只留一次性报告。遵守根目录秘密与文档路径规则。 + +## Android 按影响选测(当前规则) + +每轮先评估直接和共享依赖影响,记录选测理由;测试矩阵不是每轮全量清单。未改且不受影响的页面/设置不跑真机回归。纯逻辑优先 JVM,普通 UI 优先独立模拟器;BLE、实际权限/后台厂商差异等才做必要的定向真机验证。root 授权不代表真机必测;root 输入规则仅适用于登记真机,模拟器使用标准 Compose 测试 API。必要但没做的验证不能写成不适用。切网、长轮询、性能、生命周期和全量验收均需具体影响理由,不能顺带执行。 + +具体触发条件、命令和证据格式见 [按影响选测](安卓app/如何安卓测试/按影响选测.md)。该规则替代历史专项“每轮执行”要求,视觉/物理操作、凭据和真机登记门禁不变。 diff --git a/移动端相关内容/README.md b/移动端相关内容/README.md index 474158e..7c325a0 100644 --- a/移动端相关内容/README.md +++ b/移动端相关内容/README.md @@ -23,3 +23,7 @@ ## 视觉检查 普通前端、App 界面、BLE 入口开发没有改变驱动或显示输出实现时,不新增实屏视觉验收。协议、输出帧和驱动统计优先。确需视觉检查时先停止相关步骤,说明原因,询问用户如何启动并等待明确指示;不得自动开启摄像头、DroidCam、采集或视觉测试程序,不主动切换实屏测试图案。专用测试手机授权不等于视觉检查授权。不适用时记录“不适用”。 + +## Android 选测入口 + +遵循 [按影响选测](安卓app/如何安卓测试/按影响选测.md)。日常变更不自动执行模拟器或 root 真机全量矩阵;独立 uiTest 使用真实 Compose 界面与 Controller、测试专用假平台,不替代物理 BLE。历史版本专项不作为每轮门槛。 diff --git a/移动端相关内容/安卓app/如何安卓测试/README.md b/移动端相关内容/安卓app/如何安卓测试/README.md index 0d1ba40..c3ade58 100644 --- a/移动端相关内容/安卓app/如何安卓测试/README.md +++ b/移动端相关内容/安卓app/如何安卓测试/README.md @@ -1,5 +1,7 @@ # Android 可重复测试流程 +**先读 [按影响选测](按影响选测.md)。以下矩阵列出可选覆盖,不是每轮必跑清单;历史版本专项仅在相关影响存在时执行。** + 本文件维护步骤与合格标准,不写一次性通过结果。每轮测试前阅读 [常见问题与排障](常见问题与排障.md),优先沿用已验证方法。结果记录到 `../测试结果归档/`。真机先检查 `测试设备登记/prepare_test_device.py`,多机精确选择,日志仅使用代号。源码与 UI 尚未实现的测试不得写成通过。 远端联调脚本在电脑端使用 `requirements-host.txt` 的 Paramiko;它不属于核桃派离线安装依赖,不能加入设备端 `requirements-dev.txt` 或 OTA wheelhouse。 @@ -24,6 +26,7 @@ | ANDROID-TEST-BLE | DISCOVERY、CONNECTION、SECURITY | 主测试机真实扫描、握手、断开、后台释放、返回重连、设备服务重启、异常断线,全部无需系统配对。 | | ANDROID-TEST-WIFI | WIFI、WIFI-SECRET | 核桃派扫描、隐藏 SSID 输入、字段校验、立即生效与确认取消、错误密码、切网后 BLE 状态仍可查;无密码回读/日志泄露。实际网络变更保留恢复配置。 | | ANDROID-TEST-CONTROL | LIBRARY、PLAYBACK、DEFAULT、PREVIEW | 播放/默认/动画控制的返回值、状态与逻辑帧一致;预览单在途;用专用内容验证后恢复此前内容,不启动视觉程序。 | +| ANDROID-TEST-LIBRARY-CACHE | MOBILE-LIBRARY-CACHE、DEVICE-MOBILE-LIBRARY-CACHE | 16×16 先于 64×64、设备修订变化清理两端失效图、重连复用、清空与损坏保护、旧设备回退;真实 BLE 仅只读,不切换显示内容。 | | ANDROID-TEST-WEB | WEB-COEXIST | 网页修改手机可见,手机修改网页可见;连接短提示与常驻名称正确;初次加载不伪造连接事件;昵称 HTML 不执行。 | | ANDROID-TEST-TWO-PHONES | CONNECTION | 提供 APK 给用户装第二台;A 连接 B 不能抢占,A 断开 B 可连接,A 异常退出可释放;真实双方结果分别记录。 | | ANDROID-TEST-PERF | PREVIEW、STATUS | 广播、预览、WiFi 扫描与动画并发,记录实际刷新/截止丢失/CPU/内存,与启用前基线及既有标准比较,不要求新增目测。 | @@ -32,13 +35,11 @@ ## 3. 执行顺序 -1. 测试登记脚本与仓库卫生检查。 -2. 共享逻辑、设备业务、协议、现有本机回归;失败先修复。 -3. 模拟设备的 Android UI 与权限测试。 -4. 主测试机 BLE/控制/网络/网页联调,再做性能和生命周期。 -5. 首次双手机验收由用户安装交付 APK 协助;日常只用主机,连接仲裁/广播/协议变化才重测。 -6. 隔离重建、最终 debug APK 和脱敏证据归档。 -7. 复盘本轮失败与调整:将已验证解法补到排障指南及对应流程,单次证据保留归档;最终反馈说明更新了哪些经验文档、哪些问题仍未解决。 +1. 记录变更和共享影响,按选测规则选择编号、场景、配置;跳过无影响项。 +2. 执行相关宿主逻辑与构建检查;UI 改动按需运行独立模拟器场景。 +3. 只有必要的真机场景才检查登记、ADB/root 和所需板端凭据;不因测试手机不在线阻塞纯逻辑/UI 工作。 +4. 根据选中场景执行定向联调,恢复所有本轮改动;失败不得跳过必要证据。 +5. 只有构建/交付需要才隔离重建、打包。归档实际覆盖及未验证边界,沉淀已验证经验。 当前可以执行的登记单元测试(从仓库根目录): @@ -46,20 +47,31 @@ python -X utf8 -m unittest discover -s "移动端相关内容/安卓app/如何安卓测试/测试设备登记" -p "test_*.py" ``` -工程完成后,本文件必须补上经过验证的 Gradle task 与端到端脚本入口;不能先编造不存在的可执行命令。测试脚本应读取私有登记并在进程内传递目标,避免原始 ADB 输出进入报告。 +Gradle task 与按场景执行命令见 [按影响选测](按影响选测.md)。测试脚本应读取私有登记并在进程内传递目标,避免原始 ADB 输出进入报告。 ### 已提供的真机测试入口 用构建脚本生成 `:androidApp:assembleDebug` 和 `:androidApp:assembleDebugAndroidTest` 后: ```text -python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --adb "<已有ADB路径>" --build "<本轮隔离工程目录>" +python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario connection --adb "<已有ADB路径>" --build "<本轮隔离工程目录>" ``` -脚本先验证 android-test-a 的 USB 登记,从凭据门禁后的目标核桃派读取广播短名,仅扫描该板,安装覆盖两个 debug APK,再执行 `BleIntegrationTest`。不把临时广播身份写入公开报告。此测试只读状态、设置、内容、帧与 WiFi,不播放内容,不切网;断开后再连接核对持久 ID。运行器先验证 root 并为本 App 授予必要权限;所有点击经 root 按实时语义位置执行。权限未稳定生效必须停止等待用户,不使用普通注入或无障碍备用路径。 +脚本先验证 android-test-a 的 USB 登记,从凭据门禁后的目标核桃派读取广播短名,仅扫描该板,安装覆盖两个 debug APK,再执行所选 `BleIntegrationTest` 场景。不把临时广播身份写入公开报告。connection 仅握手、断开再连接核对持久 ID;其它读取、轮询、WiFi 扫描和后台恢复分别显式选择。运行器先验证 root 并为本 App 授予必要权限;所有点击经 root 按实时语义位置执行。权限未稳定生效必须停止等待用户,不使用普通注入或无障碍备用路径。 脚本只有明确 `OK (1 test)` 才返回成功;测试入口存在不代表真实验收通过。结果单独归档。 +### “显示内容”两档图片与缓存 + +先用 `:sharedCore:jvmTest` 检查两轮顺序、修订变化、断线取消和旧设备回退;用独立模拟器的 `content`、`settings` 场景检查预览替换和清空确认。需要验证 Android 文件缓存时选 `cache-storage`,只访问登记手机;需要验证真实板端图片读取和未变化重连时再选 `library-cache`,先运行仓库的板端凭据准备门禁。两种真机场景都使用隔离构建目录中的产品 debug APK 与 androidTest APK: + +```text +python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario cache-storage --adb "<已有ADB路径>" --build "<本轮隔离工程目录>" +python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario library-cache --adb "<已有ADB路径>" --build "<本轮隔离工程目录>" +``` + +合格标准:板端摘要的目录修订能反映条目或顺序变化,16×16 与 64×64 PNG 均可解码,失效小图被清理;App 首轮小图全部尝试后再读完整图,单图失败仍继续;目录未变时复用有效缓存且第二次图片请求为零,目录变化时删去旧修订,跨设备隔离,损坏文件按未命中处理,清空只影响内容缓存。记录首次和重连的图片请求数及 PNG 负载字节数,不将负载当作无线链路总字节数。真实 BLE 测试只读,不选择新显示内容;“当前设备画面”仍走原帧接口。 + ## 4. 证据与恢复 开发部署的真实失败恢复测试使用 `deploy_mobile_dev.py --deploy --test-rollback`,必须先结束手机控制会话。它和普通部署一样先保存可用程序副本,然后在安装文件及 systemd 配置完成后故意返回 97,触发同一 EXIT 回滚路径。脚本比较测试前后被部署程序文件、config.json、wifi_config.json 和持久身份的摘要,并确认真实主服务 active;随后仍需执行 BLE/生命周期恢复检查。此选项会短暂停止服务,但不主动切换测试图案,也不等同正式 OTA 或断电回滚测试。恢复副本保留供排查,不能把故意失败当作普通部署完成。 @@ -71,9 +83,9 @@ python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --ad `ControllerRecoveryTest` 的测试专用 SimulatedPlatform/SimulatedLink 使用同一分片与 RPC 路径,替换加密为显式测试直通实现,覆盖断线清缓存并重连、未完成请求取消、新旧扫描回调隔离。只在 commonTest 编译,生产扫描与 APK 不包含模拟入口;加密正确性由独立跨语言黄金向量测试覆盖。 -默认 BLE 回归额外执行真实 `wifi.scan`,断言返回有界且不含密码,不输出网络名称;提交故意过期的设置修订,要求返回 CONFLICT 并继续 ping 成功。不会实际改配置。 +显式选择 `--scenario wifi-scan` 才执行真实 `wifi.scan`,断言返回有界且不含密码,不输出网络名称;另选 `--scenario conflict` 提交故意过期的设置修订,要求返回 CONFLICT 并继续 ping 成功。不会实际改配置。 -真实网络激活验证可显式加 `--reapply-current-wifi`:先确认保存配置与当前已连网络相同,保留密码并将该原配置立即应用;轮询任务到成功,核对恢复连接及保存字段不变。可能短暂影响板端 WiFi,执行前说明;不替代不同 SSID、替换密码、DHCP/静态地址互换的单独验收。默认不启用,不能拿重新应用原网络当作所有网络场景都通过。 +真实网络激活验证显式选择 `--scenario wifi-reapply`:先确认保存配置与当前已连网络相同,保留密码并将该原配置立即应用;轮询任务到成功,核对恢复连接及保存字段不变。可能短暂影响板端 WiFi,执行前说明;不替代不同 SSID、替换密码、DHCP/静态地址互换的单独验收。默认不启用,不能拿重新应用原网络当作所有网络场景都通过。 `ANDROID-TEST-WIFI` 的双网络专项入口是 `run_wifi_switch_test.py --adb <已有ADB路径> --build <本轮隔离工程目录>`,需要先构建 `:androidApp:assembleDebugAndroidTest`。脚本通过 Windows 当前移动热点接口在进程内读取第二网络参数;App 仪器测试经短时私有文件读取密码,不把密码放在 ADB 参数或报告中。错误密码会令唯一受管网络暂时失联,必须单独加 `--wrong-only` 运行;该分支经 App 提交、BLE 任务失败状态与无密码日志核验,并用短时恢复计时器回到原网络。确认此分支后再加 `--skip-wrong`,依次验证热点 DHCP、静态 IPv4、回到 DHCP、立即恢复原网络和原网络复核。两种模式都先执行字段校验与 App 扫描,并在板端保存受保护的原 NetworkManager 配置;结束时检查恢复副本和定时器的清理结果。失败时若 SSH 因切网中断,等待原网络恢复并核验后再清理,不能把临时断链当成恢复失败或直接删除备份。 @@ -81,42 +93,42 @@ python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --ad `--validate-only` 只执行表单与无效 IPv4 校验,不切网;`--final-check` 只复核原网络的 App、BLE、板端状态及恢复材料清理。所有模式均遵守相同的凭据门禁和手机登记检查。 -`--verify-current-controls` 用当前修订重新提交现有亮度,要求回读不变;若设备已有活动动画,暂停、提交当前暂停位置和原倍速,finally 恢复原暂停状态。最后核对开机默认未改变。它不选择新内容或测试图案,也不代替改变亮度值、选择其他内容/默认内容的完整验收。无活动动画时明确记录该子项不适用。 +`--scenario controls` 用当前修订重新提交现有亮度,要求回读不变;若设备已有活动动画,暂停、提交当前暂停位置和原倍速,finally 恢复原暂停状态。最后核对开机默认未改变。它不选择新内容或测试图案,也不代替改变亮度值、选择其他内容/默认内容的完整验收。无活动动画时明确记录该子项不适用。 -部分厂商在覆盖安装返回成功后异步重置权限;已授权 `--grant-root` 时脚本最多三次授予本 App 蓝牙权限,每次等待 3 秒再复核,未稳定授予就停止。不得以此授权修改其他系统安全开关。 +部分厂商在覆盖安装返回成功后异步重置权限;选中需要 BLE 的真机场景时脚本最多三次授予本 App 蓝牙权限,每次等待 3 秒再复核,未稳定授予就停止。不得以此授权修改其他系统安全开关。 性能观察使用 `sample_mobile_performance.py --seconds 150 --output <新的报告JSON路径>`:仅保存刷新率、帧计数、截止丢失、OE 故障、CPU、内存、连接/动画布尔状态;不保存 SSID、标识、密码或完整状态。可与 BLE 回归同时运行。该采样属于运行观察,蓝牙始终开启时不能宣称已完成启用前后的因果对照实验。 真实服务生命周期检查:主测试手机先主动断开,运行 `python "移动端相关内容/安卓app/如何安卓测试/check_mobile_lifecycle.py" --restart-service`。脚本经过板端凭据门禁,实际 systemctl stop/start 主服务,以临时 sentinel 验证 RuntimeDirectory 在显式停服及启动后均保留;核对 config.json、wifi_config.json、mobile/identity.json 内容摘要不变,只输出布尔结果;最后删除自身 sentinel。停止后无论中间断言如何都尝试启动服务;不能用这项烟雾测试代替完整部署故障回滚、OTA 或物理显示验收。 -可使用同一运行器 `--test activity` 执行 `AppLifecycleTest`:通过 UI Automator 语义节点操作实际 Activity,核验四栏入口、后台超过 30 秒再返回自动重连及主动断开。此测试不截图、不启动摄像头、不改变设备显示内容;与 Controller 直测分开记录。 +可使用同一运行器 `--scenario activity-background` 执行 `AppLifecycleTest`:通过 UI Automator 语义节点操作实际 Activity,核验后台超过 30 秒再返回自动重连及主动断开;四栏菜单另选 navigation。此测试不截图、不启动摄像头、不改变设备显示内容;与 Controller 直测分开记录。 -若 MIUI 拒绝后台启动,测试通过 shell 显式启动本 App;若再拒绝输入注入,不直接修改系统权限。本软件测试默认启用 `--root-input`:根据语义查找到的本 App 控件当前位置发送 root input tap;后台测试仅发送 Home 键。运行器默认启用 root 输入,不提供普通输入备用分支。 +若 MIUI 拒绝后台启动,测试通过 shell 显式启动本 App;若再拒绝输入注入,不直接修改系统权限。登记真机的 Activity 场景默认启用 root 输入:根据语义查找到的本 App 控件当前位置发送 root input tap;后台测试仅发送 Home 键。运行器默认启用 root 输入,不提供普通输入备用分支。 -同时已获得 root 切换手机蓝牙授权时,可再传 `--toggle-bluetooth`:在已连接页面关闭蓝牙,等待连接状态清除、扫描入口恢复,再打开蓝牙并通过 App 扫描和连接原设备;之后继续四栏及后台测试。此参数必须与 `--test activity --root-input` 同用。测试与主机运行器都在 finally 尝试恢复蓝牙,错误不能把手机留在测试关闭状态。 +同时已获得 root 切换手机蓝牙授权时,显式选择 `--scenario bluetooth-toggle`:在已连接页面关闭蓝牙,等待连接状态清除、扫描入口恢复,再打开蓝牙并通过 App 扫描和连接原设备;该场景只验证蓝牙开关恢复,不捆绑四栏及后台测试。测试与主机运行器都在 finally 尝试恢复蓝牙,错误不能把手机留在测试关闭状态。 -`run_ble_test.py` 使用登记手机和凭据门禁指定的开发板;覆盖安装 debug App 与测试 APK。运行器默认启用 `--grant-root`,用于覆盖安装后重新授予此 App 的蓝牙扫描/连接权限。脚本不输出真实手机编号和开发板广播编号。 +`run_ble_test.py` 使用登记手机和凭据门禁指定的开发板;覆盖安装 debug App 与测试 APK。运行器在 BLE 场景中使用既有 root 授权,用于覆盖安装后重新授予此 App 的蓝牙扫描/连接权限。脚本不输出真实手机编号和开发板广播编号。 -`BleIntegrationTest` 先完成真实加密只读会话,读取状态、容量、设置、内容目录、当前帧和不含密码的网络信息;连续 60 轮间隔 2 秒读取状态与帧,主动断开后再次握手并核对持久设备 ID。随后使用生产 Controller 与真实 BLE 验证四栏数据读取、缩略图队列、短暂后台后返回取消释放、后台 30 秒释放、返回前台仅重连上次设备。测试不修改网络或显示内容。会话故障须保留具体阶段与错误,不把模拟测试结果标为实机通过。 +历史完整 BLE 验收的覆盖如下(现已拆分为明确场景,不能单跑一项声称全部通过):`BleIntegrationTest` 先完成真实加密只读会话,读取状态、容量、设置、内容目录、当前帧和不含密码的网络信息;连续 60 轮间隔 2 秒读取状态与帧,主动断开后再次握手并核对持久设备 ID。随后使用生产 Controller 与真实 BLE 验证四栏数据读取、缩略图队列、短暂后台后返回取消释放、后台 30 秒释放、返回前台仅重连上次设备。测试不修改网络或显示内容。会话故障须保留具体阶段与错误,不把模拟测试结果标为实机通过。 `RpcTimeoutTest` 验证内部请求超时关闭传输、返回普通通信异常并拒绝继续使用旧 RPC;这与用户主动取消协程不同,后者不自动重试。 ## 2026-09-26 用户最新 root 测试授权(替代前述按用途重复询问规则) -本软件调试所需各种测试权限可直接通过登记测试手机的 ADB root 获取,无需询问。所有自动测试点击一律使用 root,根据本 App 实时语义控件位置定位;滚动同样使用 root。不得通过无障碍或普通注入绕过 MIUI。测试前验证登记、ADB 和 root;权限不足或特殊情况必须停止聊天等待用户,不继续不完整测试。授权仅限调试本软件所需操作,不扩大到其他应用数据,视觉与物理操作门禁继续有效。 +本软件调试所需各种测试权限可直接通过登记测试手机的 ADB root 获取,无需询问。登记真机的自动测试点击和滚动一律使用 root,根据本 App 实时语义控件位置定位;独立模拟器使用标准 Compose 测试 API。不得通过无障碍或普通注入绕过 MIUI。测试前验证登记、ADB 和 root;权限不足或特殊情况必须停止聊天等待用户,不继续不完整测试。授权仅限调试本软件所需操作,不扩大到其他应用数据,视觉与物理操作门禁继续有效。 ANDROID-TEST-UI 增加四栏、三点菜单、昵称、长按保存设备、WiFi折叠/DHCP/确认;ANDROID-TEST-STATE 增加记忆迁移、忘记、连接中扫描、切换;ANDROID-TEST-WIFI 增加结构化失败文案;ANDROID-TEST-CONTROL 增加设备混合内容顺序。 ### 四栏版专项入口 -- `run_ble_test.py` 默认验证 root 并授予本 App 蓝牙权限;Activity 所有输入均为 root。`--test activity` 覆盖四栏、菜单、真实重命名和重连核验、恢复原昵称、忘记和再次手动连接。BLE 测试将设备网页顺序摘要与完整 BLE 分页列表摘要比较,不输出条目 ID。 +- `run_ble_test.py` 执行选中真机场景时验证 root;仅 BLE 场景授予本 App 蓝牙权限;Activity 所有输入均为 root。navigation、activity-background、rename、remember 分别覆盖导航、后台恢复、昵称恢复和忘记重连,不相互捆绑。library-order 场景将设备网页顺序摘要与完整 BLE 分页列表摘要比较,不输出条目 ID。 - `run_wifi_switch_test.py --validate-only` 不需要电脑热点,验证空 SSID、确认弹窗、静态 IP 校验;`--wrong-only` 使用当前受管网络及故意错误测试密码,先备份并建立定时恢复,最后核验原网络和清理。只有跨 SSID 的 DHCP/静态往返才需要电脑热点,按前述 `--skip-wrong` 流程运行。 -- `run_ui_layout_test.py --adb <已有ADB路径> --build <本轮隔离工程目录>` 在登记手机上临时测试约 340dp 宽度、1.3 倍字体和横屏。输入通过 root,语义窗口只用于定位和检查,不执行无障碍动作;电脑端 finally 恢复密度、字体和方向,字体/方向回读核验;若额外改变 USB 保持唤醒等设置,也必须保存、恢复并核验。权限不足停止;安全锁屏不能正常解锁时等待用户,不绕过密码。 +- `run_ui_layout_test.py --scenario layout --adb <已有ADB路径> --build <本轮隔离工程目录>` 在登记手机上临时测试约 340dp 宽度、1.3 倍字体和横屏。输入通过 root,语义窗口只用于定位和检查,不执行无障碍动作;电脑端 finally 恢复密度、字体和方向,字体/方向回读核验;若额外改变 USB 保持唤醒等设置,也必须保存、恢复并核验。权限不足停止;安全锁屏不能正常解锁时等待用户,不绕过密码。 - root 辅助脚本保持 LF 换行;文本通过 App 内临时剪贴板和 root 按键输入,结束清理,不把密码放在命令参数中。 排障指南按症状记录 root 命令解析、LF 换行、布局进程重建、语义定位、表单输入、安装权限、BLE 阶段释放及 WiFi 恢复。遇到这些情况先查 [对应解法](常见问题与排障.md),不得重复试用已知不可靠的输入或转储方法。 ### 0.2.1 顶栏、连接条目与居中验收 -ANDROID-TEST-STATE:共享逻辑验证自动/手动连接后关联、重新扫描保留、旧回调隔离、切换和失败/异常清理。ANDROID-TEST-UI:运行 run_ui_layout_test.py,在每组字体/方向下连接真实目标,按稳定语义资源标签检查连接条目断开、再次扫描保留、实际断开和再连;两页预览图片与标题中心距卡片中心不超过3物理像素,刷新位于右侧且无重叠。所有输入通过 root;测试末尾主动断开,运行器恢复布局设置。 +ANDROID-TEST-STATE:共享逻辑验证自动/手动连接后关联、重新扫描保留、旧回调隔离、切换和失败/异常清理。ANDROID-TEST-UI:仅需厂商真机布局补证时运行 run_ui_layout_test.py --scenario layout,普通布局改动优先独立模拟器。真机专项在每组字体/方向下连接真实目标,按稳定语义资源标签检查连接条目断开、再次扫描保留、实际断开和再连;两页预览图片与标题中心距卡片中心不超过3物理像素,刷新位于右侧且无重叠。所有输入通过 root;测试末尾主动断开,运行器恢复布局设置。 顶栏保留完整文字语义,菜单位于固定区域;长文本循环参数由共享实现及编译核验,语义边界不证明文字实际运动,不记为目视验收。未取得本次视觉指示时不截图、不启动摄像头。纯手机界面调整的实屏视觉检查不适用,不需切网、显示内容切换或驱动测试。 diff --git a/移动端相关内容/安卓app/如何安卓测试/device_test_runner.py b/移动端相关内容/安卓app/如何安卓测试/device_test_runner.py new file mode 100644 index 0000000..8af1d23 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/device_test_runner.py @@ -0,0 +1,206 @@ +"""Run registered BLE tests; WiFi reapplication requires its explicit option.""" +import argparse +import json +from pathlib import Path +import subprocess +import sys +import time + +sys.path.insert(0, str(Path(__file__).resolve().parent / '测试设备登记')) +from prepare_test_device import load_registration, check_connected + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument('--scenario', required=True) + parser.add_argument('--adb', required=True) + parser.add_argument('--build', type=Path, required=True) + parser.add_argument('--poll-cycles', type=int, default=60) + parser.add_argument('--test', choices=('ble', 'activity', 'layout'), default='ble') + parser.add_argument('--root-input', action='store_true', help='Compatibility flag; activity tests always use authorized root input') + parser.add_argument('--reapply-current-wifi', action='store_true', help='Explicitly reapply the already active saved WiFi profile over BLE; may briefly reconnect WiFi') + parser.add_argument('--verify-current-controls', action='store_true', help='Reapply current brightness; briefly pause active animation and restore without selecting new content') + parser.add_argument('--toggle-bluetooth', action='store_true', help='Requires explicit root Bluetooth-switch authorization plus activity root input') + parser.add_argument('--grant-root', action='store_true', help='Compatibility flag; runner always verifies and grants authorized App permissions') + args = parser.parse_args(argv) + from run_ble_test import SCENARIOS + from run_emulator_ui_test import passed + if args.scenario not in SCENARIOS or args.test != SCENARIOS[args.scenario][0]: + parser.error('Scenario and instrumentation family do not match') + if args.reapply_current_wifi != (args.scenario == 'wifi-reapply') or args.verify_current_controls != (args.scenario == 'controls') or args.toggle_bluetooth != (args.scenario == 'bluetooth-toggle'): + parser.error('Mutation flags must match the explicit scenario') + if args.poll_cycles and args.scenario != 'polling': parser.error('Polling requires polling scenario') + args.grant_root = True + if args.test in ("activity", "layout"): args.root_input = True + if not 0 <= args.poll_cycles <= 60: + parser.error('poll-cycles must be between 0 and 60') + if args.root_input and args.test == 'ble': + parser.error('root-input only applies to activity tests') + if args.reapply_current_wifi and args.test != 'ble': + parser.error('reapply-current-wifi only applies to BLE tests') + if args.verify_current_controls and args.test != 'ble': + parser.error('verify-current-controls only applies to BLE tests') + if args.toggle_bluetooth and not (args.test == 'activity' and args.root_input): + parser.error('toggle-bluetooth requires activity and root-input') + device = next(d for d in load_registration() if d['alias'] == 'android-test-a') + check_connected(args.adb, device) + root_check = subprocess.run([args.adb, '-s', device['serial'], 'shell', 'su', '-c', 'id -u'], capture_output=True, text=True, timeout=15) + if root_check.returncode or root_check.stdout.strip() != '0': + raise RuntimeError('测试手机 root 权限不可用;停止测试,等待用户处理,不使用备用注入方式') + board_id = board_name = library_hash = "" + if SCENARIOS[args.scenario][1]: + from remote_support import connect, execute + client, fields = connect() + try: + code, _, _ = execute(client, "command -v python3 >/dev/null && python3 --version", password=fields['密码']) + if code: raise RuntimeError('Remote Python unavailable') + code, output, error = execute(client, "python3 -c \"import json; p=json.load(open('/var/lib/matrix-screen-controller/mobile/identity.json')); print(json.dumps({'device_id':p['device_id']}))\"", password=fields['密码']) + if code: + raise RuntimeError('Cannot read authorized board identity') + board_id = json.loads(output)['device_id'] + from uuid import UUID + UUID(board_id) + board_name = 'QMS-' + board_id.replace('-', '')[:8] + library_hash = "" + if args.scenario == "library-order": + code, output, _ = execute(client, "python3 -c \"import urllib.request,json,hashlib; d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/library/order',timeout=5)); print(hashlib.sha256('\\n'.join(i['type']+'|'+i['id'] for i in d['items']).encode()).hexdigest())\"", password=fields['密码']) + library_hash = output.strip() + if code or len(library_hash) != 64 or any(c not in '0123456789abcdef' for c in library_hash): + raise RuntimeError('Cannot read device library order digest') + if not board_name.startswith('QMS-') or len(board_name) != 12: + raise RuntimeError('Invalid board identity') + finally: + client.close() + def adb(*command, timeout=120, quiet=False): + try: + result = subprocess.run([args.adb, '-s', device['serial'], *command], capture_output=True, + text=True, encoding='utf-8', errors='replace', timeout=timeout) + except subprocess.TimeoutExpired: + # ADB's host timeout does not stop Android instrumentation itself. + if command[:3] == ('shell', 'am', 'instrument'): + subprocess.run([args.adb, '-s', device['serial'], 'shell', 'am', 'force-stop', + 'org.qimiaoscreen.controller'], capture_output=True, timeout=15) + raise RuntimeError('ADB operation timed out; device identifiers omitted') from None + output = result.stdout + result.stderr + for secret, alias in ((device['serial'], '[test-phone]'), (board_name, '[test-board]'), (board_id, '[test-board-id]')): + if secret: output = output.replace(secret, alias) + if not quiet: + print(output, flush=True) + if result.returncode: + raise RuntimeError('ADB command failed') + return output + api = int(adb('shell', 'getprop', 'ro.build.version.sdk', quiet=True).strip()) + permissions = ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT') if api >= 31 else ('ACCESS_FINE_LOCATION',) + permission_original = {} + if args.scenario == 'navigation': + existing = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True) + permission_original = {p: 'android.permission.' + p + ': granted=true' in existing for p in permissions} + layout_original = None + layout_density = None + try: + for relative in ('androidApp/build/outputs/apk/debug/androidApp-debug.apk', + 'androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk'): + apk = args.build / relative + if not apk.is_file(): + raise RuntimeError('APK missing') + if 'Success' not in adb('install', '-r', str(apk)): + raise RuntimeError('APK installation did not succeed') + adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller') + if args.grant_root and args.scenario != 'navigation': + # Some vendor package managers asynchronously reset permissions after + # install succeeds. Verify after a settling window, not just at grant. + for attempt in range(3): + for permission in permissions: + adb('shell', 'su', '-c', 'pm grant org.qimiaoscreen.controller android.permission.' + permission) + time.sleep(3) + verified = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True) + if all('android.permission.' + p + ': granted=true' in verified for p in permissions): + break + else: + raise RuntimeError('Bluetooth permission did not remain granted after installation') + if args.scenario == 'navigation': + for permission in permissions: + adb('shell', 'su', '-c', 'pm revoke org.qimiaoscreen.controller android.permission.' + permission, quiet=True) + current = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True) + if any('android.permission.' + p + ': granted=true' in current for p in permissions): + raise RuntimeError('Navigation must not retain permission to auto-connect') + if args.root_input: + adb('push', str(Path(__file__).with_name('authorized_app_input.sh')), '/data/local/tmp/qms-authorized-input.sh', quiet=True) + if args.scenario == "connection-entry": + adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True) + if args.scenario == "layout": + adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True) + def root(command): + return adb("shell", "su", "-c", command, quiet=True).strip() + import re + layout_original = {key: root("settings get system " + key) for key in ("font_scale", "accelerometer_rotation", "user_rotation")} + layout_density = re.search(r"Override density: (\d+)", root("wm density")) + layout_density = layout_density[1] if layout_density else "reset" + dimensions = re.findall(r"(\d+)x(\d+)", root("wm size"))[-1] + narrow = min(map(int, dimensions)) * 160 // 340 + for font, rotation in (("1.0", "0"), ("1.3", "0"), ("1.3", "1")) if args.scenario == "layout" else ((None, None),): + if layout_original: + root("am force-stop org.qimiaoscreen.controller") + root("wm density " + str(narrow)) + root("settings put system font_scale " + font) + root("settings put system accelerometer_rotation 0") + root("settings put system user_rotation " + rotation) + root("input keyevent KEYCODE_WAKEUP") + root("wm dismiss-keyguard") + root("am start -W -n org.qimiaoscreen.controller/.MainActivity") + board_args = ['-e', 'device_name', board_name] if board_name else [] + if library_hash: board_args += ['-e', 'library_order_hash', library_hash] + output = adb('shell', 'am', 'instrument', '-w', '-r', *board_args, + '-e', 'scenario', args.scenario, + '-e', 'poll_cycles', str(args.poll_cycles), + '-e', 'root_input', str(args.root_input).lower(), + '-e', 'reapply_current_wifi', str(args.reapply_current_wifi).lower(), + '-e', 'verify_current_controls', str(args.verify_current_controls).lower(), + '-e', 'toggle_bluetooth', str(args.toggle_bluetooth).lower(), + '-e', 'class', 'org.qimiaoscreen.controller.' + ('LibraryCacheTest' if args.scenario == 'cache-storage' else + 'LibraryBleCacheTest' if args.scenario == 'library-cache' else + {'ble': 'BleIntegrationTest', 'activity': 'AppLifecycleTest', 'layout': 'LayoutTest'}[args.test]), + 'org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner', timeout=360) + if not passed(output): + raise RuntimeError("Instrumentation did not pass") + if layout_original: + print("Layout passed: font=" + font + ", rotation=" + rotation) + finally: + errors = [] + def cleanup(label, action): + try: action() + except Exception: errors.append(label) + cleanup('stop-test', lambda: adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller', quiet=True)) + if layout_original and layout_density is not None: + cleanup('stop', lambda: root('am force-stop org.qimiaoscreen.controller')) + cleanup('density', lambda: root('wm density ' + layout_density)) + def verify_density(): + value = re.search(r'Override density: (\d+)', root('wm density')) + if (value[1] if value else 'reset') != layout_density: + raise RuntimeError('density restore failed') + cleanup('density verification', verify_density) + for key, value in layout_original.items(): + def restore(key=key, value=value): + root('settings delete system ' + key if value == 'null' else 'settings put system ' + key + ' ' + value) + if root('settings get system ' + key) != value: raise RuntimeError('settings restore failed') + cleanup(key, restore) + for permission, granted in permission_original.items(): + def restore_permission(permission=permission, granted=granted): + adb('shell', 'su', '-c', 'pm ' + ('grant' if granted else 'revoke') + ' org.qimiaoscreen.controller android.permission.' + permission, quiet=True) + actual = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True) + if ('android.permission.' + permission + ': granted=true' in actual) != granted: + raise RuntimeError('Permission restore failed') + cleanup('permission ' + permission, restore_permission) + if args.toggle_bluetooth: + cleanup('bluetooth', lambda: adb('shell', 'su', '-c', 'cmd bluetooth_manager enable', quiet=True)) + for helper in ('qms-authorized-root.sh', 'qms-authorized-input.sh'): + cleanup(helper, lambda helper=helper: adb('shell', 'rm', '-f', '/data/local/tmp/' + helper, quiet=True)) + if errors: + raise RuntimeError('Cleanup failed: ' + ', '.join(errors)) + print('Cleanup verified', flush=True) + if not passed(output): + raise RuntimeError('BLE instrumentation did not pass') + + +if __name__ == '__main__': + main() diff --git a/移动端相关内容/安卓app/如何安卓测试/run_ble_test.py b/移动端相关内容/安卓app/如何安卓测试/run_ble_test.py index 1a60148..c4bba2b 100644 --- a/移动端相关内容/安卓app/如何安卓测试/run_ble_test.py +++ b/移动端相关内容/安卓app/如何安卓测试/run_ble_test.py @@ -1,154 +1,68 @@ -"""Run registered BLE tests; WiFi reapplication requires its explicit option.""" +"""Explicit, side-effect-free planning followed by selected registered-device scenarios.""" import argparse import json from pathlib import Path -import subprocess -import sys -import time -sys.path.insert(0, str(Path(__file__).resolve().parent / '测试设备登记')) -from prepare_test_device import load_registration, check_connected -from remote_support import connect, execute +# name: (instrumentation family, board required, side effect) +SCENARIOS = { + 'connection': ('ble', True, 'scan, handshake, disconnect and reconnect'), + 'data': ('ble', True, 'read status/settings/storage/library/frame/wifi'), + 'library-order': ('ble', True, 'read BLE pages and board order digest'), + 'library-cache': ('ble', True, 'read two image tiers, then verify unchanged reconnect reuses app cache'), + 'cache-storage': ('ble', False, 'verify private image cache isolation, corruption handling and clear'), + 'conflict': ('ble', True, 'submit deliberately stale revision; expect rejection'), + 'polling': ('ble', True, 'up to 60 status/frame polls'), + 'controller-background': ('ble', True, 'controller background release and reconnect'), + 'wifi-scan': ('ble', True, 'scan board WiFi without switching'), + 'controls': ('ble', True, 'write current values; temporarily pause active animation and restore'), + 'wifi-reapply': ('ble', True, 'reapply current network; brief network interruption'), + 'navigation': ('activity', False, 'navigate disconnected App and menus; no board access'), + 'activity-background': ('activity', True, 'Home, background release and reconnect'), + 'rename': ('activity', True, 'rename device then restore name'), + 'remember': ('activity', True, 'forget and reconnect test device'), + 'bluetooth-toggle': ('activity', True, 'disable and restore phone Bluetooth'), + 'connection-entry': ('layout', True, 'connected entry, rescan and reconnect; layout restore'), + 'layout': ('layout', True, 'three layout configurations and frame bounds; layout restore'), +} +COMMON = ('connection', 'data', 'library-order', 'navigation') -def main(): - parser = argparse.ArgumentParser() - parser.add_argument('--adb', required=True) - parser.add_argument('--build', type=Path, required=True) - parser.add_argument('--poll-cycles', type=int, default=60) - parser.add_argument('--test', choices=('ble', 'activity', 'layout'), default='ble') - parser.add_argument('--root-input', action='store_true', help='Compatibility flag; activity tests always use authorized root input') - parser.add_argument('--reapply-current-wifi', action='store_true', help='Explicitly reapply the already active saved WiFi profile over BLE; may briefly reconnect WiFi') - parser.add_argument('--verify-current-controls', action='store_true', help='Reapply current brightness; briefly pause active animation and restore without selecting new content') - parser.add_argument('--toggle-bluetooth', action='store_true', help='Requires explicit root Bluetooth-switch authorization plus activity root input') - parser.add_argument('--grant-root', action='store_true', help='Compatibility flag; runner always verifies and grants authorized App permissions') - args = parser.parse_args() - args.grant_root = True - if args.test in ("activity", "layout"): args.root_input = True - if not 0 <= args.poll_cycles <= 60: - parser.error('poll-cycles must be between 0 and 60') - if args.root_input and args.test == 'ble': - parser.error('root-input only applies to activity tests') - if args.reapply_current_wifi and args.test != 'ble': - parser.error('reapply-current-wifi only applies to BLE tests') - if args.verify_current_controls and args.test != 'ble': - parser.error('verify-current-controls only applies to BLE tests') - if args.toggle_bluetooth and not (args.test == 'activity' and args.root_input): - parser.error('toggle-bluetooth requires activity and root-input') - device = next(d for d in load_registration() if d['alias'] == 'android-test-a') - check_connected(args.adb, device) - root_check = subprocess.run([args.adb, '-s', device['serial'], 'shell', 'su', '-c', 'id -u'], capture_output=True, text=True, timeout=15) - if root_check.returncode or root_check.stdout.strip() != '0': - raise RuntimeError('测试手机 root 权限不可用;停止测试,等待用户处理,不使用备用注入方式') - client, fields = connect() - try: - code, output, error = execute(client, "python3 -c \"import json; p=json.load(open('/var/lib/matrix-screen-controller/mobile/identity.json')); print(json.dumps({'device_id':p['device_id']}))\"", password=fields['密码']) - if code: - raise RuntimeError('Cannot read authorized board identity') - board_id = json.loads(output)['device_id'] - from uuid import UUID - UUID(board_id) - board_name = 'QMS-' + board_id.replace('-', '')[:8] - code, output, _ = execute(client, "python3 -c \"import urllib.request,json,hashlib; d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/library/order',timeout=5)); print(hashlib.sha256('\\n'.join(i['type']+'|'+i['id'] for i in d['items']).encode()).hexdigest())\"", password=fields['密码']) - library_hash = output.strip() - if code or len(library_hash) != 64 or any(c not in '0123456789abcdef' for c in library_hash): - raise RuntimeError('Cannot read device library order digest') - if not board_name.startswith('QMS-') or len(board_name) != 12: - raise RuntimeError('Invalid board identity') - finally: - client.close() - def adb(*command, timeout=120, quiet=False): - try: - result = subprocess.run([args.adb, '-s', device['serial'], *command], capture_output=True, - text=True, encoding='utf-8', errors='replace', timeout=timeout) - except subprocess.TimeoutExpired: - # ADB's host timeout does not stop Android instrumentation itself. - if command[:3] == ('shell', 'am', 'instrument'): - subprocess.run([args.adb, '-s', device['serial'], 'shell', 'am', 'force-stop', - 'org.qimiaoscreen.controller'], capture_output=True, timeout=15) - raise RuntimeError('ADB operation timed out; device identifiers omitted') from None - output = (result.stdout + result.stderr).replace(device['serial'], '[test-phone]').replace(board_name, '[test-board]').replace(board_id, '[test-board-id]') - if not quiet: - print(output, flush=True) - if result.returncode: - raise RuntimeError('ADB command failed') - return output - for relative in ('androidApp/build/outputs/apk/debug/androidApp-debug.apk', - 'androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk'): - apk = args.build / relative - if not apk.is_file(): - raise RuntimeError('APK missing') - adb('install', '-r', str(apk)) - adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller') - if args.grant_root: - # Some vendor package managers asynchronously reset permissions after - # install succeeds. Verify after a settling window, not just at grant. - for attempt in range(3): - for permission in ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT'): - adb('shell', 'su', '-c', 'pm grant org.qimiaoscreen.controller android.permission.' + permission) - time.sleep(3) - verified = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True) - if all('android.permission.' + p + ': granted=true' in verified for p in ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT')): - break - else: - raise RuntimeError('Bluetooth permission did not remain granted after installation') - if args.root_input: - adb('push', str(Path(__file__).with_name('authorized_app_input.sh')), '/data/local/tmp/qms-authorized-input.sh', quiet=True) - layout_original = None - if args.test == "layout": - adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True) - def root(command): - return adb("shell", "su", "-c", command, quiet=True).strip() - import re - layout_original = {key: root("settings get system " + key) for key in ("font_scale", "accelerometer_rotation", "user_rotation")} - layout_density = re.search(r"Override density: (\d+)", root("wm density")) - layout_density = layout_density[1] if layout_density else "reset" - dimensions = re.findall(r"(\d+)x(\d+)", root("wm size"))[-1] - narrow = min(map(int, dimensions)) * 160 // 340 - try: - for font, rotation in (("1.0", "0"), ("1.3", "0"), ("1.3", "1")) if args.test == "layout" else ((None, None),): - if layout_original: - root("am force-stop org.qimiaoscreen.controller") - root("wm density " + str(narrow)) - root("settings put system font_scale " + font) - root("settings put system accelerometer_rotation 0") - root("settings put system user_rotation " + rotation) - root("input keyevent KEYCODE_WAKEUP") - root("wm dismiss-keyguard") - root("am start -W -n org.qimiaoscreen.controller/.MainActivity") - output = adb('shell', 'am', 'instrument', '-w', '-r', '-e', 'device_name', board_name, - '-e', 'poll_cycles', str(args.poll_cycles), - '-e', 'library_order_hash', library_hash, - '-e', 'root_input', str(args.root_input).lower(), - '-e', 'reapply_current_wifi', str(args.reapply_current_wifi).lower(), - '-e', 'verify_current_controls', str(args.verify_current_controls).lower(), - '-e', 'toggle_bluetooth', str(args.toggle_bluetooth).lower(), - '-e', 'class', 'org.qimiaoscreen.controller.' + {'ble': 'BleIntegrationTest', 'activity': 'AppLifecycleTest', 'layout': 'LayoutTest'}[args.test], - 'org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner', timeout=360) - if "OK (1 test)" not in output: - raise RuntimeError("Instrumentation did not pass") - if layout_original: - print("Layout passed: font=" + font + ", rotation=" + rotation) - finally: - if layout_original: - root("am force-stop org.qimiaoscreen.controller") - root("wm density " + layout_density) - restored_density = re.search(r"Override density: (\d+)", root("wm density")) - restored_density = restored_density[1] if restored_density else "reset" - if restored_density != layout_density: raise RuntimeError("Layout density restore failed") - for key, value in layout_original.items(): - root("settings delete system " + key if value == "null" else "settings put system " + key + " " + value) - if root("settings get system " + key) != value: raise RuntimeError("Layout settings restore failed") - print("Layout settings restored and verified", flush=True) - if args.test == "layout": - adb("shell", "rm", "-f", "/data/local/tmp/qms-authorized-root.sh", quiet=True) - if args.toggle_bluetooth: - adb('shell', 'su', '-c', 'cmd bluetooth_manager enable', quiet=True) - if args.root_input: - adb('shell', 'rm', '-f', '/data/local/tmp/qms-authorized-input.sh', quiet=True) - if 'OK (1 test)' not in output: - raise RuntimeError('BLE instrumentation did not pass') +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--list-scenarios', action='store_true') + p.add_argument('--dry-run', action='store_true') + choose = p.add_mutually_exclusive_group() + choose.add_argument('--scenario', action='append', choices=SCENARIOS) + choose.add_argument('--full-common', action='store_true', help='Explicit common regression; excludes mutation, long polling and recovery tests') + p.add_argument('--adb') + p.add_argument('--build', type=Path) + p.add_argument('--poll-cycles', type=int, default=60) + args = p.parse_args(argv) + if args.list_scenarios: + for name, (family, board, effect) in SCENARIOS.items(): + print(f'{name}: family={family}; board={board}; {effect}') + return 0 + selected = list(COMMON) if args.full_common else list(dict.fromkeys(args.scenario or [])) + if not selected: + p.print_help(); return 2 + if not 1 <= args.poll_cycles <= 60: p.error('poll-cycles must be 1..60') + plan = [{'scenario': n, 'phone': 'registered root phone', 'board': SCENARIOS[n][1], 'effect': SCENARIOS[n][2]} for n in selected] + print(json.dumps({'selected': plan, 'unselected': [n for n in SCENARIOS if n not in selected]}, ensure_ascii=False), flush=True) + if args.dry_run: return 0 + if not args.adb or not args.build: p.error('--adb and --build required for execution') + from device_test_runner import main as execute + for name in selected: + flags = ['--adb', args.adb, '--build', str(args.build), '--scenario', name, + '--test', SCENARIOS[name][0], '--poll-cycles', str(args.poll_cycles if name == 'polling' else 0)] + flags += {'controls': ['--verify-current-controls'], 'wifi-reapply': ['--reapply-current-wifi'], + 'bluetooth-toggle': ['--toggle-bluetooth']}.get(name, []) + try: execute(flags) + except Exception: + print(json.dumps({'scenario': name, 'result': 'failed', 'remaining': 'not run'}), flush=True) + raise + print(json.dumps({'scenario': name, 'result': 'passed'}), flush=True) + return 0 if __name__ == '__main__': - main() + raise SystemExit(main()) diff --git a/移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py b/移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py new file mode 100644 index 0000000..6fee5d2 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py @@ -0,0 +1,152 @@ +"""Run selected isolated Compose UI tests on an explicitly named Android Studio AVD.""" +import argparse +import json +from pathlib import Path +import re +import subprocess +import time + +SCENARIOS = ('connection', 'status', 'content', 'settings', 'navigation', 'layout') +CONFIGS = {'baseline': (None, '1.0', '0'), 'narrow': (340, '1.0', '0'), + 'large-font': (340, '1.3', '0'), 'landscape': (None, '1.3', '1')} +PACKAGE = 'org.qimiaoscreen.uitest' + + +def passed(output): + return bool(re.search(r'^OK \(1 test\)\s*$', output, re.M)) and not any( + marker in output for marker in ('FAILURES!!!', 'INSTRUMENTATION_FAILED', 'Process crashed')) + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--list-scenarios', action='store_true') + p.add_argument('--dry-run', action='store_true') + choice = p.add_mutually_exclusive_group() + choice.add_argument('--scenario', action='append', choices=SCENARIOS) + choice.add_argument('--all-ui', action='store_true') + p.add_argument('--configuration', action='append', choices=CONFIGS) + p.add_argument('--avd') + p.add_argument('--adb') + p.add_argument('--emulator') + p.add_argument('--sysdir', type=Path, help='Optional system image directory for a separately stored SDK image') + p.add_argument('--port', type=int, default=5580) + p.add_argument('--build', type=Path) + p.add_argument('--report', type=Path) + args = p.parse_args(argv) + if args.list_scenarios: + print(json.dumps({'scenarios': SCENARIOS, 'configurations': list(CONFIGS)})); return 0 + selected = list(SCENARIOS) if args.all_ui else list(dict.fromkeys(args.scenario or [])) + if not selected: p.print_help(); return 2 + configs = list(dict.fromkeys(args.configuration or ['baseline'])) + plan = [{'scenario': s, 'configuration': c, 'result': 'not run'} for c in configs for s in selected] + print(json.dumps({'selected': plan, 'physical_phone': False, 'board': False, 'root': False}), flush=True) + if args.dry_run: return 0 + if not args.adb or not args.avd or not args.build: p.error('--adb, --avd and --build required') + if not re.fullmatch(r'[A-Za-z0-9_.-]+', args.avd): p.error('Invalid AVD name') + if args.port % 2 or not 5554 <= args.port <= 5682: p.error('Expected an even emulator console port') + apk = args.build / 'uiTest/build/outputs/apk/debug/uiTest-debug.apk' + if not apk.is_file(): p.error('Build :uiTest:assembleDebug first') + if args.report and args.report.exists(): p.error('Report already exists; choose a new path') + serial = 'emulator-' + str(args.port) + started = False + verified_target = False + original = {} + cleanup_errors = [] + process = None + def adb(*command, timeout=30): + try: + result = subprocess.run([args.adb, '-s', serial, *command], capture_output=True, + text=True, encoding='utf-8', errors='replace', timeout=timeout) + except subprocess.TimeoutExpired: + raise RuntimeError('Emulator operation timed out') from None + if result.returncode: raise RuntimeError('Emulator operation failed') + return result.stdout + result.stderr + def cleanup(name, action): + try: action() + except Exception: cleanup_errors.append(name) + def restore_setting(key, value): + adb('shell', 'settings', 'delete', 'system', key) if value == 'null' else adb('shell', 'settings', 'put', 'system', key, value) + if adb('shell', 'settings', 'get', 'system', key).strip() != value: raise RuntimeError('Restore failed') + failure = None + try: + # An occupied port must not cause selection of another device. + state = subprocess.run([args.adb, '-s', serial, 'get-state'], capture_output=True, timeout=15) + if state.returncode: + if not args.emulator: raise RuntimeError('AVD is not running; supply --emulator to start it') + available = subprocess.run([args.emulator, '-list-avds'], capture_output=True, text=True, timeout=20) + if args.avd not in available.stdout.splitlines(): raise RuntimeError('Named AVD does not exist; create it in Android Studio Device Manager') + command = [args.emulator, '-avd', args.avd, '-port', str(args.port), '-no-window', '-no-audio', '-no-snapshot', '-no-boot-anim'] + if args.sysdir: command += ['-sysdir', str(args.sysdir)] + process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + started = True + deadline = time.monotonic() + 180 + while True: + if process is not None and process.poll() is not None: raise RuntimeError('Emulator exited before boot') + try: + if adb('shell', 'getprop', 'sys.boot_completed').strip() == '1': break + except RuntimeError: pass + if time.monotonic() >= deadline: raise RuntimeError('AVD boot timeout') + time.sleep(2) + if adb('shell', 'getprop', 'ro.kernel.qemu').strip() != '1': raise RuntimeError('Refusing a physical device') + if adb('emu', 'avd', 'name').splitlines()[0].strip() != args.avd: raise RuntimeError('AVD name does not match requested target') + verified_target = True + original = {k: adb('shell', 'settings', 'get', 'system', k).strip() + for k in ('font_scale', 'accelerometer_rotation', 'user_rotation')} + density = re.search(r'Override density: (\d+)', adb('shell', 'wm', 'density')) + original['density'] = density[1] if density else 'reset' + dims = re.findall(r'(\d+)x(\d+)', adb('shell', 'wm', 'size'))[-1] + result = adb('install', '-r', str(apk), timeout=120) + if 'Success' not in result: raise RuntimeError('Test APK installation failed') + for item in plan: + name, config = item['scenario'], item['configuration'] + width, font, rotation = CONFIGS[config] + adb('shell', 'am', 'force-stop', PACKAGE) + adb('shell', 'wm', 'density', str(min(map(int, dims)) * 160 // width) if width else original['density']) + for key, value in [('font_scale', font), ('accelerometer_rotation', '0'), ('user_rotation', rotation)]: + adb('shell', 'settings', 'put', 'system', key, value) + try: + output = adb('shell', 'am', 'instrument', '-w', '-r', '-e', 'class', + PACKAGE + '.IsolatedUiTest#' + name, + PACKAGE + '/androidx.test.runner.AndroidJUnitRunner', timeout=120) + if not passed(output): + # Isolated fixtures contain no private data. Keep assertion diagnostics for repair. + item["diagnostic"] = output[:2500] + print(output[:2500] + ("\n[remaining diagnostics omitted]" if len(output) > 2500 else ""), flush=True) + raise RuntimeError('Selected Compose scenario failed') + item['result'] = 'passed' + except Exception: + item['result'] = 'failed'; raise + print(json.dumps(item), flush=True) + except Exception as e: + failure = str(e) + finally: + if original: + cleanup('stop-test', lambda: adb('shell', 'am', 'force-stop', PACKAGE)) + for key, value in original.items(): + if key != 'density': cleanup(key, lambda k=key, v=value: restore_setting(k, v)) + def density_restore(): + adb('shell', 'wm', 'density', original['density']) + current = re.search(r'Override density: (\d+)', adb('shell', 'wm', 'density')) + if (current[1] if current else 'reset') != original['density']: raise RuntimeError('Density restore failed') + cleanup('density', density_restore) + if started and process is not None and process.poll() is None: + if verified_target: + cleanup('stop-owned-emulator', lambda: adb('emu', 'kill')) + else: + process.terminate() + if process is not None: + try: process.wait(timeout=20) + except subprocess.TimeoutExpired: + process.terminate(); cleanup_errors.append('emulator-exit') + report = {'tests': plan, 'failure': failure, 'cleanup_errors': cleanup_errors, + 'not_selected': [s for s in SCENARIOS if s not in selected]} + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + with args.report.open('x', encoding='utf-8') as f: json.dump(report, f, ensure_ascii=False, indent=2) + print(json.dumps(report, ensure_ascii=False), flush=True) + return 1 if failure or cleanup_errors else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/移动端相关内容/安卓app/如何安卓测试/run_ui_layout_test.py b/移动端相关内容/安卓app/如何安卓测试/run_ui_layout_test.py index d291445..2afd4f0 100644 --- a/移动端相关内容/安卓app/如何安卓测试/run_ui_layout_test.py +++ b/移动端相关内容/安卓app/如何安卓测试/run_ui_layout_test.py @@ -1,18 +1,5 @@ -"""Use the persistent Android semantic test with root input and restored settings.""" -from pathlib import Path -import argparse -import subprocess +"""Compatibility entry; explicitly select layout or connection-entry.""" import sys - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument('--adb', required=True) - parser.add_argument('--build', type=Path, required=True) - args = parser.parse_args() - return subprocess.run([sys.executable, str(Path(__file__).with_name('run_ble_test.py')), - '--adb', args.adb, '--build', str(args.build), '--test', 'layout']).returncode - - +from run_ble_test import main if __name__ == '__main__': - raise SystemExit(main()) + raise SystemExit(main(sys.argv[1:])) diff --git a/移动端相关内容/安卓app/如何安卓测试/run_wifi_switch_test.py b/移动端相关内容/安卓app/如何安卓测试/run_wifi_switch_test.py index b6a559d..2766d44 100644 --- a/移动端相关内容/安卓app/如何安卓测试/run_wifi_switch_test.py +++ b/移动端相关内容/安卓app/如何安卓测试/run_wifi_switch_test.py @@ -18,7 +18,7 @@ import subprocess import sys import time -from remote_support import connect, execute + sys.path.insert(0, str(Path(__file__).resolve().parent / "测试设备登记")) from prepare_test_device import check_connected, load_registration @@ -259,8 +259,10 @@ def wifi_snapshot(client, fields: dict) -> dict: def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("--adb", type=Path, required=True) - parser.add_argument("--build", type=Path, required=True) + parser.add_argument("--adb", type=Path) + parser.add_argument("--list-scenarios", action="store_true") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--build", type=Path) parser.add_argument("--resume", action="store_true", help="Continue after the saved recovery profile is active on the computer hotspot") parser.add_argument("--root-swipe", action="store_true", help="Requires explicit root permission for scrolling only the foreground App settings list") parser.add_argument("--skip-wrong", action="store_true", help="Use only when the wrong-password branch has been separately observed and archived") @@ -268,6 +270,19 @@ def main() -> int: parser.add_argument("--validate-only", action="store_true", help="Verify form and invalid IPv4 without switching networks") parser.add_argument("--final-check", action="store_true", help="Verify App, BLE, board, and recovery cleanup on the original network") args = parser.parse_args() + modes = ['validate_only', 'wrong_only', 'skip_wrong', 'final_check'] + if args.list_scenarios: + print('validate-only: form only; wrong-only: failed activation with timed restore; skip-wrong: cross-network DHCP/static roundtrip; final-check: restored state checks') + return 0 + selected = [m for m in modes if getattr(args, m)] + if len(selected) != 1: parser.error('Select exactly one explicit WiFi mode') + if args.resume and not args.skip_wrong: parser.error('--resume requires --skip-wrong') + if args.dry_run: + print(json.dumps({'scenario': selected[0], 'root_phone': True, 'board': True, 'network_change': selected[0] in ('wrong_only', 'skip_wrong')})) + return 0 + if not args.adb or not args.build: parser.error('--adb and --build required') + global connect, execute + from remote_support import connect, execute args.root_swipe = True if not (args.wrong_only or args.skip_wrong or args.resume or args.validate_only or args.final_check): raise RuntimeError("请选择 --validate-only、--wrong-only、--final-check 或已完成错误密码单测后的 --skip-wrong") diff --git a/移动端相关内容/安卓app/如何安卓测试/test_test_selection.py b/移动端相关内容/安卓app/如何安卓测试/test_test_selection.py new file mode 100644 index 0000000..bba553c --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/test_test_selection.py @@ -0,0 +1,120 @@ +"""Host-only safety tests: selection must never imply device or network access.""" +import contextlib +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import types +import unittest +from unittest.mock import patch +import run_ble_test as device +import run_emulator_ui_test as emulator + +class SelectionTests(unittest.TestCase): + def quiet(self): return contextlib.redirect_stdout(io.StringIO()) + def test_previews_need_no_device_or_dependencies(self): + with patch('subprocess.run', side_effect=AssertionError('unexpected device access')), self.quiet(): + for runner, name in ((device, 'navigation'), (emulator, 'settings')): + self.assertEqual(runner.main(['--list-scenarios']), 0) + self.assertEqual(runner.main(['--scenario', name, '--dry-run']), 0) + self.assertEqual(runner.main([]), 2) + def test_one_scenario_cannot_expand_to_ble_or_network(self): + calls = [] + fake = types.SimpleNamespace(main=lambda args: calls.append(args)) + with patch.dict(sys.modules, {'device_test_runner': fake}), self.quiet(): + self.assertEqual(device.main(['--scenario', 'navigation', '--adb', 'fake', '--build', 'fake']), 0) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][calls[0].index('--scenario') + 1], 'navigation') + self.assertNotIn('--reapply-current-wifi', calls[0]) + self.assertEqual(calls[0][calls[0].index('--poll-cycles') + 1], '0') + def test_failure_stops_later_scenarios(self): + calls = [] + def fail(args): + calls.append(args); raise RuntimeError('expected') + with patch.dict(sys.modules, {'device_test_runner': types.SimpleNamespace(main=fail)}), self.quiet(): + with self.assertRaises(RuntimeError): + device.main(['--scenario', 'connection', '--scenario', 'data', '--adb', 'fake', '--build', 'fake']) + self.assertEqual(len(calls), 1) + def test_common_does_not_include_disruptive_scenarios(self): + self.assertFalse(set(device.COMMON) & {'wifi-reapply', 'controls', 'polling', 'bluetooth-toggle', 'rename', 'remember'}) + def test_zero_tests_or_crash_is_not_success(self): + self.assertTrue(emulator.passed('OK (1 test)\n')) + for text in ('OK (0 tests)', 'OK (2 tests)', 'FAILURES!!!\nOK (1 test)', 'Process crashed\nOK (1 test)'): + self.assertFalse(emulator.passed(text)) + def test_failed_instrumentation_restores_each_setting_and_records_failure(self): + with tempfile.TemporaryDirectory() as tmp: + b = Path(tmp); apk = b / 'uiTest/build/outputs/apk/debug/uiTest-debug.apk' + apk.parent.mkdir(parents=True); apk.write_bytes(b'fixture') + state = {'font_scale': '1.0', 'accelerometer_rotation': '1', 'user_rotation': '0'} + history = [] + def run(cmd, **kwargs): + history.append(cmd) + args = cmd[3:] + out = '' + if args == ['get-state']: out = 'device' + elif args[:2] == ['shell', 'getprop']: out = '1' + elif args == ['emu', 'avd', 'name']: out = 'fixture\nOK' + elif args[:3] == ['shell', 'settings', 'get']: out = state[args[4]] + elif args[:3] == ['shell', 'settings', 'put']: state[args[4]] = args[5] + elif args == ['shell', 'wm', 'size']: out = 'Physical size: 1080x1920' + elif args == ['shell', 'wm', 'density']: out = 'Physical density: 420' + elif args[:1] == ['install']: out = 'Success' + elif args[:3] == ['shell', 'am', 'instrument']: raise subprocess.TimeoutExpired(cmd, 120) + return subprocess.CompletedProcess(cmd, 0, out, '') + report = b / 'report.json' + with patch('subprocess.run', side_effect=run), self.quiet(): + result = emulator.main(['--scenario', 'navigation', '--avd', 'fixture', '--adb', 'fake', '--build', str(b), '--report', str(report)]) + self.assertEqual(result, 1) + self.assertEqual(state, {'font_scale': '1.0', 'accelerometer_rotation': '1', 'user_rotation': '0'}) + evidence = json.loads(report.read_text(encoding='utf-8')) + self.assertEqual(evidence['tests'][0]['result'], 'failed') + self.assertIn('timed out', evidence['failure']) + self.assertFalse(evidence['cleanup_errors']) + self.assertTrue(any(cmd[3:6] == ['shell', 'am', 'force-stop'] for cmd in history)) + def test_wifi_modes_are_explicit_and_dry_run_has_no_remote_import(self): + import run_wifi_switch_test as wifi + for argv in (['run_wifi_switch_test.py', '--list-scenarios'], + ['run_wifi_switch_test.py', '--validate-only', '--dry-run']): + with patch.object(sys, 'argv', argv), patch('subprocess.run', side_effect=AssertionError('unexpected access')), patch.dict(sys.modules, {'remote_support': None}), self.quiet(): + self.assertEqual(wifi.main(), 0) + for argv in (['run_wifi_switch_test.py'], ['run_wifi_switch_test.py', '--wrong-only', '--skip-wrong', '--dry-run']): + with patch.object(sys, 'argv', argv), self.quiet(), contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): wifi.main() + + def test_real_navigation_adapter_has_no_board_access_and_restores_permissions(self): + import device_test_runner as runner + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp) + for name in ('debug/androidApp-debug.apk', 'androidTest/debug/androidApp-debug-androidTest.apk'): + apk = base / 'androidApp/build/outputs/apk' / name + apk.parent.mkdir(parents=True, exist_ok=True); apk.write_bytes(b'fixture') + grants = {'BLUETOOTH_SCAN': True, 'BLUETOOTH_CONNECT': False} + calls = [] + def execute(command, **kwargs): + calls.append(command) + args = command[3:]; out = '' + if args == ['shell', 'su', '-c', 'id -u']: out = '0' + elif args == ['shell', 'getprop', 'ro.build.version.sdk']: out = '35' + elif args == ['shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller']: + out = '\n'.join('android.permission.' + k + ': granted=' + str(v).lower() for k, v in grants.items()) + elif args[:3] == ['shell', 'su', '-c'] and args[3].startswith('pm '): + operation = args[3].split(); grants[operation[-1].split('.')[-1]] = operation[1] == 'grant' + elif args[:3] == ['shell', 'am', 'instrument']: + self.assertFalse(any(grants.values())) + self.assertNotIn('device_name', args) + self.assertNotIn('library_order_hash', args) + out = 'OK (1 test)\n' + elif args[:1] == ['install']: out = 'Success' + return subprocess.CompletedProcess(command, 0, out, '') + with patch.object(runner, 'load_registration', return_value=[{'alias': 'android-test-a', 'serial': 'fixture-serial'}]), patch.object(runner, 'check_connected'), patch('subprocess.run', side_effect=execute), patch.dict(sys.modules, {'remote_support': None}), self.quiet(): + runner.main(['--scenario', 'navigation', '--test', 'activity', '--poll-cycles', '0', '--adb', 'fake', '--build', str(base)]) + self.assertEqual(grants, {'BLUETOOTH_SCAN': True, 'BLUETOOTH_CONNECT': False}) + self.assertTrue(any(cmd[3:6] == ['shell', 'am', 'force-stop'] for cmd in calls)) + + def test_invalid_scenario_rejected_before_any_io(self): + with patch('subprocess.run', side_effect=AssertionError('unexpected access')), self.quiet(), contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): device.main(['--scenario', 'everything']) + +if __name__ == '__main__': unittest.main() diff --git a/移动端相关内容/安卓app/如何安卓测试/常见问题与排障.md b/移动端相关内容/安卓app/如何安卓测试/常见问题与排障.md index fe55e52..22d0318 100644 --- a/移动端相关内容/安卓app/如何安卓测试/常见问题与排障.md +++ b/移动端相关内容/安卓app/如何安卓测试/常见问题与排障.md @@ -69,3 +69,19 @@ Compose 的禁用语义可能位于父节点,文字节点自身仍 enabled。 滑动后通过两次间隔400毫秒的实时边界相等检查确认目标停稳(最多5秒),然后仅发送一次 root 点击;不因未看到中间状态而重复点击连接。记录初次连接、连接中扫描、两处预览、条目断开、手动重连等阶段。短暂“连接中”未被观测不代表未发起请求,终态以认证连接和条目状态核验为准。 本轮失败、恢复与最终结果见 `../测试结果归档/2026-09-27_界面细节优化/`;重测通过不证明此前连接未出现的唯一根因。字体、旋转和密度恢复后均回读核验。 + +## 13. 按影响选测与独立模拟器(2026-09-29) + +- 先读 [按影响选测](按影响选测.md),选中场景后才执行环境门禁。不能因为手机登记/网络不可用而阻塞不依赖它们的 JVM、模拟器或文档检查。需要真机但环境不可用时仍是“未验证”。 +- `--list-scenarios` / `--dry-run` 不导入远端联调依赖、不访问凭据或 ADB。电脑系统 Python 未装 Paramiko 时,优先复用本机测试环境的 `.venv` 并检查 import;不要给核桃派添加主机依赖。 +- com.android.test 插件已由同版本 AGP 放入 classpath 时,uiTest 使用无版本 `id("com.android.test")`;显式重复版本可能导致 unknown version 冲突。uiTest 的 Compose 测试库与现有 Compose Multiplatform 锁定版本一致。 +- 独立 uiTest 使用 self-instrumenting APK 和实际 Controller,不从反射篡改内部状态。fake RPC 必须遵循真实字段契约:内容条目需要 revision,缺失会在 thumbnailKey 中失败。此类夹具错误应修夹具,不弱化产品断言。 +- 真机 navigation 临时撤销扫描/连接权限,防止已有记忆触发自动重连;finally 恢复原权限并回读,不访问板端身份。不将此场景用于权限允许/拒绝的产品验收。 +- AVD 镜像 ZIP 手动解压不等于 SDK 包已登记。单独存放 SDK 时,avdmanager 必须定位同一 SDK 根;镜像 package.xml 保留官方 type-details/版本/许可证,复用已有 Emulator。优先使用 Android Studio SDK Manager 正常安装的包,不能靠反复下载解决根目录不一致。 +- 模拟器测试出现失败,保留场景与配置结果,独立恢复字体、旋转、密度;只重跑受影响/未运行场景。失败不能记成全量通过。标准 Compose 输入仅适用于模拟器,登记真机仍用 root。 + +## 14. 内容预览在真实动画条目返回 BAD_REQUEST(2026-09-29) + +真实用户动画的首帧可能是 `path` 指向持久图片,演示动画则可能是内嵌 `image`。新增 BLE 图片接口时要先检查 `animations.playback_snapshot` 的实际首帧结构,并沿设备既有路径验证与解码规则读取两种形式;不能把 `path` 当作无图或错误参数。空动画仍应按不可播放条目处理。复测须覆盖用户动画和演示动画,并做全库图片只读读取;本轮修正和实测见 [内容缓存验收](../测试结果归档/2026-09-29_内容缓存/README.md)。 + +模拟器 AVD 名称若存在但启动提示系统目录不存在,先核对该名称的配置指向和镜像登记。保留旧配置,使用已安装镜像新建专用 AVD 再测试;不要因名称存在就反复启动,也不要擅自清空原 AVD。真机 instrumentation 前同时确认产品 APK 与 androidTest APK 已构建,只有测试 APK 时先补产品构建,再运行选中场景。 diff --git a/移动端相关内容/安卓app/如何安卓测试/按影响选测.md b/移动端相关内容/安卓app/如何安卓测试/按影响选测.md new file mode 100644 index 0000000..48286e6 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/按影响选测.md @@ -0,0 +1,92 @@ +# Android 按变更影响选测(当前规则) + +适用 MOBILE-TEST-SELECTION、TEST-MOBILE-SELECTION、TEST-MOBILE-INTEGRATION。本文优先于历史版本专项验收中的固定执行顺序。授权决定怎样操作设备,不代表每轮必须使用设备。 + +## 选择范围 + +先列变更、直接调用与共享依赖影响,再选择能提供足够证据的最低层级。不能仅以页面文件未修改判定无影响,也不能以新功能提交/打包为由自动重跑所有页面。 + +| 变更 | 默认验证 | 升级为定向真机的条件 | +|---|---|---| +| 文档、映射 | 文档一致性、仓库卫生 | 无 | +| 解析、校验、业务状态 | sharedCore JVM / 双端协议测试 | 实际平台交互改变或模拟无法解释故障 | +| 文案、布局、表单、导航 | 受影响 uiTest 场景,baseline 配置 | 厂商/实际设备特有问题或模拟证据不足 | +| BLE/GATT/握手/重连 | 单元测试和对应真机连接场景 | 必须覆盖实际改变的通信路径 | +| 权限、Activity/后台 | 相应 Android 平台测试;必要真机 | 真实蓝牙权限、MIUI、后台限制改变 | +| WiFi | 校验和模拟器 settings | 激活、切换、恢复实现改变才真正切网 | +| 构建、SDK、依赖 | 构建及受影响兼容性 | 覆盖安装、关键通信或平台风险 | +| systemd、部署、驱动 | 设备侧既有流程 | 不因普通手机界面修改触发 | + +共享导航/主题影响多个页面时覆盖相关页面;局部修改不测无影响页面。模拟器同样按需:默认单一 baseline;布局适配才增加 narrow/large-font/landscape;API/权限兼容变化才增加相关 API 的 AVD。不能用 API 35 的模拟界面结果冒充 API 26/30/31+ 权限验收。 + +全量仅在明确完整验收或影响无法可靠收敛时使用,写明原因。`--full-common` 是明确定义的四项公共回归,不等于全量验收;切网、写入、长轮询和异常恢复始终单独选择。发布候选仍按发布风险选择关键链路,打包成功不代表验收成功。 + +已运行且相关的低成本回归通过后,不无理由重复运行。必要的测试因环境缺失没做,记录“未验证”;无影响的测试记录“按影响跳过”。没有自动将失败改成跳过的机制。 + +## 场景和稳定编号映射 + +| 层级/入口 | 场景 | 对应编号 / 依赖与副作用 | +|---|---|---| +| sharedCore:jvmTest | 现有协议、状态、恢复、超时、加密 | ANDROID-TEST-CONTRACT / STATE;无设备 | +| uiTest 模拟器 | connection/status/content/settings/navigation/layout | ANDROID-TEST-UI;测试专用假平台,实际界面与 Controller | +| run_ble_test.py | connection | ANDROID-TEST-BLE;真实扫描/握手/断开/重新扫描重连 | +| 同上 | data / library-order | ANDROID-TEST-BLE / CONTROL;只读,只有 order 读取板端目录摘要 | +| 同上 | library-cache | ANDROID-TEST-LIBRARY-CACHE;登记手机与核桃派只读,两轮图片后重新连接,报告图片请求数与 PNG 负载字节数,不改变显示内容 | +| 同上 | cache-storage | ANDROID-TEST-LIBRARY-CACHE;登记手机的隔离缓存目录验证跨设备隔离、损坏保护与清空,不访问核桃派 | +| 同上 | conflict / wifi-scan | ANDROID-TEST-CONTRACT / WIFI;过期修订拒绝 / 板端扫描;不切网 | +| 同上 | polling | ANDROID-TEST-PERF;最多 60 轮状态/帧读取,不替代性能因果对照 | +| 同上 | controller-background / activity-background | ANDROID-TEST-STATE / BLE;后台释放与重连 | +| 同上 | navigation | ANDROID-TEST-UI;登记真机 root 导航;临时撤销并恢复蓝牙权限防止旧记忆自动连接,不访问板端 | +| 同上 | rename / remember | ANDROID-TEST-UI / STATE;昵称恢复 / 忘记后重新连接 | +| 同上 | bluetooth-toggle | ANDROID-TEST-PERMISSION;真实关闭并恢复蓝牙 | +| 同上 | connection-entry / layout | ANDROID-TEST-UI;条目重扫/断开/再连;layout 另加三组布局和帧边界 | +| 同上 | controls / wifi-reapply | ANDROID-TEST-CONTROL / WIFI;当前值写入和动画恢复 / 当前网络重新激活,须明确选择 | +| run_wifi_switch_test.py | --validate-only / --wrong-only / --skip-wrong / --final-check | ANDROID-TEST-WIFI;四者必须且只能选一个,后两类切网专项按原恢复规则 | + +每个真机场景单独建立/释放会话。普通 UI 首选模拟器;真机 navigation 是厂商输入等特定问题的补证入口。`connection-entry` 不改变密度/字体/方向;`layout` 才做原有三组配置。其它设备生命周期、双手机、性能采样仍按各自编号的触发条件独立执行。 + +## 可复现命令 + +先阅读排障指南。命令从仓库根执行,路径参数使用本机已安装工具的真实路径,不把路径写进仓库。电脑远端联调使用已有安装 Paramiko 的环境;依赖见 requirements-host.txt,不加入核桃派依赖。 + +```text +python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --list-scenarios +python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario connection --dry-run +python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario connection --adb "" --build "<隔离工程目录>" +python "移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py" --scenario settings --dry-run +python "移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py" --scenario settings --avd "" --adb "" --emulator "<已安装Emulator路径>" --build "<隔离工程目录>" --report "<新的结果JSON路径>" +python "移动端相关内容/安卓app/如何安卓测试/run_wifi_switch_test.py" --validate-only --dry-run +``` + +构建沿用 build_android.py,任务按需指定:`:sharedCore:jvmTest`、`:androidApp:assembleDebug`、`:androidApp:assembleDebugAndroidTest`、`:uiTest:assembleDebug`。uiTest 是 self-instrumenting 测试 APK;只安装到明确选择且核验过的 AVD,不装到登记真机,不安装产品 APK。fake platform/transport、测试 Activity 均只存在于 uiTest;不调用真实 BLE、SSH、摄像头,不声明真实权限验收。 + +模拟器入口支持重复 `--scenario` 和 `--configuration`;默认 baseline,只在明确完整 UI 验收时用 `--all-ui`。复用 Android Studio Device Manager 的 AVD;缺少时用 SDK avdmanager 创建专用 AVD,使用官方系统镜像。`--port` 默认 5580,启动前指定 AVD、连接后核对名称和 ro.kernel.qemu,拒绝物理手机或不匹配目标。可用 `--sysdir` 指向独立存储的系统镜像。只关闭本次启动的模拟器;字体、方向、密度恢复并核验。不能擅自 wipe-data 或覆盖已有 AVD。 + +所有运行器无场景时只显示用法;列表/预览不访问登记、凭据、ADB、SSH。非法组合在设备访问前拒绝。旧 `--test activity`、无参数默认 BLE 的命令已取消:分别改用明确的 `--scenario`。run_ui_layout_test.py 保留转发入口,也必须显式选 layout 或 connection-entry(推荐统一用 run_ble_test.py)。 + +## 证据与边界 + +每轮归档填写: + +```text +变更及共享影响: +选中编号 / 场景 / 环境 / 配置 / 选择理由: +源码摘要、App/设备/协议版本(只记与本轮相关者): +结果:通过 / 失败 / 按影响跳过 / 必要但未验证 +跳过项及理由: +失败、清理、恢复核验、重测及未解决边界: +经验更新位置: +``` + +宿主调度检查:`python -m unittest discover -s "移动端相关内容/安卓app/如何安卓测试" -p "test_test_selection.py"`。这是运行器选测/失败恢复测试,不代表 BLE/模拟器已经通过。 + +模拟 UI 已覆盖空状态、扫描错误恢复、导航、WiFi 空名称禁用及确认取消、内容读入和帧布局。没有实现的 OS 权限矩阵和完整 Activity 状态恢复不冒充已覆盖;今后相关功能变化时补相应测试。语义/边界断言不证明文字实际运动或视觉质量。视觉和电气门禁不变。 + +## 官方依据(2026-09-29 核对) + +- [Android 测试策略](https://developer.android.com/training/testing/fundamentals/strategies):选择最低有效层级,并非每个功能都在所有层级重复验证。 +- [Compose 独立测试](https://developer.android.com/develop/ui/compose/testing/common-patterns):隔离界面输入,使用测试 API 验证行为。 +- [Android 测试基础](https://developer.android.com/training/testing/fundamentals):instrumentation 可运行于真机或模拟器。 +- [模拟器网络能力](https://developer.android.com/studio/run/emulator-networking):新版本支持部分 Bluetooth 模拟,不能笼统写“模拟器完全不支持 BLE”;本项目 fake 测试仍不等于物理射频、实际核桃派或厂商兼容验证。 + +以上是制定项目选测规则的依据,具体场景划分是本项目的工程选择,不是官方强制清单。 diff --git a/移动端相关内容/安卓app/安卓开发详细设计.md b/移动端相关内容/安卓app/安卓开发详细设计.md index 7ec497b..fb99b6f 100644 --- a/移动端相关内容/安卓app/安卓开发详细设计.md +++ b/移动端相关内容/安卓app/安卓开发详细设计.md @@ -18,7 +18,7 @@ Android 平台以 Java 安全 API 实现 P-256 ECDH/HKDF-SHA256/AES-GCM, 扫描单轮 12 秒;点击重试开始新轮,不无限循环。自动重连扫描上次持久 ID 对应短编号,握手校验完整 ID,不因名称相同认错设备;候选完整 ID 不符则释放并报错,用户可重新扫描。App 最多一个 BluetoothGatt。每项 GATT 操作等待回调或超时后再执行下一项;迟到回调按连接 generation 丢弃。 -用户控制队列优先,状态次之,缩略图与当前帧最低优先。单个预览在途,数据无变化不反复下载。背景立即停止轮询和缩略图,30 秒延迟任务释放连接;回前台取消延迟,Ready 则刷新,非 Ready 则重连上次设备。进程死亡不假设回调可执行,由设备心跳门限回收会话。 +用户控制队列优先,状态与当前帧次之,内容图片在空闲轮询时一次传一张。显示内容前台每 10 秒查询目录修订;全部可预览条目先传 16×16 PNG,第一轮尝试完成后再传 64×64 完整图。单个预览在途,数据无变化不反复下载。背景立即停止轮询和内容图片,30 秒延迟任务释放连接;回前台取消延迟,Ready 则刷新,非 Ready 则重连上次设备。进程死亡不假设回调可执行,由设备心跳门限回收会话。 普通操作请求超时 10 秒,扫描 WiFi 20 秒,切网任务最多轮询 60 秒后转“结果尚未确认,可重新读取”;超时不是事务回滚证据,不自动重发变更。 @@ -40,7 +40,7 @@ WiFi:扫描来自设备;允许手工 SSID;密码动作“保留/替换 Android 8–11 按平台要求申请前台定位扫描权限并解释用途,系统位置开关影响扫描时提供设置入口;12+ 申请 BLUETOOTH_SCAN/CONNECT,并声明扫描不用于定位。不申请后台定位、联系人、文件全盘访问或摄像头权限。权限拒绝后允许重试,永久拒绝显示系统设置入口;不在启动时重复轰炸权限弹窗。 -本地 SharedPreferences 仅保存 last_device_id 与 client_name。缩略图仅驻留内存,每次断线/新连接清空;键为内容类型、条目 ID、修订,以连接隔离设备,最多 128 项或合计 8 MiB Base64 字符,按插入顺序淘汰。不是磁盘缓存,也不是 LRU。设备业务配置以核桃派为准,不自动恢复手机历史副本。禁用敏感测试数据备份;密钥和 WiFi 密码不持久化。 +本地 SharedPreferences 保存连接记忆与手机昵称。内容目录及两档图片存于 Android 专属 cacheDir:设备 ID 目录隔离,图片按类型、条目 ID、修订和档位散列命名;目录原子写入,图片读取验证 PNG 尺寸,整体最多 128 MiB 并按最近使用时间淘汰。内存保留最多 128 张或 8 MiB Base64 字符,可见项按需从磁盘回填。目录修订未变才复用;变化后完整翻页并清理失效图,断线不显示旧列表。软件设置可清空该缓存,不清除设备记忆。当前设备画面沿用原有会话缓存,不写磁盘。设备业务配置以核桃派为准,不自动恢复手机历史副本。禁用敏感测试数据备份;密钥和 WiFi 密码不持久化。 ## 5. 设备侧集成 @@ -72,3 +72,7 @@ BlueZ D-Bus 接入作为可恢复模块,单独管理连接/广播,不在 ## 2026-09-27 界面修订 共享状态增加仅当前认证会话有效的 connectedDevice 扫描快照,以本次句柄关联按钮,持久身份仍使用完整设备 ID。扫描重置保留它,重复广播按句柄去重;所有会话结束清空关联。顶栏使用共享 Compose 跑马灯,只在溢出时无限循环,文本变化重启。两处预览复用居中卡片,标题按刷新按钮实际宽度预留对称空间。App 0.2.1/versionCode 4,仅 debug。 + +## Android 选测入口 + +遵循 [按影响选测](如何安卓测试/按影响选测.md)。日常变更不自动执行模拟器或 root 真机全量矩阵;独立 uiTest 使用真实 Compose 界面与 Controller、测试专用假平台,不替代物理 BLE。历史版本专项不作为每轮门槛。 diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/build.gradle.kts b/移动端相关内容/安卓app/安卓程序源代码/androidApp/build.gradle.kts index d98cd15..f050106 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/androidApp/build.gradle.kts +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "org.qimiaoscreen.controller" minSdk = 26 targetSdk = 37 - versionCode = 4 - versionName = "0.2.1" + versionCode = 5 + versionName = "0.2.2" buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt index da89baf..1d9ab97 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt @@ -15,6 +15,8 @@ import org.junit.runner.RunWith class AppLifecycleTest { @Test fun actualActivityReturnsFromBackground() { val instrumentation = InstrumentationRegistry.getInstrumentation() + val scenario = checkNotNull(InstrumentationRegistry.getArguments().getString("scenario")) + check(scenario in setOf("navigation", "activity-background", "rename", "remember", "bluetooth-toggle")) val ui = UiDevice.getInstance(instrumentation) val rootInput = InstrumentationRegistry.getArguments().getString("root_input") == "true" fun rootCommand(args: String) { @@ -44,79 +46,82 @@ class AppLifecycleTest { click(checkNotNull(device.parent.parent.findObject(By.text("连接")))) assertTrue(ui.wait(Until.hasObject(By.text("断开连接")), 30_000)) } - if (!ui.wait(Until.hasObject(By.text("断开连接")), 30_000)) { - scanAndConnect() + if (scenario == "navigation") { + for (label in listOf("设备状态", "显示内容", "设备设置", "连接")) { + click(checkNotNull(ui.wait(Until.findObject(By.text(label)), 5_000))) + } + click(ui.findObject(By.text("⋮"))) + click(checkNotNull(ui.wait(Until.findObject(By.text("关于")), 5_000))) + assertTrue(ui.wait(Until.hasObject(By.textContains("版本 " + BuildConfig.VERSION_NAME)), 5_000)) + click(ui.findObject(By.text("关闭"))) + return } - if (InstrumentationRegistry.getArguments().getString("toggle_bluetooth") == "true") { - check(rootInput) - try { - rootCommand("bluetooth-disable") - assertTrue("Connection state must clear after Bluetooth is disabled", - ui.wait(Until.hasObject(By.text("扫描设备")), 15_000)) - } finally { rootCommand("bluetooth-enable") } - Thread.sleep(3_000) - scanAndConnect() - } - click(ui.findObject(By.text("设备状态"))) - assertTrue(ui.wait(Until.hasObject(By.text("设备画面")), 15_000)) - check(!ui.hasObject(By.text("运行状态"))) - click(ui.findObject(By.text("显示内容"))) - assertTrue(ui.wait(Until.hasObject(By.text("可播放内容")), 15_000)) - click(ui.findObject(By.text("设备设置"))) - assertTrue(ui.wait(Until.hasObject(By.text("屏幕方向")), 15_000)) - check(!ui.hasObject(By.text("性能模式"))) - click(ui.findObject(By.text("连接"))) - click(ui.findObject(By.text("⋮"))) - click(checkNotNull(ui.wait(Until.findObject(By.text("关于")), 5_000))) - assertTrue(ui.wait(Until.hasObject(By.textContains("版本 " + BuildConfig.VERSION_NAME)), 5_000)) - click(ui.findObject(By.text("关闭"))) - click(ui.findObject(By.text("⋮"))) - click(checkNotNull(ui.wait(Until.findObject(By.text("软件设置")), 5_000))) - assertTrue(ui.wait(Until.hasObject(By.text("当前手机昵称")), 5_000)) - click(ui.findObject(By.text("取消"))) - check(rootInput); rootCommand("home") - Thread.sleep(33_000) - launch() - assertTrue("Activity should reconnect after background release", - ui.wait(Until.hasObject(By.text("断开连接")), 35_000)) - fun openRename() { - val marker = checkNotNull(ui.wait(Until.findObject(By.text("已连接")), 5_000)) - val point = marker.visibleCenter - rootCommand("swipe ${point.x} ${point.y} ${point.x} ${point.y} 700") - click(checkNotNull(ui.wait(Until.findObject(By.text("重命名设备")), 5_000))) - assertTrue(ui.wait(Until.hasObject(By.text("设备昵称")), 5_000)) - } - fun rename(value: String) { - val field = checkNotNull(ui.wait(Until.findObject(By.clazz("android.widget.EditText")), 5_000)) - val clipboard = instrumentation.targetContext.getSystemService(android.content.ClipboardManager::class.java) - clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", value)) - try { - click(field) - rootCommand("clear-field ${(field.text.length + 2).coerceIn(2, 512)}") - rootCommand("paste") - rootCommand("hide-keyboard") - } finally { clipboard.clearPrimaryClip() } - click(checkNotNull(ui.wait(Until.findObject(By.text("保存")), 5_000))) - assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:$value")), 15_000)) - } - openRename() - val originalName = ui.findObject(By.clazz("android.widget.EditText")).text - var renamed = false try { - rename("界面验证屏") - renamed = true - click(ui.findObject(By.text("断开连接"))) - scanAndConnect() - assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:界面验证屏")), 5_000)) + if (!ui.wait(Until.hasObject(By.text("断开连接")), 30_000)) { + scanAndConnect() + } + if (InstrumentationRegistry.getArguments().getString("toggle_bluetooth") == "true") { + check(rootInput) + try { + rootCommand("bluetooth-disable") + assertTrue("Connection state must clear after Bluetooth is disabled", + ui.wait(Until.hasObject(By.text("扫描设备")), 15_000)) + } finally { rootCommand("bluetooth-enable") } + Thread.sleep(3_000) + scanAndConnect() + } + if (scenario == "activity-background") { + check(rootInput); rootCommand("home") + Thread.sleep(33_000) + launch() + assertTrue("Activity should reconnect after background release", + ui.wait(Until.hasObject(By.text("断开连接")), 35_000)) + } + fun openRename() { + val marker = checkNotNull(ui.wait(Until.findObject(By.text("已连接")), 5_000)) + val point = marker.visibleCenter + rootCommand("swipe ${point.x} ${point.y} ${point.x} ${point.y} 700") + click(checkNotNull(ui.wait(Until.findObject(By.text("重命名设备")), 5_000))) + assertTrue(ui.wait(Until.hasObject(By.text("设备昵称")), 5_000)) + } + fun rename(value: String) { + val field = checkNotNull(ui.wait(Until.findObject(By.clazz("android.widget.EditText")), 5_000)) + val clipboard = instrumentation.targetContext.getSystemService(android.content.ClipboardManager::class.java) + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", value)) + try { + click(field) + rootCommand("clear-field ${(field.text.length + 2).coerceIn(2, 512)}") + rootCommand("paste") + rootCommand("hide-keyboard") + } finally { clipboard.clearPrimaryClip() } + click(checkNotNull(ui.wait(Until.findObject(By.text("保存")), 5_000))) + assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:$value")), 15_000)) + } + if (scenario == "rename") { + openRename() + val originalName = ui.findObject(By.clazz("android.widget.EditText")).text + var renamed = false + try { + renamed = true + rename("界面验证屏") + click(ui.findObject(By.text("断开连接"))) + scanAndConnect() + assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:界面验证屏")), 5_000)) + } finally { + if (renamed) { openRename(); rename(originalName) } + } + } + if (scenario == "remember") { + val again = ui.findObject(By.text("已连接")).visibleCenter + rootCommand("swipe ${again.x} ${again.y} ${again.x} ${again.y} 700") + click(checkNotNull(ui.wait(Until.findObject(By.text("取消记忆")), 5_000))) + assertTrue(ui.wait(Until.hasObject(By.text("暂无已保存设备,扫描后选择即可连接")), 5_000)) + scanAndConnect() + } } finally { - if (renamed) { openRename(); rename(originalName) } + launch() + ui.wait(Until.findObject(By.text("断开连接")), 5_000)?.let { click(it) } } - val again = ui.findObject(By.text("已连接")).visibleCenter - rootCommand("swipe ${again.x} ${again.y} ${again.x} ${again.y} 700") - click(checkNotNull(ui.wait(Until.findObject(By.text("取消记忆")), 5_000))) - assertTrue(ui.wait(Until.hasObject(By.text("暂无已保存设备,扫描后选择即可连接")), 5_000)) - scanAndConnect() - click(ui.findObject(By.text("断开连接"))) assertTrue(ui.wait(Until.hasObject(By.text("扫描设备")), 5_000)) } } diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt index 287f487..3d27885 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt @@ -24,6 +24,8 @@ class BleIntegrationTest { @Test fun encryptedReadOnlySessionAndReconnect() = runBlocking { val instrumentation = InstrumentationRegistry.getInstrumentation() fun stage(name: String) { instrumentation.sendStatus(2, Bundle().apply { putString("stream", "BLE stage: $name\n") }) } + val scenario = checkNotNull(InstrumentationRegistry.getArguments().getString("scenario")) + check(scenario in setOf("connection", "data", "library-order", "conflict", "polling", "controller-background", "wifi-scan", "controls", "wifi-reapply")) val context = instrumentation.targetContext val permissions = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) @@ -44,7 +46,7 @@ class BleIntegrationTest { stage("scan complete") ble.stopScan() var identity: String? = null - repeat(2) { iteration -> + repeat(if (scenario == "connection") 2 else if (scenario == "controller-background") 0 else 1) { iteration -> // Reconnection uses a fresh scan, as production does. A scan // handle is transient and must not be retained across sessions. val target = if (iteration == 0) device else discover() @@ -68,6 +70,7 @@ class BleIntegrationTest { val id = hello.getValue("device_id").jsonPrimitive.content if (identity == null) identity = id else assertEquals(identity, id) assertEquals(1, hello.getValue("protocol_major").jsonPrimitive.int) + if (scenario == "data") { val status = rpc.call("status.get") stage("status received") assertTrue(status.containsKey("state")) @@ -76,11 +79,13 @@ class BleIntegrationTest { assertTrue(settings.getValue("values").jsonObject.containsKey("brightness")) rpc.call("storage.get") stage("storage received") + } + if (scenario in setOf("data", "library-order")) { val library = rpc.call("library.list") stage("library received") assertTrue(library.getValue("items").jsonArray.isNotEmpty()) val expectedOrder = InstrumentationRegistry.getArguments().getString("library_order_hash") - if (expectedOrder != null) { + if (scenario == "library-order") { val all = library.getValue("items").jsonArray.map { it.jsonObject }.toMutableList() var cursor = library["next_cursor"]?.jsonPrimitive?.contentOrNull while (cursor != null) { @@ -95,6 +100,8 @@ class BleIntegrationTest { assertEquals("BLE must preserve device library order", expectedOrder, digest) stage("library persistent order verified") } + } + if (scenario == "data") { val frame = rpc.call("frame.get") stage("frame received") assertEquals("image/png", frame.getValue("mime").jsonPrimitive.content) @@ -102,11 +109,16 @@ class BleIntegrationTest { val saved = wifi["saved"] if (saved != null && saved != JsonNull) assertFalse(saved.jsonObject.containsKey("password")) assertTrue(rpc.call("session.ping").getValue("alive").jsonPrimitive.boolean) + } if (iteration == 0) { + if (scenario == "wifi-scan") { val networks = rpc.call("wifi.scan").getValue("networks").jsonArray assertTrue(networks.size <= 100) assertTrue(networks.all { !it.jsonObject.containsKey("password") }) stage("WiFi scan completed without logging network names") + } + if (scenario == "conflict") { + val settings = rpc.call("settings.get") try { rpc.call("settings.patch", buildJsonObject { put("expected_revision", "deliberately-stale-test-revision") @@ -116,6 +128,7 @@ class BleIntegrationTest { } catch (e: DeviceFailure) { assertEquals("CONFLICT", e.code) } assertTrue(rpc.call("session.ping").getValue("alive").jsonPrimitive.boolean) stage("configuration conflict preserves session") + } if (InstrumentationRegistry.getArguments().getString("verify_current_controls") == "true") { // Exercise accepted writes without changing the selected // content, defaults, brightness, direction or network. @@ -180,7 +193,7 @@ class BleIntegrationTest { }) stage("current WiFi reapplied with password retained; BLE remained usable") } - repeat(InstrumentationRegistry.getArguments().getString("poll_cycles")?.toInt() ?: 60) { cycle -> + if (scenario == "polling") repeat(InstrumentationRegistry.getArguments().getString("poll_cycles")?.toInt() ?: 60) { cycle -> delay(2_000) rpc.call("status.get") rpc.call("frame.get") @@ -190,7 +203,7 @@ class BleIntegrationTest { } finally { stage("intentional session close $iteration"); rpc.close() } if (iteration == 0) delay(3_000) } - delay(3_000) + if (scenario == "controller-background") { val saved = mutableMapOf() val platform = object : MobilePlatform { override val model = "android-test-a" @@ -204,21 +217,13 @@ class BleIntegrationTest { val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) val controller = Controller(platform, controllerScope) try { - withContext(Dispatchers.Main) { controller.connect(device) } + withContext(Dispatchers.Main) { controller.connect(discover()) } try { withTimeout(30_000) { controller.state.first { it.status != null } } } catch (e: TimeoutCancellationException) { error("Controller failed: phase=${controller.state.value.phase}, error=${controller.state.value.error}") } stage("controller device page ready") - withContext(Dispatchers.Main) { controller.page(2) } - withTimeout(20_000) { controller.state.first { it.library.isNotEmpty() && !it.busy } } - withContext(Dispatchers.Main) { controller.state.value.library.take(3).forEach(controller::thumbnail) } - delay(10_000) - assertNotNull(controller.state.value.identity) - withContext(Dispatchers.Main) { controller.page(3) } - withTimeout(20_000) { controller.state.first { it.wifi != null && !it.busy } } - stage("controller content and settings ready") withContext(Dispatchers.Main) { controller.foreground(false) } delay(5_000) withContext(Dispatchers.Main) { controller.foreground(true) } @@ -229,12 +234,13 @@ class BleIntegrationTest { stage("controller background released") delay(3_000) withContext(Dispatchers.Main) { controller.foreground(true) } - withTimeout(35_000) { controller.state.first { it.identity != null && it.wifi != null } } + withTimeout(35_000) { controller.state.first { it.identity != null && it.status != null } } stage("controller foreground reconnected") } finally { withContext(Dispatchers.Main) { controller.disconnect() } controllerScope.cancel() } + } } finally { ble.stopScan() } } } diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt index bfdd6e4..31063e4 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt @@ -76,8 +76,6 @@ class LayoutTest { } ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity") assertTrue(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000)) - for (label in listOf("连接", "设备状态", "显示内容", "设备设置", "连接")) click(label) - click("⋮"); click("关于"); click("关闭") fun tagged(tag: String): UiObject2 = checkNotNull(visible(By.res(tag), 35_000, tag == "connected_scan_disconnect")) { "Required App semantic tag unavailable: $tag" } @@ -118,7 +116,7 @@ class LayoutTest { tagged("connected_scan_disconnect") Thread.sleep(13_000) assertTrue(ui.hasObject(By.res("connected_scan_disconnect"))) - for (page in listOf("设备状态", "显示内容")) { + if (InstrumentationRegistry.getArguments().getString("scenario") == "layout") for (page in listOf("设备状态", "显示内容")) { stage("frame-" + if (page == "设备状态") "status" else "content") click(page) tagged("frame_image") diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryBleCacheTest.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryBleCacheTest.kt new file mode 100644 index 0000000..9e99995 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryBleCacheTest.kt @@ -0,0 +1,141 @@ +package org.qimiaoscreen.controller + +import android.Manifest +import android.content.ContextWrapper +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.util.Base64 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.* +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.qimiaoscreen.core.* +import java.io.File + +/** Read-only BLE cache check; records aggregate image payload counts, never IDs. */ +@RunWith(AndroidJUnit4::class) +class LibraryBleCacheTest { + @Test fun coldAndWarmLibrary() = runBlocking { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val permissions = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + check(permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) + val expectedName = checkNotNull(InstrumentationRegistry.getArguments().getString("device_name")) + val sandbox = File(context.cacheDir, "library-ble-test-${System.nanoTime()}").apply { mkdirs() } + val cache = AndroidLibraryCache(object : ContextWrapper(context) { override fun getCacheDir() = sandbox }) + val ble = AndroidBle(context) + suspend fun discover(): DiscoveredDevice { + val found = CompletableDeferred() + try { + ble.scan({ if (it.name == expectedName) found.complete(it) }, { found.completeExceptionally(IllegalStateException(it)) }) + return withTimeout(20_000) { found.await() } + } finally { ble.stopScan() } + } + suspend fun session(block: suspend (DeviceRpc, JsonObject) -> T): T { + val link = ble.connect(discover().handle) + val rpc = DeviceRpc(link, JavaSecureChannel()) + try { + val identity = rpc.open("android-test-a") + assertEquals(1, identity.getValue("protocol_minor").jsonPrimitive.int) + assertTrue(identity.getValue("capabilities").jsonArray.any { it.jsonPrimitive.content == "library_progressive" }) + return block(rpc, identity) + } finally { rpc.close() } + } + try { + cache.clear() + val first = session { rpc, identity -> + val deviceId = identity.getValue("device_id").jsonPrimitive.content + val summary = try { rpc.call("library.summary") } + catch (e: DeviceFailure) { error("library.summary failed: ${e.code}") } + val revision = summary.getValue("library_revision").jsonPrimitive.content + val items = mutableListOf() + var cursor: String? = null + do { + val page = try { rpc.call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } }) } + catch (e: DeviceFailure) { error("library.list page failed: ${e.code}") } + assertEquals(revision, page.getValue("library_revision").jsonPrimitive.content) + items += page.getValue("items").jsonArray.map { it.jsonObject } + cursor = page["next_cursor"]?.jsonPrimitive?.contentOrNull + } while (cursor != null) + assertEquals(summary.getValue("item_count").jsonPrimitive.int, items.size) + cache.saveCatalog(deviceId, CachedLibrary(revision, items)) + var requests = 0 + var bytes = 0L + val images = items.filter { it["playable"]?.jsonPrimitive?.booleanOrNull != false } + for ((method, tier) in listOf("library.preview" to "preview", "library.thumbnail" to "full")) { + for ((index, item) in images.withIndex()) { + val response = try { rpc.call(method, item) } + catch (e: DeviceFailure) { error("$method index=$index type=${item.getValue("type").jsonPrimitive.content} failed: ${e.code}") } + assertEquals("image/png", response.getValue("mime").jsonPrimitive.content) + val encoded = response.getValue("data_base64").jsonPrimitive.content + requests++ + bytes += Base64.decode(encoded, Base64.DEFAULT).size + cache.writeImage(deviceId, item, tier, encoded) + } + } + assertEquals(2 * images.size, requests) + assertTrue(bytes > 0) + assertEquals(revision, rpc.call("library.summary").getValue("library_revision").jsonPrimitive.content) + instrumentation.sendStatus(2, Bundle().apply { putString("stream", "BLE library cold: items=${items.size}, image requests=$requests, PNG payload bytes=$bytes\n") }) + Triple(deviceId, revision, images) + } + session { rpc, identity -> + assertEquals(first.first, identity.getValue("device_id").jsonPrimitive.content) + assertEquals(first.second, rpc.call("library.summary").getValue("library_revision").jsonPrimitive.content) + val catalog = cache.loadCatalog(first.first) + assertEquals(first.second, catalog?.revision) + for (item in first.third) { + assertNotNull(cache.readImage(first.first, item, "preview")) + assertNotNull(cache.readImage(first.first, item, "full")) + } + instrumentation.sendStatus(2, Bundle().apply { putString("stream", "BLE library warm: image requests=0, PNG payload bytes=0\n") }) + } + var controllerWrites = 0 + val countingCache = object : LibraryCache by cache { + override suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) { + controllerWrites++ + cache.writeImage(deviceId, item, tier, base64) + } + } + val platform = object : MobilePlatform { + override val model = "android-test-a" + override val libraryCache: LibraryCache = countingCache + private val saved = mutableMapOf() + override fun load(key: String) = saved[key] + override fun save(key: String, value: String) { saved[key] = value } + override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) = ble.scan(onDevice, onError) + override fun stopScan() = ble.stopScan() + override suspend fun connect(handle: String) = ble.connect(handle) + override fun crypto(): SecureChannel = JavaSecureChannel() + } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val controller = Controller(platform, scope) + try { + withContext(Dispatchers.Main) { controller.connect(discover()) } + withTimeout(30_000) { controller.state.first { it.status != null } } + withContext(Dispatchers.Main) { controller.page(2) } + withTimeout(30_000) { controller.state.first { it.library.size >= first.third.size && it.thumbnails.size == first.third.size } } + delay(3_000) + assertEquals(0, controllerWrites) + instrumentation.sendStatus(2, Bundle().apply { putString("stream", "Production Controller warm: image cache writes=0\n") }) + } finally { + withContext(Dispatchers.Main) { controller.disconnect() } + scope.cancel() + } + } finally { ble.stopScan(); sandbox.deleteRecursively() } + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryCacheTest.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryCacheTest.kt new file mode 100644 index 0000000..4d037cd --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryCacheTest.kt @@ -0,0 +1,55 @@ +package org.qimiaoscreen.controller + +import android.content.ContextWrapper +import android.graphics.Bitmap +import android.util.Base64 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.qimiaoscreen.core.CachedLibrary +import java.io.ByteArrayOutputStream +import java.io.File + +@RunWith(AndroidJUnit4::class) +class LibraryCacheTest { + @Test fun revisionIsolationCorruptionAndClear() = runBlocking { + val target = InstrumentationRegistry.getInstrumentation().targetContext + val sandbox = File(target.cacheDir, "library-cache-test-${System.nanoTime()}").apply { mkdirs() } + val context = object : ContextWrapper(target) { override fun getCacheDir() = sandbox } + val cache = AndroidLibraryCache(context) + val old = buildJsonObject { put("type", "template"); put("id", "test-item"); put("revision", "one"); put("name", "测试") } + val updated = buildJsonObject { put("type", "template"); put("id", "test-item"); put("revision", "two"); put("name", "测试") } + fun png(size: Int): String { + val output = ByteArrayOutputStream() + Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply { + eraseColor(android.graphics.Color.RED) + compress(Bitmap.CompressFormat.PNG, 100, output) + recycle() + } + return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP) + } + try { + cache.saveCatalog("device-a", CachedLibrary("catalog-one", listOf(old))) + cache.writeImage("device-a", old, "preview", png(16)) + cache.writeImage("device-a", old, "full", png(64)) + assertEquals("catalog-one", cache.loadCatalog("device-a")?.revision) + assertNotNull(cache.readImage("device-a", old, "preview")) + assertNull(cache.readImage("device-b", old, "full")) + cache.retain("device-a", listOf(updated)) + assertNull(cache.readImage("device-a", old, "preview")) + cache.writeImage("device-a", updated, "preview", png(16)) + val cachedFile = File(sandbox, "qms-library-v1").walkTopDown().first { it.isFile && it.name.endsWith(".png") } + cachedFile.writeText("corrupt") + assertNull(cache.readImage("device-a", updated, "preview")) + assertTrue(cache.sizeBytes() > 0) + cache.clear() + assertNull(cache.loadCatalog("device-a")) + assertEquals(0L, cache.sizeBytes()) + } finally { sandbox.deleteRecursively() } + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidLibraryCache.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidLibraryCache.kt new file mode 100644 index 0000000..bc01842 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidLibraryCache.kt @@ -0,0 +1,88 @@ +package org.qimiaoscreen.controller + +import android.content.Context +import android.graphics.BitmapFactory +import android.util.Base64 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.* +import org.qimiaoscreen.core.* +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + +/** Only files below this app's content-cache directory are ever deleted. */ +class AndroidLibraryCache(context: Context) : LibraryCache { + private val root = File(context.cacheDir, "qms-library-v1") + private val limit = 128L * 1024 * 1024 + private fun hash(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + private fun deviceDir(id: String) = File(root, hash(id)) + private fun imageName(item: JsonObject, tier: String): String = hash( + listOf(tier, item.getValue("type").jsonPrimitive.content, item.getValue("id").jsonPrimitive.content, + item.getValue("revision").jsonPrimitive.content).joinToString("\u0000")) + ".png" + private fun imageFile(id: String, item: JsonObject, tier: String) = File(deviceDir(id), imageName(item, tier)) + private fun atomicWrite(file: File, bytes: ByteArray) { + file.parentFile?.mkdirs() + val temp = File(file.parentFile, file.name + ".tmp") + try { + temp.writeBytes(bytes) + Files.move(temp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } finally { temp.delete() } + } + private fun validPng(bytes: ByteArray, tier: String): Boolean { + if (bytes.size !in 1..65536 || tier !in listOf("preview", "full")) return false + if (bytes.size < 8 || !bytes.copyOfRange(0, 8).contentEquals(byteArrayOf(137.toByte(), 80, 78, 71, 13, 10, 26, 10))) return false + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + val expected = if (tier == "preview") 16 else 64 + if (options.outWidth != expected || options.outHeight != expected) return false + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) != null + } + override suspend fun loadCatalog(deviceId: String): CachedLibrary? = withContext(Dispatchers.IO) { + runCatching { + val value = Json.parseToJsonElement(File(deviceDir(deviceId), "catalog.json").readText(Charsets.UTF_8)).jsonObject + val items = value.getValue("items").jsonArray.map { it.jsonObject } + require(items.all { item -> listOf("type", "id", "revision", "name").all { it in item } }) + CachedLibrary(value.getValue("revision").jsonPrimitive.content, items) + }.getOrNull() + } + override suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary) = withContext(Dispatchers.IO) { + val data = buildJsonObject { put("revision", catalog.revision); put("items", JsonArray(catalog.items)) } + atomicWrite(File(deviceDir(deviceId), "catalog.json"), data.toString().toByteArray(Charsets.UTF_8)) + } + override suspend fun readImage(deviceId: String, item: JsonObject, tier: String): String? = withContext(Dispatchers.IO) { + val file = imageFile(deviceId, item, tier) + val bytes = if (file.length() in 1..65536) runCatching { file.readBytes() }.getOrNull() else null + if (bytes == null || !validPng(bytes, tier)) { file.delete(); null } + else { file.setLastModified(System.currentTimeMillis()); Base64.encodeToString(bytes, Base64.NO_WRAP) } + } + override suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) = withContext(Dispatchers.IO) { + val bytes = Base64.decode(base64, Base64.DEFAULT) + require(validPng(bytes, tier)) { "设备返回的图片无效" } + atomicWrite(imageFile(deviceId, item, tier), bytes) + trim() + } + override suspend fun retain(deviceId: String, items: List) = withContext(Dispatchers.IO) { + val expected = items.flatMap { item -> listOf(imageName(item, "preview"), imageName(item, "full")) }.toSet() + deviceDir(deviceId).listFiles()?.forEach { file -> + if (file.isFile && file.name.endsWith(".png") && file.name !in expected) file.delete() + } + Unit + } + private fun cacheFiles(): List = root.listFiles()?.flatMap { it.listFiles()?.filter { f -> f.isFile } ?: emptyList() } ?: emptyList() + private fun trim() { + val files = cacheFiles().sortedBy { it.lastModified() } + var total = files.sumOf { it.length() } + for (file in files) { + if (total <= limit) break + val length = file.length() + if (file.delete()) total -= length + } + } + override suspend fun sizeBytes(): Long = withContext(Dispatchers.IO) { + root.listFiles()?.sumOf { directory -> directory.listFiles()?.sumOf { it.length() } ?: 0L } ?: 0L + } + override suspend fun clear() = withContext(Dispatchers.IO) { root.deleteRecursively(); Unit } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt index e28c2c9..e2662dc 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt @@ -45,6 +45,7 @@ class MainActivity : ComponentActivity() { val preferences = getSharedPreferences("controller", MODE_PRIVATE) val platform = object : MobilePlatform { override val model = Build.MODEL + override val libraryCache: LibraryCache = AndroidLibraryCache(applicationContext) override fun load(key: String) = preferences.getString(key, null) override fun save(key: String, value: String) { preferences.edit().putString(key, value).apply() } override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) = ble.scan(onDevice, onError) diff --git a/移动端相关内容/安卓app/安卓程序源代码/settings.gradle.kts b/移动端相关内容/安卓app/安卓程序源代码/settings.gradle.kts index c766a13..6d18105 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/settings.gradle.kts +++ b/移动端相关内容/安卓app/安卓程序源代码/settings.gradle.kts @@ -5,3 +5,5 @@ dependencyResolutionManagement { } rootProject.name = "QimiaoScreen" include(":androidApp", ":sharedCore", ":sharedUi") + +include(":uiTest") diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt index b365a20..85a046a 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt @@ -9,6 +9,7 @@ import kotlin.time.Clock interface MobilePlatform { val model: String + val libraryCache: LibraryCache? get() = null fun load(key: String): String? fun save(key: String, value: String) fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) @@ -31,7 +32,8 @@ data class ControllerState( val status: JsonObject? = null, val storage: JsonObject? = null, val settings: JsonObject? = null, val wifi: JsonObject? = null, val library: List = emptyList(), val networks: List = emptyList(), - val thumbnails: Map = emptyMap(), val frame: String? = null, + val previews: Map = emptyMap(), val thumbnails: Map = emptyMap(), + val cacheBytes: Long = 0, val frame: String? = null, val frameCheckedAtMs: Long? = null, val defaultContent: JsonObject? = null, ) @@ -76,11 +78,22 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout private var releaseJob: Job? = null private var foreground = true private var frameRevision: String? = null + private var libraryRevision: String? = null + private var libraryLoaded = false + private var lastLibraryCheckMs = 0L + private var cacheGeneration = 0L private var connectionJob: Job? = null private var commandJob: Job? = null private var connectionGeneration = 0L private var scanGeneration = 0L + private val pendingPreviews = linkedMapOf() private val pendingThumbnails = linkedMapOf() + private val hydrating = mutableSetOf() + init { scope.launch { + try { platform.libraryCache?.sizeBytes()?.let { size -> change { it.copy(cacheBytes = size) } } } + catch (e: CancellationException) { throw e } + catch (_: Exception) { /* Cache can disappear under storage pressure. */ } + } } private fun change(update: (ControllerState) -> ControllerState) { mutable.update(update) } fun dismissError() = change { it.copy(error = null) } fun reportError(message: String) = change { it.copy(error = message) } @@ -114,7 +127,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout if (connectionJob?.isActive == true || rpc != null) disconnect() scanGeneration++; scanJob?.cancel(); platform.stopScan() val generation = ++connectionGeneration - frameRevision = null; pendingThumbnails.clear() + frameRevision = null; resetLibraryQueue() change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, devices = it.devices, phase = "正在连接") } connectionJob = scope.launch { var pending: DeviceRpc? = null @@ -150,11 +163,11 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout connectionGeneration++; scanGeneration++ connectionJob?.cancel(); commandJob?.cancel(); scanJob?.cancel(); pollJob?.cancel(); releaseJob?.cancel() platform.stopScan(); rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; requestedDeviceId = null - pendingThumbnails.clear() + resetLibraryQueue() change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered) } } private fun connectionLost(message: String) { - rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; pendingThumbnails.clear() + rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; resetLibraryQueue() change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) } } fun foreground(value: Boolean) { @@ -162,13 +175,25 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout releaseJob?.cancel() if (value) { if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true) + else if (rpc != null && state.value.page == 2) action { refreshLibrary(true) } } else { + pendingPreviews.clear(); pendingThumbnails.clear() scanJob?.cancel(); platform.stopScan() change { it.copy(scanning = false) } releaseJob = scope.launch { delay(30_000); disconnect() } } } - fun page(value: Int) { change { it.copy(page = value) }; if (rpc != null) action { refreshPage() } } + fun page(value: Int) { + if (value != 2) { pendingPreviews.clear(); pendingThumbnails.clear() } + change { it.copy(page = value) } + if (rpc != null) action { refreshPage() } + } + private fun resetLibraryQueue() { + pendingPreviews.clear(); pendingThumbnails.clear() + hydrating.clear() + cacheGeneration++ + libraryRevision = null; libraryLoaded = false; lastLibraryCheckMs = 0L + } private fun capabilities() = state.value.identity?.get("capabilities")?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList() fun supports(capability: String) = capability in capabilities() private suspend fun call(method: String, params: JsonObject = buildJsonObject {}) = checkNotNull(rpc).call(method, params) @@ -198,30 +223,102 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout when (state.value.page) { 0 -> Unit 1 -> if (supports("frame")) refreshFrame() - 2 -> if (supports("library")) { - var items: List = emptyList() - for (attempt in 0..1) { - try { - val loaded = mutableListOf() - var cursor: String? = null - do { - val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } }) - loaded += result.getValue("items").jsonArray.map { it.jsonObject } - cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull - } while (cursor != null) - items = loaded - break - } catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e } - } - val defaultContent = call("content.default.get") - change { it.copy(library = items, defaultContent = defaultContent) } - } + 2 -> if (supports("library")) refreshLibrary(true) 3 -> { if (supports("settings")) { val value = call("settings.get"); change { it.copy(settings = value) } } if (supports("wifi")) { val value = call("wifi.get"); change { it.copy(wifi = value) } } } } } + private suspend fun loadLibraryPages(): Pair> { + for (attempt in 0..1) { + try { + val items = mutableListOf() + var cursor: String? = null + var revision: String? = null + do { + val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } }) + val pageRevision = result.getValue("library_revision").jsonPrimitive.content + if (revision != null && pageRevision != revision) throw DeviceFailure("CONFLICT", "内容列表已改变") + revision = pageRevision + items += result.getValue("items").jsonArray.map { it.jsonObject } + cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull + } while (cursor != null) + return checkNotNull(revision) to items + } catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e } + } + error("内容列表读取失败") + } + private suspend fun cachedImage(deviceId: String, item: JsonObject, tier: String): String? = + try { platform.libraryCache?.readImage(deviceId, item, tier) } + catch (e: CancellationException) { throw e } + catch (_: Exception) { null } + + private fun rememberImage(key: String, image: String, full: Boolean) { + change { old -> + val values = ((if (full) old.thumbnails else old.previews) + (key to image)).toMutableMap() + while (values.size > 128 || values.values.sumOf { it.length } > 8 * 1024 * 1024) values.remove(values.keys.first()) + if (full) old.copy(thumbnails = values) else old.copy(previews = values) + } + } + private suspend fun queueLibraryImages(deviceId: String, items: List) { + pendingPreviews.clear(); pendingThumbnails.clear() + for (item in items) { + if (item["playable"]?.jsonPrimitive?.booleanOrNull == false) continue + val key = thumbnailKey(item) + val full = cachedImage(deviceId, item, "full") + if (full != null) { rememberImage(key, full, true); continue } + if (supports("library_progressive")) { + val preview = cachedImage(deviceId, item, "preview") + if (preview != null) rememberImage(key, preview, false) + else pendingPreviews[key] = item + pendingThumbnails[key] = item + } + } + } + private suspend fun refreshLibrary(forceQueue: Boolean) { + val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content ?: return + val progressive = supports("library_progressive") + val summary = if (progressive) call("library.summary") else null + val expectedRevision = summary?.get("library_revision")?.jsonPrimitive?.content + lastLibraryCheckMs = Clock.System.now().toEpochMilliseconds() + val changed = !libraryLoaded || !progressive || expectedRevision != libraryRevision + var items = state.value.library + if (changed) { + cacheGeneration++ + val cached = if (expectedRevision != null) platform.libraryCache?.loadCatalog(deviceId) else null + if (cached != null && cached.revision == expectedRevision && + cached.items.size == summary.getValue("item_count").jsonPrimitive.int) { + items = cached.items + libraryRevision = cached.revision + } else { + change { it.copy(library = emptyList(), previews = emptyMap(), thumbnails = emptyMap()) } + val loaded = loadLibraryPages() + if (expectedRevision != null && loaded.first != expectedRevision) { + lastLibraryCheckMs = 0L + throw DeviceFailure("CONFLICT", "内容列表已改变,请重新加载") + } + libraryRevision = loaded.first + items = loaded.second + try { platform.libraryCache?.saveCatalog(deviceId, CachedLibrary(loaded.first, items)) } + catch (e: CancellationException) { throw e } + catch (_: Exception) { /* Cache loss must not close a working BLE connection. */ } + } + try { platform.libraryCache?.retain(deviceId, items) } + catch (e: CancellationException) { throw e } + catch (_: Exception) { /* Regenerable files can be pruned next time. */ } + libraryLoaded = true + change { it.copy(library = items, previews = emptyMap(), thumbnails = emptyMap()) } + } + if (changed || forceQueue) queueLibraryImages(deviceId, items) + if (forceQueue) { + val defaultContent = call("content.default.get") + change { it.copy(defaultContent = defaultContent) } + } + try { platform.libraryCache?.sizeBytes()?.let { size -> change { it.copy(cacheBytes = size) } } } + catch (e: CancellationException) { throw e } + catch (_: Exception) { /* Storage reporting is optional. */ } + } private fun startPolling() { pollJob?.cancel() pollJob = scope.launch { @@ -232,19 +329,28 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout if (!foreground) { call("session.ping"); continue } refreshStatus() if (state.value.page in 1..2 && supports("frame")) refreshFrame() - if (state.value.page == 2 && !state.value.busy && pendingThumbnails.isNotEmpty()) { - val (key, item) = pendingThumbnails.entries.first() - pendingThumbnails.remove(key) - val image = call("library.thumbnail", item).getValue("data_base64").jsonPrimitive.content - change { old -> - val cache = (old.thumbnails + (key to image)).toMutableMap() - while (cache.size > 128 || cache.values.sumOf { it.length } > 8 * 1024 * 1024) cache.remove(cache.keys.first()) - old.copy(thumbnails = cache) + if (state.value.page == 2 && supports("library") && + Clock.System.now().toEpochMilliseconds() - lastLibraryCheckMs >= 10_000) refreshLibrary(false) + if (state.value.page == 2 && !state.value.busy && (pendingPreviews.isNotEmpty() || pendingThumbnails.isNotEmpty())) { + val preview = pendingPreviews.isNotEmpty() + val entry = if (preview) pendingPreviews.entries.first() else pendingThumbnails.entries.first() + val key = entry.key + val item = entry.value + if (preview) pendingPreviews.remove(key) else pendingThumbnails.remove(key) + val image = call(if (preview) "library.preview" else "library.thumbnail", item).getValue("data_base64").jsonPrimitive.content + currentCoroutineContext().ensureActive() + val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content + if (deviceId != null) { + try { platform.libraryCache?.writeImage(deviceId, item, if (preview) "preview" else "full", image) } + catch (e: CancellationException) { throw e } + catch (_: Exception) { /* Disk cache may be evicted or unavailable. */ } + rememberImage(key, image, !preview) } } } catch (e: DeviceFailure) { // A stale thumbnail or other business rejection does not // invalidate the authenticated transport. + if (e.code == "CONFLICT" || e.code == "NOT_FOUND") lastLibraryCheckMs = 0L change { it.copy(error = e.message) } } catch (e: Exception) { if (e !is CancellationException) { @@ -262,9 +368,37 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout } fun refresh() = action { refreshStatus(); refreshPage(); if (state.value.page == 2 && supports("frame")) refreshFrame() } fun thumbnail(item: JsonObject) { + if (item["playable"]?.jsonPrimitive?.booleanOrNull == false) return val key = thumbnailKey(item) - if (key !in state.value.thumbnails) { - if (pendingThumbnails.size < 128) pendingThumbnails[key] = item + if (key in state.value.thumbnails || key in hydrating) return + val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content ?: return + val generation = connectionGeneration + val cacheEpoch = cacheGeneration + hydrating += key + scope.launch { + try { + val full = cachedImage(deviceId, item, "full") + val preview = if (full == null && supports("library_progressive")) cachedImage(deviceId, item, "preview") else null + if (generation == connectionGeneration && cacheEpoch == cacheGeneration && state.value.page == 2 && + state.value.library.any { thumbnailKey(it) == key }) { + if (full != null) rememberImage(key, full, true) + else if (preview != null && key !in state.value.previews) rememberImage(key, preview, false) + else if (!supports("library_progressive") && key !in pendingThumbnails) pendingThumbnails[key] = item + } + } finally { hydrating.remove(key) } + } + } + fun clearContentCache() { + scope.launch { + try { + commands.withLock { + platform.libraryCache?.clear() + resetLibraryQueue() + change { it.copy(library = emptyList(), previews = emptyMap(), thumbnails = emptyMap(), cacheBytes = 0) } + if (rpc != null && state.value.page == 2) refreshLibrary(true) + } + } catch (e: CancellationException) { throw e } + catch (e: Exception) { change { it.copy(error = e.message ?: "清空缓存失败") } } } } fun play(item: JsonObject, default: Boolean = false) = action { diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/LibraryCache.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/LibraryCache.kt new file mode 100644 index 0000000..e15012a --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/LibraryCache.kt @@ -0,0 +1,16 @@ +package org.qimiaoscreen.core + +import kotlinx.serialization.json.JsonObject + +data class CachedLibrary(val revision: String, val items: List) + +/** Regenerable content-list cache. Implementations never persist device settings or credentials. */ +interface LibraryCache { + suspend fun loadCatalog(deviceId: String): CachedLibrary? + suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary) + suspend fun readImage(deviceId: String, item: JsonObject, tier: String): String? + suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) + suspend fun retain(deviceId: String, items: List) + suspend fun sizeBytes(): Long + suspend fun clear() +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt index 26cdeaf..cece1b9 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt @@ -9,6 +9,11 @@ import kotlin.test.* /** Test-only simulated device; never included in production scans or APK code. */ private class SimulatedPlatform : MobilePlatform { override val model = "simulated-test-phone" + var progressive = false + var libraryRevision = "library-v1" + var itemRevision = "item-v1" + val called = mutableListOf() + override val libraryCache: LibraryCache = SimulatedLibraryCache() val preferences = mutableMapOf() val links = mutableListOf() val scanCallbacks = mutableListOf<(DiscoveredDevice) -> Unit>() @@ -17,7 +22,7 @@ private class SimulatedPlatform : MobilePlatform { override fun save(key: String, value: String) { preferences[key] = value } override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) { scanCallbacks += onDevice } override fun stopScan() {} - override suspend fun connect(handle: String) = SimulatedLink(if (handle == "second-handle") "11111111-0000-4000-8000-000000000001" else "00000000-0000-4000-8000-000000000001", signal).also { links += it } + override suspend fun connect(handle: String) = SimulatedLink(if (handle == "second-handle") "11111111-0000-4000-8000-000000000001" else "00000000-0000-4000-8000-000000000001", signal, this).also { links += it } override fun crypto() = object : SecureChannel { override val hello = byteArrayOf(1) override fun finish(serverHello: ByteArray) {} @@ -26,7 +31,23 @@ private class SimulatedPlatform : MobilePlatform { } } -private class SimulatedLink(private val deviceId: String, private val signal: Deferred?) : DeviceLink { +private class SimulatedLibraryCache : LibraryCache { + val catalogs = mutableMapOf() + val images = mutableMapOf() + private fun key(id: String, item: JsonObject, tier: String) = "$id|${thumbnailKey(item)}|$tier" + override suspend fun loadCatalog(deviceId: String) = catalogs[deviceId] + override suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary) { catalogs[deviceId] = catalog } + override suspend fun readImage(deviceId: String, item: JsonObject, tier: String) = images[key(deviceId, item, tier)] + override suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) { images[key(deviceId, item, tier)] = base64 } + override suspend fun retain(deviceId: String, items: List) { + val valid = items.flatMap { listOf(key(deviceId, it, "preview"), key(deviceId, it, "full")) }.toSet() + images.keys.removeAll { it.startsWith("$deviceId|") && it !in valid } + } + override suspend fun sizeBytes() = images.values.sumOf { it.length.toLong() } + override suspend fun clear() { catalogs.clear(); images.clear() } +} + +private class SimulatedLink(private val deviceId: String, private val signal: Deferred?, private val platform: SimulatedPlatform) : DeviceLink { override val mtu = 247 private val receiver = Reassembler() private val messages = Channel(Channel.UNLIMITED) @@ -41,16 +62,29 @@ private class SimulatedLink(private val deviceId: String, private val signal: De val response = if (kind == 1) byteArrayOf(2) else { val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject val method = request.getValue("method").jsonPrimitive.content + platform.called += method if (method == failure) throw DeviceTransportFailure("simulated connection loss") if (method == "status.get" && blockStorage) { storageEntered.complete(Unit); awaitCancellation() } val result = when (method) { - "session.open" -> """{"device_id":"$deviceId","device_name":"模拟屏","short_id":"${deviceId.take(8)}","protocol_major":1,"capabilities":["status","frame"]}""" + "session.open" -> """{"device_id":"$deviceId","device_name":"模拟屏","short_id":"${deviceId.take(8)}","protocol_major":1,"capabilities":["status","frame","library"${if (platform.progressive) ",\"library_progressive\"" else ""}]}""" "device.rename" -> buildJsonObject { put("device_id", deviceId); put("device_name", request.getValue("params").jsonObject.getValue("name")) put("short_id", deviceId.take(8)); put("protocol_major", 1); put("capabilities", JsonArray(emptyList())) }.toString() "status.get" -> """{"state":{}}""" "frame.get" -> """{"frame_revision":"test-frame","data_base64":"test-frame-data"}""" + "library.summary" -> """{"library_revision":"${platform.libraryRevision}","item_count":2}""" + "library.list" -> buildJsonObject { + put("library_revision", platform.libraryRevision) + put("next_cursor", JsonNull) + put("items", JsonArray((1..2).map { number -> buildJsonObject { + put("type", "template"); put("id", "item-$number"); put("revision", platform.itemRevision) + put("name", "条目$number"); put("playable", true) + } })) + }.toString() + "content.default.get" -> """{"name":"默认内容"}""" + "library.preview" -> """{"mime":"image/png","data_base64":"small"}""" + "library.thumbnail" -> """{"mime":"image/png","data_base64":"full"}""" else -> "{}" } buildJsonObject { @@ -239,4 +273,53 @@ class ControllerRecoveryTest { } finally { controller.disconnect(); scope.cancel() } } + @Test fun progressiveLibraryUsesTwoPassesAndReusesVerifiedCache() = runBlocking { + val platform = SimulatedPlatform().apply { progressive = true } + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val controller = Controller(platform, scope) + try { + controller.connect(device) + withTimeout(2_000) { controller.state.first { it.status != null } } + controller.page(2) + withTimeout(5_000) { controller.state.first { it.library.size == 2 && !it.busy } } + withTimeout(16_000) { controller.state.first { it.thumbnails.size == 2 } } + assertEquals(listOf("library.preview", "library.preview", "library.thumbnail", "library.thumbnail"), + platform.called.filter { it == "library.preview" || it == "library.thumbnail" }) + val cache = platform.libraryCache as SimulatedLibraryCache + assertEquals(4, cache.images.size) + controller.disconnect() + platform.called.clear() + controller.connect(device) + withTimeout(5_000) { controller.state.first { it.library.size == 2 && it.thumbnails.size == 2 } } + assertTrue("library.summary" in platform.called) + assertFalse(platform.called.any { it in listOf("library.list", "library.preview", "library.thumbnail") }) + platform.libraryRevision = "library-v2" + platform.itemRevision = "item-v2" + controller.refresh() + withTimeout(5_000) { controller.state.first { it.library.firstOrNull()?.get("revision")?.jsonPrimitive?.content == "item-v2" && !it.busy } } + assertTrue("library.list" in platform.called) + assertFalse(cache.images.keys.any { "item-v1" in it }) + controller.disconnect() + controller.clearContentCache() + withTimeout(2_000) { controller.state.first { it.cacheBytes == 0L } } + assertTrue(cache.images.isEmpty()) + assertTrue(controller.state.value.remembered.isNotEmpty()) + } finally { controller.disconnect(); scope.cancel() } + } + + @Test fun oldDeviceFetchesVisibleFullImageWithoutNewMethods() = runBlocking { + val platform = SimulatedPlatform().apply { progressive = false } + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val controller = Controller(platform, scope) + try { + controller.connect(device) + withTimeout(2_000) { controller.state.first { it.status != null } } + controller.page(2) + withTimeout(5_000) { controller.state.first { it.library.size == 2 && !it.busy } } + controller.thumbnail(controller.state.value.library.first()) + withTimeout(6_000) { controller.state.first { it.thumbnails.isNotEmpty() } } + assertFalse(platform.called.any { it in listOf("library.summary", "library.preview") }) + } finally { controller.disconnect(); scope.cancel() } + } + } diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt index f15d958..3afd842 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt @@ -78,10 +78,18 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im if (dialog == "settings") { var name by remember { mutableStateOf(state.clientName) } AlertDialog(onDismissRequest = { dialog = null }, title = { Text("软件设置") }, - text = { OutlinedTextField(name, { name = it.take(40) }, label = { Text("当前手机昵称") }, singleLine = true) }, + text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField(name, { name = it.take(40) }, label = { Text("当前手机昵称") }, singleLine = true) + Text("内容缓存:${state.cacheBytes / 1024} KiB") + TextButton({ dialog = "clear-cache" }, Modifier.testTag("cache_clear_open")) { Text("清空内容缓存") } + } }, confirmButton = { TextButton({ controller.renamePhone(name); if (name.trim().isNotEmpty()) dialog = null }, enabled = !state.busy) { Text("保存") } }, dismissButton = { TextButton({ dialog = null }) { Text("取消") } }) } + if (dialog == "clear-cache") AlertDialog(onDismissRequest = { dialog = "settings" }, title = { Text("清空内容缓存") }, + text = { Text("删除本机保存的内容列表、预览图和完整图。再次打开显示内容时会重新从设备获取。") }, + confirmButton = { TextButton({ controller.clearContentCache(); dialog = null }, Modifier.testTag("cache_clear_confirm")) { Text("清空") } }, + dismissButton = { TextButton({ dialog = "settings" }) { Text("取消") } }) } } @@ -172,8 +180,8 @@ private fun ContentPage(state: ControllerState, c: Controller, decode: (String) item { Text("可播放内容", style = MaterialTheme.typography.titleLarge) } items(state.library, key = { it.text("type") + it.text("id") }) { item -> val key = thumbnailKey(item) - LaunchedEffect(key) { c.thumbnail(item) } - val encoded = state.thumbnails[key] + val encoded = state.thumbnails[key] ?: state.previews[key] + LaunchedEffect(key, encoded) { c.thumbnail(item) } val image = remember(encoded) { encoded?.let(decode) } Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (image != null) Image(image, item.text("name"), Modifier.size(64.dp)); Text(item.text("name")) } diff --git a/移动端相关内容/安卓app/安卓程序源代码/uiTest/build.gradle.kts b/移动端相关内容/安卓app/安卓程序源代码/uiTest/build.gradle.kts new file mode 100644 index 0000000..b4aa32d --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/uiTest/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + id("com.android.test") + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose.multiplatform) +} +android { + namespace = "org.qimiaoscreen.uitest" + compileSdk = 37 + targetProjectPath = ":androidApp" + experimentalProperties["android.experimental.self-instrumenting"] = true + defaultConfig { + minSdk = 26 + targetSdk = 37 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures { compose = true } + buildTypes { getByName("debug") { isDebuggable = true } } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} +dependencies { + implementation(project(":sharedCore")) + implementation(project(":sharedUi")) + implementation(libs.activity.compose) + implementation(libs.coroutines.android) + implementation(libs.serialization.json) + implementation("org.jetbrains.compose.ui:ui-test-junit4:1.12.1") + implementation("androidx.test:runner:1.7.0") + implementation("androidx.test.ext:junit:1.3.0") +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/AndroidManifest.xml b/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1d5d854 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt b/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt new file mode 100644 index 0000000..95a3b58 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt @@ -0,0 +1,79 @@ +package org.qimiaoscreen.uitest + +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import android.os.Bundle +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.serialization.json.* +import org.qimiaoscreen.core.* +import org.qimiaoscreen.ui.QimiaoApp +import androidx.compose.ui.graphics.ImageBitmap + +/** Only in the self-instrumenting test APK. No AndroidBle, network or private data. */ +class FixtureActivity : ComponentActivity() { + val platform = FixturePlatform() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val controller = Controller(platform, scope) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { QimiaoApp(controller, controller::scan, { ImageBitmap(64, 64) }) } + } + override fun onDestroy() { controller.disconnect(); scope.cancel(); super.onDestroy() } +} +class FixturePlatform : MobilePlatform { + override val model = "模拟测试手机" + private val saved = mutableMapOf() + val calls = mutableListOf() + var scanError = false + var emptyLibrary = true + override fun load(key: String) = saved[key] + override fun save(key: String, value: String) { saved[key] = value } + override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) { + if (scanError) onError("模拟扫描失败") else onDevice(DiscoveredDevice("fixture", "QMS-00000000", -40)) + } + override fun stopScan() {} + override suspend fun connect(handle: String): DeviceLink { + check(handle == "fixture") + return FixtureLink(this) + } + override fun crypto() = object : SecureChannel { + override val hello = byteArrayOf(1) + override fun finish(serverHello: ByteArray) {} + override fun encrypt(payload: ByteArray) = payload + override fun decrypt(record: ByteArray) = record + } +} +private class FixtureLink(val platform: FixturePlatform) : DeviceLink { + override val mtu = 247 + private val reassembler = Reassembler() + private val messages = Channel(Channel.UNLIMITED) + private var sequence = 0L + override suspend fun send(fragment: ByteArray) { + val (kind, payload) = reassembler.accept(fragment) ?: return + val response = if (kind == 1) byteArrayOf(2) else { + val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject + val method = request.getValue("method").jsonPrimitive.content + platform.calls += method + val result = when (method) { + "session.open" -> """{"device_id":"00000000-0000-4000-8000-000000000001","device_name":"模拟小屏幕","short_id":"00000000","protocol_major":1,"capabilities":["status","frame","library","library_progressive","settings","wifi","wifi_scan"]}""" + "session.ping" -> """{"alive":true}""" + "status.get" -> """{"state":{"animation_playback":{"active":false}}}""" + "frame.get" -> """{"frame_revision":"fixture","data_base64":"fixture-image"}""" + "settings.get" -> """{"revision":"fixture","values":{"brightness":40,"orientation":0,"prompt_delay_seconds":5}}""" + "wifi.get" -> """{"revision":"fixture","saved":null,"active":{"connected":false}}""" + "wifi.scan" -> """{"networks":[]}""" + "library.summary" -> if (platform.emptyLibrary) """{"library_revision":"fixture-empty","item_count":0}""" else """{"library_revision":"fixture-full","item_count":1}""" + "library.list" -> if (platform.emptyLibrary) """{"library_revision":"fixture-empty","items":[],"next_cursor":null}""" else """{"library_revision":"fixture-full","items":[{"id":"fixture","type":"template","name":"测试模板","revision":"fixture-1","playable":true}],"next_cursor":null}""" + "content.default.get" -> """{"name":"测试默认"}""" + "library.preview" -> """{"data_base64":"fixture-preview"}""" + "library.thumbnail" -> """{"data_base64":"fixture-image"}""" + else -> error("Unexpected fixture RPC: $method") + } + buildJsonObject { put("id", request.getValue("id")); put("ok", true); put("result", Protocol.json.parseToJsonElement(result)) }.toString().encodeToByteArray() + } + Protocol.encode(if (kind == 1) 2 else 3, sequence++, response, mtu).forEach { messages.send(it) } + } + override suspend fun receive() = messages.receive() + override fun close() { messages.close() } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt b/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt new file mode 100644 index 0000000..6e554c9 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt @@ -0,0 +1,93 @@ +package org.qimiaoscreen.uitest + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import org.junit.Rule +import org.junit.Test +import org.junit.Assert.* + +class IsolatedUiTest { + @get:Rule val ui = createAndroidComposeRule() + private fun click(text: String) { ui.onNodeWithText(text).performClick() } + private fun connect() { + click("扫描设备") + ui.waitUntil(5_000) { ui.activity.controller.state.value.devices.isNotEmpty() } + ui.onNodeWithTag("scan_connect").performScrollTo().performClick() + ui.waitUntil(5_000) { ui.activity.controller.state.value.status != null } + } + @Test fun connection() { + ui.onNodeWithText("暂无已保存设备,扫描后选择即可连接").assertExists() + ui.runOnIdle { ui.activity.platform.scanError = true } + click("扫描设备") + ui.onNodeWithText("模拟扫描失败").assertExists() + click("知道了") + ui.onNodeWithTag("controller_error").assertDoesNotExist() + ui.runOnIdle { ui.activity.platform.scanError = false } + connect() + ui.onNodeWithTag("connected_scan_disconnect").performScrollTo().assertIsEnabled().performClick() + ui.waitUntil(5_000) { ui.activity.controller.state.value.identity == null } + } + @Test fun status() { + click("设备状态") + ui.onNodeWithText("请先在连接页连接设备").assertExists() + click("连接"); connect(); click("设备状态") + ui.waitUntil(5_000) { ui.activity.controller.state.value.frame != null } + ui.onNodeWithTag("frame_card").assertIsDisplayed() + ui.onNodeWithTag("frame_refresh").performClick() + ui.waitUntil(5_000) { !ui.activity.controller.state.value.busy } + assertTrue(ui.activity.platform.calls.contains("frame.get")) + } + @Test fun content() { + connect(); click("显示内容") + ui.waitUntil(5_000) { !ui.activity.controller.state.value.busy } + ui.onNodeWithText("可播放内容").performScrollTo().assertIsDisplayed() + ui.onNodeWithText("播放").assertDoesNotExist() + ui.runOnIdle { ui.activity.platform.emptyLibrary = false; ui.activity.controller.refresh() } + ui.waitUntil(5_000) { ui.activity.controller.state.value.library.isNotEmpty() } + ui.onNodeWithText("测试模板").performScrollTo().assertIsDisplayed() + ui.onNodeWithText("播放").assertIsEnabled() + ui.waitUntil(7_000) { ui.activity.controller.state.value.previews.isNotEmpty() } + ui.waitUntil(7_000) { ui.activity.controller.state.value.thumbnails.isNotEmpty() } + assertTrue(ui.activity.platform.calls.indexOf("library.preview") < ui.activity.platform.calls.indexOf("library.thumbnail")) + assertFalse(ui.activity.platform.calls.contains("content.play")) + } + @Test fun settings() { + connect(); click("设备设置") + ui.waitUntil(5_000) { ui.activity.controller.state.value.settings != null } + ui.onNodeWithText("WiFi设置 展开").performScrollTo().performClick() + ui.onNodeWithText("保存WiFi设置").performScrollTo().assertIsNotEnabled() + ui.onNodeWithText("WiFi名称").performScrollTo().performTextInput("fixture-network") + ui.onNodeWithText("保存WiFi设置").performScrollTo().performClick() + ui.onNodeWithText("立即生效WiFi设置").assertExists() + click("取消") + assertFalse(ui.activity.platform.calls.contains("wifi.set")) + } + @Test fun navigation() { + for (page in listOf("设备状态", "显示内容", "设备设置", "连接")) click(page) + click("⋮"); click("关于") + ui.onNodeWithText("关于奇妙小屏幕").assertExists(); click("关闭") + click("⋮"); click("软件设置") + ui.onNodeWithText("当前手机昵称").performTextReplacement("模拟昵称") + click("保存") + ui.runOnIdle { assertEquals("模拟昵称", ui.activity.controller.state.value.clientName) } + click("⋮"); click("软件设置") + ui.onNodeWithText("内容缓存:0 KiB").assertExists() + ui.onNodeWithTag("cache_clear_open").performClick() + ui.onNodeWithTag("cache_clear_confirm").performClick() + assertTrue(ui.activity.platform.calls.isEmpty()) + } + @Test fun layout() { + connect() + for (page in listOf("设备状态", "显示内容")) { + click(page) + ui.waitUntil(5_000) { ui.activity.controller.state.value.frame != null } + val card = ui.onNodeWithTag("frame_card").fetchSemanticsNode().boundsInRoot + val image = ui.onNodeWithTag("frame_image").fetchSemanticsNode().boundsInRoot + val title = ui.onNodeWithTag("frame_title").fetchSemanticsNode().boundsInRoot + val refresh = ui.onNodeWithTag("frame_refresh").fetchSemanticsNode().boundsInRoot + assertTrue(kotlin.math.abs(image.center.x - card.center.x) <= 3) + assertTrue(kotlin.math.abs(title.center.x - card.center.x) <= 3) + assertTrue(title.right <= refresh.left) + } + } +} diff --git a/移动端相关内容/安卓app/构建与发布/README.md b/移动端相关内容/安卓app/构建与发布/README.md index bd5fe24..1e08ce7 100644 --- a/移动端相关内容/安卓app/构建与发布/README.md +++ b/移动端相关内容/安卓app/构建与发布/README.md @@ -39,7 +39,7 @@ python "移动端相关内容/安卓app/构建与发布/build_android.py" --work ## 交付前检查 -从隔离目录依文档重新构建并验证;完成相关测试、协议互操作和真实 BLE 联调;运行根仓库卫生检查。交付说明准确区分通过、未验证和不适用。第二台手机验收 APK 放到同一清晰可找到的交付目录,用户安装后再进行实机互斥测试。 +从隔离目录依文档重新构建并验证;按 [影响选测](../如何安卓测试/按影响选测.md) 完成相关验证;仅通信路径/平台行为受影响时做相应协议互操作和定向真实 BLE 联调;运行根仓库卫生检查。交付说明准确区分通过、未验证和不适用。第二台手机验收 APK 放到同一清晰可找到的交付目录,用户安装后再进行实机互斥测试。 ## 可重复打包 @@ -62,3 +62,7 @@ Python 在 Windows 缺少 IANA tzdata 时,固定北京时间时间戳可使用 打包前重新核对隔离源码、实际 APK、构建日期和签名摘要;测试源码调整也要同步隔离工程,不能测试旧测试 APK。测试/构建遇到的新问题在完成后更新相应文档并在最终反馈说明,具体输入和真机排障见 [测试排障指南](../如何安卓测试/常见问题与排障.md)。 Android 0.2.1 / versionCode 4:顶栏长文本循环、连接条目断开/扫描保留、居中预览;仅debug签名。交付和本轮覆盖范围见交付包0.2.1-4及测试结果归档2026-09-27_界面细节优化。 + +独立模拟器 UI 测试 APK 构建任务为 `:uiTest:assembleDebug`,只用于验证,不进入产品交付包;测试框架调整不提升产品版本。 + +Android 0.2.2 / versionCode 5:显示内容两档图片传输、128 MiB 本地缓存及软件设置清空;协议 1.1 增量能力,仍只允许 debug 签名。设备功能部署沿用回滚开发流程,正式设备版本不因本次开发部署递增。验收以本轮测试归档为准。 diff --git a/移动端相关内容/安卓app/构建与发布/package_debug.py b/移动端相关内容/安卓app/构建与发布/package_debug.py index d77b888..35f7f62 100644 --- a/移动端相关内容/安卓app/构建与发布/package_debug.py +++ b/移动端相关内容/安卓app/构建与发布/package_debug.py @@ -48,7 +48,7 @@ def main(): build_date = re.search(r'BUILD_DATE = "([^"]+)"', build_config)[1] manifest = dict(app_version=version, version_code=code, signing='debug', build_date=build_date, status='development-validation-see-test-report', apk_sha256=digest(apk), - certificate_sha256=certificate, protocol_major=1, minimum_android_api=26, + certificate_sha256=certificate, protocol_major=1, protocol_minor=1, minimum_android_api=26, target_android_api=37, build_tools='36.0.0', toolchain=versions, sources=entries) (destination / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') (destination / 'SHA256SUMS').write_text(f'{digest(apk)} {name}\n', encoding='utf-8') diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/README.md b/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/README.md new file mode 100644 index 0000000..1469fd4 --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/README.md @@ -0,0 +1,7 @@ +# 奇妙小屏幕 0.2.2 debug(versionCode 5) + +仅 debug 签名,可覆盖安装相同调试证书的旧包。Android 8.0 以上,真实验证机为 Android 13。 + +本包为当前开发验证产物;验收范围和未验证项见 `../../../测试结果归档/实施状态.md`。不能将 APK 成功生成视为所有首版验收通过。设备需已部署移动控制功能,历史设备版本号本身不代表支持 BLE。 + +SHA-256:`c3ae5ab3caa252f60c715d6f8ecf177767c1e7bc3ea12652b688efe5a3fc3c07`。源码摘要与签名证书摘要见 manifest.json。 diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/SHA256SUMS b/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/SHA256SUMS new file mode 100644 index 0000000..f9ea65a --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/SHA256SUMS @@ -0,0 +1 @@ +c3ae5ab3caa252f60c715d6f8ecf177767c1e7bc3ea12652b688efe5a3fc3c07 奇妙小屏幕-0.2.2-debug.apk diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/manifest.json b/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/manifest.json new file mode 100644 index 0000000..6a652e0 --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/manifest.json @@ -0,0 +1,175 @@ +{ + "app_version": "0.2.2", + "version_code": 5, + "signing": "debug", + "build_date": "2026-09-29 22:18 +08:00", + "status": "development-validation-see-test-report", + "apk_sha256": "c3ae5ab3caa252f60c715d6f8ecf177767c1e7bc3ea12652b688efe5a3fc3c07", + "certificate_sha256": "b413e68c0bd2311c491ebd087ac5791c7c2ad8267793f92f6055d5521bd3ef4d", + "protocol_major": 1, + "protocol_minor": 1, + "minimum_android_api": 26, + "target_android_api": 37, + "build_tools": "36.0.0", + "toolchain": { + "kotlin": "2.4.20", + "agp": "9.3.1", + "compose": "1.12.1", + "coroutines": "1.10.2", + "serialization": "1.9.0" + }, + "sources": [ + { + "path": "androidApp/build.gradle.kts", + "sha256": "4d1443c0c5ad4c20bd149e84ee95c600a8558a552b39102b7b049ab6897cad01" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt", + "sha256": "7b75279ea8bba61fd9a35848339f765e931abb2748dcb2ab08f37f85afd21084" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt", + "sha256": "f286cf70cd00b7e872371f0fbc55f6be46e52602ef6890e974aca1c32eb006fa" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt", + "sha256": "56e99aa9fd54b39fa7bba0efbd24e875afe0e20827deacd654f999f065c83b12" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryBleCacheTest.kt", + "sha256": "01636b443b36532e8b6aa55eaf9ef0976697122362ca24f05a1da6bb958e419f" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryCacheTest.kt", + "sha256": "a225a1d2a59bee7f693acf82feefc285e4777a63c5c8a711b61523429e547360" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/WifiSwitchTest.kt", + "sha256": "5ce335ff84fee01230733971964fefde3fcc6103172d3c40fee64f50838b7bcf" + }, + { + "path": "androidApp/src/main/AndroidManifest.xml", + "sha256": "186ed22b19731ea5bf3567e40d6487b95f1bced3dd2fce605de6557cb7c5763e" + }, + { + "path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidBle.kt", + "sha256": "a1af9d6593c3be1af0f2a39a3869eae2e1bbbb1dab8b775e06da5dcaa739e9ff" + }, + { + "path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidLibraryCache.kt", + "sha256": "c8904caa25123b0085678d8e01e6bbf27ab0eb25e3767334a539c1dc7aebdba1" + }, + { + "path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt", + "sha256": "fe0dd31eccefc6881e2c1a7473c447e28e0e60673a53e6cd767675825283c96a" + }, + { + "path": "build.gradle.kts", + "sha256": "feb3b6e64b846f0408cc40e95ae4b632ce9288a56bd6151bc3bbcc46f0549b49" + }, + { + "path": "gradle/libs.versions.toml", + "sha256": "ed393bb2b4d81a162f6b3d21968eeca113e23c2f5d5d22c3be9d104bc592a633" + }, + { + "path": "gradle/wrapper/gradle-wrapper.jar", + "sha256": "497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7" + }, + { + "path": "gradle/wrapper/gradle-wrapper.properties", + "sha256": "2cb0f9ddf992a26fc4947c91019136bff5e1ccfbf93bd905402b1abf69d15bc5" + }, + { + "path": "gradle.properties", + "sha256": "e7d684b7795fd04ab2e7e96038fa2bf4f9fd633578aa3bb2f5fe367b4f5ec159" + }, + { + "path": "gradlew", + "sha256": "ab5c0cad16305af2e619c159c1f58dd68d07fab9c11e36701e109c0277407f7a" + }, + { + "path": "gradlew.bat", + "sha256": "475c4f08cd57cf2faa819e7f36d72aa93f0ad646ea23a8f7fa3ef54dee1cbc52" + }, + { + "path": "README.md", + "sha256": "7232ebbac0899152e312429fecaf6b3e14b31265b0c50a95c99a6213aa04b2cd" + }, + { + "path": "settings.gradle.kts", + "sha256": "3a1adf811d65cfd2ce28bfeb711990511a29094b8a5b5378f2860c86c9fe83e2" + }, + { + "path": "sharedCore/build.gradle.kts", + "sha256": "9a420e39e3c655f676572d4486e415dbc8b4fd45904db6ad7b64d7fcd71f084b" + }, + { + "path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt", + "sha256": "fa5dd1b999be7e9edb126dc3bc7049a536487e6ec443c4199626ee30ff01f7d0" + }, + { + "path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt", + "sha256": "870345382351bbf6d37f4332c0fab099cb47b7b703428a98567a0086c3316be6" + }, + { + "path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/LibraryCache.kt", + "sha256": "d260abe091fa35ad79a663cdae9284147a000e51d8ee1f87f6d7e28138de3fe6" + }, + { + "path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Protocol.kt", + "sha256": "99264aff225076ef1b423801b9bba8797e0e06a9e759339bc52e896620e4b694" + }, + { + "path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/SecureChannel.kt", + "sha256": "bde40388bb74c9d47a4e099a14a0baeca3b6bda1539023dbd7dca831c6f24039" + }, + { + "path": "sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt", + "sha256": "98b05b59c42f714f0ff356a4ed942e1564ecf3c02ded9ed9a108fc7b1cba00e5" + }, + { + "path": "sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ProtocolTest.kt", + "sha256": "5d26d3a50c5887d72dc91b2081273fbe4a5a968f01451ef75ab78903d5d42217" + }, + { + "path": "sharedCore/src/javaCryptoMain/kotlin/org/qimiaoscreen/core/JavaSecureChannel.kt", + "sha256": "554d663ceb6ec1be095b31e9ff9549af3ecd02f975059c22ce566c87225b05eb" + }, + { + "path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/CryptoInteropTest.kt", + "sha256": "2ddb06c4476952a69bf01c0a3a7164625ae8c0bfe9194349450a2e254888d4b3" + }, + { + "path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/RpcTimeoutTest.kt", + "sha256": "fca298fb2266ee7e68e6df76d837f7f191e0046aa5ba321a304c4b4369e064a6" + }, + { + "path": "sharedCore/src/jvmTest/resources/protocol-v1.json", + "sha256": "6280953b95a3f5d1ae531d7feaa6a62848ffa3e438e58308d2a27f04723803dc" + }, + { + "path": "sharedUi/build.gradle.kts", + "sha256": "d469b330d248647e6e93f98d69eb46730adab329ebaf71cbfd896d5426fd0ff1" + }, + { + "path": "sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt", + "sha256": "951851755951dbbc92d526c35e1bc4d98db153d6f226709836847468b61c4b9c" + }, + { + "path": "uiTest/build.gradle.kts", + "sha256": "958d9d5509c9b0fd3076f0d22a4544b66e11a4ba47e728107b8ea357cdaffaf3" + }, + { + "path": "uiTest/src/main/AndroidManifest.xml", + "sha256": "ef50ab1e26f0caaadd7997799dd47d510cb7b4042b421ebe5354f4d2d42708dc" + }, + { + "path": "uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt", + "sha256": "0d9bc54d81752aeec28b14fa87a48357e1343c6450ec326a7fad6fc8be55eac1" + }, + { + "path": "uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt", + "sha256": "1aaa2daa4005f5211c088a3c6579317a94d3de92e494a32614ec7f63053ab9a5" + } + ] +} diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/奇妙小屏幕-0.2.2-debug.apk b/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/奇妙小屏幕-0.2.2-debug.apk new file mode 100644 index 0000000..6e63d85 Binary files /dev/null and b/移动端相关内容/安卓app/构建与发布/交付包/0.2.2-5/奇妙小屏幕-0.2.2-debug.apk differ diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-29_内容缓存/README.md b/移动端相关内容/安卓app/测试结果归档/2026-09-29_内容缓存/README.md new file mode 100644 index 0000000..97f09e5 --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-29_内容缓存/README.md @@ -0,0 +1,31 @@ +# 2026-09-29 “显示内容”两档图片与缓存验收 + +## 变更与选测 + +核桃派协议增加 `library.summary`、`library.preview` 和能力 `library_progressive`;`library.thumbnail` 仍是 64×64 PNG。板端按条目修订保存可再生 16×16 缓存并清理失效文件。App 按设备、条目修订和画质保存目录与图片,内容页先请求全部小图,再逐项请求完整图;有缓存时以目录修订确认复用。“当前设备画面”的 `frame.get` 路径不变,断线不提供离线列表。App 更新到 0.2.2/versionCode 5,设备 `VERSION` 保持 1.1.3,功能时间为 `2026-09-29T22:15+08:00`。 + +直接影响目录、图片 BLE 请求、Android 私有文件缓存、内容页与软件设置。因此选择设备协议/缓存本机测试、sharedCore JVM、内容与设置的独立模拟器、登记手机的 `cache-storage` 和 `library-cache` 两个定向场景,以及 debug 构建/覆盖安装。未改动显示驱动、实际输出、WiFi、权限、后台策略或当前帧读取;这些专项按影响跳过。实屏视觉检查不适用,没有切换显示内容、测试图案或启动摄像头。 + +## 实际结果 + +| 验证 | 结果 | +|---|---| +| 板端本机 | `test_mobile_control.py` 19 项通过;与 `test_mobile_protocol.py` 合计的协议/控制定向测试 27 项通过。覆盖目录修订和顺序、16×16 PNG、旧 64×64 接口、失效小图清理、实际用户动画首帧和空动画。最终动画修复后重跑控制测试 19 项通过。 | +| App 共享逻辑 | `:sharedCore:jvmTest` 通过;覆盖两轮顺序、重连复用、修订变化删除旧项、清空、旧设备回退及断线取消。 | +| 独立模拟器 | API 35 的 content、settings、navigation 场景通过;内容页验证小图先于完整图、设置页清空确认。 | +| 登记手机缓存存储 | `cache-storage` 通过;验证设备隔离、修订清理、损坏文件按未命中处理和清空。 | +| 登记手机真实 BLE | `library-cache` 通过:现有目录 15 项,首次两档图片共 30 次请求,PNG 负载 77,058 字节;未变化的重连图片请求 0 次、PNG 负载 0 字节;生产 Controller 复用缓存时图片写入 0 次。这里统计的是 PNG 负载,不是包含协议与无线链路开销的总传输字节数。 | +| 设备部署 | 使用现有可回滚开发部署流程,最终服务 active、移动接口可用;部署的移动控制、小图缓存及功能时间文件摘要与本地一致,恢复副本保留。未修改设备 `VERSION`,未导出 OTA/IMG。 | +| App 交付 | `:androidApp:assembleDebug`、`:androidApp:assembleDebugAndroidTest` 成功;仅 debug 签名。登记手机覆盖安装后的 APK 摘要与交付包一致:`c3ae5ab3caa252f60c715d6f8ecf177767c1e7bc3ea12652b688efe5a3fc3c07`。 | + +## 首次失败、修复及复测 + +- 首次真实 BLE 测试在一条用户动画的 `library.preview` 返回 `BAD_REQUEST`。该动画首帧使用 `path` 指向持久图片,而旧演示动画使用内嵌 `image`;预览/完整图提取现均识别并验证两种形式。控制测试和真实 BLE 全库两档读取复测通过。不能把首次失败写成网络故障。 +- 既有 AVD 名称曾指向已不存在的磁盘目录。保留旧配置,使用已登记的 SDK 镜像新建专用 AVD 后完成上述独立模拟器场景;未清空或覆盖旧 AVD。 +- 一次本地调用只准备了 instrumentation APK,缺少产品 APK;在同一隔离工程补构建产品 APK 后运行成功。运行真机场景前应同时确认两个 APK 都存在。 + +可复用方法已更新到 [测试流程](../../如何安卓测试/README.md) 和 [排障指南](../../如何安卓测试/常见问题与排障.md)。本归档不保存凭据、真实设备标识、开发机绝对路径或无线数据内容。 + +## 边界 + +真实 BLE 结果证明设备上全部现存 15 项的两档接口可读取,且未变化重连没有再次下载;首次加载的 App 逐帧呈现顺序由 JVM 和模拟器验证,不把测试探针的请求顺序冒充真实界面目视结果。没有执行长时间前后台循环、跨 SSID、第二台手机、旧 Android 系统或完整无线链路计量;本轮变更不要求这些专项。没有发现复测后仍未解决的本轮产品故障。 diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/README.md b/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/README.md new file mode 100644 index 0000000..33927c4 --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/README.md @@ -0,0 +1,47 @@ +# 2026-09-29 Android 按影响选测改造 + +## 本轮范围和选择理由 + +改变测试基础设施、场景调度、假数据 UI 环境及协作规则。产品源码、设备部署、协议与版本不变;未导出或发布 APK。测试构建使用既有 debug 签名;源摘要及测试 APK 摘要见 manifest.json,APK 仅为临时验证产物。 + +本轮新增模拟器框架,因此首次验证所有六个基础场景,并仅对 layout 扩展三组配置。真机只选 connection(验证真实适配器/握手调度)和 navigation(验证 root 输入及权限恢复)。不把框架首次覆盖扩展为以后每轮全量要求。 + +## 结果 + +| 验证 | 实际结果 | +|---|---| +| sharedCore:jvmTest | 15 个测试,0 failures / errors | +| Android 产品、真机 instrumentation、独立 uiTest 构建 | 通过;隔离工程使用原有 Gradle 9.5.0 / AGP 9.3.1 / Kotlin 2.4.20 / Compose 1.12.1 | +| 宿主调度、预览、失败传播、权限/布局恢复 | 9 个 unittest 通过 | +| 原登记门禁回归 | 8 个 unittest 通过 | +| 模拟器 API 35 baseline | connection、status、content、settings、navigation、layout 均通过 | +| 模拟器 layout 配置 | narrow(约340dp)、large-font(1.3)、landscape 均通过 | +| 模拟器恢复 | 所有运行的字体、方向、密度恢复回读通过;本轮启动实例已退出 | +| 真机 android-test-a / connection | 加密握手、断开、重新扫描重连通过,instrumentation 16.476 秒;未执行数据读取/切网/轮询/后台专项 | +| 真机 android-test-a / navigation | 初次 8.19 秒通过;清理范围调整后 8.898 秒通过;独立导航不连接板端,权限恢复和辅助脚本清理通过 | +| 产品 APK 隔离检查 | dex 不含 FixtureActivity / FixturePlatform / IsolatedUiTest | +| Git diff 空白检查、Python AST | 通过 | + +模拟器逐项结果见三个 JSON。首轮 connection/status 已通过,不因后续内容夹具修复重复运行;后续四项单独重测及补测通过。JSON 中 not_selected 仅描述单次运行,不能误读为整轮没测。 + +最终 Kotlin 调整仅减少 connection-entry 的无关菜单操作、减少 controller-background 的内容加载,并整理缩进;已重新编译。真实验证证明 connection/navigation 的执行路径;其它新拆分专项只有编译/调度证据,未声称均已真机通过。 + +## 本轮失败与修复 + +- 初次 uiTest 配置出现 com.android.test 重复插件版本,以及不存在的 compose.uiTestJUnit4 accessor:复用 AGP classpath 并使用锁定的显式 Compose 测试依赖后构建通过。 +- 系统 Python 缺少 Paramiko:未进入远端测试;改用项目已有本机测试 venv 后真实连接通过,没有为设备端添加依赖。 +- 新 AVD 独立镜像缺包登记且 SDK 根解析不一致:补齐官方包元数据/许可证并正确指定工具对应 SDK 根、复用原 Emulator 后成功;未覆盖现有软件。 +- 模拟器首轮 content 因假条目漏 revision 而失败;补齐真实契约字段后该场景通过。首次失败保留于 ui-baseline-1.json。 +- 最后安装返回值检查修改出现 Python 缩进错误,AST/宿主测试发现;修正后 AST 和全部 9 项宿主测试通过,未在错误状态下运行设备。 + +可复用经验更新在 [常见问题与排障](../../如何安卓测试/常见问题与排障.md) 第13节,规则和命令更新在 [按影响选测](../../如何安卓测试/按影响选测.md)。 + +## 按影响跳过与验证边界 + +本轮未执行实际切网、蓝牙开关、昵称/记忆写入、后台长等待、长轮询、双手机、性能和 systemd 停启;未部署核桃派、切换屏幕内容、启动摄像头或截图。驱动/实屏视觉不适用。 + +上述真机拆分专项保留为必要时的执行入口,当前实际设备结果仍为未验证,不能用宿主调度通过替代。独立模拟 UI 不证明真实射频、密码错误网络激活、Android 8–11 定位权限、MIUI 后台策略或视觉动画质量;相关修改须补对应证据。 + +没有发现本轮新增的未解决产品问题;现有构建提示 sharedUi commonTest 未接入 compilation 仍属于既有边界,本轮 UI 实测在独立 uiTest 内完成。 + +仓库卫生检查通过(624 个 Git 候选文件)。自动审批拒绝临时隔离构建目录删除,目录保留于仓库外受保护临时目录,未尝试其他删除途径。后续构建不依赖它;可复用 SDK、系统镜像和 AVD 保留供按需测试。 diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/manifest.json b/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/manifest.json new file mode 100644 index 0000000..1339d6a --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/manifest.json @@ -0,0 +1,47 @@ +{ + "app_version": "0.2.1", + "versionCode": 4, + "protocol": "1.0 unchanged", + "device_version": "not queried: no device deployment or contract change", + "apk_sha256": { + "androidApp-debug.apk": "75badab341484feea4a95ef697538ad5a879700cfe07850049c5cbbb9fc2094c", + "androidApp-debug-androidTest.apk": "3fa608647f8d27146d25f03af8c9bed1b830c8ccf7b09595eae6ebc45e41308d", + "uiTest-debug.apk": "20f5ac32fe1bbce70bbcf44e1673fb3c6d5d71f58d3effcbe94434f919fdb109" + }, + "source_sha256": { + "androidApp/build.gradle.kts": "7bfbd5d27819700243b7ab1e318eec91024dc170059a92d587400608ec8a59b5", + "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt": "7b75279ea8bba61fd9a35848339f765e931abb2748dcb2ab08f37f85afd21084", + "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt": "f286cf70cd00b7e872371f0fbc55f6be46e52602ef6890e974aca1c32eb006fa", + "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt": "56e99aa9fd54b39fa7bba0efbd24e875afe0e20827deacd654f999f065c83b12", + "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/WifiSwitchTest.kt": "5ce335ff84fee01230733971964fefde3fcc6103172d3c40fee64f50838b7bcf", + "androidApp/src/main/AndroidManifest.xml": "186ed22b19731ea5bf3567e40d6487b95f1bced3dd2fce605de6557cb7c5763e", + "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidBle.kt": "a1af9d6593c3be1af0f2a39a3869eae2e1bbbb1dab8b775e06da5dcaa739e9ff", + "androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt": "7bece662b79093a19fcdf34ad344cf42a57b60291f0536823395ae06663f4f23", + "build.gradle.kts": "feb3b6e64b846f0408cc40e95ae4b632ce9288a56bd6151bc3bbcc46f0549b49", + "gradle/libs.versions.toml": "ed393bb2b4d81a162f6b3d21968eeca113e23c2f5d5d22c3be9d104bc592a633", + "gradle/wrapper/gradle-wrapper.jar": "497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7", + "gradle/wrapper/gradle-wrapper.properties": "2cb0f9ddf992a26fc4947c91019136bff5e1ccfbf93bd905402b1abf69d15bc5", + "gradle.properties": "e7d684b7795fd04ab2e7e96038fa2bf4f9fd633578aa3bb2f5fe367b4f5ec159", + "gradlew": "ab5c0cad16305af2e619c159c1f58dd68d07fab9c11e36701e109c0277407f7a", + "gradlew.bat": "475c4f08cd57cf2faa819e7f36d72aa93f0ad646ea23a8f7fa3ef54dee1cbc52", + "README.md": "7232ebbac0899152e312429fecaf6b3e14b31265b0c50a95c99a6213aa04b2cd", + "settings.gradle.kts": "3a1adf811d65cfd2ce28bfeb711990511a29094b8a5b5378f2860c86c9fe83e2", + "sharedCore/build.gradle.kts": "9a420e39e3c655f676572d4486e415dbc8b4fd45904db6ad7b64d7fcd71f084b", + "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt": "48e7bd52250d541088ca2756a8a1dbd37aeedab1ffc03338c9bb7c6ed9d39d7d", + "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt": "870345382351bbf6d37f4332c0fab099cb47b7b703428a98567a0086c3316be6", + "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Protocol.kt": "99264aff225076ef1b423801b9bba8797e0e06a9e759339bc52e896620e4b694", + "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/SecureChannel.kt": "bde40388bb74c9d47a4e099a14a0baeca3b6bda1539023dbd7dca831c6f24039", + "sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt": "9cfd5291d5442ba79802e4193124bc2f22f05cd9a577001d33913d56b140a4a6", + "sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ProtocolTest.kt": "5d26d3a50c5887d72dc91b2081273fbe4a5a968f01451ef75ab78903d5d42217", + "sharedCore/src/javaCryptoMain/kotlin/org/qimiaoscreen/core/JavaSecureChannel.kt": "554d663ceb6ec1be095b31e9ff9549af3ecd02f975059c22ce566c87225b05eb", + "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/CryptoInteropTest.kt": "2ddb06c4476952a69bf01c0a3a7164625ae8c0bfe9194349450a2e254888d4b3", + "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/RpcTimeoutTest.kt": "fca298fb2266ee7e68e6df76d837f7f191e0046aa5ba321a304c4b4369e064a6", + "sharedCore/src/jvmTest/resources/protocol-v1.json": "6280953b95a3f5d1ae531d7feaa6a62848ffa3e438e58308d2a27f04723803dc", + "sharedUi/build.gradle.kts": "d469b330d248647e6e93f98d69eb46730adab329ebaf71cbfd896d5426fd0ff1", + "sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt": "884e61008e49fd3f1c62969cbe38a798f896734fc70f5219b4e00bcb34bfe9a3", + "uiTest/build.gradle.kts": "958d9d5509c9b0fd3076f0d22a4544b66e11a4ba47e728107b8ea357cdaffaf3", + "uiTest/src/main/AndroidManifest.xml": "ef50ab1e26f0caaadd7997799dd47d510cb7b4042b421ebe5354f4d2d42708dc", + "uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt": "86b3d340d1219a4644573a20d78a4f31ac1efbe20df8c16f291eef18507ded41", + "uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt": "149bf7e3b024206cbf836da8ec113f1c992ae5be0b066f090ca962f808f47188" + } +} diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/ui-baseline-1.json b/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/ui-baseline-1.json new file mode 100644 index 0000000..017d3fe --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/ui-baseline-1.json @@ -0,0 +1,37 @@ +{ + "tests": [ + { + "scenario": "connection", + "configuration": "baseline", + "result": "passed" + }, + { + "scenario": "status", + "configuration": "baseline", + "result": "passed" + }, + { + "scenario": "content", + "configuration": "baseline", + "result": "failed" + }, + { + "scenario": "settings", + "configuration": "baseline", + "result": "not run" + }, + { + "scenario": "navigation", + "configuration": "baseline", + "result": "not run" + }, + { + "scenario": "layout", + "configuration": "baseline", + "result": "not run" + } + ], + "failure": "Selected Compose scenario failed", + "cleanup_errors": [], + "not_selected": [] +} \ No newline at end of file diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/ui-baseline-2.json b/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/ui-baseline-2.json new file mode 100644 index 0000000..27c20cf --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/ui-baseline-2.json @@ -0,0 +1,30 @@ +{ + "tests": [ + { + "scenario": "content", + "configuration": "baseline", + "result": "passed" + }, + { + "scenario": "settings", + "configuration": "baseline", + "result": "passed" + }, + { + "scenario": "navigation", + "configuration": "baseline", + "result": "passed" + }, + { + "scenario": "layout", + "configuration": "baseline", + "result": "passed" + } + ], + "failure": null, + "cleanup_errors": [], + "not_selected": [ + "connection", + "status" + ] +} \ No newline at end of file diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/ui-layout-configs-1.json b/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/ui-layout-configs-1.json new file mode 100644 index 0000000..cc7a11f --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-29_按影响选测改造/ui-layout-configs-1.json @@ -0,0 +1,28 @@ +{ + "tests": [ + { + "scenario": "layout", + "configuration": "narrow", + "result": "passed" + }, + { + "scenario": "layout", + "configuration": "large-font", + "result": "passed" + }, + { + "scenario": "layout", + "configuration": "landscape", + "result": "passed" + } + ], + "failure": null, + "cleanup_errors": [], + "not_selected": [ + "connection", + "status", + "content", + "settings", + "navigation" + ] +} \ No newline at end of file diff --git a/移动端相关内容/安卓app/测试结果归档/实施状态.md b/移动端相关内容/安卓app/测试结果归档/实施状态.md index 29ca6d1..8b303fa 100644 --- a/移动端相关内容/安卓app/测试结果归档/实施状态.md +++ b/移动端相关内容/安卓app/测试结果归档/实施状态.md @@ -1,5 +1,9 @@ # 当前实施状态 +更新时间:2026-09-29。当前 App 为 **0.2.2 / versionCode 5**,设备协议 1.1 的“显示内容”两档图片与跨重连缓存已部署。交付包见 `../构建与发布/交付包/0.2.2-5/`,本轮选测、实测请求量、失败修复与未验证边界见 [内容缓存验收](2026-09-29_内容缓存/README.md)。登记手机上的最终 APK 与交付包摘要一致;核桃派服务正常,设备 `VERSION` 仍为 1.1.3,功能时间为 `2026-09-29T22:15+08:00`。本轮只做 debug 签名,没有导出 OTA/IMG。 + +以下为历史验收记录,不将历史通过项自动归为本轮重测: + 更新时间:2026-09-27。当前App为 **0.2.1 / versionCode 4**,顶栏循环、扫描条目断开/连接保留、两页预览居中已实现。交付包 `../构建与发布/交付包/0.2.1-4/`;本轮证据及未验证边界见 [界面细节优化验收](2026-09-27_界面细节优化/README.md)。JVM15项、root三组布局、实际Activity和BLE只读回归通过,最终安装摘要一致;早期测试失败与修正保留。测试流程和排障第12节已更新。设备协议、服务和驱动不变。 以下为历史验收记录,不将历史通过项自动归为本轮重测: diff --git a/移动端相关内容/开发要求/移动端需求.md b/移动端相关内容/开发要求/移动端需求.md index c511b2c..91f34c4 100644 --- a/移动端相关内容/开发要求/移动端需求.md +++ b/移动端相关内容/开发要求/移动端需求.md @@ -72,3 +72,13 @@ MOBILE-RECONNECT/MOBILE-ERRORS 补充:断线清除旧画面、状态、缩 - MOBILE-STATUS:顶栏长文字自动向左无限循环(30 dp/秒、每轮间隔1秒),短文字静止;菜单固定,完整语义文字保留。 - MOBILE-DISCOVERY/CONNECTION:握手成功后扫描条目显示已连接及断开按钮;再次扫描保留当前连接条目,旧扫描信号不作实时信号。断开、失败和异常清理会话关联;其他条目仍可连接,连接期间禁止重复操作。 - MOBILE-PREVIEW:两处设备画面、标题、等待与过期提示按整个卡片居中,刷新按钮固定右侧;标题预留对称空间,放大字体不与按钮重叠。 + +## 按影响选测 + +MOBILE-TEST-SELECTION:先评估直接及共享依赖影响,再选最低有效层级。纯逻辑用 JVM;普通 UI 用独立模拟器;真实通信、权限/后台厂商差异用定向真机。未改页面不因发布新功能重复测试。root 仅约束登记真机输入,模拟器使用标准 Compose 测试 API。测试授权不等于每轮执行义务。矩阵及证据格式见测试入口。 + +## 2026-09-29 内容缓存 + +MOBILE-LIBRARY-CACHE:显示内容的全部条目先请求 16×16 预览,再在预览阶段全部完成一次尝试后逐项取得 64×64 完整画面。缓存按持久设备 ID、类型、条目 ID、修订和档位隔离;连接、进入内容页及内容页前台定期检查设备目录修订,未变复用本地目录与图片,变化后先完整读取目录再清理失效缓存。离线不显示旧列表。单张失败显示占位且不阻塞后续项目;旧设备退回现有图库读取方式。当前设备画面不参与此缓存。 + +MOBILE-CACHE-CLEAR:软件设置显示内容缓存大小,确认后仅清空内容目录与图片缓存;不清除昵称、设备记忆、当前帧或设备内容。内容页前台才进行图片后台补传,控制操作优先,切页、后台、断线停止队列。 diff --git a/移动端相关内容/开发要求/设备通信协议.md b/移动端相关内容/开发要求/设备通信协议.md index 703c2b6..d96e5e9 100644 --- a/移动端相关内容/开发要求/设备通信协议.md +++ b/移动端相关内容/开发要求/设备通信协议.md @@ -59,6 +59,8 @@ ClientHello 字段:`protocol_major:1, protocol_minor:0, public_key, random`。 | `wifi.set` | `expected_revision, ssid, security, password_action, password?, ipv4_mode, address?, prefix?, gateway?, dns_servers, activation` | password_action 为 keep/replace/none;none 仅开放网络;activation 为 immediate/next_boot;返回配置修订与可选 task_id。 | | `task.get` | `task_id` | `state:pending/running/succeeded/failed, stage, error?`;只允许查询本协议产生的有界任务,不提供任意系统任务入口。 | | `library.list` | `cursor?, limit` | 默认 20、最大 50;返回库修订、条目和下一游标。条目含 type、id、revision、name、is_demo、playable、thumbnail_revision。翻页库变化返回 CONFLICT。 | +| `library.summary` | 空 | 返回 `library_revision,item_count`;与 `library.list` 使用同一目录视图和修订,供新版 App 判断缓存是否可复用。 | +| `library.preview` | `type,id,revision` | 返回 `mime:image/png, data_base64`,16×16 静态预览;动画使用首帧。 | | `library.thumbnail` | `type,id,revision` | 返回 `mime:image/png, data_base64`,64×64 静态缩略图;动画使用首帧。 | | `content.play` | `type,id,revision` | 使用既有内容解析与显示服务;过期拒绝,不能顺便修改默认内容。 | | `content.default.get` | 空 | 当前默认内容元数据。 | @@ -74,6 +76,8 @@ settings 与 wifi 的 revision 为设备生成的不透明字符串,相关状 BLE 只接收白名单方法。旧客户端遇新能力忽略;旧设备缺某能力时客户端明确提示。不兼容主版本停止控制并返回列表。蓝牙关闭、主服务重启、OTA 维护或适配器消失时失效会话不可复用;恢复后重新握手,不恢复旧密钥。 +协议 1.1 增加 `library_progressive` capability;仅声明该能力的设备可调用 `library.summary` 和 `library.preview`。协议 1.0 的 `library.list`、`library.thumbnail` 和当前帧接口保持原义;旧 App 忽略新能力,新 App 遇旧设备读取完整目录并沿用 64×64 图。内容缓存不参与设备设置或控制写入。 + ## 6. 契约测试要求 共享固定的测试私钥、hello 原始字节、HKDF 输出、双向密文与分片向量;测试密钥明确仅为公开夹具,不能来自设备。Python 与 Kotlin 双向互验,覆盖 MTU 23、大 MTU、边界分片、非 ASCII 昵称、错误公钥、篡改、重放、断线、库翻页冲突、任务失败和主版本拒绝。接口最终字段模型及黄金向量必须随实现提交,未经测试不能写成协议验收通过。 diff --git a/移动端相关内容/开发要求/跨端兼容与变更记录.md b/移动端相关内容/开发要求/跨端兼容与变更记录.md index c4171ed..0f67751 100644 --- a/移动端相关内容/开发要求/跨端兼容与变更记录.md +++ b/移动端相关内容/开发要求/跨端兼容与变更记录.md @@ -58,3 +58,11 @@ Android 断线清理与旧扫描回调隔离已通过模拟及 Activity 真机 ## 2026-09-27 Android 0.2.1 界面优化 MOBILE-STATUS/DISCOVERY/PREVIEW:顶栏自动循环、扫描条目断开与连接保留、设备画面居中。仅修改手机界面及会话显示状态;协议1.0、设备接口、广播仲裁和驱动不变,不要求设备升级。不推进设备发布版本或 FEATURE_UPDATED_AT。 + +## 2026-09-29 Android 测试流程 + +新增按影响选测、场景运行器和独立模拟器 uiTest。仅测试基础设施和协作规则变化;产品 App、设备接口、协议、驱动及部署契约不变,不升级版本、不发布 APK,不改设备功能时间。旧无场景默认真机命令改为显式 --scenario;迁移命令见测试入口。 + +## 2026-09-29 内容图库缓存 + +DEVICE-MOBILE-LIBRARY-CACHE/MOBILE-LIBRARY-CACHE:设备协议 1.1 在主版本 1 下新增 `library_progressive`、`library.summary` 和 16×16 `library.preview`,保留既有 64×64 `library.thumbnail` 与旧目录契约。Android 0.2.2 使用分档缓存和渐进加载,旧设备回退现有方式;旧 App 可继续连接新设备。设备与 App 本轮均获用户明确迭代和部署授权。验证证据待本轮测试归档,不涉及驱动或当前帧输出。