Improve controller behavior and update project requirements
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "org.qimiaoscreen.controller"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 4
|
||||
versionName = "0.2.1"
|
||||
versionCode = 5
|
||||
versionName = "0.2.2"
|
||||
buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"")
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
+74
-69
@@ -15,6 +15,8 @@ import org.junit.runner.RunWith
|
||||
class AppLifecycleTest {
|
||||
@Test fun actualActivityReturnsFromBackground() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val scenario = checkNotNull(InstrumentationRegistry.getArguments().getString("scenario"))
|
||||
check(scenario in setOf("navigation", "activity-background", "rename", "remember", "bluetooth-toggle"))
|
||||
val ui = UiDevice.getInstance(instrumentation)
|
||||
val rootInput = InstrumentationRegistry.getArguments().getString("root_input") == "true"
|
||||
fun rootCommand(args: String) {
|
||||
@@ -44,79 +46,82 @@ class AppLifecycleTest {
|
||||
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 (scenario == "navigation") {
|
||||
for (label in listOf("设备状态", "显示内容", "设备设置", "连接")) {
|
||||
click(checkNotNull(ui.wait(Until.findObject(By.text(label)), 5_000)))
|
||||
}
|
||||
click(ui.findObject(By.text("⋮")))
|
||||
click(checkNotNull(ui.wait(Until.findObject(By.text("关于")), 5_000)))
|
||||
assertTrue(ui.wait(Until.hasObject(By.textContains("版本 " + BuildConfig.VERSION_NAME)), 5_000))
|
||||
click(ui.findObject(By.text("关闭")))
|
||||
return
|
||||
}
|
||||
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("版本 " + BuildConfig.VERSION_NAME)), 5_000))
|
||||
click(ui.findObject(By.text("关闭")))
|
||||
click(ui.findObject(By.text("⋮")))
|
||||
click(checkNotNull(ui.wait(Until.findObject(By.text("软件设置")), 5_000)))
|
||||
assertTrue(ui.wait(Until.hasObject(By.text("当前手机昵称")), 5_000))
|
||||
click(ui.findObject(By.text("取消")))
|
||||
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))
|
||||
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()
|
||||
}
|
||||
if (scenario == "activity-background") {
|
||||
check(rootInput); rootCommand("home")
|
||||
Thread.sleep(33_000)
|
||||
launch()
|
||||
assertTrue("Activity should reconnect after background release",
|
||||
ui.wait(Until.hasObject(By.text("断开连接")), 35_000))
|
||||
}
|
||||
fun openRename() {
|
||||
val marker = checkNotNull(ui.wait(Until.findObject(By.text("已连接")), 5_000))
|
||||
val point = marker.visibleCenter
|
||||
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))
|
||||
}
|
||||
if (scenario == "rename") {
|
||||
openRename()
|
||||
val originalName = ui.findObject(By.clazz("android.widget.EditText")).text
|
||||
var renamed = false
|
||||
try {
|
||||
renamed = true
|
||||
rename("界面验证屏")
|
||||
click(ui.findObject(By.text("断开连接")))
|
||||
scanAndConnect()
|
||||
assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:界面验证屏")), 5_000))
|
||||
} finally {
|
||||
if (renamed) { openRename(); rename(originalName) }
|
||||
}
|
||||
}
|
||||
if (scenario == "remember") {
|
||||
val again = ui.findObject(By.text("已连接")).visibleCenter
|
||||
rootCommand("swipe ${again.x} ${again.y} ${again.x} ${again.y} 700")
|
||||
click(checkNotNull(ui.wait(Until.findObject(By.text("取消记忆")), 5_000)))
|
||||
assertTrue(ui.wait(Until.hasObject(By.text("暂无已保存设备,扫描后选择即可连接")), 5_000))
|
||||
scanAndConnect()
|
||||
}
|
||||
} finally {
|
||||
if (renamed) { openRename(); rename(originalName) }
|
||||
launch()
|
||||
ui.wait(Until.findObject(By.text("断开连接")), 5_000)?.let { click(it) }
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
+20
-14
@@ -24,6 +24,8 @@ class BleIntegrationTest {
|
||||
@Test fun encryptedReadOnlySessionAndReconnect() = runBlocking {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
fun stage(name: String) { instrumentation.sendStatus(2, Bundle().apply { putString("stream", "BLE stage: $name\n") }) }
|
||||
val scenario = checkNotNull(InstrumentationRegistry.getArguments().getString("scenario"))
|
||||
check(scenario in setOf("connection", "data", "library-order", "conflict", "polling", "controller-background", "wifi-scan", "controls", "wifi-reapply"))
|
||||
val context = instrumentation.targetContext
|
||||
val permissions = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
@@ -44,7 +46,7 @@ class BleIntegrationTest {
|
||||
stage("scan complete")
|
||||
ble.stopScan()
|
||||
var identity: String? = null
|
||||
repeat(2) { iteration ->
|
||||
repeat(if (scenario == "connection") 2 else if (scenario == "controller-background") 0 else 1) { iteration ->
|
||||
// Reconnection uses a fresh scan, as production does. A scan
|
||||
// handle is transient and must not be retained across sessions.
|
||||
val target = if (iteration == 0) device else discover()
|
||||
@@ -68,6 +70,7 @@ class BleIntegrationTest {
|
||||
val id = hello.getValue("device_id").jsonPrimitive.content
|
||||
if (identity == null) identity = id else assertEquals(identity, id)
|
||||
assertEquals(1, hello.getValue("protocol_major").jsonPrimitive.int)
|
||||
if (scenario == "data") {
|
||||
val status = rpc.call("status.get")
|
||||
stage("status received")
|
||||
assertTrue(status.containsKey("state"))
|
||||
@@ -76,11 +79,13 @@ class BleIntegrationTest {
|
||||
assertTrue(settings.getValue("values").jsonObject.containsKey("brightness"))
|
||||
rpc.call("storage.get")
|
||||
stage("storage received")
|
||||
}
|
||||
if (scenario in setOf("data", "library-order")) {
|
||||
val library = rpc.call("library.list")
|
||||
stage("library received")
|
||||
assertTrue(library.getValue("items").jsonArray.isNotEmpty())
|
||||
val expectedOrder = InstrumentationRegistry.getArguments().getString("library_order_hash")
|
||||
if (expectedOrder != null) {
|
||||
if (scenario == "library-order") {
|
||||
val all = library.getValue("items").jsonArray.map { it.jsonObject }.toMutableList()
|
||||
var cursor = library["next_cursor"]?.jsonPrimitive?.contentOrNull
|
||||
while (cursor != null) {
|
||||
@@ -95,6 +100,8 @@ class BleIntegrationTest {
|
||||
assertEquals("BLE must preserve device library order", expectedOrder, digest)
|
||||
stage("library persistent order verified")
|
||||
}
|
||||
}
|
||||
if (scenario == "data") {
|
||||
val frame = rpc.call("frame.get")
|
||||
stage("frame received")
|
||||
assertEquals("image/png", frame.getValue("mime").jsonPrimitive.content)
|
||||
@@ -102,11 +109,16 @@ class BleIntegrationTest {
|
||||
val saved = wifi["saved"]
|
||||
if (saved != null && saved != JsonNull) assertFalse(saved.jsonObject.containsKey("password"))
|
||||
assertTrue(rpc.call("session.ping").getValue("alive").jsonPrimitive.boolean)
|
||||
}
|
||||
if (iteration == 0) {
|
||||
if (scenario == "wifi-scan") {
|
||||
val networks = rpc.call("wifi.scan").getValue("networks").jsonArray
|
||||
assertTrue(networks.size <= 100)
|
||||
assertTrue(networks.all { !it.jsonObject.containsKey("password") })
|
||||
stage("WiFi scan completed without logging network names")
|
||||
}
|
||||
if (scenario == "conflict") {
|
||||
val settings = rpc.call("settings.get")
|
||||
try {
|
||||
rpc.call("settings.patch", buildJsonObject {
|
||||
put("expected_revision", "deliberately-stale-test-revision")
|
||||
@@ -116,6 +128,7 @@ class BleIntegrationTest {
|
||||
} catch (e: DeviceFailure) { assertEquals("CONFLICT", e.code) }
|
||||
assertTrue(rpc.call("session.ping").getValue("alive").jsonPrimitive.boolean)
|
||||
stage("configuration conflict preserves session")
|
||||
}
|
||||
if (InstrumentationRegistry.getArguments().getString("verify_current_controls") == "true") {
|
||||
// Exercise accepted writes without changing the selected
|
||||
// content, defaults, brightness, direction or network.
|
||||
@@ -180,7 +193,7 @@ class BleIntegrationTest {
|
||||
})
|
||||
stage("current WiFi reapplied with password retained; BLE remained usable")
|
||||
}
|
||||
repeat(InstrumentationRegistry.getArguments().getString("poll_cycles")?.toInt() ?: 60) { cycle ->
|
||||
if (scenario == "polling") repeat(InstrumentationRegistry.getArguments().getString("poll_cycles")?.toInt() ?: 60) { cycle ->
|
||||
delay(2_000)
|
||||
rpc.call("status.get")
|
||||
rpc.call("frame.get")
|
||||
@@ -190,7 +203,7 @@ class BleIntegrationTest {
|
||||
} finally { stage("intentional session close $iteration"); rpc.close() }
|
||||
if (iteration == 0) delay(3_000)
|
||||
}
|
||||
delay(3_000)
|
||||
if (scenario == "controller-background") {
|
||||
val saved = mutableMapOf<String, String>()
|
||||
val platform = object : MobilePlatform {
|
||||
override val model = "android-test-a"
|
||||
@@ -204,21 +217,13 @@ class BleIntegrationTest {
|
||||
val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
val controller = Controller(platform, controllerScope)
|
||||
try {
|
||||
withContext(Dispatchers.Main) { controller.connect(device) }
|
||||
withContext(Dispatchers.Main) { controller.connect(discover()) }
|
||||
try {
|
||||
withTimeout(30_000) { controller.state.first { it.status != null } }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
error("Controller failed: phase=${controller.state.value.phase}, error=${controller.state.value.error}")
|
||||
}
|
||||
stage("controller device page ready")
|
||||
withContext(Dispatchers.Main) { controller.page(2) }
|
||||
withTimeout(20_000) { controller.state.first { it.library.isNotEmpty() && !it.busy } }
|
||||
withContext(Dispatchers.Main) { controller.state.value.library.take(3).forEach(controller::thumbnail) }
|
||||
delay(10_000)
|
||||
assertNotNull(controller.state.value.identity)
|
||||
withContext(Dispatchers.Main) { controller.page(3) }
|
||||
withTimeout(20_000) { controller.state.first { it.wifi != null && !it.busy } }
|
||||
stage("controller content and settings ready")
|
||||
withContext(Dispatchers.Main) { controller.foreground(false) }
|
||||
delay(5_000)
|
||||
withContext(Dispatchers.Main) { controller.foreground(true) }
|
||||
@@ -229,12 +234,13 @@ class BleIntegrationTest {
|
||||
stage("controller background released")
|
||||
delay(3_000)
|
||||
withContext(Dispatchers.Main) { controller.foreground(true) }
|
||||
withTimeout(35_000) { controller.state.first { it.identity != null && it.wifi != null } }
|
||||
withTimeout(35_000) { controller.state.first { it.identity != null && it.status != null } }
|
||||
stage("controller foreground reconnected")
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) { controller.disconnect() }
|
||||
controllerScope.cancel()
|
||||
}
|
||||
}
|
||||
} finally { ble.stopScan() }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -76,8 +76,6 @@ class LayoutTest {
|
||||
}
|
||||
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
|
||||
assertTrue(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000))
|
||||
for (label in listOf("连接", "设备状态", "显示内容", "设备设置", "连接")) click(label)
|
||||
click("⋮"); click("关于"); click("关闭")
|
||||
fun tagged(tag: String): UiObject2 = checkNotNull(visible(By.res(tag), 35_000, tag == "connected_scan_disconnect")) {
|
||||
"Required App semantic tag unavailable: $tag"
|
||||
}
|
||||
@@ -118,7 +116,7 @@ class LayoutTest {
|
||||
tagged("connected_scan_disconnect")
|
||||
Thread.sleep(13_000)
|
||||
assertTrue(ui.hasObject(By.res("connected_scan_disconnect")))
|
||||
for (page in listOf("设备状态", "显示内容")) {
|
||||
if (InstrumentationRegistry.getArguments().getString("scenario") == "layout") for (page in listOf("设备状态", "显示内容")) {
|
||||
stage("frame-" + if (page == "设备状态") "status" else "content")
|
||||
click(page)
|
||||
tagged("frame_image")
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package org.qimiaoscreen.controller
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContextWrapper
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.qimiaoscreen.core.*
|
||||
import java.io.File
|
||||
|
||||
/** Read-only BLE cache check; records aggregate image payload counts, never IDs. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LibraryBleCacheTest {
|
||||
@Test fun coldAndWarmLibrary() = runBlocking {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val permissions = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
check(permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED })
|
||||
val expectedName = checkNotNull(InstrumentationRegistry.getArguments().getString("device_name"))
|
||||
val sandbox = File(context.cacheDir, "library-ble-test-${System.nanoTime()}").apply { mkdirs() }
|
||||
val cache = AndroidLibraryCache(object : ContextWrapper(context) { override fun getCacheDir() = sandbox })
|
||||
val ble = AndroidBle(context)
|
||||
suspend fun discover(): DiscoveredDevice {
|
||||
val found = CompletableDeferred<DiscoveredDevice>()
|
||||
try {
|
||||
ble.scan({ if (it.name == expectedName) found.complete(it) }, { found.completeExceptionally(IllegalStateException(it)) })
|
||||
return withTimeout(20_000) { found.await() }
|
||||
} finally { ble.stopScan() }
|
||||
}
|
||||
suspend fun <T> session(block: suspend (DeviceRpc, JsonObject) -> T): T {
|
||||
val link = ble.connect(discover().handle)
|
||||
val rpc = DeviceRpc(link, JavaSecureChannel())
|
||||
try {
|
||||
val identity = rpc.open("android-test-a")
|
||||
assertEquals(1, identity.getValue("protocol_minor").jsonPrimitive.int)
|
||||
assertTrue(identity.getValue("capabilities").jsonArray.any { it.jsonPrimitive.content == "library_progressive" })
|
||||
return block(rpc, identity)
|
||||
} finally { rpc.close() }
|
||||
}
|
||||
try {
|
||||
cache.clear()
|
||||
val first = session { rpc, identity ->
|
||||
val deviceId = identity.getValue("device_id").jsonPrimitive.content
|
||||
val summary = try { rpc.call("library.summary") }
|
||||
catch (e: DeviceFailure) { error("library.summary failed: ${e.code}") }
|
||||
val revision = summary.getValue("library_revision").jsonPrimitive.content
|
||||
val items = mutableListOf<JsonObject>()
|
||||
var cursor: String? = null
|
||||
do {
|
||||
val page = try { rpc.call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } }) }
|
||||
catch (e: DeviceFailure) { error("library.list page failed: ${e.code}") }
|
||||
assertEquals(revision, page.getValue("library_revision").jsonPrimitive.content)
|
||||
items += page.getValue("items").jsonArray.map { it.jsonObject }
|
||||
cursor = page["next_cursor"]?.jsonPrimitive?.contentOrNull
|
||||
} while (cursor != null)
|
||||
assertEquals(summary.getValue("item_count").jsonPrimitive.int, items.size)
|
||||
cache.saveCatalog(deviceId, CachedLibrary(revision, items))
|
||||
var requests = 0
|
||||
var bytes = 0L
|
||||
val images = items.filter { it["playable"]?.jsonPrimitive?.booleanOrNull != false }
|
||||
for ((method, tier) in listOf("library.preview" to "preview", "library.thumbnail" to "full")) {
|
||||
for ((index, item) in images.withIndex()) {
|
||||
val response = try { rpc.call(method, item) }
|
||||
catch (e: DeviceFailure) { error("$method index=$index type=${item.getValue("type").jsonPrimitive.content} failed: ${e.code}") }
|
||||
assertEquals("image/png", response.getValue("mime").jsonPrimitive.content)
|
||||
val encoded = response.getValue("data_base64").jsonPrimitive.content
|
||||
requests++
|
||||
bytes += Base64.decode(encoded, Base64.DEFAULT).size
|
||||
cache.writeImage(deviceId, item, tier, encoded)
|
||||
}
|
||||
}
|
||||
assertEquals(2 * images.size, requests)
|
||||
assertTrue(bytes > 0)
|
||||
assertEquals(revision, rpc.call("library.summary").getValue("library_revision").jsonPrimitive.content)
|
||||
instrumentation.sendStatus(2, Bundle().apply { putString("stream", "BLE library cold: items=${items.size}, image requests=$requests, PNG payload bytes=$bytes\n") })
|
||||
Triple(deviceId, revision, images)
|
||||
}
|
||||
session { rpc, identity ->
|
||||
assertEquals(first.first, identity.getValue("device_id").jsonPrimitive.content)
|
||||
assertEquals(first.second, rpc.call("library.summary").getValue("library_revision").jsonPrimitive.content)
|
||||
val catalog = cache.loadCatalog(first.first)
|
||||
assertEquals(first.second, catalog?.revision)
|
||||
for (item in first.third) {
|
||||
assertNotNull(cache.readImage(first.first, item, "preview"))
|
||||
assertNotNull(cache.readImage(first.first, item, "full"))
|
||||
}
|
||||
instrumentation.sendStatus(2, Bundle().apply { putString("stream", "BLE library warm: image requests=0, PNG payload bytes=0\n") })
|
||||
}
|
||||
var controllerWrites = 0
|
||||
val countingCache = object : LibraryCache by cache {
|
||||
override suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) {
|
||||
controllerWrites++
|
||||
cache.writeImage(deviceId, item, tier, base64)
|
||||
}
|
||||
}
|
||||
val platform = object : MobilePlatform {
|
||||
override val model = "android-test-a"
|
||||
override val libraryCache: LibraryCache = countingCache
|
||||
private val saved = mutableMapOf<String, String>()
|
||||
override fun load(key: String) = saved[key]
|
||||
override fun save(key: String, value: String) { saved[key] = value }
|
||||
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) = ble.scan(onDevice, onError)
|
||||
override fun stopScan() = ble.stopScan()
|
||||
override suspend fun connect(handle: String) = ble.connect(handle)
|
||||
override fun crypto(): SecureChannel = JavaSecureChannel()
|
||||
}
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
withContext(Dispatchers.Main) { controller.connect(discover()) }
|
||||
withTimeout(30_000) { controller.state.first { it.status != null } }
|
||||
withContext(Dispatchers.Main) { controller.page(2) }
|
||||
withTimeout(30_000) { controller.state.first { it.library.size >= first.third.size && it.thumbnails.size == first.third.size } }
|
||||
delay(3_000)
|
||||
assertEquals(0, controllerWrites)
|
||||
instrumentation.sendStatus(2, Bundle().apply { putString("stream", "Production Controller warm: image cache writes=0\n") })
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) { controller.disconnect() }
|
||||
scope.cancel()
|
||||
}
|
||||
} finally { ble.stopScan(); sandbox.deleteRecursively() }
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package org.qimiaoscreen.controller
|
||||
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Base64
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.qimiaoscreen.core.CachedLibrary
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LibraryCacheTest {
|
||||
@Test fun revisionIsolationCorruptionAndClear() = runBlocking {
|
||||
val target = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val sandbox = File(target.cacheDir, "library-cache-test-${System.nanoTime()}").apply { mkdirs() }
|
||||
val context = object : ContextWrapper(target) { override fun getCacheDir() = sandbox }
|
||||
val cache = AndroidLibraryCache(context)
|
||||
val old = buildJsonObject { put("type", "template"); put("id", "test-item"); put("revision", "one"); put("name", "测试") }
|
||||
val updated = buildJsonObject { put("type", "template"); put("id", "test-item"); put("revision", "two"); put("name", "测试") }
|
||||
fun png(size: Int): String {
|
||||
val output = ByteArrayOutputStream()
|
||||
Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(android.graphics.Color.RED)
|
||||
compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||
recycle()
|
||||
}
|
||||
return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
try {
|
||||
cache.saveCatalog("device-a", CachedLibrary("catalog-one", listOf(old)))
|
||||
cache.writeImage("device-a", old, "preview", png(16))
|
||||
cache.writeImage("device-a", old, "full", png(64))
|
||||
assertEquals("catalog-one", cache.loadCatalog("device-a")?.revision)
|
||||
assertNotNull(cache.readImage("device-a", old, "preview"))
|
||||
assertNull(cache.readImage("device-b", old, "full"))
|
||||
cache.retain("device-a", listOf(updated))
|
||||
assertNull(cache.readImage("device-a", old, "preview"))
|
||||
cache.writeImage("device-a", updated, "preview", png(16))
|
||||
val cachedFile = File(sandbox, "qms-library-v1").walkTopDown().first { it.isFile && it.name.endsWith(".png") }
|
||||
cachedFile.writeText("corrupt")
|
||||
assertNull(cache.readImage("device-a", updated, "preview"))
|
||||
assertTrue(cache.sizeBytes() > 0)
|
||||
cache.clear()
|
||||
assertNull(cache.loadCatalog("device-a"))
|
||||
assertEquals(0L, cache.sizeBytes())
|
||||
} finally { sandbox.deleteRecursively() }
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package org.qimiaoscreen.controller
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.*
|
||||
import org.qimiaoscreen.core.*
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Only files below this app's content-cache directory are ever deleted. */
|
||||
class AndroidLibraryCache(context: Context) : LibraryCache {
|
||||
private val root = File(context.cacheDir, "qms-library-v1")
|
||||
private val limit = 128L * 1024 * 1024
|
||||
private fun hash(value: String): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||||
private fun deviceDir(id: String) = File(root, hash(id))
|
||||
private fun imageName(item: JsonObject, tier: String): String = hash(
|
||||
listOf(tier, item.getValue("type").jsonPrimitive.content, item.getValue("id").jsonPrimitive.content,
|
||||
item.getValue("revision").jsonPrimitive.content).joinToString("\u0000")) + ".png"
|
||||
private fun imageFile(id: String, item: JsonObject, tier: String) = File(deviceDir(id), imageName(item, tier))
|
||||
private fun atomicWrite(file: File, bytes: ByteArray) {
|
||||
file.parentFile?.mkdirs()
|
||||
val temp = File(file.parentFile, file.name + ".tmp")
|
||||
try {
|
||||
temp.writeBytes(bytes)
|
||||
Files.move(temp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
|
||||
} finally { temp.delete() }
|
||||
}
|
||||
private fun validPng(bytes: ByteArray, tier: String): Boolean {
|
||||
if (bytes.size !in 1..65536 || tier !in listOf("preview", "full")) return false
|
||||
if (bytes.size < 8 || !bytes.copyOfRange(0, 8).contentEquals(byteArrayOf(137.toByte(), 80, 78, 71, 13, 10, 26, 10))) return false
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
val expected = if (tier == "preview") 16 else 64
|
||||
if (options.outWidth != expected || options.outHeight != expected) return false
|
||||
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) != null
|
||||
}
|
||||
override suspend fun loadCatalog(deviceId: String): CachedLibrary? = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val value = Json.parseToJsonElement(File(deviceDir(deviceId), "catalog.json").readText(Charsets.UTF_8)).jsonObject
|
||||
val items = value.getValue("items").jsonArray.map { it.jsonObject }
|
||||
require(items.all { item -> listOf("type", "id", "revision", "name").all { it in item } })
|
||||
CachedLibrary(value.getValue("revision").jsonPrimitive.content, items)
|
||||
}.getOrNull()
|
||||
}
|
||||
override suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary) = withContext(Dispatchers.IO) {
|
||||
val data = buildJsonObject { put("revision", catalog.revision); put("items", JsonArray(catalog.items)) }
|
||||
atomicWrite(File(deviceDir(deviceId), "catalog.json"), data.toString().toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
override suspend fun readImage(deviceId: String, item: JsonObject, tier: String): String? = withContext(Dispatchers.IO) {
|
||||
val file = imageFile(deviceId, item, tier)
|
||||
val bytes = if (file.length() in 1..65536) runCatching { file.readBytes() }.getOrNull() else null
|
||||
if (bytes == null || !validPng(bytes, tier)) { file.delete(); null }
|
||||
else { file.setLastModified(System.currentTimeMillis()); Base64.encodeToString(bytes, Base64.NO_WRAP) }
|
||||
}
|
||||
override suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) = withContext(Dispatchers.IO) {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
require(validPng(bytes, tier)) { "设备返回的图片无效" }
|
||||
atomicWrite(imageFile(deviceId, item, tier), bytes)
|
||||
trim()
|
||||
}
|
||||
override suspend fun retain(deviceId: String, items: List<JsonObject>) = withContext(Dispatchers.IO) {
|
||||
val expected = items.flatMap { item -> listOf(imageName(item, "preview"), imageName(item, "full")) }.toSet()
|
||||
deviceDir(deviceId).listFiles()?.forEach { file ->
|
||||
if (file.isFile && file.name.endsWith(".png") && file.name !in expected) file.delete()
|
||||
}
|
||||
Unit
|
||||
}
|
||||
private fun cacheFiles(): List<File> = root.listFiles()?.flatMap { it.listFiles()?.filter { f -> f.isFile } ?: emptyList() } ?: emptyList()
|
||||
private fun trim() {
|
||||
val files = cacheFiles().sortedBy { it.lastModified() }
|
||||
var total = files.sumOf { it.length() }
|
||||
for (file in files) {
|
||||
if (total <= limit) break
|
||||
val length = file.length()
|
||||
if (file.delete()) total -= length
|
||||
}
|
||||
}
|
||||
override suspend fun sizeBytes(): Long = withContext(Dispatchers.IO) {
|
||||
root.listFiles()?.sumOf { directory -> directory.listFiles()?.sumOf { it.length() } ?: 0L } ?: 0L
|
||||
}
|
||||
override suspend fun clear() = withContext(Dispatchers.IO) { root.deleteRecursively(); Unit }
|
||||
}
|
||||
+1
@@ -45,6 +45,7 @@ class MainActivity : ComponentActivity() {
|
||||
val preferences = getSharedPreferences("controller", MODE_PRIVATE)
|
||||
val platform = object : MobilePlatform {
|
||||
override val model = Build.MODEL
|
||||
override val libraryCache: LibraryCache = AndroidLibraryCache(applicationContext)
|
||||
override fun load(key: String) = preferences.getString(key, null)
|
||||
override fun save(key: String, value: String) { preferences.edit().putString(key, value).apply() }
|
||||
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) = ble.scan(onDevice, onError)
|
||||
|
||||
@@ -5,3 +5,5 @@ dependencyResolutionManagement {
|
||||
}
|
||||
rootProject.name = "QimiaoScreen"
|
||||
include(":androidApp", ":sharedCore", ":sharedUi")
|
||||
|
||||
include(":uiTest")
|
||||
|
||||
+167
-33
@@ -9,6 +9,7 @@ import kotlin.time.Clock
|
||||
|
||||
interface MobilePlatform {
|
||||
val model: String
|
||||
val libraryCache: LibraryCache? get() = null
|
||||
fun load(key: String): String?
|
||||
fun save(key: String, value: String)
|
||||
fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit)
|
||||
@@ -31,7 +32,8 @@ data class ControllerState(
|
||||
val status: JsonObject? = null, val storage: JsonObject? = null,
|
||||
val settings: JsonObject? = null, val wifi: JsonObject? = null,
|
||||
val library: List<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(),
|
||||
val thumbnails: Map<String, String> = emptyMap(), val frame: String? = null,
|
||||
val previews: Map<String, String> = emptyMap(), val thumbnails: Map<String, String> = emptyMap(),
|
||||
val cacheBytes: Long = 0, val frame: String? = null,
|
||||
val frameCheckedAtMs: Long? = null, val defaultContent: JsonObject? = null,
|
||||
)
|
||||
|
||||
@@ -76,11 +78,22 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
private var releaseJob: Job? = null
|
||||
private var foreground = true
|
||||
private var frameRevision: String? = null
|
||||
private var libraryRevision: String? = null
|
||||
private var libraryLoaded = false
|
||||
private var lastLibraryCheckMs = 0L
|
||||
private var cacheGeneration = 0L
|
||||
private var connectionJob: Job? = null
|
||||
private var commandJob: Job? = null
|
||||
private var connectionGeneration = 0L
|
||||
private var scanGeneration = 0L
|
||||
private val pendingPreviews = linkedMapOf<String, JsonObject>()
|
||||
private val pendingThumbnails = linkedMapOf<String, JsonObject>()
|
||||
private val hydrating = mutableSetOf<String>()
|
||||
init { scope.launch {
|
||||
try { platform.libraryCache?.sizeBytes()?.let { size -> change { it.copy(cacheBytes = size) } } }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { /* Cache can disappear under storage pressure. */ }
|
||||
} }
|
||||
private fun change(update: (ControllerState) -> ControllerState) { mutable.update(update) }
|
||||
fun dismissError() = change { it.copy(error = null) }
|
||||
fun reportError(message: String) = change { it.copy(error = message) }
|
||||
@@ -114,7 +127,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
if (connectionJob?.isActive == true || rpc != null) disconnect()
|
||||
scanGeneration++; scanJob?.cancel(); platform.stopScan()
|
||||
val generation = ++connectionGeneration
|
||||
frameRevision = null; pendingThumbnails.clear()
|
||||
frameRevision = null; resetLibraryQueue()
|
||||
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, devices = it.devices, phase = "正在连接") }
|
||||
connectionJob = scope.launch {
|
||||
var pending: DeviceRpc? = null
|
||||
@@ -150,11 +163,11 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
connectionGeneration++; scanGeneration++
|
||||
connectionJob?.cancel(); commandJob?.cancel(); scanJob?.cancel(); pollJob?.cancel(); releaseJob?.cancel()
|
||||
platform.stopScan(); rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; requestedDeviceId = null
|
||||
pendingThumbnails.clear()
|
||||
resetLibraryQueue()
|
||||
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered) }
|
||||
}
|
||||
private fun connectionLost(message: String) {
|
||||
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; pendingThumbnails.clear()
|
||||
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; resetLibraryQueue()
|
||||
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) }
|
||||
}
|
||||
fun foreground(value: Boolean) {
|
||||
@@ -162,13 +175,25 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
releaseJob?.cancel()
|
||||
if (value) {
|
||||
if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true)
|
||||
else if (rpc != null && state.value.page == 2) action { refreshLibrary(true) }
|
||||
} else {
|
||||
pendingPreviews.clear(); pendingThumbnails.clear()
|
||||
scanJob?.cancel(); platform.stopScan()
|
||||
change { it.copy(scanning = false) }
|
||||
releaseJob = scope.launch { delay(30_000); disconnect() }
|
||||
}
|
||||
}
|
||||
fun page(value: Int) { change { it.copy(page = value) }; if (rpc != null) action { refreshPage() } }
|
||||
fun page(value: Int) {
|
||||
if (value != 2) { pendingPreviews.clear(); pendingThumbnails.clear() }
|
||||
change { it.copy(page = value) }
|
||||
if (rpc != null) action { refreshPage() }
|
||||
}
|
||||
private fun resetLibraryQueue() {
|
||||
pendingPreviews.clear(); pendingThumbnails.clear()
|
||||
hydrating.clear()
|
||||
cacheGeneration++
|
||||
libraryRevision = null; libraryLoaded = false; lastLibraryCheckMs = 0L
|
||||
}
|
||||
private fun capabilities() = state.value.identity?.get("capabilities")?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()
|
||||
fun supports(capability: String) = capability in capabilities()
|
||||
private suspend fun call(method: String, params: JsonObject = buildJsonObject {}) = checkNotNull(rpc).call(method, params)
|
||||
@@ -198,30 +223,102 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
when (state.value.page) {
|
||||
0 -> Unit
|
||||
1 -> if (supports("frame")) refreshFrame()
|
||||
2 -> if (supports("library")) {
|
||||
var items: List<JsonObject> = emptyList()
|
||||
for (attempt in 0..1) {
|
||||
try {
|
||||
val loaded = mutableListOf<JsonObject>()
|
||||
var cursor: String? = null
|
||||
do {
|
||||
val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } })
|
||||
loaded += result.getValue("items").jsonArray.map { it.jsonObject }
|
||||
cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull
|
||||
} while (cursor != null)
|
||||
items = loaded
|
||||
break
|
||||
} catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e }
|
||||
}
|
||||
val defaultContent = call("content.default.get")
|
||||
change { it.copy(library = items, defaultContent = defaultContent) }
|
||||
}
|
||||
2 -> if (supports("library")) refreshLibrary(true)
|
||||
3 -> {
|
||||
if (supports("settings")) { val value = call("settings.get"); change { it.copy(settings = value) } }
|
||||
if (supports("wifi")) { val value = call("wifi.get"); change { it.copy(wifi = value) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
private suspend fun loadLibraryPages(): Pair<String, List<JsonObject>> {
|
||||
for (attempt in 0..1) {
|
||||
try {
|
||||
val items = mutableListOf<JsonObject>()
|
||||
var cursor: String? = null
|
||||
var revision: String? = null
|
||||
do {
|
||||
val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } })
|
||||
val pageRevision = result.getValue("library_revision").jsonPrimitive.content
|
||||
if (revision != null && pageRevision != revision) throw DeviceFailure("CONFLICT", "内容列表已改变")
|
||||
revision = pageRevision
|
||||
items += result.getValue("items").jsonArray.map { it.jsonObject }
|
||||
cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull
|
||||
} while (cursor != null)
|
||||
return checkNotNull(revision) to items
|
||||
} catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e }
|
||||
}
|
||||
error("内容列表读取失败")
|
||||
}
|
||||
private suspend fun cachedImage(deviceId: String, item: JsonObject, tier: String): String? =
|
||||
try { platform.libraryCache?.readImage(deviceId, item, tier) }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { null }
|
||||
|
||||
private fun rememberImage(key: String, image: String, full: Boolean) {
|
||||
change { old ->
|
||||
val values = ((if (full) old.thumbnails else old.previews) + (key to image)).toMutableMap()
|
||||
while (values.size > 128 || values.values.sumOf { it.length } > 8 * 1024 * 1024) values.remove(values.keys.first())
|
||||
if (full) old.copy(thumbnails = values) else old.copy(previews = values)
|
||||
}
|
||||
}
|
||||
private suspend fun queueLibraryImages(deviceId: String, items: List<JsonObject>) {
|
||||
pendingPreviews.clear(); pendingThumbnails.clear()
|
||||
for (item in items) {
|
||||
if (item["playable"]?.jsonPrimitive?.booleanOrNull == false) continue
|
||||
val key = thumbnailKey(item)
|
||||
val full = cachedImage(deviceId, item, "full")
|
||||
if (full != null) { rememberImage(key, full, true); continue }
|
||||
if (supports("library_progressive")) {
|
||||
val preview = cachedImage(deviceId, item, "preview")
|
||||
if (preview != null) rememberImage(key, preview, false)
|
||||
else pendingPreviews[key] = item
|
||||
pendingThumbnails[key] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
private suspend fun refreshLibrary(forceQueue: Boolean) {
|
||||
val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content ?: return
|
||||
val progressive = supports("library_progressive")
|
||||
val summary = if (progressive) call("library.summary") else null
|
||||
val expectedRevision = summary?.get("library_revision")?.jsonPrimitive?.content
|
||||
lastLibraryCheckMs = Clock.System.now().toEpochMilliseconds()
|
||||
val changed = !libraryLoaded || !progressive || expectedRevision != libraryRevision
|
||||
var items = state.value.library
|
||||
if (changed) {
|
||||
cacheGeneration++
|
||||
val cached = if (expectedRevision != null) platform.libraryCache?.loadCatalog(deviceId) else null
|
||||
if (cached != null && cached.revision == expectedRevision &&
|
||||
cached.items.size == summary.getValue("item_count").jsonPrimitive.int) {
|
||||
items = cached.items
|
||||
libraryRevision = cached.revision
|
||||
} else {
|
||||
change { it.copy(library = emptyList(), previews = emptyMap(), thumbnails = emptyMap()) }
|
||||
val loaded = loadLibraryPages()
|
||||
if (expectedRevision != null && loaded.first != expectedRevision) {
|
||||
lastLibraryCheckMs = 0L
|
||||
throw DeviceFailure("CONFLICT", "内容列表已改变,请重新加载")
|
||||
}
|
||||
libraryRevision = loaded.first
|
||||
items = loaded.second
|
||||
try { platform.libraryCache?.saveCatalog(deviceId, CachedLibrary(loaded.first, items)) }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { /* Cache loss must not close a working BLE connection. */ }
|
||||
}
|
||||
try { platform.libraryCache?.retain(deviceId, items) }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { /* Regenerable files can be pruned next time. */ }
|
||||
libraryLoaded = true
|
||||
change { it.copy(library = items, previews = emptyMap(), thumbnails = emptyMap()) }
|
||||
}
|
||||
if (changed || forceQueue) queueLibraryImages(deviceId, items)
|
||||
if (forceQueue) {
|
||||
val defaultContent = call("content.default.get")
|
||||
change { it.copy(defaultContent = defaultContent) }
|
||||
}
|
||||
try { platform.libraryCache?.sizeBytes()?.let { size -> change { it.copy(cacheBytes = size) } } }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { /* Storage reporting is optional. */ }
|
||||
}
|
||||
private fun startPolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = scope.launch {
|
||||
@@ -232,19 +329,28 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
if (!foreground) { call("session.ping"); continue }
|
||||
refreshStatus()
|
||||
if (state.value.page in 1..2 && supports("frame")) refreshFrame()
|
||||
if (state.value.page == 2 && !state.value.busy && pendingThumbnails.isNotEmpty()) {
|
||||
val (key, item) = pendingThumbnails.entries.first()
|
||||
pendingThumbnails.remove(key)
|
||||
val image = call("library.thumbnail", item).getValue("data_base64").jsonPrimitive.content
|
||||
change { old ->
|
||||
val cache = (old.thumbnails + (key to image)).toMutableMap()
|
||||
while (cache.size > 128 || cache.values.sumOf { it.length } > 8 * 1024 * 1024) cache.remove(cache.keys.first())
|
||||
old.copy(thumbnails = cache)
|
||||
if (state.value.page == 2 && supports("library") &&
|
||||
Clock.System.now().toEpochMilliseconds() - lastLibraryCheckMs >= 10_000) refreshLibrary(false)
|
||||
if (state.value.page == 2 && !state.value.busy && (pendingPreviews.isNotEmpty() || pendingThumbnails.isNotEmpty())) {
|
||||
val preview = pendingPreviews.isNotEmpty()
|
||||
val entry = if (preview) pendingPreviews.entries.first() else pendingThumbnails.entries.first()
|
||||
val key = entry.key
|
||||
val item = entry.value
|
||||
if (preview) pendingPreviews.remove(key) else pendingThumbnails.remove(key)
|
||||
val image = call(if (preview) "library.preview" else "library.thumbnail", item).getValue("data_base64").jsonPrimitive.content
|
||||
currentCoroutineContext().ensureActive()
|
||||
val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content
|
||||
if (deviceId != null) {
|
||||
try { platform.libraryCache?.writeImage(deviceId, item, if (preview) "preview" else "full", image) }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { /* Disk cache may be evicted or unavailable. */ }
|
||||
rememberImage(key, image, !preview)
|
||||
}
|
||||
}
|
||||
} catch (e: DeviceFailure) {
|
||||
// A stale thumbnail or other business rejection does not
|
||||
// invalidate the authenticated transport.
|
||||
if (e.code == "CONFLICT" || e.code == "NOT_FOUND") lastLibraryCheckMs = 0L
|
||||
change { it.copy(error = e.message) }
|
||||
} catch (e: Exception) {
|
||||
if (e !is CancellationException) {
|
||||
@@ -262,9 +368,37 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
}
|
||||
fun refresh() = action { refreshStatus(); refreshPage(); if (state.value.page == 2 && supports("frame")) refreshFrame() }
|
||||
fun thumbnail(item: JsonObject) {
|
||||
if (item["playable"]?.jsonPrimitive?.booleanOrNull == false) return
|
||||
val key = thumbnailKey(item)
|
||||
if (key !in state.value.thumbnails) {
|
||||
if (pendingThumbnails.size < 128) pendingThumbnails[key] = item
|
||||
if (key in state.value.thumbnails || key in hydrating) return
|
||||
val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content ?: return
|
||||
val generation = connectionGeneration
|
||||
val cacheEpoch = cacheGeneration
|
||||
hydrating += key
|
||||
scope.launch {
|
||||
try {
|
||||
val full = cachedImage(deviceId, item, "full")
|
||||
val preview = if (full == null && supports("library_progressive")) cachedImage(deviceId, item, "preview") else null
|
||||
if (generation == connectionGeneration && cacheEpoch == cacheGeneration && state.value.page == 2 &&
|
||||
state.value.library.any { thumbnailKey(it) == key }) {
|
||||
if (full != null) rememberImage(key, full, true)
|
||||
else if (preview != null && key !in state.value.previews) rememberImage(key, preview, false)
|
||||
else if (!supports("library_progressive") && key !in pendingThumbnails) pendingThumbnails[key] = item
|
||||
}
|
||||
} finally { hydrating.remove(key) }
|
||||
}
|
||||
}
|
||||
fun clearContentCache() {
|
||||
scope.launch {
|
||||
try {
|
||||
commands.withLock {
|
||||
platform.libraryCache?.clear()
|
||||
resetLibraryQueue()
|
||||
change { it.copy(library = emptyList(), previews = emptyMap(), thumbnails = emptyMap(), cacheBytes = 0) }
|
||||
if (rpc != null && state.value.page == 2) refreshLibrary(true)
|
||||
}
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { change { it.copy(error = e.message ?: "清空缓存失败") } }
|
||||
}
|
||||
}
|
||||
fun play(item: JsonObject, default: Boolean = false) = action {
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
data class CachedLibrary(val revision: String, val items: List<JsonObject>)
|
||||
|
||||
/** Regenerable content-list cache. Implementations never persist device settings or credentials. */
|
||||
interface LibraryCache {
|
||||
suspend fun loadCatalog(deviceId: String): CachedLibrary?
|
||||
suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary)
|
||||
suspend fun readImage(deviceId: String, item: JsonObject, tier: String): String?
|
||||
suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String)
|
||||
suspend fun retain(deviceId: String, items: List<JsonObject>)
|
||||
suspend fun sizeBytes(): Long
|
||||
suspend fun clear()
|
||||
}
|
||||
+86
-3
@@ -9,6 +9,11 @@ import kotlin.test.*
|
||||
/** Test-only simulated device; never included in production scans or APK code. */
|
||||
private class SimulatedPlatform : MobilePlatform {
|
||||
override val model = "simulated-test-phone"
|
||||
var progressive = false
|
||||
var libraryRevision = "library-v1"
|
||||
var itemRevision = "item-v1"
|
||||
val called = mutableListOf<String>()
|
||||
override val libraryCache: LibraryCache = SimulatedLibraryCache()
|
||||
val preferences = mutableMapOf<String, String>()
|
||||
val links = mutableListOf<SimulatedLink>()
|
||||
val scanCallbacks = mutableListOf<(DiscoveredDevice) -> Unit>()
|
||||
@@ -17,7 +22,7 @@ private class SimulatedPlatform : MobilePlatform {
|
||||
override fun save(key: String, value: String) { preferences[key] = value }
|
||||
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) { scanCallbacks += onDevice }
|
||||
override fun stopScan() {}
|
||||
override suspend fun connect(handle: String) = SimulatedLink(if (handle == "second-handle") "11111111-0000-4000-8000-000000000001" else "00000000-0000-4000-8000-000000000001", signal).also { links += it }
|
||||
override suspend fun connect(handle: String) = SimulatedLink(if (handle == "second-handle") "11111111-0000-4000-8000-000000000001" else "00000000-0000-4000-8000-000000000001", signal, this).also { links += it }
|
||||
override fun crypto() = object : SecureChannel {
|
||||
override val hello = byteArrayOf(1)
|
||||
override fun finish(serverHello: ByteArray) {}
|
||||
@@ -26,7 +31,23 @@ private class SimulatedPlatform : MobilePlatform {
|
||||
}
|
||||
}
|
||||
|
||||
private class SimulatedLink(private val deviceId: String, private val signal: Deferred<Int?>?) : DeviceLink {
|
||||
private class SimulatedLibraryCache : LibraryCache {
|
||||
val catalogs = mutableMapOf<String, CachedLibrary>()
|
||||
val images = mutableMapOf<String, String>()
|
||||
private fun key(id: String, item: JsonObject, tier: String) = "$id|${thumbnailKey(item)}|$tier"
|
||||
override suspend fun loadCatalog(deviceId: String) = catalogs[deviceId]
|
||||
override suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary) { catalogs[deviceId] = catalog }
|
||||
override suspend fun readImage(deviceId: String, item: JsonObject, tier: String) = images[key(deviceId, item, tier)]
|
||||
override suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) { images[key(deviceId, item, tier)] = base64 }
|
||||
override suspend fun retain(deviceId: String, items: List<JsonObject>) {
|
||||
val valid = items.flatMap { listOf(key(deviceId, it, "preview"), key(deviceId, it, "full")) }.toSet()
|
||||
images.keys.removeAll { it.startsWith("$deviceId|") && it !in valid }
|
||||
}
|
||||
override suspend fun sizeBytes() = images.values.sumOf { it.length.toLong() }
|
||||
override suspend fun clear() { catalogs.clear(); images.clear() }
|
||||
}
|
||||
|
||||
private class SimulatedLink(private val deviceId: String, private val signal: Deferred<Int?>?, private val platform: SimulatedPlatform) : DeviceLink {
|
||||
override val mtu = 247
|
||||
private val receiver = Reassembler()
|
||||
private val messages = Channel<ByteArray>(Channel.UNLIMITED)
|
||||
@@ -41,16 +62,29 @@ private class SimulatedLink(private val deviceId: String, private val signal: De
|
||||
val response = if (kind == 1) byteArrayOf(2) else {
|
||||
val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
|
||||
val method = request.getValue("method").jsonPrimitive.content
|
||||
platform.called += method
|
||||
if (method == failure) throw DeviceTransportFailure("simulated connection loss")
|
||||
if (method == "status.get" && blockStorage) { storageEntered.complete(Unit); awaitCancellation() }
|
||||
val result = when (method) {
|
||||
"session.open" -> """{"device_id":"$deviceId","device_name":"模拟屏","short_id":"${deviceId.take(8)}","protocol_major":1,"capabilities":["status","frame"]}"""
|
||||
"session.open" -> """{"device_id":"$deviceId","device_name":"模拟屏","short_id":"${deviceId.take(8)}","protocol_major":1,"capabilities":["status","frame","library"${if (platform.progressive) ",\"library_progressive\"" else ""}]}"""
|
||||
"device.rename" -> buildJsonObject {
|
||||
put("device_id", deviceId); put("device_name", request.getValue("params").jsonObject.getValue("name"))
|
||||
put("short_id", deviceId.take(8)); put("protocol_major", 1); put("capabilities", JsonArray(emptyList()))
|
||||
}.toString()
|
||||
"status.get" -> """{"state":{}}"""
|
||||
"frame.get" -> """{"frame_revision":"test-frame","data_base64":"test-frame-data"}"""
|
||||
"library.summary" -> """{"library_revision":"${platform.libraryRevision}","item_count":2}"""
|
||||
"library.list" -> buildJsonObject {
|
||||
put("library_revision", platform.libraryRevision)
|
||||
put("next_cursor", JsonNull)
|
||||
put("items", JsonArray((1..2).map { number -> buildJsonObject {
|
||||
put("type", "template"); put("id", "item-$number"); put("revision", platform.itemRevision)
|
||||
put("name", "条目$number"); put("playable", true)
|
||||
} }))
|
||||
}.toString()
|
||||
"content.default.get" -> """{"name":"默认内容"}"""
|
||||
"library.preview" -> """{"mime":"image/png","data_base64":"small"}"""
|
||||
"library.thumbnail" -> """{"mime":"image/png","data_base64":"full"}"""
|
||||
else -> "{}"
|
||||
}
|
||||
buildJsonObject {
|
||||
@@ -239,4 +273,53 @@ class ControllerRecoveryTest {
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
@Test fun progressiveLibraryUsesTwoPassesAndReusesVerifiedCache() = runBlocking {
|
||||
val platform = SimulatedPlatform().apply { progressive = true }
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
controller.page(2)
|
||||
withTimeout(5_000) { controller.state.first { it.library.size == 2 && !it.busy } }
|
||||
withTimeout(16_000) { controller.state.first { it.thumbnails.size == 2 } }
|
||||
assertEquals(listOf("library.preview", "library.preview", "library.thumbnail", "library.thumbnail"),
|
||||
platform.called.filter { it == "library.preview" || it == "library.thumbnail" })
|
||||
val cache = platform.libraryCache as SimulatedLibraryCache
|
||||
assertEquals(4, cache.images.size)
|
||||
controller.disconnect()
|
||||
platform.called.clear()
|
||||
controller.connect(device)
|
||||
withTimeout(5_000) { controller.state.first { it.library.size == 2 && it.thumbnails.size == 2 } }
|
||||
assertTrue("library.summary" in platform.called)
|
||||
assertFalse(platform.called.any { it in listOf("library.list", "library.preview", "library.thumbnail") })
|
||||
platform.libraryRevision = "library-v2"
|
||||
platform.itemRevision = "item-v2"
|
||||
controller.refresh()
|
||||
withTimeout(5_000) { controller.state.first { it.library.firstOrNull()?.get("revision")?.jsonPrimitive?.content == "item-v2" && !it.busy } }
|
||||
assertTrue("library.list" in platform.called)
|
||||
assertFalse(cache.images.keys.any { "item-v1" in it })
|
||||
controller.disconnect()
|
||||
controller.clearContentCache()
|
||||
withTimeout(2_000) { controller.state.first { it.cacheBytes == 0L } }
|
||||
assertTrue(cache.images.isEmpty())
|
||||
assertTrue(controller.state.value.remembered.isNotEmpty())
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
@Test fun oldDeviceFetchesVisibleFullImageWithoutNewMethods() = runBlocking {
|
||||
val platform = SimulatedPlatform().apply { progressive = false }
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
controller.page(2)
|
||||
withTimeout(5_000) { controller.state.first { it.library.size == 2 && !it.busy } }
|
||||
controller.thumbnail(controller.state.value.library.first())
|
||||
withTimeout(6_000) { controller.state.first { it.thumbnails.isNotEmpty() } }
|
||||
assertFalse(platform.called.any { it in listOf("library.summary", "library.preview") })
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+11
-3
@@ -78,10 +78,18 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im
|
||||
if (dialog == "settings") {
|
||||
var name by remember { mutableStateOf(state.clientName) }
|
||||
AlertDialog(onDismissRequest = { dialog = null }, title = { Text("软件设置") },
|
||||
text = { OutlinedTextField(name, { name = it.take(40) }, label = { Text("当前手机昵称") }, singleLine = true) },
|
||||
text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(name, { name = it.take(40) }, label = { Text("当前手机昵称") }, singleLine = true)
|
||||
Text("内容缓存:${state.cacheBytes / 1024} KiB")
|
||||
TextButton({ dialog = "clear-cache" }, Modifier.testTag("cache_clear_open")) { Text("清空内容缓存") }
|
||||
} },
|
||||
confirmButton = { TextButton({ controller.renamePhone(name); if (name.trim().isNotEmpty()) dialog = null }, enabled = !state.busy) { Text("保存") } },
|
||||
dismissButton = { TextButton({ dialog = null }) { Text("取消") } })
|
||||
}
|
||||
if (dialog == "clear-cache") AlertDialog(onDismissRequest = { dialog = "settings" }, title = { Text("清空内容缓存") },
|
||||
text = { Text("删除本机保存的内容列表、预览图和完整图。再次打开显示内容时会重新从设备获取。") },
|
||||
confirmButton = { TextButton({ controller.clearContentCache(); dialog = null }, Modifier.testTag("cache_clear_confirm")) { Text("清空") } },
|
||||
dismissButton = { TextButton({ dialog = "settings" }) { Text("取消") } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,8 +180,8 @@ private fun ContentPage(state: ControllerState, c: Controller, decode: (String)
|
||||
item { Text("可播放内容", style = MaterialTheme.typography.titleLarge) }
|
||||
items(state.library, key = { it.text("type") + it.text("id") }) { item ->
|
||||
val key = thumbnailKey(item)
|
||||
LaunchedEffect(key) { c.thumbnail(item) }
|
||||
val encoded = state.thumbnails[key]
|
||||
val encoded = state.thumbnails[key] ?: state.previews[key]
|
||||
LaunchedEffect(key, encoded) { c.thumbnail(item) }
|
||||
val image = remember(encoded) { encoded?.let(decode) }
|
||||
Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (image != null) Image(image, item.text("name"), Modifier.size(64.dp)); Text(item.text("name")) }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
plugins {
|
||||
id("com.android.test")
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
}
|
||||
android {
|
||||
namespace = "org.qimiaoscreen.uitest"
|
||||
compileSdk = 37
|
||||
targetProjectPath = ":androidApp"
|
||||
experimentalProperties["android.experimental.self-instrumenting"] = true
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
buildTypes { getByName("debug") { isDebuggable = true } }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation(project(":sharedCore"))
|
||||
implementation(project(":sharedUi"))
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.coroutines.android)
|
||||
implementation(libs.serialization.json)
|
||||
implementation("org.jetbrains.compose.ui:ui-test-junit4:1.12.1")
|
||||
implementation("androidx.test:runner:1.7.0")
|
||||
implementation("androidx.test.ext:junit:1.3.0")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:label="QMS UI tests" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false">
|
||||
<activity android:name=".FixtureActivity" android:exported="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package org.qimiaoscreen.uitest
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import android.os.Bundle
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.serialization.json.*
|
||||
import org.qimiaoscreen.core.*
|
||||
import org.qimiaoscreen.ui.QimiaoApp
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
||||
/** Only in the self-instrumenting test APK. No AndroidBle, network or private data. */
|
||||
class FixtureActivity : ComponentActivity() {
|
||||
val platform = FixturePlatform()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
val controller = Controller(platform, scope)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent { QimiaoApp(controller, controller::scan, { ImageBitmap(64, 64) }) }
|
||||
}
|
||||
override fun onDestroy() { controller.disconnect(); scope.cancel(); super.onDestroy() }
|
||||
}
|
||||
class FixturePlatform : MobilePlatform {
|
||||
override val model = "模拟测试手机"
|
||||
private val saved = mutableMapOf<String, String>()
|
||||
val calls = mutableListOf<String>()
|
||||
var scanError = false
|
||||
var emptyLibrary = true
|
||||
override fun load(key: String) = saved[key]
|
||||
override fun save(key: String, value: String) { saved[key] = value }
|
||||
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) {
|
||||
if (scanError) onError("模拟扫描失败") else onDevice(DiscoveredDevice("fixture", "QMS-00000000", -40))
|
||||
}
|
||||
override fun stopScan() {}
|
||||
override suspend fun connect(handle: String): DeviceLink {
|
||||
check(handle == "fixture")
|
||||
return FixtureLink(this)
|
||||
}
|
||||
override fun crypto() = object : SecureChannel {
|
||||
override val hello = byteArrayOf(1)
|
||||
override fun finish(serverHello: ByteArray) {}
|
||||
override fun encrypt(payload: ByteArray) = payload
|
||||
override fun decrypt(record: ByteArray) = record
|
||||
}
|
||||
}
|
||||
private class FixtureLink(val platform: FixturePlatform) : DeviceLink {
|
||||
override val mtu = 247
|
||||
private val reassembler = Reassembler()
|
||||
private val messages = Channel<ByteArray>(Channel.UNLIMITED)
|
||||
private var sequence = 0L
|
||||
override suspend fun send(fragment: ByteArray) {
|
||||
val (kind, payload) = reassembler.accept(fragment) ?: return
|
||||
val response = if (kind == 1) byteArrayOf(2) else {
|
||||
val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
|
||||
val method = request.getValue("method").jsonPrimitive.content
|
||||
platform.calls += method
|
||||
val result = when (method) {
|
||||
"session.open" -> """{"device_id":"00000000-0000-4000-8000-000000000001","device_name":"模拟小屏幕","short_id":"00000000","protocol_major":1,"capabilities":["status","frame","library","library_progressive","settings","wifi","wifi_scan"]}"""
|
||||
"session.ping" -> """{"alive":true}"""
|
||||
"status.get" -> """{"state":{"animation_playback":{"active":false}}}"""
|
||||
"frame.get" -> """{"frame_revision":"fixture","data_base64":"fixture-image"}"""
|
||||
"settings.get" -> """{"revision":"fixture","values":{"brightness":40,"orientation":0,"prompt_delay_seconds":5}}"""
|
||||
"wifi.get" -> """{"revision":"fixture","saved":null,"active":{"connected":false}}"""
|
||||
"wifi.scan" -> """{"networks":[]}"""
|
||||
"library.summary" -> if (platform.emptyLibrary) """{"library_revision":"fixture-empty","item_count":0}""" else """{"library_revision":"fixture-full","item_count":1}"""
|
||||
"library.list" -> if (platform.emptyLibrary) """{"library_revision":"fixture-empty","items":[],"next_cursor":null}""" else """{"library_revision":"fixture-full","items":[{"id":"fixture","type":"template","name":"测试模板","revision":"fixture-1","playable":true}],"next_cursor":null}"""
|
||||
"content.default.get" -> """{"name":"测试默认"}"""
|
||||
"library.preview" -> """{"data_base64":"fixture-preview"}"""
|
||||
"library.thumbnail" -> """{"data_base64":"fixture-image"}"""
|
||||
else -> error("Unexpected fixture RPC: $method")
|
||||
}
|
||||
buildJsonObject { put("id", request.getValue("id")); put("ok", true); put("result", Protocol.json.parseToJsonElement(result)) }.toString().encodeToByteArray()
|
||||
}
|
||||
Protocol.encode(if (kind == 1) 2 else 3, sequence++, response, mtu).forEach { messages.send(it) }
|
||||
}
|
||||
override suspend fun receive() = messages.receive()
|
||||
override fun close() { messages.close() }
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package org.qimiaoscreen.uitest
|
||||
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.*
|
||||
|
||||
class IsolatedUiTest {
|
||||
@get:Rule val ui = createAndroidComposeRule<FixtureActivity>()
|
||||
private fun click(text: String) { ui.onNodeWithText(text).performClick() }
|
||||
private fun connect() {
|
||||
click("扫描设备")
|
||||
ui.waitUntil(5_000) { ui.activity.controller.state.value.devices.isNotEmpty() }
|
||||
ui.onNodeWithTag("scan_connect").performScrollTo().performClick()
|
||||
ui.waitUntil(5_000) { ui.activity.controller.state.value.status != null }
|
||||
}
|
||||
@Test fun connection() {
|
||||
ui.onNodeWithText("暂无已保存设备,扫描后选择即可连接").assertExists()
|
||||
ui.runOnIdle { ui.activity.platform.scanError = true }
|
||||
click("扫描设备")
|
||||
ui.onNodeWithText("模拟扫描失败").assertExists()
|
||||
click("知道了")
|
||||
ui.onNodeWithTag("controller_error").assertDoesNotExist()
|
||||
ui.runOnIdle { ui.activity.platform.scanError = false }
|
||||
connect()
|
||||
ui.onNodeWithTag("connected_scan_disconnect").performScrollTo().assertIsEnabled().performClick()
|
||||
ui.waitUntil(5_000) { ui.activity.controller.state.value.identity == null }
|
||||
}
|
||||
@Test fun status() {
|
||||
click("设备状态")
|
||||
ui.onNodeWithText("请先在连接页连接设备").assertExists()
|
||||
click("连接"); connect(); click("设备状态")
|
||||
ui.waitUntil(5_000) { ui.activity.controller.state.value.frame != null }
|
||||
ui.onNodeWithTag("frame_card").assertIsDisplayed()
|
||||
ui.onNodeWithTag("frame_refresh").performClick()
|
||||
ui.waitUntil(5_000) { !ui.activity.controller.state.value.busy }
|
||||
assertTrue(ui.activity.platform.calls.contains("frame.get"))
|
||||
}
|
||||
@Test fun content() {
|
||||
connect(); click("显示内容")
|
||||
ui.waitUntil(5_000) { !ui.activity.controller.state.value.busy }
|
||||
ui.onNodeWithText("可播放内容").performScrollTo().assertIsDisplayed()
|
||||
ui.onNodeWithText("播放").assertDoesNotExist()
|
||||
ui.runOnIdle { ui.activity.platform.emptyLibrary = false; ui.activity.controller.refresh() }
|
||||
ui.waitUntil(5_000) { ui.activity.controller.state.value.library.isNotEmpty() }
|
||||
ui.onNodeWithText("测试模板").performScrollTo().assertIsDisplayed()
|
||||
ui.onNodeWithText("播放").assertIsEnabled()
|
||||
ui.waitUntil(7_000) { ui.activity.controller.state.value.previews.isNotEmpty() }
|
||||
ui.waitUntil(7_000) { ui.activity.controller.state.value.thumbnails.isNotEmpty() }
|
||||
assertTrue(ui.activity.platform.calls.indexOf("library.preview") < ui.activity.platform.calls.indexOf("library.thumbnail"))
|
||||
assertFalse(ui.activity.platform.calls.contains("content.play"))
|
||||
}
|
||||
@Test fun settings() {
|
||||
connect(); click("设备设置")
|
||||
ui.waitUntil(5_000) { ui.activity.controller.state.value.settings != null }
|
||||
ui.onNodeWithText("WiFi设置 展开").performScrollTo().performClick()
|
||||
ui.onNodeWithText("保存WiFi设置").performScrollTo().assertIsNotEnabled()
|
||||
ui.onNodeWithText("WiFi名称").performScrollTo().performTextInput("fixture-network")
|
||||
ui.onNodeWithText("保存WiFi设置").performScrollTo().performClick()
|
||||
ui.onNodeWithText("立即生效WiFi设置").assertExists()
|
||||
click("取消")
|
||||
assertFalse(ui.activity.platform.calls.contains("wifi.set"))
|
||||
}
|
||||
@Test fun navigation() {
|
||||
for (page in listOf("设备状态", "显示内容", "设备设置", "连接")) click(page)
|
||||
click("⋮"); click("关于")
|
||||
ui.onNodeWithText("关于奇妙小屏幕").assertExists(); click("关闭")
|
||||
click("⋮"); click("软件设置")
|
||||
ui.onNodeWithText("当前手机昵称").performTextReplacement("模拟昵称")
|
||||
click("保存")
|
||||
ui.runOnIdle { assertEquals("模拟昵称", ui.activity.controller.state.value.clientName) }
|
||||
click("⋮"); click("软件设置")
|
||||
ui.onNodeWithText("内容缓存:0 KiB").assertExists()
|
||||
ui.onNodeWithTag("cache_clear_open").performClick()
|
||||
ui.onNodeWithTag("cache_clear_confirm").performClick()
|
||||
assertTrue(ui.activity.platform.calls.isEmpty())
|
||||
}
|
||||
@Test fun layout() {
|
||||
connect()
|
||||
for (page in listOf("设备状态", "显示内容")) {
|
||||
click(page)
|
||||
ui.waitUntil(5_000) { ui.activity.controller.state.value.frame != null }
|
||||
val card = ui.onNodeWithTag("frame_card").fetchSemanticsNode().boundsInRoot
|
||||
val image = ui.onNodeWithTag("frame_image").fetchSemanticsNode().boundsInRoot
|
||||
val title = ui.onNodeWithTag("frame_title").fetchSemanticsNode().boundsInRoot
|
||||
val refresh = ui.onNodeWithTag("frame_refresh").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(kotlin.math.abs(image.center.x - card.center.x) <= 3)
|
||||
assertTrue(kotlin.math.abs(title.center.x - card.center.x) <= 3)
|
||||
assertTrue(title.right <= refresh.left)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user