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
@@ -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)