同步移动端工程、设备控制改进与发布资料
This commit is contained in:
+122
@@ -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))
|
||||
}
|
||||
}
|
||||
+240
@@ -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() }
|
||||
}
|
||||
}
|
||||
+41
@@ -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("关闭")
|
||||
}
|
||||
}
|
||||
+252
@@ -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>
|
||||
+179
@@ -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
|
||||
}
|
||||
}
|
||||
+65
@@ -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() }
|
||||
}
|
||||
Reference in New Issue
Block a user