Improve controller behavior and update project requirements
This commit is contained in:
+167
-33
@@ -9,6 +9,7 @@ import kotlin.time.Clock
|
||||
|
||||
interface MobilePlatform {
|
||||
val model: String
|
||||
val libraryCache: LibraryCache? get() = null
|
||||
fun load(key: String): String?
|
||||
fun save(key: String, value: String)
|
||||
fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit)
|
||||
@@ -31,7 +32,8 @@ data class ControllerState(
|
||||
val status: JsonObject? = null, val storage: JsonObject? = null,
|
||||
val settings: JsonObject? = null, val wifi: JsonObject? = null,
|
||||
val library: List<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(),
|
||||
val thumbnails: Map<String, String> = emptyMap(), val frame: String? = null,
|
||||
val previews: Map<String, String> = emptyMap(), val thumbnails: Map<String, String> = emptyMap(),
|
||||
val cacheBytes: Long = 0, val frame: String? = null,
|
||||
val frameCheckedAtMs: Long? = null, val defaultContent: JsonObject? = null,
|
||||
)
|
||||
|
||||
@@ -76,11 +78,22 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
private var releaseJob: Job? = null
|
||||
private var foreground = true
|
||||
private var frameRevision: String? = null
|
||||
private var libraryRevision: String? = null
|
||||
private var libraryLoaded = false
|
||||
private var lastLibraryCheckMs = 0L
|
||||
private var cacheGeneration = 0L
|
||||
private var connectionJob: Job? = null
|
||||
private var commandJob: Job? = null
|
||||
private var connectionGeneration = 0L
|
||||
private var scanGeneration = 0L
|
||||
private val pendingPreviews = linkedMapOf<String, JsonObject>()
|
||||
private val pendingThumbnails = linkedMapOf<String, JsonObject>()
|
||||
private val hydrating = mutableSetOf<String>()
|
||||
init { scope.launch {
|
||||
try { platform.libraryCache?.sizeBytes()?.let { size -> change { it.copy(cacheBytes = size) } } }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { /* Cache can disappear under storage pressure. */ }
|
||||
} }
|
||||
private fun change(update: (ControllerState) -> ControllerState) { mutable.update(update) }
|
||||
fun dismissError() = change { it.copy(error = null) }
|
||||
fun reportError(message: String) = change { it.copy(error = message) }
|
||||
@@ -114,7 +127,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
if (connectionJob?.isActive == true || rpc != null) disconnect()
|
||||
scanGeneration++; scanJob?.cancel(); platform.stopScan()
|
||||
val generation = ++connectionGeneration
|
||||
frameRevision = null; pendingThumbnails.clear()
|
||||
frameRevision = null; resetLibraryQueue()
|
||||
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, devices = it.devices, phase = "正在连接") }
|
||||
connectionJob = scope.launch {
|
||||
var pending: DeviceRpc? = null
|
||||
@@ -150,11 +163,11 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
connectionGeneration++; scanGeneration++
|
||||
connectionJob?.cancel(); commandJob?.cancel(); scanJob?.cancel(); pollJob?.cancel(); releaseJob?.cancel()
|
||||
platform.stopScan(); rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; requestedDeviceId = null
|
||||
pendingThumbnails.clear()
|
||||
resetLibraryQueue()
|
||||
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered) }
|
||||
}
|
||||
private fun connectionLost(message: String) {
|
||||
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; pendingThumbnails.clear()
|
||||
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; resetLibraryQueue()
|
||||
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) }
|
||||
}
|
||||
fun foreground(value: Boolean) {
|
||||
@@ -162,13 +175,25 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
releaseJob?.cancel()
|
||||
if (value) {
|
||||
if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true)
|
||||
else if (rpc != null && state.value.page == 2) action { refreshLibrary(true) }
|
||||
} else {
|
||||
pendingPreviews.clear(); pendingThumbnails.clear()
|
||||
scanJob?.cancel(); platform.stopScan()
|
||||
change { it.copy(scanning = false) }
|
||||
releaseJob = scope.launch { delay(30_000); disconnect() }
|
||||
}
|
||||
}
|
||||
fun page(value: Int) { change { it.copy(page = value) }; if (rpc != null) action { refreshPage() } }
|
||||
fun page(value: Int) {
|
||||
if (value != 2) { pendingPreviews.clear(); pendingThumbnails.clear() }
|
||||
change { it.copy(page = value) }
|
||||
if (rpc != null) action { refreshPage() }
|
||||
}
|
||||
private fun resetLibraryQueue() {
|
||||
pendingPreviews.clear(); pendingThumbnails.clear()
|
||||
hydrating.clear()
|
||||
cacheGeneration++
|
||||
libraryRevision = null; libraryLoaded = false; lastLibraryCheckMs = 0L
|
||||
}
|
||||
private fun capabilities() = state.value.identity?.get("capabilities")?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()
|
||||
fun supports(capability: String) = capability in capabilities()
|
||||
private suspend fun call(method: String, params: JsonObject = buildJsonObject {}) = checkNotNull(rpc).call(method, params)
|
||||
@@ -198,30 +223,102 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
when (state.value.page) {
|
||||
0 -> Unit
|
||||
1 -> if (supports("frame")) refreshFrame()
|
||||
2 -> if (supports("library")) {
|
||||
var items: List<JsonObject> = emptyList()
|
||||
for (attempt in 0..1) {
|
||||
try {
|
||||
val loaded = mutableListOf<JsonObject>()
|
||||
var cursor: String? = null
|
||||
do {
|
||||
val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } })
|
||||
loaded += result.getValue("items").jsonArray.map { it.jsonObject }
|
||||
cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull
|
||||
} while (cursor != null)
|
||||
items = loaded
|
||||
break
|
||||
} catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e }
|
||||
}
|
||||
val defaultContent = call("content.default.get")
|
||||
change { it.copy(library = items, defaultContent = defaultContent) }
|
||||
}
|
||||
2 -> if (supports("library")) refreshLibrary(true)
|
||||
3 -> {
|
||||
if (supports("settings")) { val value = call("settings.get"); change { it.copy(settings = value) } }
|
||||
if (supports("wifi")) { val value = call("wifi.get"); change { it.copy(wifi = value) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
private suspend fun loadLibraryPages(): Pair<String, List<JsonObject>> {
|
||||
for (attempt in 0..1) {
|
||||
try {
|
||||
val items = mutableListOf<JsonObject>()
|
||||
var cursor: String? = null
|
||||
var revision: String? = null
|
||||
do {
|
||||
val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } })
|
||||
val pageRevision = result.getValue("library_revision").jsonPrimitive.content
|
||||
if (revision != null && pageRevision != revision) throw DeviceFailure("CONFLICT", "内容列表已改变")
|
||||
revision = pageRevision
|
||||
items += result.getValue("items").jsonArray.map { it.jsonObject }
|
||||
cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull
|
||||
} while (cursor != null)
|
||||
return checkNotNull(revision) to items
|
||||
} catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e }
|
||||
}
|
||||
error("内容列表读取失败")
|
||||
}
|
||||
private suspend fun cachedImage(deviceId: String, item: JsonObject, tier: String): String? =
|
||||
try { platform.libraryCache?.readImage(deviceId, item, tier) }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { null }
|
||||
|
||||
private fun rememberImage(key: String, image: String, full: Boolean) {
|
||||
change { old ->
|
||||
val values = ((if (full) old.thumbnails else old.previews) + (key to image)).toMutableMap()
|
||||
while (values.size > 128 || values.values.sumOf { it.length } > 8 * 1024 * 1024) values.remove(values.keys.first())
|
||||
if (full) old.copy(thumbnails = values) else old.copy(previews = values)
|
||||
}
|
||||
}
|
||||
private suspend fun queueLibraryImages(deviceId: String, items: List<JsonObject>) {
|
||||
pendingPreviews.clear(); pendingThumbnails.clear()
|
||||
for (item in items) {
|
||||
if (item["playable"]?.jsonPrimitive?.booleanOrNull == false) continue
|
||||
val key = thumbnailKey(item)
|
||||
val full = cachedImage(deviceId, item, "full")
|
||||
if (full != null) { rememberImage(key, full, true); continue }
|
||||
if (supports("library_progressive")) {
|
||||
val preview = cachedImage(deviceId, item, "preview")
|
||||
if (preview != null) rememberImage(key, preview, false)
|
||||
else pendingPreviews[key] = item
|
||||
pendingThumbnails[key] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
private suspend fun refreshLibrary(forceQueue: Boolean) {
|
||||
val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content ?: return
|
||||
val progressive = supports("library_progressive")
|
||||
val summary = if (progressive) call("library.summary") else null
|
||||
val expectedRevision = summary?.get("library_revision")?.jsonPrimitive?.content
|
||||
lastLibraryCheckMs = Clock.System.now().toEpochMilliseconds()
|
||||
val changed = !libraryLoaded || !progressive || expectedRevision != libraryRevision
|
||||
var items = state.value.library
|
||||
if (changed) {
|
||||
cacheGeneration++
|
||||
val cached = if (expectedRevision != null) platform.libraryCache?.loadCatalog(deviceId) else null
|
||||
if (cached != null && cached.revision == expectedRevision &&
|
||||
cached.items.size == summary.getValue("item_count").jsonPrimitive.int) {
|
||||
items = cached.items
|
||||
libraryRevision = cached.revision
|
||||
} else {
|
||||
change { it.copy(library = emptyList(), previews = emptyMap(), thumbnails = emptyMap()) }
|
||||
val loaded = loadLibraryPages()
|
||||
if (expectedRevision != null && loaded.first != expectedRevision) {
|
||||
lastLibraryCheckMs = 0L
|
||||
throw DeviceFailure("CONFLICT", "内容列表已改变,请重新加载")
|
||||
}
|
||||
libraryRevision = loaded.first
|
||||
items = loaded.second
|
||||
try { platform.libraryCache?.saveCatalog(deviceId, CachedLibrary(loaded.first, items)) }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { /* Cache loss must not close a working BLE connection. */ }
|
||||
}
|
||||
try { platform.libraryCache?.retain(deviceId, items) }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { /* Regenerable files can be pruned next time. */ }
|
||||
libraryLoaded = true
|
||||
change { it.copy(library = items, previews = emptyMap(), thumbnails = emptyMap()) }
|
||||
}
|
||||
if (changed || forceQueue) queueLibraryImages(deviceId, items)
|
||||
if (forceQueue) {
|
||||
val defaultContent = call("content.default.get")
|
||||
change { it.copy(defaultContent = defaultContent) }
|
||||
}
|
||||
try { platform.libraryCache?.sizeBytes()?.let { size -> change { it.copy(cacheBytes = size) } } }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { /* Storage reporting is optional. */ }
|
||||
}
|
||||
private fun startPolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = scope.launch {
|
||||
@@ -232,19 +329,28 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
if (!foreground) { call("session.ping"); continue }
|
||||
refreshStatus()
|
||||
if (state.value.page in 1..2 && supports("frame")) refreshFrame()
|
||||
if (state.value.page == 2 && !state.value.busy && pendingThumbnails.isNotEmpty()) {
|
||||
val (key, item) = pendingThumbnails.entries.first()
|
||||
pendingThumbnails.remove(key)
|
||||
val image = call("library.thumbnail", item).getValue("data_base64").jsonPrimitive.content
|
||||
change { old ->
|
||||
val cache = (old.thumbnails + (key to image)).toMutableMap()
|
||||
while (cache.size > 128 || cache.values.sumOf { it.length } > 8 * 1024 * 1024) cache.remove(cache.keys.first())
|
||||
old.copy(thumbnails = cache)
|
||||
if (state.value.page == 2 && supports("library") &&
|
||||
Clock.System.now().toEpochMilliseconds() - lastLibraryCheckMs >= 10_000) refreshLibrary(false)
|
||||
if (state.value.page == 2 && !state.value.busy && (pendingPreviews.isNotEmpty() || pendingThumbnails.isNotEmpty())) {
|
||||
val preview = pendingPreviews.isNotEmpty()
|
||||
val entry = if (preview) pendingPreviews.entries.first() else pendingThumbnails.entries.first()
|
||||
val key = entry.key
|
||||
val item = entry.value
|
||||
if (preview) pendingPreviews.remove(key) else pendingThumbnails.remove(key)
|
||||
val image = call(if (preview) "library.preview" else "library.thumbnail", item).getValue("data_base64").jsonPrimitive.content
|
||||
currentCoroutineContext().ensureActive()
|
||||
val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content
|
||||
if (deviceId != null) {
|
||||
try { platform.libraryCache?.writeImage(deviceId, item, if (preview) "preview" else "full", image) }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { /* Disk cache may be evicted or unavailable. */ }
|
||||
rememberImage(key, image, !preview)
|
||||
}
|
||||
}
|
||||
} catch (e: DeviceFailure) {
|
||||
// A stale thumbnail or other business rejection does not
|
||||
// invalidate the authenticated transport.
|
||||
if (e.code == "CONFLICT" || e.code == "NOT_FOUND") lastLibraryCheckMs = 0L
|
||||
change { it.copy(error = e.message) }
|
||||
} catch (e: Exception) {
|
||||
if (e !is CancellationException) {
|
||||
@@ -262,9 +368,37 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
}
|
||||
fun refresh() = action { refreshStatus(); refreshPage(); if (state.value.page == 2 && supports("frame")) refreshFrame() }
|
||||
fun thumbnail(item: JsonObject) {
|
||||
if (item["playable"]?.jsonPrimitive?.booleanOrNull == false) return
|
||||
val key = thumbnailKey(item)
|
||||
if (key !in state.value.thumbnails) {
|
||||
if (pendingThumbnails.size < 128) pendingThumbnails[key] = item
|
||||
if (key in state.value.thumbnails || key in hydrating) return
|
||||
val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content ?: return
|
||||
val generation = connectionGeneration
|
||||
val cacheEpoch = cacheGeneration
|
||||
hydrating += key
|
||||
scope.launch {
|
||||
try {
|
||||
val full = cachedImage(deviceId, item, "full")
|
||||
val preview = if (full == null && supports("library_progressive")) cachedImage(deviceId, item, "preview") else null
|
||||
if (generation == connectionGeneration && cacheEpoch == cacheGeneration && state.value.page == 2 &&
|
||||
state.value.library.any { thumbnailKey(it) == key }) {
|
||||
if (full != null) rememberImage(key, full, true)
|
||||
else if (preview != null && key !in state.value.previews) rememberImage(key, preview, false)
|
||||
else if (!supports("library_progressive") && key !in pendingThumbnails) pendingThumbnails[key] = item
|
||||
}
|
||||
} finally { hydrating.remove(key) }
|
||||
}
|
||||
}
|
||||
fun clearContentCache() {
|
||||
scope.launch {
|
||||
try {
|
||||
commands.withLock {
|
||||
platform.libraryCache?.clear()
|
||||
resetLibraryQueue()
|
||||
change { it.copy(library = emptyList(), previews = emptyMap(), thumbnails = emptyMap(), cacheBytes = 0) }
|
||||
if (rpc != null && state.value.page == 2) refreshLibrary(true)
|
||||
}
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { change { it.copy(error = e.message ?: "清空缓存失败") } }
|
||||
}
|
||||
}
|
||||
fun play(item: JsonObject, default: Boolean = false) = action {
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
data class CachedLibrary(val revision: String, val items: List<JsonObject>)
|
||||
|
||||
/** Regenerable content-list cache. Implementations never persist device settings or credentials. */
|
||||
interface LibraryCache {
|
||||
suspend fun loadCatalog(deviceId: String): CachedLibrary?
|
||||
suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary)
|
||||
suspend fun readImage(deviceId: String, item: JsonObject, tier: String): String?
|
||||
suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String)
|
||||
suspend fun retain(deviceId: String, items: List<JsonObject>)
|
||||
suspend fun sizeBytes(): Long
|
||||
suspend fun clear()
|
||||
}
|
||||
+86
-3
@@ -9,6 +9,11 @@ import kotlin.test.*
|
||||
/** Test-only simulated device; never included in production scans or APK code. */
|
||||
private class SimulatedPlatform : MobilePlatform {
|
||||
override val model = "simulated-test-phone"
|
||||
var progressive = false
|
||||
var libraryRevision = "library-v1"
|
||||
var itemRevision = "item-v1"
|
||||
val called = mutableListOf<String>()
|
||||
override val libraryCache: LibraryCache = SimulatedLibraryCache()
|
||||
val preferences = mutableMapOf<String, String>()
|
||||
val links = mutableListOf<SimulatedLink>()
|
||||
val scanCallbacks = mutableListOf<(DiscoveredDevice) -> Unit>()
|
||||
@@ -17,7 +22,7 @@ private class SimulatedPlatform : MobilePlatform {
|
||||
override fun save(key: String, value: String) { preferences[key] = value }
|
||||
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) { scanCallbacks += onDevice }
|
||||
override fun stopScan() {}
|
||||
override suspend fun connect(handle: String) = SimulatedLink(if (handle == "second-handle") "11111111-0000-4000-8000-000000000001" else "00000000-0000-4000-8000-000000000001", signal).also { links += it }
|
||||
override suspend fun connect(handle: String) = SimulatedLink(if (handle == "second-handle") "11111111-0000-4000-8000-000000000001" else "00000000-0000-4000-8000-000000000001", signal, this).also { links += it }
|
||||
override fun crypto() = object : SecureChannel {
|
||||
override val hello = byteArrayOf(1)
|
||||
override fun finish(serverHello: ByteArray) {}
|
||||
@@ -26,7 +31,23 @@ private class SimulatedPlatform : MobilePlatform {
|
||||
}
|
||||
}
|
||||
|
||||
private class SimulatedLink(private val deviceId: String, private val signal: Deferred<Int?>?) : DeviceLink {
|
||||
private class SimulatedLibraryCache : LibraryCache {
|
||||
val catalogs = mutableMapOf<String, CachedLibrary>()
|
||||
val images = mutableMapOf<String, String>()
|
||||
private fun key(id: String, item: JsonObject, tier: String) = "$id|${thumbnailKey(item)}|$tier"
|
||||
override suspend fun loadCatalog(deviceId: String) = catalogs[deviceId]
|
||||
override suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary) { catalogs[deviceId] = catalog }
|
||||
override suspend fun readImage(deviceId: String, item: JsonObject, tier: String) = images[key(deviceId, item, tier)]
|
||||
override suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) { images[key(deviceId, item, tier)] = base64 }
|
||||
override suspend fun retain(deviceId: String, items: List<JsonObject>) {
|
||||
val valid = items.flatMap { listOf(key(deviceId, it, "preview"), key(deviceId, it, "full")) }.toSet()
|
||||
images.keys.removeAll { it.startsWith("$deviceId|") && it !in valid }
|
||||
}
|
||||
override suspend fun sizeBytes() = images.values.sumOf { it.length.toLong() }
|
||||
override suspend fun clear() { catalogs.clear(); images.clear() }
|
||||
}
|
||||
|
||||
private class SimulatedLink(private val deviceId: String, private val signal: Deferred<Int?>?, private val platform: SimulatedPlatform) : DeviceLink {
|
||||
override val mtu = 247
|
||||
private val receiver = Reassembler()
|
||||
private val messages = Channel<ByteArray>(Channel.UNLIMITED)
|
||||
@@ -41,16 +62,29 @@ private class SimulatedLink(private val deviceId: String, private val signal: De
|
||||
val response = if (kind == 1) byteArrayOf(2) else {
|
||||
val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
|
||||
val method = request.getValue("method").jsonPrimitive.content
|
||||
platform.called += method
|
||||
if (method == failure) throw DeviceTransportFailure("simulated connection loss")
|
||||
if (method == "status.get" && blockStorage) { storageEntered.complete(Unit); awaitCancellation() }
|
||||
val result = when (method) {
|
||||
"session.open" -> """{"device_id":"$deviceId","device_name":"模拟屏","short_id":"${deviceId.take(8)}","protocol_major":1,"capabilities":["status","frame"]}"""
|
||||
"session.open" -> """{"device_id":"$deviceId","device_name":"模拟屏","short_id":"${deviceId.take(8)}","protocol_major":1,"capabilities":["status","frame","library"${if (platform.progressive) ",\"library_progressive\"" else ""}]}"""
|
||||
"device.rename" -> buildJsonObject {
|
||||
put("device_id", deviceId); put("device_name", request.getValue("params").jsonObject.getValue("name"))
|
||||
put("short_id", deviceId.take(8)); put("protocol_major", 1); put("capabilities", JsonArray(emptyList()))
|
||||
}.toString()
|
||||
"status.get" -> """{"state":{}}"""
|
||||
"frame.get" -> """{"frame_revision":"test-frame","data_base64":"test-frame-data"}"""
|
||||
"library.summary" -> """{"library_revision":"${platform.libraryRevision}","item_count":2}"""
|
||||
"library.list" -> buildJsonObject {
|
||||
put("library_revision", platform.libraryRevision)
|
||||
put("next_cursor", JsonNull)
|
||||
put("items", JsonArray((1..2).map { number -> buildJsonObject {
|
||||
put("type", "template"); put("id", "item-$number"); put("revision", platform.itemRevision)
|
||||
put("name", "条目$number"); put("playable", true)
|
||||
} }))
|
||||
}.toString()
|
||||
"content.default.get" -> """{"name":"默认内容"}"""
|
||||
"library.preview" -> """{"mime":"image/png","data_base64":"small"}"""
|
||||
"library.thumbnail" -> """{"mime":"image/png","data_base64":"full"}"""
|
||||
else -> "{}"
|
||||
}
|
||||
buildJsonObject {
|
||||
@@ -239,4 +273,53 @@ class ControllerRecoveryTest {
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
@Test fun progressiveLibraryUsesTwoPassesAndReusesVerifiedCache() = runBlocking {
|
||||
val platform = SimulatedPlatform().apply { progressive = true }
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
controller.page(2)
|
||||
withTimeout(5_000) { controller.state.first { it.library.size == 2 && !it.busy } }
|
||||
withTimeout(16_000) { controller.state.first { it.thumbnails.size == 2 } }
|
||||
assertEquals(listOf("library.preview", "library.preview", "library.thumbnail", "library.thumbnail"),
|
||||
platform.called.filter { it == "library.preview" || it == "library.thumbnail" })
|
||||
val cache = platform.libraryCache as SimulatedLibraryCache
|
||||
assertEquals(4, cache.images.size)
|
||||
controller.disconnect()
|
||||
platform.called.clear()
|
||||
controller.connect(device)
|
||||
withTimeout(5_000) { controller.state.first { it.library.size == 2 && it.thumbnails.size == 2 } }
|
||||
assertTrue("library.summary" in platform.called)
|
||||
assertFalse(platform.called.any { it in listOf("library.list", "library.preview", "library.thumbnail") })
|
||||
platform.libraryRevision = "library-v2"
|
||||
platform.itemRevision = "item-v2"
|
||||
controller.refresh()
|
||||
withTimeout(5_000) { controller.state.first { it.library.firstOrNull()?.get("revision")?.jsonPrimitive?.content == "item-v2" && !it.busy } }
|
||||
assertTrue("library.list" in platform.called)
|
||||
assertFalse(cache.images.keys.any { "item-v1" in it })
|
||||
controller.disconnect()
|
||||
controller.clearContentCache()
|
||||
withTimeout(2_000) { controller.state.first { it.cacheBytes == 0L } }
|
||||
assertTrue(cache.images.isEmpty())
|
||||
assertTrue(controller.state.value.remembered.isNotEmpty())
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
@Test fun oldDeviceFetchesVisibleFullImageWithoutNewMethods() = runBlocking {
|
||||
val platform = SimulatedPlatform().apply { progressive = false }
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
controller.page(2)
|
||||
withTimeout(5_000) { controller.state.first { it.library.size == 2 && !it.busy } }
|
||||
controller.thumbnail(controller.state.value.library.first())
|
||||
withTimeout(6_000) { controller.state.first { it.thumbnails.isNotEmpty() } }
|
||||
assertFalse(platform.called.any { it in listOf("library.summary", "library.preview") })
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user