Improve controller behavior and update project requirements
This commit is contained in:
@@ -102,3 +102,9 @@
|
||||
## 9. 测试经验与交付反馈
|
||||
|
||||
每轮测试完成后,将测试中遇到的问题及已验证解法补到对应可重复测试流程、排障指南或构建文档,防止后续重复试错;单次失败证据、恢复及重测结果留在测试归档。已知问题先查已有经验,不把未证实推测固化成根因。最终反馈说明经验更新位置和仍未解决的边界;没有新增经验时沿用已有记录,不重复复制。文档不得包含真实凭据、测试设备标识或开发机绝对路径。
|
||||
|
||||
## Android 按影响选测(当前规则)
|
||||
|
||||
每轮先评估直接和共享依赖影响,记录选测理由;测试矩阵不是每轮全量清单。未改且不受影响的页面/设置不跑真机回归。纯逻辑优先 JVM,普通 UI 优先独立模拟器;BLE、实际权限/后台厂商差异等才做必要的定向真机验证。root 授权不代表真机必测;root 输入规则仅适用于登记真机,模拟器使用标准 Compose 测试 API。必要但没做的验证不能写成不适用。切网、长轮询、性能、生命周期和全量验收均需具体影响理由,不能顺带执行。
|
||||
|
||||
具体触发条件、命令和证据格式见 [按影响选测](移动端相关内容/安卓app/如何安卓测试/按影响选测.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 对比首次加载和未变化重连的图片请求与字节数。当前帧、显示输出和驱动不变,无需新增实屏视觉验收。
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026-09-27T17:51+08:00
|
||||
2026-09-29T22:15+08:00
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
@@ -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'):
|
||||
|
||||
@@ -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。历史版本专项不作为每轮门槛。
|
||||
|
||||
@@ -50,3 +50,9 @@ ANDROID-TEST-UI 增加四栏、三点菜单、昵称、长按保存设备、WiFi
|
||||
- 每轮测试前读 `安卓app/如何安卓测试/常见问题与排障.md`;遇到已有症状先沿用已验证方法,不从头试错。
|
||||
- 测试中出现失败、临时修正或恢复操作,完成后必须复盘。可复用的已验证解法更新对应测试流程、排障或构建文档;单次日志、耗时和重测结果写测试归档,未知根因明确保留,不把重测通过当作根因证明。
|
||||
- 最终反馈须说明经验已写入哪些文档及未解决项;无新增经验时不重复造条目。不仅在聊天里解释,也不能只留一次性报告。遵守根目录秘密与文档路径规则。
|
||||
|
||||
## Android 按影响选测(当前规则)
|
||||
|
||||
每轮先评估直接和共享依赖影响,记录选测理由;测试矩阵不是每轮全量清单。未改且不受影响的页面/设置不跑真机回归。纯逻辑优先 JVM,普通 UI 优先独立模拟器;BLE、实际权限/后台厂商差异等才做必要的定向真机验证。root 授权不代表真机必测;root 输入规则仅适用于登记真机,模拟器使用标准 Compose 测试 API。必要但没做的验证不能写成不适用。切网、长轮询、性能、生命周期和全量验收均需具体影响理由,不能顺带执行。
|
||||
|
||||
具体触发条件、命令和证据格式见 [按影响选测](安卓app/如何安卓测试/按影响选测.md)。该规则替代历史专项“每轮执行”要求,视觉/物理操作、凭据和真机登记门禁不变。
|
||||
|
||||
@@ -23,3 +23,7 @@
|
||||
## 视觉检查
|
||||
|
||||
普通前端、App 界面、BLE 入口开发没有改变驱动或显示输出实现时,不新增实屏视觉验收。协议、输出帧和驱动统计优先。确需视觉检查时先停止相关步骤,说明原因,询问用户如何启动并等待明确指示;不得自动开启摄像头、DroidCam、采集或视觉测试程序,不主动切换实屏测试图案。专用测试手机授权不等于视觉检查授权。不适用时记录“不适用”。
|
||||
|
||||
## Android 选测入口
|
||||
|
||||
遵循 [按影响选测](安卓app/如何安卓测试/按影响选测.md)。日常变更不自动执行模拟器或 root 真机全量矩阵;独立 uiTest 使用真实 Compose 界面与 Controller、测试专用假平台,不替代物理 BLE。历史版本专项不作为每轮门槛。
|
||||
|
||||
@@ -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;测试末尾主动断开,运行器恢复布局设置。
|
||||
|
||||
顶栏保留完整文字语义,菜单位于固定区域;长文本循环参数由共享实现及编译核验,语义边界不证明文字实际运动,不记为目视验收。未取得本次视觉指示时不截图、不启动摄像头。纯手机界面调整的实屏视觉检查不适用,不需切网、显示内容切换或驱动测试。
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
@@ -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:]))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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 时先补产品构建,再运行选中场景。
|
||||
|
||||
@@ -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 "<ADB路径>" --build "<隔离工程目录>"
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py" --scenario settings --dry-run
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py" --scenario settings --avd "<AVD名称>" --adb "<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 测试仍不等于物理射频、实际核桃派或厂商兼容验证。
|
||||
|
||||
以上是制定项目选测规则的依据,具体场景划分是本项目的工程选择,不是官方强制清单。
|
||||
@@ -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。历史版本专项不作为每轮门槛。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+24
-19
@@ -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,6 +46,17 @@ class AppLifecycleTest {
|
||||
click(checkNotNull(device.parent.parent.findObject(By.text("连接"))))
|
||||
assertTrue(ui.wait(Until.hasObject(By.text("断开连接")), 30_000))
|
||||
}
|
||||
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
|
||||
}
|
||||
try {
|
||||
if (!ui.wait(Until.hasObject(By.text("断开连接")), 30_000)) {
|
||||
scanAndConnect()
|
||||
}
|
||||
@@ -57,28 +70,13 @@ class AppLifecycleTest {
|
||||
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("取消")))
|
||||
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
|
||||
@@ -99,24 +97,31 @@ class AppLifecycleTest {
|
||||
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 {
|
||||
rename("界面验证屏")
|
||||
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()
|
||||
click(ui.findObject(By.text("断开连接")))
|
||||
}
|
||||
} finally {
|
||||
launch()
|
||||
ui.wait(Until.findObject(By.text("断开连接")), 5_000)?.let { click(it) }
|
||||
}
|
||||
assertTrue(ui.wait(Until.hasObject(By.text("扫描设备")), 5_000))
|
||||
}
|
||||
}
|
||||
|
||||
+20
-14
@@ -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<String, String>()
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -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")
|
||||
|
||||
+141
@@ -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<DiscoveredDevice>()
|
||||
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 <T> 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<JsonObject>()
|
||||
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<String, String>()
|
||||
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() }
|
||||
}
|
||||
}
|
||||
+55
@@ -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() }
|
||||
}
|
||||
}
|
||||
+88
@@ -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<JsonObject>) = 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<File> = 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 }
|
||||
}
|
||||
+1
@@ -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)
|
||||
|
||||
@@ -5,3 +5,5 @@ dependencyResolutionManagement {
|
||||
}
|
||||
rootProject.name = "QimiaoScreen"
|
||||
include(":androidApp", ":sharedCore", ":sharedUi")
|
||||
|
||||
include(":uiTest")
|
||||
|
||||
+167
-33
@@ -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<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(),
|
||||
val thumbnails: Map<String, String> = emptyMap(), val frame: String? = null,
|
||||
val previews: Map<String, String> = emptyMap(), val thumbnails: Map<String, String> = 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<String, JsonObject>()
|
||||
private val pendingThumbnails = linkedMapOf<String, JsonObject>()
|
||||
private val hydrating = mutableSetOf<String>()
|
||||
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<JsonObject> = emptyList()
|
||||
for (attempt in 0..1) {
|
||||
try {
|
||||
val loaded = mutableListOf<JsonObject>()
|
||||
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<String, List<JsonObject>> {
|
||||
for (attempt in 0..1) {
|
||||
try {
|
||||
val items = mutableListOf<JsonObject>()
|
||||
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<JsonObject>) {
|
||||
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 {
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
data class CachedLibrary(val revision: String, val items: List<JsonObject>)
|
||||
|
||||
/** 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<JsonObject>)
|
||||
suspend fun sizeBytes(): Long
|
||||
suspend fun clear()
|
||||
}
|
||||
+86
-3
@@ -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<String>()
|
||||
override val libraryCache: LibraryCache = SimulatedLibraryCache()
|
||||
val preferences = mutableMapOf<String, String>()
|
||||
val links = mutableListOf<SimulatedLink>()
|
||||
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<Int?>?) : DeviceLink {
|
||||
private class SimulatedLibraryCache : LibraryCache {
|
||||
val catalogs = mutableMapOf<String, CachedLibrary>()
|
||||
val images = mutableMapOf<String, String>()
|
||||
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<JsonObject>) {
|
||||
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<Int?>?, private val platform: SimulatedPlatform) : DeviceLink {
|
||||
override val mtu = 247
|
||||
private val receiver = Reassembler()
|
||||
private val messages = Channel<ByteArray>(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() }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+11
-3
@@ -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")) }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:label="QMS UI tests" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false">
|
||||
<activity android:name=".FixtureActivity" android:exported="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
+79
@@ -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<String, String>()
|
||||
val calls = mutableListOf<String>()
|
||||
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<ByteArray>(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() }
|
||||
}
|
||||
+93
@@ -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<FixtureActivity>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 签名。设备功能部署沿用回滚开发流程,正式设备版本不因本次开发部署递增。验收以本轮测试归档为准。
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 奇妙小屏幕 0.2.2 debug(versionCode 5)
|
||||
|
||||
仅 debug 签名,可覆盖安装相同调试证书的旧包。Android 8.0 以上,真实验证机为 Android 13。
|
||||
|
||||
本包为当前开发验证产物;验收范围和未验证项见 `../../../测试结果归档/实施状态.md`。不能将 APK 成功生成视为所有首版验收通过。设备需已部署移动控制功能,历史设备版本号本身不代表支持 BLE。
|
||||
|
||||
SHA-256:`c3ae5ab3caa252f60c715d6f8ecf177767c1e7bc3ea12652b688efe5a3fc3c07`。源码摘要与签名证书摘要见 manifest.json。
|
||||
@@ -0,0 +1 @@
|
||||
c3ae5ab3caa252f60c715d6f8ecf177767c1e7bc3ea12652b688efe5a3fc3c07 奇妙小屏幕-0.2.2-debug.apk
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -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 系统或完整无线链路计量;本轮变更不要求这些专项。没有发现复测后仍未解决的本轮产品故障。
|
||||
@@ -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 保留供按需测试。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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节已更新。设备协议、服务和驱动不变。
|
||||
|
||||
以下为历史验收记录,不将历史通过项自动归为本轮重测:
|
||||
|
||||
@@ -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:软件设置显示内容缓存大小,确认后仅清空内容目录与图片缓存;不清除昵称、设备记忆、当前帧或设备内容。内容页前台才进行图片后台补传,控制操作优先,切页、后台、断线停止队列。
|
||||
|
||||
@@ -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 昵称、错误公钥、篡改、重放、断线、库翻页冲突、任务失败和主版本拒绝。接口最终字段模型及黄金向量必须随实现提交,未经测试不能写成协议验收通过。
|
||||
|
||||
@@ -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 本轮均获用户明确迭代和部署授权。验证证据待本轮测试归档,不涉及驱动或当前帧输出。
|
||||
|
||||
Reference in New Issue
Block a user