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)
|
||||
|
||||
Reference in New Issue
Block a user