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