Improve controller behavior and update project requirements

This commit is contained in:
2026-09-30 15:42:36 +08:00
parent 55374356ed
commit 071f06d365
52 changed files with 2205 additions and 335 deletions
@@ -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"
}
@@ -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))
}
}
@@ -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() }
}
}
@@ -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")
@@ -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() }
}
}
@@ -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() }
}
}
@@ -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 }
}
@@ -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")
@@ -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 {
@@ -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()
}
@@ -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() }
}
}
@@ -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>
@@ -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() }
}
@@ -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)
}
}
}