Improve Android connection and preview UI testing
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "org.qimiaoscreen.controller"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 3
|
||||
versionName = "0.2.0"
|
||||
versionCode = 4
|
||||
versionName = "0.2.1"
|
||||
buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"")
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ class AppLifecycleTest {
|
||||
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))
|
||||
assertTrue(ui.wait(Until.hasObject(By.textContains("版本 " + BuildConfig.VERSION_NAME)), 5_000))
|
||||
click(ui.findObject(By.text("关闭")))
|
||||
click(ui.findObject(By.text("⋮")))
|
||||
click(checkNotNull(ui.wait(Until.findObject(By.text("软件设置")), 5_000)))
|
||||
|
||||
+118
-8
@@ -3,13 +3,16 @@ 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.BySelector
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiObject2
|
||||
import androidx.test.uiautomator.StaleObjectException
|
||||
import androidx.test.uiautomator.Until
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Read semantic bounds; inject all input through the authorized root helper. */
|
||||
@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"))
|
||||
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) {
|
||||
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}")
|
||||
check(visible(By.text(label), 15_000, label == "扫描设备") != null) { "Layout control unavailable: $label" }
|
||||
stableTap { ui.findObjects(By.text(label)).filter { !it.visibleBounds.isEmpty }.maxByOrNull { it.visibleCenter.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("关闭")
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -27,6 +27,7 @@ data class ControllerState(
|
||||
val page: Int = 0, val phase: String = "未连接", val scanning: Boolean = false,
|
||||
val busy: Boolean = false, val error: String? = null, val clientName: String = "",
|
||||
val devices: List<DiscoveredDevice> = emptyList(), val identity: JsonObject? = null,
|
||||
val connectedDevice: DiscoveredDevice? = null,
|
||||
val status: JsonObject? = null, val storage: JsonObject? = null,
|
||||
val settings: JsonObject? = null, val wifi: JsonObject? = null,
|
||||
val library: List<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(),
|
||||
@@ -88,7 +89,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
scanJob?.cancel()
|
||||
platform.stopScan()
|
||||
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 short = lastId?.replace("-", "")?.take(8)
|
||||
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) {
|
||||
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 (connectionJob?.isActive == true || rpc != null) disconnect()
|
||||
scanGeneration++; scanJob?.cancel(); platform.stopScan()
|
||||
@@ -120,6 +122,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
activeLink = platform.connect(device.handle)
|
||||
pending = DeviceRpc(checkNotNull(activeLink), platform.crypto())
|
||||
val identity = pending.open(state.value.clientName)
|
||||
if (generation != connectionGeneration) { pending.close(); return@launch }
|
||||
val id = identity.getValue("device_id").jsonPrimitive.content
|
||||
require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" }
|
||||
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,
|
||||
identity.getValue("device_name").jsonPrimitive.content, identity.getValue("short_id").jsonPrimitive.content,
|
||||
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()
|
||||
startPolling()
|
||||
} catch (e: Exception) {
|
||||
pending?.close()
|
||||
if (generation == connectionGeneration) {
|
||||
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 ?: "连接失败") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-2
@@ -12,11 +12,12 @@ private class SimulatedPlatform : MobilePlatform {
|
||||
val preferences = mutableMapOf<String, String>()
|
||||
val links = mutableListOf<SimulatedLink>()
|
||||
val scanCallbacks = mutableListOf<(DiscoveredDevice) -> Unit>()
|
||||
var signal: Deferred<Int?>? = null
|
||||
override fun load(key: String) = preferences[key]
|
||||
override fun save(key: String, value: String) { preferences[key] = value }
|
||||
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) { scanCallbacks += onDevice }
|
||||
override fun stopScan() {}
|
||||
override suspend fun connect(handle: String) = SimulatedLink(if (handle == "second-handle") "11111111-0000-4000-8000-000000000001" else "00000000-0000-4000-8000-000000000001").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 val hello = byteArrayOf(1)
|
||||
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
|
||||
private val receiver = Reassembler()
|
||||
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) }
|
||||
}
|
||||
override suspend fun receive() = messages.receive()
|
||||
override suspend fun readSignal(): Int? = signal?.await()
|
||||
override fun close() { closed = true; messages.close() }
|
||||
}
|
||||
|
||||
@@ -82,6 +84,7 @@ class ControllerRecoveryTest {
|
||||
assertNull(controller.state.value.frame)
|
||||
assertNull(controller.state.value.frameCheckedAtMs)
|
||||
assertNull(controller.state.value.storage)
|
||||
assertNull(controller.state.value.connectedDevice)
|
||||
assertTrue(platform.links.last().closed)
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
@@ -135,11 +138,15 @@ class ControllerRecoveryTest {
|
||||
assertEquals("模拟屏", remembered.name)
|
||||
controller.scan()
|
||||
assertNotNull(controller.state.value.identity)
|
||||
assertEquals(device, controller.state.value.connectedDevice)
|
||||
assertEquals(listOf(device), controller.state.value.devices)
|
||||
assertTrue(controller.state.value.scanning)
|
||||
platform.scanCallbacks.last()(device)
|
||||
withTimeout(2_000) { controller.state.first { it.devices.isNotEmpty() } }
|
||||
assertEquals(1, platform.links.size)
|
||||
controller.disconnect()
|
||||
assertNull(controller.state.value.connectedDevice)
|
||||
assertTrue(controller.state.value.devices.isEmpty())
|
||||
assertEquals(remembered.id, Controller(platform, scope).state.value.remembered.single().id)
|
||||
controller.forget(remembered.id)
|
||||
controller.foreground(true)
|
||||
@@ -164,6 +171,8 @@ class ControllerRecoveryTest {
|
||||
assertTrue(old.closed)
|
||||
assertEquals(2, platform.links.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")
|
||||
assertNull(controller.state.value.identity)
|
||||
assertTrue(platform.links.last().closed)
|
||||
@@ -181,6 +190,37 @@ class ControllerRecoveryTest {
|
||||
platform.scanCallbacks.last()(device.copy(name = "QMS-00000000"))
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
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() }
|
||||
}
|
||||
|
||||
|
||||
+35
-15
@@ -7,6 +7,13 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
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.text.style.TextOverflow
|
||||
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)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> ImageBitmap?,
|
||||
appInfo: AppInfo = AppInfo("—", "—", "—")) {
|
||||
@@ -34,12 +42,16 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im
|
||||
icon = { Text(listOf("◉", "▣", "▦", "⚙")[index]) }, label = { Text(name) })
|
||||
}
|
||||
} }) { 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) {
|
||||
val identity = state.identity
|
||||
Text(if (identity == null) "当前连接设备:未连接${if (state.phase == "正在连接") " · 连接中" else ""}"
|
||||
else "当前连接设备:${identity.text("device_name")}(${identity.text("short_id")}) · ${wifiSummary(state)}",
|
||||
Modifier.weight(1f), style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
val summary = if (identity == null) "当前连接设备:未连接${if (state.phase == "正在连接") " · 连接中" else ""}"
|
||||
else "当前连接设备:${identity.text("device_name")}(${identity.text("short_id")}) · ${wifiSummary(state)}"
|
||||
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 {
|
||||
TextButton({ menu = true }) { Text("⋮") }
|
||||
DropdownMenu(menu, { menu = false }) {
|
||||
@@ -50,7 +62,7 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im
|
||||
}
|
||||
if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
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) {
|
||||
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 renaming by remember { mutableStateOf(false) }
|
||||
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) }
|
||||
if (state.remembered.isEmpty()) item { Text("暂无已保存设备,扫描后选择即可连接") }
|
||||
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) }
|
||||
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) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(device.name)
|
||||
Text("信号 ${device.signal} dBm${if (c.rememberedFor(device) != null) " · 已记忆" else ""}", style = MaterialTheme.typography.bodySmall)
|
||||
Text(if (connected) state.identity!!.text("device_name") else device.name)
|
||||
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)) {
|
||||
@@ -130,13 +146,17 @@ private fun Frame(encoded: String?, checkedAt: Long?, decode: (String) -> ImageB
|
||||
val bitmap = remember(encoded) { encoded?.let(decode) }
|
||||
var now by remember { mutableLongStateOf(Clock.System.now().toEpochMilliseconds()) }
|
||||
LaunchedEffect(checkedAt) { while (true) { now = Clock.System.now().toEpochMilliseconds(); delay(1_000) } }
|
||||
Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("设备画面", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium)
|
||||
TextButton(refresh, enabled = enabled) { Text("手动刷新") }
|
||||
val density = LocalDensity.current
|
||||
var refreshWidth by remember { mutableStateOf(96.dp) }
|
||||
Card(Modifier.fillMaxWidth().testTag("frame_card")) { Column(Modifier.fillMaxWidth().padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
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 (checkedAt != null && now - checkedAt > 6_000) Text("画面可能已过期,请刷新", style = MaterialTheme.typography.bodySmall)
|
||||
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, textAlign = TextAlign.Center)
|
||||
} }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user