同步移动端工程、设备控制改进与发布资料

This commit is contained in:
2026-09-26 19:21:12 +08:00
parent 912ba432cf
commit 98eadc5c8b
137 changed files with 9274 additions and 85 deletions
@@ -0,0 +1,122 @@
package org.qimiaoscreen.controller
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Semantic UI checks only: no screenshot, camera or display-content changes. */
@RunWith(AndroidJUnit4::class)
class AppLifecycleTest {
@Test fun actualActivityReturnsFromBackground() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val ui = UiDevice.getInstance(instrumentation)
val rootInput = InstrumentationRegistry.getArguments().getString("root_input") == "true"
fun rootCommand(args: String) {
val result = ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $args")
check(result.contains("QMS_INPUT_OK")) { "Authorized root input did not complete" }
}
fun click(node: UiObject2) {
check(rootInput) { "All test clicks require authorized root input" }
check(node.applicationPackage == "org.qimiaoscreen.controller") { "Input target outside App" }
val point = node.visibleCenter
rootCommand("tap ${point.x} ${point.y}")
}
ui.wakeUp()
fun launch() {
// MIUI rejects background Activity starts made by the test's app UID.
// Start the explicit authorized app through the normal shell test API.
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
check(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000)) {
"App did not become foreground"
}
}
launch()
fun scanAndConnect() {
click(checkNotNull(ui.wait(Until.findObject(By.text("扫描设备")), 10_000)))
val name = InstrumentationRegistry.getArguments().getString("device_name")!!
val device = checkNotNull(ui.wait(Until.findObject(By.text(name)), 20_000))
click(checkNotNull(device.parent.parent.findObject(By.text("连接"))))
assertTrue(ui.wait(Until.hasObject(By.text("断开连接")), 30_000))
}
if (!ui.wait(Until.hasObject(By.text("断开连接")), 30_000)) {
scanAndConnect()
}
if (InstrumentationRegistry.getArguments().getString("toggle_bluetooth") == "true") {
check(rootInput)
try {
rootCommand("bluetooth-disable")
assertTrue("Connection state must clear after Bluetooth is disabled",
ui.wait(Until.hasObject(By.text("扫描设备")), 15_000))
} finally { rootCommand("bluetooth-enable") }
Thread.sleep(3_000)
scanAndConnect()
}
click(ui.findObject(By.text("设备状态")))
assertTrue(ui.wait(Until.hasObject(By.text("设备画面")), 15_000))
check(!ui.hasObject(By.text("运行状态")))
click(ui.findObject(By.text("显示内容")))
assertTrue(ui.wait(Until.hasObject(By.text("可播放内容")), 15_000))
click(ui.findObject(By.text("设备设置")))
assertTrue(ui.wait(Until.hasObject(By.text("屏幕方向")), 15_000))
check(!ui.hasObject(By.text("性能模式")))
click(ui.findObject(By.text("连接")))
click(ui.findObject(By.text("⋮")))
click(checkNotNull(ui.wait(Until.findObject(By.text("关于")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.textContains("版本 0.2.0")), 5_000))
click(ui.findObject(By.text("关闭")))
click(ui.findObject(By.text("⋮")))
click(checkNotNull(ui.wait(Until.findObject(By.text("软件设置")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("当前手机昵称")), 5_000))
click(ui.findObject(By.text("取消")))
check(rootInput); rootCommand("home")
Thread.sleep(33_000)
launch()
assertTrue("Activity should reconnect after background release",
ui.wait(Until.hasObject(By.text("断开连接")), 35_000))
fun openRename() {
val marker = checkNotNull(ui.wait(Until.findObject(By.text("已连接")), 5_000))
val point = marker.visibleCenter
rootCommand("swipe ${point.x} ${point.y} ${point.x} ${point.y} 700")
click(checkNotNull(ui.wait(Until.findObject(By.text("重命名设备")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("设备昵称")), 5_000))
}
fun rename(value: String) {
val field = checkNotNull(ui.wait(Until.findObject(By.clazz("android.widget.EditText")), 5_000))
val clipboard = instrumentation.targetContext.getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", value))
try {
click(field)
rootCommand("clear-field ${(field.text.length + 2).coerceIn(2, 512)}")
rootCommand("paste")
rootCommand("hide-keyboard")
} finally { clipboard.clearPrimaryClip() }
click(checkNotNull(ui.wait(Until.findObject(By.text("保存")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:$value")), 15_000))
}
openRename()
val originalName = ui.findObject(By.clazz("android.widget.EditText")).text
var renamed = false
try {
rename("界面验证屏")
renamed = true
click(ui.findObject(By.text("断开连接")))
scanAndConnect()
assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:界面验证屏")), 5_000))
} finally {
if (renamed) { openRename(); rename(originalName) }
}
val again = ui.findObject(By.text("已连接")).visibleCenter
rootCommand("swipe ${again.x} ${again.y} ${again.x} ${again.y} 700")
click(checkNotNull(ui.wait(Until.findObject(By.text("取消记忆")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("暂无已保存设备,扫描后选择即可连接")), 5_000))
scanAndConnect()
click(ui.findObject(By.text("断开连接")))
assertTrue(ui.wait(Until.hasObject(By.text("扫描设备")), 5_000))
}
}
@@ -0,0 +1,240 @@
package org.qimiaoscreen.controller
import android.Manifest
import android.os.Build
import android.os.Bundle
import android.content.Intent
import android.content.pm.PackageManager
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.*
import org.junit.Test
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.qimiaoscreen.core.*
/** Read-only by default; an explicit flag can reapply the already active WiFi profile. */
@RunWith(AndroidJUnit4::class)
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 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 }) {
"Test permissions missing; stop and grant using authorized root runner"
}
val ble = AndroidBle(context)
val expectedName = checkNotNull(InstrumentationRegistry.getArguments().getString("device_name")) { "Explicit test board required" }
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() }
}
try {
val device = discover()
stage("scan complete")
ble.stopScan()
var identity: String? = null
repeat(2) { 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()
val link = ble.connect(target.handle)
val traced = object : DeviceLink {
override val mtu get() = link.mtu
override suspend fun send(fragment: ByteArray) {
stage("send kind=${fragment[3]} index=${fragment.readInt(8, 2)} count=${fragment.readInt(10, 2)} bytes=${fragment.size} mtu=$mtu")
link.send(fragment)
}
override suspend fun receive(): ByteArray = link.receive().also {
if (it.readInt(8, 2) == 0L) stage("receive kind=${it[3]} count=${it.readInt(10, 2)} bytes=${it.size}")
}
override fun close() = link.close()
}
val rpc = DeviceRpc(traced, JavaSecureChannel())
stage("GATT ready $iteration")
try {
val hello = rpc.open("android-test-a")
stage("encrypted session open")
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)
val status = rpc.call("status.get")
stage("status received")
assertTrue(status.containsKey("state"))
val settings = rpc.call("settings.get")
stage("settings received")
assertTrue(settings.getValue("values").jsonObject.containsKey("brightness"))
rpc.call("storage.get")
stage("storage received")
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) {
val all = library.getValue("items").jsonArray.map { it.jsonObject }.toMutableList()
var cursor = library["next_cursor"]?.jsonPrimitive?.contentOrNull
while (cursor != null) {
val page = rpc.call("library.list", buildJsonObject { put("cursor", cursor); put("limit", 50) })
all += page.getValue("items").jsonArray.map { it.jsonObject }
cursor = page["next_cursor"]?.jsonPrimitive?.contentOrNull
}
val canonical = all.filter { it["is_demo"]?.jsonPrimitive?.booleanOrNull != true }
.joinToString("\n") { it.getValue("type").jsonPrimitive.content + "|" + it.getValue("id").jsonPrimitive.content }
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(canonical.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it.toInt() and 255) }
assertEquals("BLE must preserve device library order", expectedOrder, digest)
stage("library persistent order verified")
}
val frame = rpc.call("frame.get")
stage("frame received")
assertEquals("image/png", frame.getValue("mime").jsonPrimitive.content)
val wifi = rpc.call("wifi.get")
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) {
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")
try {
rpc.call("settings.patch", buildJsonObject {
put("expected_revision", "deliberately-stale-test-revision")
put("changes", buildJsonObject { put("brightness", settings.getValue("values").jsonObject.getValue("brightness")) })
})
fail("Stale revision must be rejected")
} 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.
val currentSettings = rpc.call("settings.get")
val sameBrightness = currentSettings.getValue("values").jsonObject.getValue("brightness")
val patched = rpc.call("settings.patch", buildJsonObject {
put("expected_revision", currentSettings.getValue("revision"))
put("changes", buildJsonObject { put("brightness", sameBrightness) })
})
assertEquals(sameBrightness, patched.getValue("values").jsonObject.getValue("brightness"))
val originalDefault = rpc.call("content.default.get")
val currentState = rpc.call("status.get").getValue("state").jsonObject
val originalPlayback = currentState.getValue("animation_playback").jsonObject
if (originalPlayback["active"]?.jsonPrimitive?.booleanOrNull == true) {
val session = originalPlayback.getValue("session_id")
val originalPaused = originalPlayback.getValue("paused")
try {
val paused = rpc.call("playback.patch", buildJsonObject { put("session_id", session); put("paused", true) })
assertTrue(paused.getValue("animation_playback").jsonObject.getValue("paused").jsonPrimitive.boolean)
val held = paused.getValue("animation_playback").jsonObject
rpc.call("playback.patch", buildJsonObject {
put("session_id", session); put("position_ms", held.getValue("position_ms")); put("speed", originalPlayback.getValue("speed"))
})
} finally {
rpc.call("playback.patch", buildJsonObject { put("session_id", session); put("paused", originalPaused) })
}
stage("animation pause, same-position seek and same-speed write; original pause restored")
} else stage("animation write not applicable: no active animation")
assertEquals(originalDefault, rpc.call("content.default.get"))
stage("accepted settings write and default readback; selected content unchanged")
}
if (InstrumentationRegistry.getArguments().getString("reapply_current_wifi") == "true") {
val before = rpc.call("wifi.get")
val savedNetwork = before.getValue("saved").jsonObject
val activeNetwork = before.getValue("active").jsonObject
check(activeNetwork["connected"]?.jsonPrimitive?.boolean == true &&
activeNetwork["ssid"] == savedNetwork["ssid"]) { "Saved network must already be active" }
val security = savedNetwork.getValue("security").jsonPrimitive.content
check(security == "open" || savedNetwork["password_configured"]?.jsonPrimitive?.boolean == true)
val request = buildJsonObject {
for (key in listOf("ssid", "security", "ipv4_mode", "address", "prefix", "gateway", "dns_servers"))
savedNetwork[key]?.let { put(key, it) }
put("expected_revision", before.getValue("revision"))
put("password_action", if (security == "open") "none" else "keep")
put("activation", "immediate")
}
val task = rpc.call("wifi.set", request).getValue("task_id")
withTimeout(45_000) {
while (true) {
val result = rpc.call("task.get", buildJsonObject { put("task_id", task) })
val taskState = result.getValue("state").jsonPrimitive.content
check(taskState != "failed") { "Current-network activation failed" }
if (taskState == "succeeded") break
delay(1_000)
}
}
val after = rpc.call("wifi.get")
assertTrue("Current network restored", after.getValue("active").jsonObject["ssid"] == activeNetwork["ssid"])
assertTrue("Network is connected", after.getValue("active").jsonObject.getValue("connected").jsonPrimitive.boolean)
assertTrue("Saved fields retained", listOf("ssid", "security", "ipv4_mode", "address", "prefix", "gateway", "dns_servers").all {
after.getValue("saved").jsonObject[it] == savedNetwork[it]
})
stage("current WiFi reapplied with password retained; BLE remained usable")
}
repeat(InstrumentationRegistry.getArguments().getString("poll_cycles")?.toInt() ?: 60) { cycle ->
delay(2_000)
rpc.call("status.get")
rpc.call("frame.get")
if (cycle % 10 == 0) stage("continuous polling cycle $cycle")
}
}
} finally { stage("intentional session close $iteration"); rpc.close() }
if (iteration == 0) delay(3_000)
}
delay(3_000)
val saved = mutableMapOf<String, String>()
val platform = object : MobilePlatform {
override val model = "android-test-a"
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 controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val controller = Controller(platform, controllerScope)
try {
withContext(Dispatchers.Main) { controller.connect(device) }
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) }
delay(27_000)
assertNotNull("Returning before 30 seconds must cancel release", controller.state.value.identity)
withContext(Dispatchers.Main) { controller.foreground(false) }
withTimeout(35_000) { controller.state.first { it.identity == null } }
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 } }
stage("controller foreground reconnected")
} finally {
withContext(Dispatchers.Main) { controller.disconnect() }
controllerScope.cancel()
}
} finally { ble.stopScan() }
}
}
@@ -0,0 +1,41 @@
package org.qimiaoscreen.controller
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Read semantic bounds; inject all input through the authorized root helper. */
@RunWith(AndroidJUnit4::class)
class LayoutTest {
@Test fun narrowLargeFontAndLandscape() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val ui = UiDevice.getInstance(instrumentation)
fun root(command: String) = ui.executeShellCommand("sh /data/local/tmp/qms-authorized-root.sh $command").trim()
check(root("id -u") == "0") { "Root unavailable; stop testing" }
fun input(command: String) {
check(ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $command").contains("QMS_INPUT_OK"))
Thread.sleep(250)
}
fun click(label: String) {
check(ui.wait(Until.hasObject(By.text(label)), 15_000)) { "Layout control unavailable: $label" }
val node = checkNotNull(ui.findObjects(By.text(label)).filter { !it.visibleBounds.isEmpty }
.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")
assertTrue(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000))
for (label in listOf("连接", "设备状态", "显示内容", "设备设置", "连接")) click(label)
click("⋮"); click("关于"); click("关闭")
}
}
@@ -0,0 +1,252 @@
package org.qimiaoscreen.controller
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import android.graphics.Rect
import android.view.accessibility.AccessibilityNodeInfo
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/** Real App WiFi form. Secrets arrive through a short-lived shell-only file, never test arguments or logs. */
@RunWith(AndroidJUnit4::class)
class WifiSwitchTest {
@Test fun wifiFormStage() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val ui = UiDevice.getInstance(instrumentation)
val args = InstrumentationRegistry.getArguments()
val stage = checkNotNull(args.getString("wifi_stage"))
val rootSwipe = true
val config = Json.parseToJsonElement(
ui.executeShellCommand("cat /data/local/tmp/qms-wifi-test-private.json")
).jsonObject
fun value(key: String) = config.getValue(key).jsonPrimitive.content
fun rootCommand(command: String) {
check(ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $command").contains("QMS_INPUT_OK"))
}
fun click(node: UiObject2) {
var target = node
while ((!target.isClickable || target.visibleBounds.isEmpty) && target.parent != null) target = target.parent
check(target.applicationPackage == "org.qimiaoscreen.controller")
check(!target.visibleBounds.isEmpty)
val center = target.visibleCenter
instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream", "UI tap class=${target.className} bounds=${target.visibleBounds}\n") })
rootCommand("tap ${center.x} ${center.y}")
ui.waitForIdle(1_000)
Thread.sleep(250)
}
fun scroll(down: Boolean): Boolean {
val root = checkNotNull(instrumentation.uiAutomation.rootInActiveWindow)
val pending = ArrayDeque<AccessibilityNodeInfo>()
pending.add(root)
var scroller: AccessibilityNodeInfo? = null
var height = 0
while (pending.isNotEmpty()) {
val node = pending.removeFirst()
val bounds = Rect()
node.getBoundsInScreen(bounds)
if (node.isScrollable && bounds.height() > height && bounds.width() > ui.displayWidth / 2) {
scroller = node
height = bounds.height()
}
for (index in 0 until node.childCount) node.getChild(index)?.let(pending::add)
}
checkNotNull(scroller) { "App settings list has no accessibility scroll action" }
if (rootSwipe) {
val bounds = Rect()
scroller.getBoundsInScreen(bounds)
val x = bounds.centerX()
val upper = bounds.top + bounds.height() / 5
val lower = bounds.bottom - bounds.height() / 5
rootCommand("swipe $x ${if (down) lower else upper} $x ${if (down) upper else lower} 350")
Thread.sleep(300)
return true
}
error("Root scrolling is required")
}
fun find(label: String): UiObject2 {
repeat(14) {
ui.wait(Until.findObject(By.text(label)), 1_000)?.let { return it }
scroll(true)
}
repeat(14) {
ui.wait(Until.findObject(By.text(label)), 1_000)?.let { return it }
scroll(false)
}
error("App control unavailable: $label")
}
fun edit(label: String, text: String) {
repeat(5) {
try {
val labelNode = find(label)
val field = generateSequence(labelNode) { it.parent }.first { it.className == "android.widget.EditText" }
repeat(40) { if (!field.isEnabled) Thread.sleep(100) }
check(field.isEnabled) { "App edit field remained disabled" }
val clipboard = instrumentation.targetContext.getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", text))
try {
click(field)
rootCommand("clear-field ${(field.text.length + 2).coerceIn(2, 512)}")
if (text.isNotEmpty()) rootCommand("paste")
rootCommand("hide-keyboard")
} finally { clipboard.clearPrimaryClip() }
return
} catch (_: androidx.test.uiautomator.StaleObjectException) {
Thread.sleep(300)
}
}
error("App edit field kept changing: $label")
}
fun dhcp(enabled: Boolean) {
val label = find("自动获取IP(DHCP)")
val bounds = label.visibleBounds
val toggle = if (label.isCheckable) label else checkNotNull(ui.findObjects(By.checkable(true))
.filter { kotlin.math.abs(it.visibleCenter.y - bounds.centerY()) < bounds.height().coerceAtLeast(48) }
.maxByOrNull { it.visibleCenter.x }) { "DHCP switch unavailable beside its label" }
if (toggle.isChecked != enabled) click(toggle)
}
fun choose(label: String) {
repeat(6) {
try {
val text = find(label)
val chip = generateSequence(text) { it.parent }.first { it.isClickable }
if (chip.isChecked) return
if (chip.isEnabled) click(chip)
Thread.sleep(500)
val updated = generateSequence(find(label)) { it.parent }.first { it.isClickable }
if (updated.isChecked) return
} catch (_: androidx.test.uiautomator.StaleObjectException) {
Thread.sleep(300)
}
}
error("App choice did not become selected: $label")
}
fun findStatus(): String {
repeat(14) {
ui.findObject(By.textStartsWith("当前WiFi:"))?.let { return it.text }
scroll(false)
}
error("Current WiFi status unavailable")
}
fun assertWifi(saved: String, active: String) {
val label = find("WiFi名称")
val field = generateSequence(label) { it.parent }.first { it.className == "android.widget.EditText" }
assertEquals(saved, field.text)
val current = findStatus()
assertTrue(current.contains(active))
val ip = Regex("\\b(?:[0-9]{1,3}\\.){3}[0-9]{1,3}\\b").find(current)?.value
checkNotNull(ip) { "Current WiFi IP unavailable" }
File(instrumentation.targetContext.filesDir, "qms-wifi-test-ip.txt").writeText(ip, Charsets.UTF_8)
}
ui.wakeUp()
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
check(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000))
if (!ui.wait(Until.hasObject(By.text("断开连接")), 25_000)) {
click(find("扫描设备"))
val board = checkNotNull(ui.wait(Until.findObject(By.text(value("board_name"))), 20_000))
click(checkNotNull(board.parent.parent.findObject(By.text("连接"))))
check(ui.wait(Until.hasObject(By.text("断开连接")), 30_000))
}
click(find("设备设置"))
check(ui.wait(Until.hasObject(By.text("屏幕方向")), 15_000))
ui.waitForIdle(1_000)
click(find("WiFi设置 展开"))
check(ui.wait(Until.hasObject(By.text("WiFi设置 收起")), 5_000))
when (stage) {
"validate" -> {
edit("WiFi名称", "")
val field = generateSequence(find("WiFi名称")) { it.parent }.first { it.className == "android.widget.EditText" }
instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream", "UI tap diagnostic: SSID empty=${field.text.isEmpty()}\n") })
val save = find("保存WiFi设置")
assertTrue("Empty SSID must disable save", generateSequence(save) { it.parent }.any { !it.isEnabled })
edit("WiFi名称", value("original_ssid"))
assertWifi(value("original_ssid"), value("original_ssid"))
}
"validate_invalid" -> {
edit("WiFi名称", value("original_ssid"))
dhcp(false)
edit("静态IP地址", "999.999.999.999")
edit("前缀长度(例如24)", "24")
edit("网关", value("original_gateway"))
click(find("保存WiFi设置"))
check(ui.wait(Until.hasObject(By.text("立即生效WiFi设置")), 5_000))
click(find("确定"))
check(ui.wait(Until.hasObject(By.textContains("参数无效")), 12_000)) { "Invalid IPv4 was not rejected" }
click(find("知道了"))
}
"scan" -> {
click(find("扫描WiFi"))
Thread.sleep(12_000)
check(!ui.hasObject(By.text("知道了"))) { "App WiFi scan reported an error" }
}
"wrong", "dhcp", "manual", "dhcp_return", "restore" -> {
val target = if (stage == "restore") value("original_ssid") else value("hotspot_ssid")
edit("WiFi名称", target)
if (stage == "wrong" || stage == "dhcp" || stage == "restore") {
val password = if (stage == "wrong") value("wrong_password") else if (stage == "restore") value("original_password") else value("hotspot_password")
edit("WiFi密码", password)
val show = find("显示输入的密码")
val bounds = show.visibleBounds
val checkbox = if (show.isCheckable) show else checkNotNull(ui.findObjects(By.checkable(true))
.filter { kotlin.math.abs(it.visibleCenter.y - bounds.centerY()) < bounds.height().coerceAtLeast(48) }
.minByOrNull { it.visibleCenter.x })
click(checkbox)
val label = find("WiFi密码")
val field = generateSequence(label) { it.parent }.first { it.className == "android.widget.EditText" }
assertEquals(password, field.text)
}
dhcp(!(stage == "manual" || stage == "restore" && value("original_mode") == "manual"))
if (stage == "manual" || stage == "restore" && value("original_mode") == "manual") {
val prefix = if (stage == "manual") "24" else value("original_prefix")
val address = if (stage == "manual") value("static_address") else value("original_address")
val gateway = if (stage == "manual") value("hotspot_gateway") else value("original_gateway")
val dns = if (stage == "manual") value("hotspot_gateway") else value("original_dns")
edit("静态IP地址", address)
edit("前缀长度(例如24)", prefix)
edit("网关", gateway)
edit("DNS(多个用逗号分隔)", dns)
}
click(find("保存WiFi设置"))
check(ui.wait(Until.hasObject(By.text("立即生效WiFi设置")), 5_000))
click(find("确定"))
if (stage == "wrong") {
Thread.sleep(45_000)
check(listOf("密码错误", "网络连接失败", "WiFi不存在", "无法获取IP").any { ui.hasObject(By.textContains(it)) }) {
"Wrong-password attempt must show a WiFi failure"
}
instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream",
if (ui.hasObject(By.textContains("密码错误"))) "AUTH_FAILED_CONFIRMED\n" else "WIFI_FAILURE_UNCLASSIFIED\n") })
} else {
Thread.sleep(5_000)
}
assertFalse(ui.hasObject(By.text(value("hotspot_password"))))
assertFalse(ui.hasObject(By.text(value("original_password"))))
assertFalse(ui.hasObject(By.text(value("wrong_password"))))
}
"verify" -> assertWifi(value("original_ssid"), value("original_ssid"))
"inspect_failure" -> {
val current = findStatus()
check(current.contains("失败") || current.contains("密码错误") || current.contains("不存在") || current.contains("无法获取IP")) { "Expected human-readable WiFi failure" }
}
else -> error("Unknown WiFi test stage")
}
// Release the authenticated owner before the next instrumentation process.
ui.findObject(By.text("知道了"))?.let(::click)
click(find("连接"))
val disconnect = ui.wait(Until.findObject(By.text("断开连接")), 3_000)
if (disconnect != null) click(disconnect)
Thread.sleep(2_000)
}
}
@@ -0,0 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application android:label="奇妙小屏幕" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false" android:supportsRtl="true">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,179 @@
package org.qimiaoscreen.controller
import android.annotation.SuppressLint
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.os.ParcelUuid
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import org.qimiaoscreen.core.*
import java.util.UUID
/** Permissions are requested by the Activity before calling this adapter. */
@SuppressLint("MissingPermission")
class AndroidBle(private val context: Context) {
private val adapter get() = context.getSystemService(BluetoothManager::class.java).adapter
private var scanning: ScanCallback? = null
fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) {
stopScan()
val scanner = adapter?.bluetoothLeScanner ?: run { onError("请打开手机蓝牙"); return }
val callback = object : ScanCallback() {
override fun onScanResult(type: Int, result: ScanResult) {
onDevice(DiscoveredDevice(result.device.address, result.scanRecord?.deviceName ?: "奇妙小屏幕", result.rssi))
}
override fun onScanFailed(code: Int) { onError("蓝牙扫描失败($code)") }
}
scanning = callback
scanner.startScan(listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(UUID.fromString(Protocol.SERVICE))).build()),
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), callback)
}
fun stopScan() { scanning?.let { adapter?.bluetoothLeScanner?.stopScan(it) }; scanning = null }
suspend fun connect(handle: String): DeviceLink {
stopScan()
// Retry only GATT establishment, before any application request exists.
// Never switch device or replay an ambiguous business mutation.
repeat(2) { attempt ->
val link = GattLink()
try {
val gatt = adapter.getRemoteDevice(handle).connectGatt(context, false, link.callback, BluetoothDevice.TRANSPORT_LE)
link.attach(gatt)
withTimeout(15_000) { link.ready.await() }
return link
} catch (e: Exception) {
link.close()
if (e is CancellationException && e !is TimeoutCancellationException) throw e
if (attempt == 1) {
if (e is TimeoutCancellationException) throw DeviceTransportFailure("蓝牙连接建立超时,请重新扫描")
throw e
}
delay(1_000)
}
}
error("蓝牙连接失败")
}
}
@SuppressLint("MissingPermission")
private class GattLink : DeviceLink {
@Volatile override var mtu = 23
private set
val ready = CompletableDeferred<Unit>()
private val messages = Channel<ByteArray>(256)
private val writes = Mutex()
@Volatile private var pending: CompletableDeferred<Unit>? = null
@Volatile private var gatt: BluetoothGatt? = null
@Volatile private var rx: BluetoothGattCharacteristic? = null
@Volatile private var closed = false
private var signalPending: CompletableDeferred<Int?>? = null
private var discoveryStarted = false
private val handler = Handler(Looper.getMainLooper())
private val discoveryFallback = Runnable { gatt?.let(::discoverOnce) }
@Synchronized private fun discoverOnce(g: BluetoothGatt) {
if (closed || discoveryStarted) return
discoveryStarted = true
handler.removeCallbacks(discoveryFallback)
if (!g.discoverServices()) fail()
}
fun attach(value: BluetoothGatt) { if (closed) value.close() else gatt = value }
private fun fail() {
val error = IllegalStateException("蓝牙连接已中断")
ready.completeExceptionally(error)
pending?.completeExceptionally(error)
messages.close(error)
close()
}
val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, state: Int) {
if (status != BluetoothGatt.GATT_SUCCESS || state == BluetoothProfile.STATE_DISCONNECTED) { fail(); return }
if (state == BluetoothProfile.STATE_CONNECTED) {
attach(g)
if (!g.requestMtu(247)) discoverOnce(g)
else handler.postDelayed(discoveryFallback, 2_000)
}
}
override fun onReadRemoteRssi(g: BluetoothGatt, rssi: Int, status: Int) {
signalPending?.complete(if (status == BluetoothGatt.GATT_SUCCESS) rssi else null)
}
override fun onMtuChanged(g: BluetoothGatt, value: Int, status: Int) {
// Some vendor stacks report 517 and then 247 for one request.
// Keep the advertised receive limit conservative and do not queue
// duplicate service discovery / CCCD writes on those callbacks.
if (status == BluetoothGatt.GATT_SUCCESS) mtu = value.coerceIn(23, 247)
// A cached 517 callback can precede completion of our 247 request.
// Starting discovery on it stalls this vendor stack. Wait for the
// requested size, or the bounded fallback if the stack chooses more.
if (status != BluetoothGatt.GATT_SUCCESS || value <= 247) discoverOnce(g)
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) { fail(); return }
val service = g.getService(UUID.fromString(Protocol.SERVICE))
rx = service?.getCharacteristic(UUID.fromString(Protocol.RX))
val tx = service?.getCharacteristic(UUID.fromString(Protocol.TX))
val descriptor = tx?.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
if (rx == null || tx == null || descriptor == null || !g.setCharacteristicNotification(tx, true)) { fail(); return }
@Suppress("DEPRECATION")
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
if (!g.writeDescriptor(descriptor)) fail()
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) ready.complete(Unit) else fail()
}
@Deprecated("Legacy Android callback")
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
@Suppress("DEPRECATION")
enqueue(characteristic, characteristic.value)
}
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
enqueue(characteristic, value)
}
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) pending?.complete(Unit) else fail()
}
}
private fun enqueue(characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (characteristic.uuid.toString() != Protocol.TX || !messages.trySend(value.copyOf()).isSuccess) fail()
}
override suspend fun send(fragment: ByteArray) = writes.withLock {
check(!closed)
val completion = CompletableDeferred<Unit>()
pending = completion
try {
val characteristic = checkNotNull(rx)
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
characteristic.value = fragment
@Suppress("DEPRECATION")
check(checkNotNull(gatt).writeCharacteristic(characteristic))
withTimeout(5_000) { completion.await() }
} finally { pending = null }
}
override suspend fun receive(): ByteArray = messages.receive()
override suspend fun readSignal(): Int? {
if (closed) return null
val completion = CompletableDeferred<Int?>()
signalPending = completion
return try {
if (gatt?.readRemoteRssi() != true) null else withTimeout(1_000) { completion.await() }
} catch (_: TimeoutCancellationException) { null } finally { signalPending = null }
}
override fun close() {
if (closed) return
closed = true
handler.removeCallbacks(discoveryFallback)
val error = IllegalStateException("蓝牙连接已关闭")
ready.completeExceptionally(error)
pending?.completeExceptionally(error)
messages.close()
gatt?.disconnect(); gatt?.close(); gatt = null
}
}
@@ -0,0 +1,65 @@
package org.qimiaoscreen.controller
import android.os.Bundle
import android.os.Build
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.util.Base64
import android.app.AlertDialog
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.*
import org.qimiaoscreen.core.*
import org.qimiaoscreen.ui.QimiaoApp
import org.qimiaoscreen.ui.AppInfo
class MainActivity : ComponentActivity() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var controller: Controller
private val permissions get() = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
private fun permitted() = permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
private val requestPermission: ActivityResultLauncher<Array<String>> = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
if (permitted()) controller.scan()
else {
controller.reportError("蓝牙权限未授予,无法扫描和连接设备")
val permanent = permissions.any { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED && !shouldShowRequestPermissionRationale(it) }
AlertDialog.Builder(this).setTitle("需要蓝牙扫描权限")
.setMessage(if (permanent) "请在应用设置中允许附近设备权限;Android 8–11 需要定位权限用于蓝牙扫描。" else "权限只用于发现和连接小屏幕,不读取联系人或手机文件。")
.setNegativeButton("暂不", null)
.setPositiveButton(if (permanent) "打开应用设置" else "重试") { _, _ ->
if (permanent) startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
else requestPermission.launch(permissions)
}.show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val ble = AndroidBle(applicationContext)
val preferences = getSharedPreferences("controller", MODE_PRIVATE)
val platform = object : MobilePlatform {
override val model = Build.MODEL
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)
override fun stopScan() { if (permitted()) ble.stopScan() }
override suspend fun connect(handle: String) = ble.connect(handle)
override fun crypto(): SecureChannel = JavaSecureChannel()
}
controller = Controller(platform, scope)
setContent { QimiaoApp(controller, onScan = {
if (permitted()) controller.scan() else requestPermission.launch(permissions)
}, appInfo = AppInfo(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toString(), BuildConfig.BUILD_DATE), decode = { encoded ->
runCatching { val bytes = Base64.decode(encoded, Base64.DEFAULT); BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() }.getOrNull()
}) }
}
override fun onStart() { super.onStart(); if (::controller.isInitialized && permitted()) controller.foreground(true) }
override fun onStop() { if (::controller.isInitialized) controller.foreground(false); super.onStop() }
override fun onDestroy() { if (::controller.isInitialized) controller.disconnect(); scope.cancel(); super.onDestroy() }
}