Improve Android connection and preview UI testing

This commit is contained in:
2026-09-27 20:52:24 +08:00
parent f80c4d9938
commit 55374356ed
32 changed files with 1227 additions and 31 deletions
@@ -26,3 +26,7 @@ DEPLOY-MOBILE-BLE/DEVICE-MOBILE-LIFECYCLE 的升级约束:包含移动端安
## 2026-09-26 接口修订 ## 2026-09-26 接口修订
DEVICE-MOBILE-CONTROL:BLE library.list 与网页共用 LibraryOrderStore 的用户混合顺序,演示条目保持固定位置;顺序参与分页修订。 DEVICE-MOBILE-CONTROL:BLE library.list 与网页共用 LibraryOrderStore 的用户混合顺序,演示条目保持固定位置;顺序参与分页修订。
DEVICE-MOBILE-SETTINGS:WiFi 激活任务可选 error_code 为 AUTH_FAILED、NETWORK_UNAVAILABLE、IP_CONFIG_FAILED、UNKNOWN;原因来自 NetworkManager 的结构化状态,仅可靠认证证据可报告 AUTH_FAILED,不输出原始命令错误或密码。 DEVICE-MOBILE-SETTINGS:WiFi 激活任务可选 error_code 为 AUTH_FAILED、NETWORK_UNAVAILABLE、IP_CONFIG_FAILED、UNKNOWN;原因来自 NetworkManager 的结构化状态,仅可靠认证证据可报告 AUTH_FAILED,不输出原始命令错误或密码。
## 2026-09-27 手机界面测试补充
TEST-MOBILE-INTEGRATION:Android 顶栏溢出循环、扫描条目按认证会话切换断开且重新扫描保留、两处预览居中,按 ANDROID-TEST-STATE/UI 做共享逻辑与 root 真机布局验收。设备接口、广播仲裁与驱动不变,不需要设备部署或实屏视觉检查。
@@ -279,3 +279,7 @@ nmcli -t -f GENERAL.STATE,IP4.ADDRESS,IP4.GATEWAY device show wlan0
在含中文和空格的隔离目录测试 `prepare_credentials.py`:私有文件缺失时逐字复制示例、返回非零并要求用户填写;占位符、空值和缺字段继续失败;填写完整后成功;已有文件在所有路径均保持字节不变。随后检查文本中的 Windows/macOS/Linux 开发者主目录、当前用户名和主机名、私钥头、高可信秘密及非测试归档中的私有 IPv4;对大文件流式检查 ASCII、UTF-8 和 UTF-16LE 主机标识。测试假值、回环地址、硬件地址、设备固定部署路径和正式 IMG README 的公开默认值不得误报。 在含中文和空格的隔离目录测试 `prepare_credentials.py`:私有文件缺失时逐字复制示例、返回非零并要求用户填写;占位符、空值和缺字段继续失败;填写完整后成功;已有文件在所有路径均保持字节不变。随后检查文本中的 Windows/macOS/Linux 开发者主目录、当前用户名和主机名、私钥头、高可信秘密及非测试归档中的私有 IPv4;对大文件流式检查 ASCII、UTF-8 和 UTF-16LE 主机标识。测试假值、回环地址、硬件地址、设备固定部署路径和正式 IMG README 的公开默认值不得误报。
执行 `git diff --check`、`python -m pytest -q tests/test_repository_hygiene.py tests/test_workspace_layout.py` 和本机 `all` 套件。最终用 `git check-ignore -v` 复核私有文件由根规则命中,并确认 `git status --short --ignored` 只把秘密、本机状态、运行数据和可再生缓存列为忽略项;使用 `git lfs track` 与 `git lfs ls-files` 核对已配置和已暂存的 LFS 对象。本测试不执行 commit 或 push。 执行 `git diff --check`、`python -m pytest -q tests/test_repository_hygiene.py tests/test_workspace_layout.py` 和本机 `all` 套件。最终用 `git check-ignore -v` 复核私有文件由根规则命中,并确认 `git status --short --ignored` 只把秘密、本机状态、运行数据和可再生缓存列为忽略项;使用 `git lfs track` 与 `git lfs ls-files` 核对已配置和已暂存的 LFS 对象。本测试不执行 commit 或 push。
## Android 0.2.1 界面专项映射
TEST-MOBILE-INTEGRATION 增补 MOBILE-STATUS/DISCOVERY/PREVIEW 对应的 ANDROID-TEST-STATE/UI:顶栏溢出循环配置和完整语义、认证连接后的条目断开及扫描保留、两处预览按卡片居中且刷新不重叠。共享逻辑及登记手机root验收入口和合格标准维护在 `移动端相关内容/安卓app/如何安卓测试/README.md`。手机界面及会话显示状态变化没有修改设备服务或驱动,实屏视觉检查不适用,不增加接线、显示图案切换、网络切换或真实systemd停启步骤。
@@ -113,3 +113,11 @@ ANDROID-TEST-UI 增加四栏、三点菜单、昵称、长按保存设备、WiFi
- root 辅助脚本保持 LF 换行;文本通过 App 内临时剪贴板和 root 按键输入,结束清理,不把密码放在命令参数中。 - root 辅助脚本保持 LF 换行;文本通过 App 内临时剪贴板和 root 按键输入,结束清理,不把密码放在命令参数中。
排障指南按症状记录 root 命令解析、LF 换行、布局进程重建、语义定位、表单输入、安装权限、BLE 阶段释放及 WiFi 恢复。遇到这些情况先查 [对应解法](常见问题与排障.md),不得重复试用已知不可靠的输入或转储方法。 排障指南按症状记录 root 命令解析、LF 换行、布局进程重建、语义定位、表单输入、安装权限、BLE 阶段释放及 WiFi 恢复。遇到这些情况先查 [对应解法](常见问题与排障.md),不得重复试用已知不可靠的输入或转储方法。
### 0.2.1 顶栏、连接条目与居中验收
ANDROID-TEST-STATE:共享逻辑验证自动/手动连接后关联、重新扫描保留、旧回调隔离、切换和失败/异常清理。ANDROID-TEST-UI:运行 run_ui_layout_test.py,在每组字体/方向下连接真实目标,按稳定语义资源标签检查连接条目断开、再次扫描保留、实际断开和再连;两页预览图片与标题中心距卡片中心不超过3物理像素,刷新位于右侧且无重叠。所有输入通过 root;测试末尾主动断开,运行器恢复布局设置。
顶栏保留完整文字语义,菜单位于固定区域;长文本循环参数由共享实现及编译核验,语义边界不证明文字实际运动,不记为目视验收。未取得本次视觉指示时不截图、不启动摄像头。纯手机界面调整的实屏视觉检查不适用,不需切网、显示内容切换或驱动测试。
短视口连接列表验收使用 connection_list 的实时可见边界,以 root 滑动定位扫描条目,按当前条目容器内的资源标签选连接/断开,不能假设设备名称始终在首屏。布局恢复须同时回读密度覆盖、字体与旋转;运行器输出恢复核验结果。
@@ -133,9 +133,13 @@ def main():
if layout_original: if layout_original:
root("am force-stop org.qimiaoscreen.controller") root("am force-stop org.qimiaoscreen.controller")
root("wm density " + layout_density) 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(): for key, value in layout_original.items():
root("settings delete system " + key if value == "null" else "settings put system " + key + " " + value) 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") 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": if args.test == "layout":
adb("shell", "rm", "-f", "/data/local/tmp/qms-authorized-root.sh", quiet=True) adb("shell", "rm", "-f", "/data/local/tmp/qms-authorized-root.sh", quiet=True)
if args.toggle_bluetooth: if args.toggle_bluetooth:
@@ -61,3 +61,11 @@ Compose 的禁用语义可能位于父节点,文字节点自身仍 enabled。
每轮检查:本轮有哪些失败/调整、是否已有可重复防范步骤、恢复是否核验、哪些原因仍未知。已验证解法更新本页和对应流程/构建说明;一次性证据留归档,不能将未证实推测写成固定根因。 每轮检查:本轮有哪些失败/调整、是否已有可重复防范步骤、恢复是否核验、哪些原因仍未知。已验证解法更新本页和对应流程/构建说明;一次性证据留归档,不能将未证实推测写成固定根因。
最终反馈写明经验已更新的文档,以及尚未解决的问题或未验证边界。若没有新增经验,说明沿用现有方法即可,不复制重复条目。遵守根协作规则:不写真实凭据、手机标识、开发机绝对路径,文档更新后执行仓库卫生检查。 最终反馈写明经验已更新的文档,以及尚未解决的问题或未验证边界。若没有新增经验,说明沿用现有方法即可,不复制重复条目。遵守根协作规则:不写真实凭据、手机标识、开发机绝对路径,文档更新后执行仓库卫生检查。
## 12. 轮询或滑动后节点失效、名称可见但按钮无法点击
本轮三种布局已验证:读取语义节点期间可能出现 StaleObjectException,应在原有有界期限内丢弃旧节点并重新查找,不复用旧坐标、不放宽断言。滚动定位使用 connection_list 的实时可见边界;扫描按钮还要限制在目标设备条目容器,检查按钮启用且 visibleBounds 非空。名称露出不证明兄弟按钮可见,空边界不能取中心点点击。
滑动后通过两次间隔400毫秒的实时边界相等检查确认目标停稳(最多5秒),然后仅发送一次 root 点击;不因未看到中间状态而重复点击连接。记录初次连接、连接中扫描、两处预览、条目断开、手动重连等阶段。短暂“连接中”未被观测不代表未发起请求,终态以认证连接和条目状态核验为准。
本轮失败、恢复与最终结果见 `../测试结果归档/2026-09-27_界面细节优化/`;重测通过不证明此前连接未出现的唯一根因。字体、旋转和密度恢复后均回读核验。
@@ -68,3 +68,7 @@ BlueZ D-Bus 接入作为可恢复模块,单独管理连接/广播,不在
## 2026-09-26 四栏实现修订 ## 2026-09-26 四栏实现修订
采用稳定页面编号 0连接、1设备状态、2显示内容、3设备设置;保存设备完整 ID、昵称、短编号、最近连接时间,不保存 MAC。扫描状态与连接独立,旧回调按代数隔离;RSSI 由平台 DeviceLink 可选方法读取。顶部共用 WiFi 任务状态。菜单、折叠表单、确认弹窗使用共享 Compose;版本元数据从 Android BuildConfig 注入。非敏感 WiFi 草稿跨页保留,密码仅当前编辑驻留,离页或提交清除。原三页/JSON详情/刷新率与性能入口描述由此节替代。 采用稳定页面编号 0连接、1设备状态、2显示内容、3设备设置;保存设备完整 ID、昵称、短编号、最近连接时间,不保存 MAC。扫描状态与连接独立,旧回调按代数隔离;RSSI 由平台 DeviceLink 可选方法读取。顶部共用 WiFi 任务状态。菜单、折叠表单、确认弹窗使用共享 Compose;版本元数据从 Android BuildConfig 注入。非敏感 WiFi 草稿跨页保留,密码仅当前编辑驻留,离页或提交清除。原三页/JSON详情/刷新率与性能入口描述由此节替代。
## 2026-09-27 界面修订
共享状态增加仅当前认证会话有效的 connectedDevice 扫描快照,以本次句柄关联按钮,持久身份仍使用完整设备 ID。扫描重置保留它,重复广播按句柄去重;所有会话结束清空关联。顶栏使用共享 Compose 跑马灯,只在溢出时无限循环,文本变化重启。两处预览复用居中卡片,标题按刷新按钮实际宽度预留对称空间。App 0.2.1/versionCode 4,仅 debug。
@@ -13,8 +13,8 @@ android {
applicationId = "org.qimiaoscreen.controller" applicationId = "org.qimiaoscreen.controller"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 3 versionCode = 4
versionName = "0.2.0" versionName = "0.2.1"
buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"") buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -68,7 +68,7 @@ class AppLifecycleTest {
click(ui.findObject(By.text("连接"))) click(ui.findObject(By.text("连接")))
click(ui.findObject(By.text("⋮"))) click(ui.findObject(By.text("⋮")))
click(checkNotNull(ui.wait(Until.findObject(By.text("关于")), 5_000))) click(checkNotNull(ui.wait(Until.findObject(By.text("关于")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.textContains("版本 0.2.0")), 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(ui.findObject(By.text("⋮"))) click(ui.findObject(By.text("⋮")))
click(checkNotNull(ui.wait(Until.findObject(By.text("软件设置")), 5_000))) click(checkNotNull(ui.wait(Until.findObject(By.text("软件设置")), 5_000)))
@@ -3,13 +3,16 @@ package org.qimiaoscreen.controller
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2 import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.StaleObjectException
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import kotlin.math.abs
/** Read semantic bounds; inject all input through the authorized root helper. */ /** Read semantic bounds; inject all input through the authorized root helper. */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@@ -23,19 +26,126 @@ class LayoutTest {
check(ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $command").contains("QMS_INPUT_OK")) check(ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $command").contains("QMS_INPUT_OK"))
Thread.sleep(250) Thread.sleep(250)
} }
fun visible(selector: BySelector, timeout: Long, scroll: Boolean = false,
accept: (UiObject2) -> Boolean = { true }): UiObject2? {
val deadline = android.os.SystemClock.elapsedRealtime() + timeout
var attempts = 0
while (android.os.SystemClock.elapsedRealtime() < deadline) {
try {
ui.findObjects(selector).firstOrNull { !it.visibleBounds.isEmpty && accept(it) }?.let { return it }
val list = if (scroll) ui.findObject(By.res("connection_list")) else null
if (list != null && !list.visibleBounds.isEmpty) {
check(list.applicationPackage == "org.qimiaoscreen.controller")
val b = list.visibleBounds
check(b.left >= 0 && b.top >= 0 && b.right <= ui.displayWidth && b.bottom <= ui.displayHeight)
val upper = b.top + b.height() / 5
val lower = b.bottom - b.height() / 5
val down = attempts++ % 10 < 5
input("swipe ${b.centerX()} ${if (down) lower else upper} ${b.centerX()} ${if (down) upper else lower} 350")
}
} catch (_: StaleObjectException) {
// Polling or scrolling replaced the semantic tree. Discard it
// and resolve fresh nodes; never reuse the old coordinates.
}
Thread.sleep(250)
}
return null
}
fun stableTap(resolve: () -> UiObject2?) {
val deadline = android.os.SystemClock.elapsedRealtime() + 5_000
var previous: android.graphics.Rect? = null
while (android.os.SystemClock.elapsedRealtime() < deadline) {
try {
val node = resolve()
if (node != null) {
check(node.applicationPackage == "org.qimiaoscreen.controller")
val b = node.visibleBounds
if (!b.isEmpty && b.left >= 0 && b.top >= 0 && b.right <= ui.displayWidth && b.bottom <= ui.displayHeight) {
if (b == previous) { input("tap ${b.centerX()} ${b.centerY()}"); return }
previous = android.graphics.Rect(b)
} else previous = null
} else previous = null
} catch (_: StaleObjectException) { previous = null }
Thread.sleep(400)
}
error("Root target did not obtain stable visible bounds")
}
fun click(label: String) { fun click(label: String) {
check(ui.wait(Until.hasObject(By.text(label)), 15_000)) { "Layout control unavailable: $label" } check(visible(By.text(label), 15_000, label == "扫描设备") != null) { "Layout control unavailable: $label" }
val node = checkNotNull(ui.findObjects(By.text(label)).filter { !it.visibleBounds.isEmpty } stableTap { ui.findObjects(By.text(label)).filter { !it.visibleBounds.isEmpty }.maxByOrNull { it.visibleCenter.y } }
.maxByOrNull { it.visibleCenter.y }) { "Visible layout target unavailable: $label" }
check(node.applicationPackage == "org.qimiaoscreen.controller")
val bounds = node.visibleBounds
assertTrue(bounds.left >= 0 && bounds.top >= 0 && bounds.right <= ui.displayWidth && bounds.bottom <= ui.displayHeight)
val point = node.visibleCenter
input("tap ${point.x} ${point.y}")
} }
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity") ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
assertTrue(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000)) assertTrue(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000))
for (label in listOf("连接", "设备状态", "显示内容", "设备设置", "连接")) click(label) for (label in listOf("连接", "设备状态", "显示内容", "设备设置", "连接")) click(label)
click("⋮"); click("关于"); click("关闭") 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"
}
fun tap(node: UiObject2) {
val tag = checkNotNull(node.resourceName)
stableTap { ui.findObjects(By.res(tag)).firstOrNull { !it.visibleBounds.isEmpty && it.isEnabled } }
}
fun stage(name: String) = instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream", "Layout stage: $name\n") })
fun connectScanned() {
click("扫描设备")
val name = InstrumentationRegistry.getArguments().getString("device_name")!!
checkNotNull(visible(By.res("scan_connect"), 20_000, scroll = true) {
it.isEnabled && it.parent?.findObject(By.text(name)) != null
}) { "Visible target scan button unavailable" }
stableTap { ui.findObjects(By.res("scan_connect")).firstOrNull {
!it.visibleBounds.isEmpty && it.isEnabled && it.parent?.findObject(By.text(name)) != null
} }
stage("connect-request-observed=" + ui.hasObject(By.textContains("连接中")))
val deadline = android.os.SystemClock.elapsedRealtime() + 35_000
while (!ui.hasObject(By.res("connected_scan_disconnect")) && android.os.SystemClock.elapsedRealtime() < deadline) {
val failureText = ui.findObject(By.res("controller_error"))?.text
if (failureText != null) {
val category = listOf("超时", "蓝牙连接已中断", "蓝牙连接失败", "身份", "不兼容", "会话").firstOrNull { failureText.contains(it) } ?: "未分类"
error("Connection failed after root input: $category")
}
Thread.sleep(250)
}
tagged("connected_scan_disconnect")
}
try {
stage("initial-connect")
if (visible(By.res("connected_scan_disconnect"), 35_000, scroll = true) == null) connectScanned()
val summary = tagged("connection_summary")
assertTrue(summary.text.startsWith("当前连接设备:"))
assertTrue(summary.text.contains(" · "))
assertTrue(summary.visibleBounds.right <= ui.findObject(By.text("⋮")).visibleBounds.left)
stage("scan-while-connected"); click("扫描设备")
tagged("connected_scan_disconnect")
Thread.sleep(13_000)
assertTrue(ui.hasObject(By.res("connected_scan_disconnect")))
for (page in listOf("设备状态", "显示内容")) {
stage("frame-" + if (page == "设备状态") "status" else "content")
click(page)
tagged("frame_image")
Thread.sleep(500)
val card = tagged("frame_card").visibleBounds
val title = tagged("frame_title").visibleBounds
val image = tagged("frame_image").visibleBounds
val refresh = tagged("frame_refresh").visibleBounds
assertTrue("Title must share card center", abs(title.centerX() - card.centerX()) <= 3)
assertTrue("Image must share card center", abs(image.centerX() - card.centerX()) <= 3)
assertTrue("Title and refresh must not overlap", title.right <= refresh.left)
assertTrue(refresh.centerX() > card.centerX() && refresh.right <= card.right)
assertTrue(image.left >= card.left && image.right <= card.right)
tap(tagged("frame_refresh"))
}
click("连接")
stage("disconnect-row")
tap(tagged("connected_scan_disconnect"))
assertTrue(ui.wait(Until.gone(By.text("断开连接")), 10_000))
assertTrue(ui.wait(Until.gone(By.res("connected_scan_disconnect")), 10_000))
stage("manual-reconnect"); connectScanned()
stage("final-disconnect")
tap(tagged("connected_scan_disconnect"))
assertTrue(ui.wait(Until.gone(By.text("断开连接")), 10_000))
} finally {
click("连接")
visible(By.res("connected_scan_disconnect"), 5_000, scroll = true)?.let { tap(it) }
}
} }
} }
@@ -27,6 +27,7 @@ data class ControllerState(
val page: Int = 0, val phase: String = "未连接", val scanning: Boolean = false, val page: Int = 0, val phase: String = "未连接", val scanning: Boolean = false,
val busy: Boolean = false, val error: String? = null, val clientName: String = "", val busy: Boolean = false, val error: String? = null, val clientName: String = "",
val devices: List<DiscoveredDevice> = emptyList(), val identity: JsonObject? = null, val devices: List<DiscoveredDevice> = emptyList(), val identity: JsonObject? = null,
val connectedDevice: DiscoveredDevice? = null,
val status: JsonObject? = null, val storage: JsonObject? = null, val status: JsonObject? = null, val storage: JsonObject? = null,
val settings: JsonObject? = null, val wifi: JsonObject? = null, val settings: JsonObject? = null, val wifi: JsonObject? = null,
val library: List<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(), val library: List<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(),
@@ -88,7 +89,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
scanJob?.cancel() scanJob?.cancel()
platform.stopScan() platform.stopScan()
val generation = ++scanGeneration val generation = ++scanGeneration
change { it.copy(scanning = true, devices = emptyList(), error = null, phase = if (it.identity != null) it.phase else if (auto) "正在重连上次设备" else "正在扫描") } change { it.copy(scanning = true, devices = listOfNotNull(it.connectedDevice), error = null, phase = if (it.identity != null) it.phase else if (auto) "正在重连上次设备" else "正在扫描") }
val lastId = platform.load("last_device_id")?.takeIf { it.isNotBlank() && it !in forgottenIds() } val lastId = platform.load("last_device_id")?.takeIf { it.isNotBlank() && it !in forgottenIds() }
val short = lastId?.replace("-", "")?.take(8) val short = lastId?.replace("-", "")?.take(8)
platform.scan({ device -> scope.launch { platform.scan({ device -> scope.launch {
@@ -108,6 +109,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
} }
} }
fun connect(device: DiscoveredDevice, expectedId: String? = null) { fun connect(device: DiscoveredDevice, expectedId: String? = null) {
if (state.value.identity != null && state.value.connectedDevice?.handle == device.handle) return
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == expectedId && expectedId != null) return if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == expectedId && expectedId != null) return
if (connectionJob?.isActive == true || rpc != null) disconnect() if (connectionJob?.isActive == true || rpc != null) disconnect()
scanGeneration++; scanJob?.cancel(); platform.stopScan() scanGeneration++; scanJob?.cancel(); platform.stopScan()
@@ -120,6 +122,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
activeLink = platform.connect(device.handle) activeLink = platform.connect(device.handle)
pending = DeviceRpc(checkNotNull(activeLink), platform.crypto()) pending = DeviceRpc(checkNotNull(activeLink), platform.crypto())
val identity = pending.open(state.value.clientName) val identity = pending.open(state.value.clientName)
if (generation != connectionGeneration) { pending.close(); return@launch }
val id = identity.getValue("device_id").jsonPrimitive.content val id = identity.getValue("device_id").jsonPrimitive.content
require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" } require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" }
require(identity.getValue("protocol_major").jsonPrimitive.int == 1) { "通信协议不兼容" } require(identity.getValue("protocol_major").jsonPrimitive.int == 1) { "通信协议不兼容" }
@@ -129,14 +132,16 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
persistRemembered((state.value.remembered.filterNot { it.id == id } + RememberedDevice(id, persistRemembered((state.value.remembered.filterNot { it.id == id } + RememberedDevice(id,
identity.getValue("device_name").jsonPrimitive.content, identity.getValue("short_id").jsonPrimitive.content, identity.getValue("device_name").jsonPrimitive.content, identity.getValue("short_id").jsonPrimitive.content,
Clock.System.now().toEpochMilliseconds())).sortedByDescending { it.lastConnected }) Clock.System.now().toEpochMilliseconds())).sortedByDescending { it.lastConnected })
change { it.copy(identity = identity, phase = "已连接", signal = device.signal) } change { it.copy(identity = identity, connectedDevice = device,
devices = it.devices.filterNot { old -> old.handle == device.handle } + device,
phase = "已连接", signal = null) }
refreshAll() refreshAll()
startPolling() startPolling()
} catch (e: Exception) { } catch (e: Exception) {
pending?.close() pending?.close()
if (generation == connectionGeneration) { if (generation == connectionGeneration) {
rpc = null; activeLink = null rpc = null; activeLink = null
if (e !is CancellationException) change { it.copy(identity = null, phase = "未连接", error = e.message ?: "连接失败") } if (e !is CancellationException) change { it.copy(identity = null, connectedDevice = null, phase = "未连接", error = e.message ?: "连接失败") }
} }
} }
} }
@@ -12,11 +12,12 @@ private class SimulatedPlatform : MobilePlatform {
val preferences = mutableMapOf<String, String>() val preferences = mutableMapOf<String, String>()
val links = mutableListOf<SimulatedLink>() val links = mutableListOf<SimulatedLink>()
val scanCallbacks = mutableListOf<(DiscoveredDevice) -> Unit>() val scanCallbacks = mutableListOf<(DiscoveredDevice) -> Unit>()
var signal: Deferred<Int?>? = null
override fun load(key: String) = preferences[key] override fun load(key: String) = preferences[key]
override fun save(key: String, value: String) { preferences[key] = value } override fun save(key: String, value: String) { preferences[key] = value }
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) { scanCallbacks += onDevice } override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) { scanCallbacks += onDevice }
override fun stopScan() {} 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").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).also { links += it }
override fun crypto() = object : SecureChannel { override fun crypto() = object : SecureChannel {
override val hello = byteArrayOf(1) override val hello = byteArrayOf(1)
override fun finish(serverHello: ByteArray) {} override fun finish(serverHello: ByteArray) {}
@@ -25,7 +26,7 @@ private class SimulatedPlatform : MobilePlatform {
} }
} }
private class SimulatedLink(private val deviceId: String) : DeviceLink { private class SimulatedLink(private val deviceId: String, private val signal: Deferred<Int?>?) : DeviceLink {
override val mtu = 247 override val mtu = 247
private val receiver = Reassembler() private val receiver = Reassembler()
private val messages = Channel<ByteArray>(Channel.UNLIMITED) private val messages = Channel<ByteArray>(Channel.UNLIMITED)
@@ -60,6 +61,7 @@ private class SimulatedLink(private val deviceId: String) : DeviceLink {
Protocol.encode(if (kind == 1) 2 else 3, sequence++, response, mtu).forEach { messages.send(it) } Protocol.encode(if (kind == 1) 2 else 3, sequence++, response, mtu).forEach { messages.send(it) }
} }
override suspend fun receive() = messages.receive() override suspend fun receive() = messages.receive()
override suspend fun readSignal(): Int? = signal?.await()
override fun close() { closed = true; messages.close() } override fun close() { closed = true; messages.close() }
} }
@@ -82,6 +84,7 @@ class ControllerRecoveryTest {
assertNull(controller.state.value.frame) assertNull(controller.state.value.frame)
assertNull(controller.state.value.frameCheckedAtMs) assertNull(controller.state.value.frameCheckedAtMs)
assertNull(controller.state.value.storage) assertNull(controller.state.value.storage)
assertNull(controller.state.value.connectedDevice)
assertTrue(platform.links.last().closed) assertTrue(platform.links.last().closed)
controller.connect(device) controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } } withTimeout(2_000) { controller.state.first { it.status != null } }
@@ -135,11 +138,15 @@ class ControllerRecoveryTest {
assertEquals("模拟屏", remembered.name) assertEquals("模拟屏", remembered.name)
controller.scan() controller.scan()
assertNotNull(controller.state.value.identity) assertNotNull(controller.state.value.identity)
assertEquals(device, controller.state.value.connectedDevice)
assertEquals(listOf(device), controller.state.value.devices)
assertTrue(controller.state.value.scanning) assertTrue(controller.state.value.scanning)
platform.scanCallbacks.last()(device) platform.scanCallbacks.last()(device)
withTimeout(2_000) { controller.state.first { it.devices.isNotEmpty() } } withTimeout(2_000) { controller.state.first { it.devices.isNotEmpty() } }
assertEquals(1, platform.links.size) assertEquals(1, platform.links.size)
controller.disconnect() controller.disconnect()
assertNull(controller.state.value.connectedDevice)
assertTrue(controller.state.value.devices.isEmpty())
assertEquals(remembered.id, Controller(platform, scope).state.value.remembered.single().id) assertEquals(remembered.id, Controller(platform, scope).state.value.remembered.single().id)
controller.forget(remembered.id) controller.forget(remembered.id)
controller.foreground(true) controller.foreground(true)
@@ -164,6 +171,8 @@ class ControllerRecoveryTest {
assertTrue(old.closed) assertTrue(old.closed)
assertEquals(2, platform.links.size) assertEquals(2, platform.links.size)
assertEquals(2, controller.state.value.remembered.size) assertEquals(2, controller.state.value.remembered.size)
assertEquals("second-handle", controller.state.value.connectedDevice?.handle)
assertFalse(controller.state.value.devices.any { it.handle == device.handle })
controller.forget("11111111-0000-4000-8000-000000000001") controller.forget("11111111-0000-4000-8000-000000000001")
assertNull(controller.state.value.identity) assertNull(controller.state.value.identity)
assertTrue(platform.links.last().closed) assertTrue(platform.links.last().closed)
@@ -181,6 +190,37 @@ class ControllerRecoveryTest {
platform.scanCallbacks.last()(device.copy(name = "QMS-00000000")) platform.scanCallbacks.last()(device.copy(name = "QMS-00000000"))
withTimeout(2_000) { controller.state.first { it.status != null } } withTimeout(2_000) { controller.state.first { it.status != null } }
assertEquals(id, controller.state.value.remembered.single().id) assertEquals(id, controller.state.value.remembered.single().id)
assertEquals(device.handle, controller.state.value.connectedDevice?.handle)
assertEquals(1, controller.state.value.devices.size)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun connectedSignalWaitsForLiveReadInsteadOfUsingScanSnapshot() = runBlocking {
val platform = SimulatedPlatform()
val signal = CompletableDeferred<Int?>()
platform.signal = signal
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.identity != null } }
assertNull(controller.state.value.signal)
signal.complete(-55)
withTimeout(2_000) { controller.state.first { it.status != null } }
assertEquals(-55, controller.state.value.signal)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun failedHandshakeCannotKeepConnectedScanEntry() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device, "unexpected-device-id")
withTimeout(2_000) { controller.state.first { it.error != null } }
assertNull(controller.state.value.identity)
assertNull(controller.state.value.connectedDevice)
assertTrue(platform.links.single().closed)
} finally { controller.disconnect(); scope.cancel() } } finally { controller.disconnect(); scope.cancel() }
} }
@@ -7,6 +7,13 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -21,6 +28,7 @@ private fun JsonObject.text(key: String, fallback: String = "—") = get(key)?.j
data class AppInfo(val version: String, val build: String, val date: String) data class AppInfo(val version: String, val build: String, val date: String)
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> ImageBitmap?, fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> ImageBitmap?,
appInfo: AppInfo = AppInfo("—", "—", "—")) { appInfo: AppInfo = AppInfo("—", "—", "—")) {
@@ -34,12 +42,16 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im
icon = { Text(listOf("◉", "▣", "▦", "⚙")[index]) }, label = { Text(name) }) icon = { Text(listOf("◉", "▣", "▦", "⚙")[index]) }, label = { Text(name) })
} }
} }) { padding -> } }) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.fillMaxSize().semantics { testTagsAsResourceId = true }.padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
val identity = state.identity val identity = state.identity
Text(if (identity == null) "当前连接设备:未连接${if (state.phase == "正在连接") " · 连接中" else ""}" val summary = if (identity == null) "当前连接设备:未连接${if (state.phase == "正在连接") " · 连接中" else ""}"
else "当前连接设备:${identity.text("device_name")}(${identity.text("short_id")}) · ${wifiSummary(state)}", else "当前连接设备:${identity.text("device_name")}(${identity.text("short_id")}) · ${wifiSummary(state)}"
Modifier.weight(1f), style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) key(summary) { Text(summary,
Modifier.weight(1f).testTag("connection_summary").clipToBounds()
.basicMarquee(iterations = Int.MAX_VALUE, repeatDelayMillis = 1_000,
initialDelayMillis = 1_000, velocity = 30.dp),
style = MaterialTheme.typography.bodySmall, maxLines = 1, softWrap = false, overflow = TextOverflow.Clip) }
Box { Box {
TextButton({ menu = true }) { Text("⋮") } TextButton({ menu = true }) { Text("⋮") }
DropdownMenu(menu, { menu = false }) { DropdownMenu(menu, { menu = false }) {
@@ -50,7 +62,7 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im
} }
if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
state.error?.let { error -> Card { Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { state.error?.let { error -> Card { Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(error, Modifier.weight(1f)); TextButton(controller::dismissError) { Text("知道了") } Text(error, Modifier.weight(1f).testTag("controller_error")); TextButton(controller::dismissError) { Text("知道了") }
} } } } } }
when (state.page) { when (state.page) {
0 -> ConnectionPage(state, controller, onScan) 0 -> ConnectionPage(state, controller, onScan)
@@ -79,7 +91,7 @@ private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Un
var selected by remember { mutableStateOf<RememberedDevice?>(null) } var selected by remember { mutableStateOf<RememberedDevice?>(null) }
var renaming by remember { mutableStateOf(false) } var renaming by remember { mutableStateOf(false) }
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { LazyColumn(Modifier.fillMaxWidth().testTag("connection_list"), verticalArrangement = Arrangement.spacedBy(10.dp)) {
item { Text("已保存的设备", style = MaterialTheme.typography.titleMedium) } item { Text("已保存的设备", style = MaterialTheme.typography.titleMedium) }
if (state.remembered.isEmpty()) item { Text("暂无已保存设备,扫描后选择即可连接") } if (state.remembered.isEmpty()) item { Text("暂无已保存设备,扫描后选择即可连接") }
items(state.remembered, key = { it.id }) { device -> items(state.remembered, key = { it.id }) { device ->
@@ -96,12 +108,16 @@ private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Un
} } } }
item { Text("扫描到的设备", style = MaterialTheme.typography.titleMedium) } item { Text("扫描到的设备", style = MaterialTheme.typography.titleMedium) }
items(state.devices, key = { it.handle }) { device -> Card(Modifier.fillMaxWidth()) { items(state.devices, key = { it.handle }) { device -> Card(Modifier.fillMaxWidth()) {
val connected = state.identity != null && state.connectedDevice?.handle == device.handle
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(device.name) Text(if (connected) state.identity!!.text("device_name") else device.name)
Text("信号 ${device.signal} dBm${if (c.rememberedFor(device) != null) " · 已记忆" else ""}", style = MaterialTheme.typography.bodySmall) Text(if (connected) "已连接 · 蓝牙信号:${state.signal?.let { "$it dBm" } ?: "暂不可用"}"
else "扫描信号 ${device.signal} dBm${if (c.rememberedFor(device) != null) " · 已记忆" else ""}", style = MaterialTheme.typography.bodySmall)
} }
Button({ c.connect(device, c.rememberedFor(device)?.id) }, enabled = state.phase != "正在连接") { Text("连接") } Button({ if (connected) c.disconnect() else c.connect(device, c.rememberedFor(device)?.id) },
Modifier.testTag(if (connected) "connected_scan_disconnect" else "scan_connect"),
enabled = state.phase != "正在连接") { Text(if (connected) "断开" else "连接") }
} }
} } } }
if (state.identity != null) item { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { if (state.identity != null) item { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -130,13 +146,17 @@ private fun Frame(encoded: String?, checkedAt: Long?, decode: (String) -> ImageB
val bitmap = remember(encoded) { encoded?.let(decode) } val bitmap = remember(encoded) { encoded?.let(decode) }
var now by remember { mutableLongStateOf(Clock.System.now().toEpochMilliseconds()) } var now by remember { mutableLongStateOf(Clock.System.now().toEpochMilliseconds()) }
LaunchedEffect(checkedAt) { while (true) { now = Clock.System.now().toEpochMilliseconds(); delay(1_000) } } LaunchedEffect(checkedAt) { while (true) { now = Clock.System.now().toEpochMilliseconds(); delay(1_000) } }
Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { val density = LocalDensity.current
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { var refreshWidth by remember { mutableStateOf(96.dp) }
Text("设备画面", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium) Card(Modifier.fillMaxWidth().testTag("frame_card")) { Column(Modifier.fillMaxWidth().padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally) {
TextButton(refresh, enabled = enabled) { Text("手动刷新") } Box(Modifier.fillMaxWidth()) {
Text("设备画面", Modifier.fillMaxWidth().padding(horizontal = refreshWidth).align(Alignment.Center).testTag("frame_title"),
style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
TextButton(refresh, Modifier.align(Alignment.CenterEnd).testTag("frame_refresh")
.onSizeChanged { refreshWidth = with(density) { it.width.toDp() } }, enabled = enabled) { Text("手动刷新") }
} }
if (bitmap != null) Image(bitmap, "设备当前输出帧", Modifier.size(160.dp)) else Text("等待画面") if (bitmap != null) Image(bitmap, "设备当前输出帧", Modifier.size(160.dp).testTag("frame_image")) else Text("等待画面", textAlign = TextAlign.Center)
if (checkedAt != null && now - checkedAt > 6_000) Text("画面可能已过期,请刷新", style = MaterialTheme.typography.bodySmall) if (checkedAt != null && now - checkedAt > 6_000) Text("画面可能已过期,请刷新", style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center)
} } } }
} }
@@ -60,3 +60,5 @@ Gradle Kotlin DSL 里 `java` 可能被插件扩展名遮蔽;构建日期实现
Python 在 Windows 缺少 IANA tzdata 时,固定北京时间时间戳可使用标准库 `timezone(timedelta(hours=8))`;这一方法只适用于明确要求的固定 UTC+08:00 时间,不当作任意地区夏令时转换方案,也不因该情况自动新增依赖。 Python 在 Windows 缺少 IANA tzdata 时,固定北京时间时间戳可使用标准库 `timezone(timedelta(hours=8))`;这一方法只适用于明确要求的固定 UTC+08:00 时间,不当作任意地区夏令时转换方案,也不因该情况自动新增依赖。
打包前重新核对隔离源码、实际 APK、构建日期和签名摘要;测试源码调整也要同步隔离工程,不能测试旧测试 APK。测试/构建遇到的新问题在完成后更新相应文档并在最终反馈说明,具体输入和真机排障见 [测试排障指南](../如何安卓测试/常见问题与排障.md)。 打包前重新核对隔离源码、实际 APK、构建日期和签名摘要;测试源码调整也要同步隔离工程,不能测试旧测试 APK。测试/构建遇到的新问题在完成后更新相应文档并在最终反馈说明,具体输入和真机排障见 [测试排障指南](../如何安卓测试/常见问题与排障.md)。
Android 0.2.1 / versionCode 4:顶栏长文本循环、连接条目断开/扫描保留、居中预览;仅debug签名。交付和本轮覆盖范围见交付包0.2.1-4及测试结果归档2026-09-27_界面细节优化。
@@ -0,0 +1,11 @@
# 奇妙小屏幕 0.2.1 debug(versionCode 4)
仅 debug 签名,可覆盖安装相同调试证书的旧包。Android 8.0 以上,真实验证机为 Android 13。
本包为当前开发验证产物;验收范围和未验证项见 `../../../测试结果归档/实施状态.md`。不能将 APK 成功生成视为所有首版验收通过。设备需已部署移动控制功能,历史设备版本号本身不代表支持 BLE。
SHA-256:`0cd888b0645d6d45ce457d2f334c69bb79fe52af3a4748080158db164fe8b9ef`。源码摘要与签名证书摘要见 manifest.json。
## 本轮验证
JVM15项、root真机三组布局、后台恢复、BLE只读回归通过,已安装APK摘要与本包一致。完整证据与边界见 [本轮报告](../../../测试结果归档/2026-09-27_界面细节优化/README.md)。顶栏循环、扫描条目断开与保留、两页预览居中;仅debug签名。
@@ -0,0 +1 @@
0cd888b0645d6d45ce457d2f334c69bb79fe52af3a4748080158db164fe8b9ef 奇妙小屏幕-0.2.1-debug.apk
@@ -0,0 +1,142 @@
{
"app_version": "0.2.1",
"version_code": 4,
"signing": "debug",
"build_date": "2026-09-27 20:28 +08:00",
"status": "development-validation-see-test-report",
"apk_sha256": "0cd888b0645d6d45ce457d2f334c69bb79fe52af3a4748080158db164fe8b9ef",
"certificate_sha256": "b413e68c0bd2311c491ebd087ac5791c7c2ad8267793f92f6055d5521bd3ef4d",
"protocol_major": 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": "7bfbd5d27819700243b7ab1e318eec91024dc170059a92d587400608ec8a59b5"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt",
"sha256": "cd0d132657280d1bd9640916acb3d4844cea7ebfe7b1955988e8f5a0a0d10ae9"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt",
"sha256": "8dc7a28279e99e46d15c914955d8974d619c4e48a2fcab887f180b15b4b183ab"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt",
"sha256": "2323f5e68b0f3d4771435628db7f880c6a8cefb27b5ea8d6c3401f3828b0e1b8"
},
{
"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/MainActivity.kt",
"sha256": "7bece662b79093a19fcdf34ad344cf42a57b60291f0536823395ae06663f4f23"
},
{
"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": "3d53a77b5b92800475897fb55f910a7cfe008e2084e0b7185ebb5ce38e22cf53"
},
{
"path": "sharedCore/build.gradle.kts",
"sha256": "9a420e39e3c655f676572d4486e415dbc8b4fd45904db6ad7b64d7fcd71f084b"
},
{
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt",
"sha256": "48e7bd52250d541088ca2756a8a1dbd37aeedab1ffc03338c9bb7c6ed9d39d7d"
},
{
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt",
"sha256": "870345382351bbf6d37f4332c0fab099cb47b7b703428a98567a0086c3316be6"
},
{
"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": "9cfd5291d5442ba79802e4193124bc2f22f05cd9a577001d33913d56b140a4a6"
},
{
"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": "884e61008e49fd3f1c62969cbe38a798f896734fc70f5219b4e00bcb34bfe9a3"
}
]
}
@@ -0,0 +1,43 @@
# Android 0.2.1 界面细节优化验收
App **0.2.1 / versionCode 4**,debug 签名,协议主版本1。测试代号 android-test-a(Android 13)。设备软件1.1.3,功能时间2026-09-27T17:51+08:00;本轮没有设备部署、驱动修改、OTA或IMG导出,也没有改设备版本或功能时间。
## 实现
顶栏长文字以30 dp/秒无限循环、每轮间隔1秒,短文字静止,内容变化从头开始,菜单固定。认证成功后的扫描条目改为断开,重新扫描保留当前连接条目;旧扫描信号不冒充实时RSSI,实时结果缺失显示暂不可用。两页设备画面、标题、等待与过期提示居中,刷新按钮仍在右侧且标题预留对称空间。
## 最终结果
- 隔离构建、JVM **15项**、Android lint通过;lint有16条警告(依赖版本、图标、现有API用法等),无错误。登记门禁unittest **8项**通过。
- root真机布局三组全部通过:约340dp、1.0倍字体竖屏 **77.282秒**;1.3倍字体竖屏 **54.004秒**;1.3倍字体横屏 **84.774秒**。每组覆盖四栏和菜单、连接中重新扫描保留、条目实际断开及手动再连、两页画面和标题中心差不超过3像素、刷新按钮右侧且不重叠。输入仅使用实时语义位置与root。密度、字体、方向最终恢复并回读通过。
- 实际Activity **87.006秒**通过:后台30秒释放及返回重连、菜单、设备昵称修改后重新连接、恢复原昵称、忘记后再次手动连接。阶段最后主动断开。
- 真实BLE只读回归 **127.671秒**通过:两次加密会话、状态/设置/存储/逻辑帧/WiFi扫描、内容列表顺序摘要、配置冲突后会话保持、Controller页面读取、后台释放和前台重连。本轮指定3轮轮询,不等同历史60轮耐久复测;未实际切网、重应用亮度或改变显示内容。
- debug签名和逐文件源码摘要核对通过;安装到登记手机的base.apk SHA-256及版本与交付包一致。证据见installed-artifact.json。
顶栏完整语义文本和菜单边界经真机核验;无限循环参数及内容变化重启经源码/编译核对,语义检查不代表实际像素运动的目视验收。未截图、未启动摄像头;实屏视觉检查不适用。
## 失败、修正及恢复
首次两组竖屏通过,横屏找不到扫描名称,见首次布局尝试.md。第二次遇到StaleObjectException(27.65秒),见layout-root-attempt-2.log。第三次第一组通过,第二组手动重连后未找到连接条目(70.945秒);第四次同阶段未找到条目(74.747秒),分别保留在attempt-3/4日志。
测试补充列表可见区域内的root滚动、失效语义节点重新获取、条目容器内可见且启用的按钮筛选、非空边界检查,以及两次间隔400毫秒的相同边界确认后只点击一次。新增阶段与脱敏错误分类用于区分输入、连接和最终状态。随后最终三组全部通过,没有放宽原验收断言,没有用普通注入或无障碍动作替代root。此前重连条目未出现的唯一根因未证实,不把重测通过作为根因证明。
可复用方法已补入 ../../如何安卓测试/常见问题与排障.md 第12节及测试README;单次证据保留在本目录。所有布局运行的finally执行恢复,最后运行另回读密度、字体、方向核验通过。
## 交付与边界
交付包:../../构建与发布/交付包/0.2.1-4/。构建日期 **2026-09-27 20:28 +08:00**。
APK SHA-256:`0cd888b0645d6d45ce457d2f334c69bb79fe52af3a4748080158db164fe8b9ef`。
源码树摘要:`671d5d7b9de3c808c3f217ee15e2780d85d3d8dffa5333437d1bf1c79eab8423`(按manifest.sources顺序,将相对路径、空格、摘要和换行连接后计算);逐文件摘要及证书摘要见交付manifest,测试APK摘要见artifact-summary.json。
旧Android版本、其他机型、真实多设备切换本轮未实测;共享逻辑覆盖设备切换、异常、身份失败及实时信号等待。沿用既有未验证边界,不声称本轮完成所有首版功能或网络专项。日志已脱敏,未记录真实凭据、手机标识或开发机绝对路径。
## 清理与仓库检查
手机短时root辅助文件由运行器finally清理;布局设置已恢复并核验,测试阶段正常主动断开。交付包与脱敏证据长期保留。
本机四个隔离构建目录的批量递归清理被自动安全审核拒绝;随后核对明确绝对路径及目录属性,以单个明确LiteralPath请求清理,仍被拒绝,返回理由仅为“blocked by policy”。未改用其他工具绕过拒绝,目录保留在受保护临时目录内:`qms-ui-021/qms-build-s_9wvlf3`、`qms-ui-021-final/qms-build-wy8k36kg`、`qms-ui-021-verified/qms-build-o9n44f_y`、`qms-ui-021-delivery/qms-build-vdbzxfp4`。这些目录不进入Git、不被后续源码或构建依赖;功能验收及交付不受影响。
最终仓库卫生检查与git diff --check通过,没有输出真实秘密。
@@ -0,0 +1,35 @@
私有凭据文件可用;未输出任何凭据值。
Performing Streamed Install
Success
Performing Streamed Install
Success
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.AppLifecycleTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=
org.qimiaoscreen.controller.AppLifecycleTest:
INSTRUMENTATION_STATUS: test=actualActivityReturnsFromBackground
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.AppLifecycleTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=actualActivityReturnsFromBackground
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_RESULT: stream=
Time: 87.006
OK (1 test)
INSTRUMENTATION_CODE: -1
Runner exit code: 0
@@ -0,0 +1,29 @@
{
"app_version": "0.2.1",
"version_code": 4,
"protocol_major": 1,
"source_tree_sha256": "671d5d7b9de3c808c3f217ee15e2780d85d3d8dffa5333437d1bf1c79eab8423",
"apk_sha256": "0cd888b0645d6d45ce457d2f334c69bb79fe52af3a4748080158db164fe8b9ef",
"build_date": "2026-09-27 20:28 +08:00",
"layout": {
"passed_runs": 3,
"seconds": [
"77.282",
"54.004",
"84.774"
]
},
"activity": {
"passed_runs": 1,
"seconds": [
"87.006"
]
},
"ble": {
"passed_runs": 1,
"seconds": [
"127.671"
]
},
"test_apk_sha256": "2ad184a000db4ef9e9d7add0cfe913a455f054f4f9b39b34b0c3b36783569a92"
}
@@ -0,0 +1,275 @@
私有凭据文件可用;未输出任何凭据值。
Performing Streamed Install
Success
Performing Streamed Install
Success
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.BleIntegrationTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=
org.qimiaoscreen.controller.BleIntegrationTest:
INSTRUMENTATION_STATUS: test=encryptedReadOnlySessionAndReconnect
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: stream=BLE stage: scan complete
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: GATT ready 0
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=1 index=0 count=1 bytes=215 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=2 count=50 bytes=20
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=134 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=2 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: encrypted session open
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=84 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=21 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: status received
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=86 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=3 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: settings received
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=85 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=1 bytes=232
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: storage received
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=86 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=16 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: library received
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: library persistent order verified
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=83 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=3 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: frame received
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=82 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=4 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=86 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=1 bytes=84
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=83 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=3 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: WiFi scan completed without logging network names
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=171 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=1 bytes=148
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=87 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=1 bytes=85
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: configuration conflict preserves session
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=85 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=21 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=84 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=3 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: continuous polling cycle 0
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=85 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=22 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=84 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=3 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=85 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=22 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=84 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=5 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: intentional session close 0
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: GATT ready 1
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=1 index=0 count=1 bytes=215 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=2 count=50 bytes=20
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=134 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=2 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: encrypted session open
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=84 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=22 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: status received
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=86 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=3 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: settings received
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=85 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=1 bytes=232
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: storage received
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=86 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=16 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: library received
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: library persistent order verified
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=83 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=3 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: frame received
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=82 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=4 bytes=244
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: send kind=3 index=0 count=1 bytes=86 mtu=247
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: receive kind=3 count=1 bytes=84
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: intentional session close 1
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: controller device page ready
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: controller content and settings ready
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: controller background released
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=BLE stage: controller foreground reconnected
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.BleIntegrationTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=encryptedReadOnlySessionAndReconnect
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_RESULT: stream=
Time: 127.671
OK (1 test)
INSTRUMENTATION_CODE: -1
Runner exit code: 0
@@ -0,0 +1,5 @@
{
"VERSION": "1.1.3",
"FEATURE_UPDATED_AT": "2026-09-27T17:51+08:00",
"service_active": true
}
@@ -0,0 +1,6 @@
{
"alias": "android-test-a",
"apk_sha256": "0cd888b0645d6d45ce457d2f334c69bb79fe52af3a4748080158db164fe8b9ef",
"installed_apk_matches": true,
"installed_version_matches": true
}
@@ -0,0 +1,30 @@
[
{
"name": "ControllerRecoveryTest[jvm]",
"tests": "9",
"failures": "0",
"errors": "0",
"time": "0.304"
},
{
"name": "CryptoInteropTest[jvm]",
"tests": "3",
"failures": "0",
"errors": "0",
"time": "0.074"
},
{
"name": "ProtocolTest[jvm]",
"tests": "2",
"failures": "0",
"errors": "0",
"time": "0.002"
},
{
"name": "RpcTimeoutTest[jvm]",
"tests": "1",
"failures": "0",
"errors": "0",
"time": "0.033"
}
]
@@ -0,0 +1,67 @@
私有凭据文件可用;未输出任何凭据值。
Performing Streamed Install
Success
Performing Streamed Install
Success
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=
org.qimiaoscreen.controller.LayoutTest:
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stack=androidx.test.uiautomator.StaleObjectException
at androidx.test.uiautomator.UiObject2.getAccessibilityNodeInfo(UiObject2.java:1018)
at androidx.test.uiautomator.UiObject2.getVisibleBounds(UiObject2.java:310)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$visible(LayoutTest.kt:32)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$tagged(LayoutTest.kt:61)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape(LayoutTest.kt:88)
INSTRUMENTATION_STATUS: stream=
Error in narrowLargeFontAndLandscape(org.qimiaoscreen.controller.LayoutTest):
androidx.test.uiautomator.StaleObjectException
at androidx.test.uiautomator.UiObject2.getAccessibilityNodeInfo(UiObject2.java:1018)
at androidx.test.uiautomator.UiObject2.getVisibleBounds(UiObject2.java:310)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$visible(LayoutTest.kt:32)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$tagged(LayoutTest.kt:61)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape(LayoutTest.kt:88)
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: -2
INSTRUMENTATION_RESULT: stream=
Time: 27.65
There was 1 failure:
1) narrowLargeFontAndLandscape(org.qimiaoscreen.controller.LayoutTest)
androidx.test.uiautomator.StaleObjectException
at androidx.test.uiautomator.UiObject2.getAccessibilityNodeInfo(UiObject2.java:1018)
at androidx.test.uiautomator.UiObject2.getVisibleBounds(UiObject2.java:310)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$visible(LayoutTest.kt:32)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$tagged(LayoutTest.kt:61)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape(LayoutTest.kt:88)
FAILURES!!!
Tests run: 1, Failures: 1
INSTRUMENTATION_CODE: -1
Layout settings restored and verified
Traceback (most recent call last):
File "<仓库根目录>\移动端相关内容\安卓app\如何安卓测试\run_ble_test.py", line 154, in <module>
main()
~~~~^^
File "<仓库根目录>\移动端相关内容\安卓app\如何安卓测试\run_ble_test.py", line 129, in main
raise RuntimeError("Instrumentation did not pass")
RuntimeError: Instrumentation did not pass
Runner exit code: 1
@@ -0,0 +1,86 @@
私有凭据文件可用;未输出任何凭据值。
Performing Streamed Install
Success
Performing Streamed Install
Success
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=
org.qimiaoscreen.controller.LayoutTest:
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_RESULT: stream=
Time: 47.366
OK (1 test)
INSTRUMENTATION_CODE: -1
Layout passed: font=1.0, rotation=0
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=
org.qimiaoscreen.controller.LayoutTest:
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stack=java.lang.IllegalStateException: Required App semantic tag unavailable: connected_scan_disconnect
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$tagged(LayoutTest.kt:67)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$connectScanned(LayoutTest.kt:80)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape(LayoutTest.kt:111)
INSTRUMENTATION_STATUS: stream=
Error in narrowLargeFontAndLandscape(org.qimiaoscreen.controller.LayoutTest):
java.lang.IllegalStateException: Required App semantic tag unavailable: connected_scan_disconnect
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$tagged(LayoutTest.kt:67)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$connectScanned(LayoutTest.kt:80)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape(LayoutTest.kt:111)
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: -2
INSTRUMENTATION_RESULT: stream=
Time: 70.945
There was 1 failure:
1) narrowLargeFontAndLandscape(org.qimiaoscreen.controller.LayoutTest)
java.lang.IllegalStateException: Required App semantic tag unavailable: connected_scan_disconnect
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$tagged(LayoutTest.kt:67)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$connectScanned(LayoutTest.kt:80)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape(LayoutTest.kt:111)
FAILURES!!!
Tests run: 1, Failures: 1
INSTRUMENTATION_CODE: -1
Layout settings restored and verified
Traceback (most recent call last):
File "<仓库根目录>\移动端相关内容\安卓app\如何安卓测试\run_ble_test.py", line 154, in <module>
main()
~~~~^^
File "<仓库根目录>\移动端相关内容\安卓app\如何安卓测试\run_ble_test.py", line 129, in main
raise RuntimeError("Instrumentation did not pass")
RuntimeError: Instrumentation did not pass
Runner exit code: 1
@@ -0,0 +1,79 @@
私有凭据文件可用;未输出任何凭据值。
Performing Streamed Install
Success
Performing Streamed Install
Success
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=
org.qimiaoscreen.controller.LayoutTest:
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: stream=Layout stage: initial-connect
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: scan-while-connected
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: frame-status
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: frame-content
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: disconnect-row
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: manual-reconnect
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stack=java.lang.IllegalStateException: Required App semantic tag unavailable: connected_scan_disconnect
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$tagged(LayoutTest.kt:68)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$connectScanned(LayoutTest.kt:85)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape(LayoutTest.kt:119)
INSTRUMENTATION_STATUS: stream=
Error in narrowLargeFontAndLandscape(org.qimiaoscreen.controller.LayoutTest):
java.lang.IllegalStateException: Required App semantic tag unavailable: connected_scan_disconnect
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$tagged(LayoutTest.kt:68)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$connectScanned(LayoutTest.kt:85)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape(LayoutTest.kt:119)
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: -2
INSTRUMENTATION_RESULT: stream=
Time: 74.747
There was 1 failure:
1) narrowLargeFontAndLandscape(org.qimiaoscreen.controller.LayoutTest)
java.lang.IllegalStateException: Required App semantic tag unavailable: connected_scan_disconnect
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$tagged(LayoutTest.kt:68)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape$connectScanned(LayoutTest.kt:85)
at org.qimiaoscreen.controller.LayoutTest.narrowLargeFontAndLandscape(LayoutTest.kt:119)
FAILURES!!!
Tests run: 1, Failures: 1
INSTRUMENTATION_CODE: -1
Layout settings restored and verified
Traceback (most recent call last):
File "<仓库根目录>\移动端相关内容\安卓app\如何安卓测试\run_ble_test.py", line 154, in <module>
main()
~~~~^^
File "<仓库根目录>\移动端相关内容\安卓app\如何安卓测试\run_ble_test.py", line 129, in main
raise RuntimeError("Instrumentation did not pass")
RuntimeError: Instrumentation did not pass
Runner exit code: 1
@@ -0,0 +1,159 @@
私有凭据文件可用;未输出任何凭据值。
Performing Streamed Install
Success
Performing Streamed Install
Success
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=
org.qimiaoscreen.controller.LayoutTest:
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: stream=Layout stage: initial-connect
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: scan-while-connected
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: frame-status
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: frame-content
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: disconnect-row
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: manual-reconnect
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: connect-request-observed=false
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: final-disconnect
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_RESULT: stream=
Time: 77.282
OK (1 test)
INSTRUMENTATION_CODE: -1
Layout passed: font=1.0, rotation=0
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=
org.qimiaoscreen.controller.LayoutTest:
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: stream=Layout stage: initial-connect
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: scan-while-connected
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: frame-status
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: frame-content
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: disconnect-row
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: manual-reconnect
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: connect-request-observed=false
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: final-disconnect
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_RESULT: stream=
Time: 54.004
OK (1 test)
INSTRUMENTATION_CODE: -1
Layout passed: font=1.3, rotation=0
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=
org.qimiaoscreen.controller.LayoutTest:
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: stream=Layout stage: initial-connect
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: scan-while-connected
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: frame-status
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: frame-content
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: disconnect-row
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: manual-reconnect
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: connect-request-observed=false
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: stream=Layout stage: final-disconnect
INSTRUMENTATION_STATUS_CODE: 2
INSTRUMENTATION_STATUS: class=org.qimiaoscreen.controller.LayoutTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=narrowLargeFontAndLandscape
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_RESULT: stream=
Time: 84.774
OK (1 test)
INSTRUMENTATION_CODE: -1
Layout passed: font=1.3, rotation=1
Layout settings restored and verified
Runner exit code: 0
@@ -0,0 +1,5 @@
# 首次布局尝试
App 0.2.1 / versionCode 4。约340dp、1.0倍字体竖屏38.726秒通过;1.3倍字体竖屏49.475秒通过。1.3倍字体横屏64.751秒失败:连接辅助函数在20秒等待内未找到扫描目标名称。当时未执行列表滚动,不能仅凭该失败区分条目不可见与未扫描到目标,也未认定蓝牙根因。
随后给连接列表增加稳定语义标签,测试按该列表实时可见边界执行有界root滑动定位;再次隔离构建后重测,结果见 layout-root.log。首次失败保留。首次运行 finally 已执行布局恢复;后续运行器增加密度回读,与既有字体和方向回读一并核验。
@@ -1,5 +1,9 @@
# 当前实施状态 # 当前实施状态
更新时间:2026-09-27。当前App为 **0.2.1 / versionCode 4**,顶栏循环、扫描条目断开/连接保留、两页预览居中已实现。交付包 `../构建与发布/交付包/0.2.1-4/`;本轮证据及未验证边界见 [界面细节优化验收](2026-09-27_界面细节优化/README.md)。JVM15项、root三组布局、实际Activity和BLE只读回归通过,最终安装摘要一致;早期测试失败与修正保留。测试流程和排障第12节已更新。设备协议、服务和驱动不变。
以下为历史验收记录,不将历史通过项自动归为本轮重测:
更新时间:2026-09-26。当前 App 为 **0.2.0 / versionCode 3**,四栏界面优化已实现。交付包:`../构建与发布/交付包/0.2.0-3/`,本轮证据与边界见 `2026-09-26_四栏界面优化.md`。设备功能已部署,协议主版本仍为 1;不导出 OTA/IMG、不推进设备发布版本。 更新时间:2026-09-26。当前 App 为 **0.2.0 / versionCode 3**,四栏界面优化已实现。交付包:`../构建与发布/交付包/0.2.0-3/`,本轮证据与边界见 `2026-09-26_四栏界面优化.md`。设备功能已部署,协议主版本仍为 1;不导出 OTA/IMG、不推进设备发布版本。
已通过:独立构建、JVM 13 项、设备相关回归 46 项、root 真机四栏/菜单/设备重命名与记忆/后台连接生命周期、窄屏大字体横屏导航、真实 systemd 生命周期。完整 BLE(301.514 秒)、错误密码明确分类及原网络恢复、最终覆盖安装摘要核对均通过,详见本轮报告;真实多设备、旧 Android、跨 SSID 与实际 DHCP/静态互换不能按模拟或表单测试记为通过。 已通过:独立构建、JVM 13 项、设备相关回归 46 项、root 真机四栏/菜单/设备重命名与记忆/后台连接生命周期、窄屏大字体横屏导航、真实 systemd 生命周期。完整 BLE(301.514 秒)、错误密码明确分类及原网络恢复、最终覆盖安装摘要核对均通过,详见本轮报告;真实多设备、旧 Android、跨 SSID 与实际 DHCP/静态互换不能按模拟或表单测试记为通过。
@@ -66,3 +66,9 @@ MOBILE-RECONNECT/MOBILE-ERRORS 补充:断线清除旧画面、状态、缩
- MOBILE-WIFI-SECRET:不回读原密码,“已设置密码”占位,原 SSID 留空保留,替换密码只驻留内存;取消确认不提交。 - MOBILE-WIFI-SECRET:不回读原密码,“已设置密码”占位,原 SSID 留空保留,替换密码只驻留内存;取消确认不提交。
- MOBILE-APP-SETTINGS:顶部三点菜单关于/软件设置,关于显示版本、构建编号及北京时间打包日期;手机昵称可离线修改并同步当前会话。 - MOBILE-APP-SETTINGS:顶部三点菜单关于/软件设置,关于显示版本、构建编号及北京时间打包日期;手机昵称可离线修改并同步当前会话。
- MOBILE-TEST-PRIVACY:本 App 调试所需测试权限可通过登记手机 ADB root 直接获取,无需重复询问;所有自动点击使用 root 并依据实时语义位置,禁止无障碍绕过。特殊情况或权限不足必须停止等待用户。视觉和物理门禁不变。 - MOBILE-TEST-PRIVACY:本 App 调试所需测试权限可通过登记手机 ADB root 直接获取,无需重复询问;所有自动点击使用 root 并依据实时语义位置,禁止无障碍绕过。特殊情况或权限不足必须停止等待用户。视觉和物理门禁不变。
## 2026-09-27 界面修订
- MOBILE-STATUS:顶栏长文字自动向左无限循环(30 dp/秒、每轮间隔1秒),短文字静止;菜单固定,完整语义文字保留。
- MOBILE-DISCOVERY/CONNECTION:握手成功后扫描条目显示已连接及断开按钮;再次扫描保留当前连接条目,旧扫描信号不作实时信号。断开、失败和异常清理会话关联;其他条目仍可连接,连接期间禁止重复操作。
- MOBILE-PREVIEW:两处设备画面、标题、等待与过期提示按整个卡片居中,刷新按钮固定右侧;标题预留对称空间,放大字体不与按钮重叠。
@@ -54,3 +54,7 @@ Android 断线清理与旧扫描回调隔离已通过模拟及 Activity 真机
### 2026-09-26 四栏优化 ### 2026-09-26 四栏优化
用户明确要求 App 迭代。App 0.2.0 / code 3:四栏、设备记忆、中文 WiFi 设置、关于与本地设置;旧记录通过后续握手迁移。设备 library.list 对齐持久顺序,task.get 增加可选 error_code;协议主版本 1,旧 App 忽略新字段。无驱动修改,无新板端依赖,不导出 OTA/IMG。实际验证结果另行归档。 用户明确要求 App 迭代。App 0.2.0 / code 3:四栏、设备记忆、中文 WiFi 设置、关于与本地设置;旧记录通过后续握手迁移。设备 library.list 对齐持久顺序,task.get 增加可选 error_code;协议主版本 1,旧 App 忽略新字段。无驱动修改,无新板端依赖,不导出 OTA/IMG。实际验证结果另行归档。
## 2026-09-27 Android 0.2.1 界面优化
MOBILE-STATUS/DISCOVERY/PREVIEW:顶栏自动循环、扫描条目断开与连接保留、设备画面居中。仅修改手机界面及会话显示状态;协议1.0、设备接口、广播仲裁和驱动不变,不要求设备升级。不推进设备发布版本或 FEATURE_UPDATED_AT。