同步移动端工程、设备控制改进与发布资料

This commit is contained in:
2026-09-26 19:21:12 +08:00
parent 912ba432cf
commit 98eadc5c8b
137 changed files with 9274 additions and 85 deletions
@@ -0,0 +1,339 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.*
import kotlin.time.Clock
interface MobilePlatform {
val model: String
fun load(key: String): String?
fun save(key: String, value: String)
fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit)
fun stopScan()
suspend fun connect(handle: String): DeviceLink
fun crypto(): SecureChannel
}
data class RememberedDevice(val id: String, val name: String, val shortId: String, val lastConnected: Long)
data class WifiDraft(val ssid: String = "", val security: String = "wpa-psk", val manual: Boolean = false,
val address: String = "", val prefix: String = "24", val gateway: String = "", val dns: String = "")
data class ControllerState(
val remembered: List<RememberedDevice> = emptyList(), val signal: Int? = null,
val wifiConnecting: Boolean = false, val wifiError: String? = null, val wifiTarget: String? = null,
val wifiDraft: WifiDraft? = null,
val page: Int = 0, val phase: String = "未连接", val scanning: Boolean = false,
val busy: Boolean = false, val error: String? = null, val clientName: String = "",
val devices: List<DiscoveredDevice> = emptyList(), val identity: JsonObject? = null,
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 frameCheckedAtMs: Long? = null, val defaultContent: JsonObject? = null,
)
class Controller(private val platform: MobilePlatform, private val scope: CoroutineScope) {
private val mutable = MutableStateFlow(ControllerState(clientName = platform.load("client_name") ?: platform.model, remembered = loadRemembered()))
val state: StateFlow<ControllerState> = mutable.asStateFlow()
private fun loadRemembered(): List<RememberedDevice> = runCatching {
platform.load("remembered_devices")?.let { Protocol.json.parseToJsonElement(it).jsonArray.map { item ->
val o = item.jsonObject
RememberedDevice(o.getValue("id").jsonPrimitive.content, o.getValue("name").jsonPrimitive.content,
o.getValue("short_id").jsonPrimitive.content, o.getValue("last_connected").jsonPrimitive.long)
} } ?: emptyList()
}.getOrDefault(emptyList())
private fun persistRemembered(items: List<RememberedDevice>) {
platform.save("remembered_devices", JsonArray(items.map { buildJsonObject {
put("id", it.id); put("name", it.name); put("short_id", it.shortId); put("last_connected", it.lastConnected)
} }).toString())
change { it.copy(remembered = items) }
}
fun rememberedFor(device: DiscoveredDevice) = state.value.remembered.firstOrNull { device.name == "QMS-${it.shortId}" }
fun forget(id: String) {
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == id) disconnect()
persistRemembered(state.value.remembered.filterNot { it.id == id })
if (platform.load("last_device_id") == id) platform.save("last_device_id", "")
platform.save("forgotten_devices", JsonArray((forgottenIds() + id).map { JsonPrimitive(it) }).toString())
}
private fun forgottenIds(): Set<String> = runCatching { platform.load("forgotten_devices")?.let {
Protocol.json.parseToJsonElement(it).jsonArray.map { v -> v.jsonPrimitive.content }.toSet()
} ?: emptySet() }.getOrDefault(emptySet())
fun connectRemembered(device: RememberedDevice) {
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == device.id) return
val found = state.value.devices.firstOrNull { it.name == "QMS-${device.shortId}" }
if (found != null) connect(found, device.id) else { requestedDeviceId = device.id; scan() }
}
private var requestedDeviceId: String? = null
private var activeLink: DeviceLink? = null
fun wifiDraft(value: WifiDraft) = change { it.copy(wifiDraft = value) }
private var rpc: DeviceRpc? = null
private val commands = Mutex()
private var scanJob: Job? = null
private var pollJob: Job? = null
private var releaseJob: Job? = null
private var foreground = true
private var frameRevision: String? = null
private var connectionJob: Job? = null
private var commandJob: Job? = null
private var connectionGeneration = 0L
private var scanGeneration = 0L
private val pendingThumbnails = linkedMapOf<String, JsonObject>()
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) }
fun scan(auto: Boolean = false) {
if (connectionJob?.isActive == true) return
scanJob?.cancel()
platform.stopScan()
val generation = ++scanGeneration
change { it.copy(scanning = true, devices = emptyList(), error = null, phase = if (it.identity != null) it.phase else if (auto) "正在重连上次设备" else "正在扫描") }
val lastId = platform.load("last_device_id")?.takeIf { it.isNotBlank() && it !in forgottenIds() }
val short = lastId?.replace("-", "")?.take(8)
platform.scan({ device -> scope.launch {
if (!state.value.scanning || generation != scanGeneration) return@launch
change { it.copy(devices = (it.devices.filterNot { old -> old.handle == device.handle } + device).sortedByDescending { d -> d.signal }) }
val requested = requestedDeviceId
if (requested != null && device.name == "QMS-${requested.replace("-", "").take(8)}") {
requestedDeviceId = null; connect(device, requested)
} else if (auto && short != null && device.name == "QMS-$short") connect(device, lastId)
} }, { error -> scope.launch {
if (generation == scanGeneration) change { it.copy(error = error, scanning = false, phase = if (it.identity != null) it.phase else "未连接") }
} })
scanJob = scope.launch {
delay(12_000); if (generation != scanGeneration) return@launch; platform.stopScan(); requestedDeviceId = null
change { it.copy(scanning = false, phase = if (it.identity == null) "未连接" else it.phase,
error = if (auto && it.identity == null && connectionJob?.isActive != true) "未找到上次设备,可稍后重试" else it.error) }
}
}
fun connect(device: DiscoveredDevice, expectedId: String? = null) {
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == expectedId && expectedId != null) return
if (connectionJob?.isActive == true || rpc != null) disconnect()
scanGeneration++; scanJob?.cancel(); platform.stopScan()
val generation = ++connectionGeneration
frameRevision = null; pendingThumbnails.clear()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, devices = it.devices, phase = "正在连接") }
connectionJob = scope.launch {
var pending: DeviceRpc? = null
try {
activeLink = platform.connect(device.handle)
pending = DeviceRpc(checkNotNull(activeLink), platform.crypto())
val identity = pending.open(state.value.clientName)
val id = identity.getValue("device_id").jsonPrimitive.content
require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" }
require(identity.getValue("protocol_major").jsonPrimitive.int == 1) { "通信协议不兼容" }
rpc = pending
platform.save("last_device_id", id)
platform.save("forgotten_devices", JsonArray((forgottenIds() - id).map { JsonPrimitive(it) }).toString())
persistRemembered((state.value.remembered.filterNot { it.id == id } + RememberedDevice(id,
identity.getValue("device_name").jsonPrimitive.content, identity.getValue("short_id").jsonPrimitive.content,
Clock.System.now().toEpochMilliseconds())).sortedByDescending { it.lastConnected })
change { it.copy(identity = identity, phase = "已连接", signal = device.signal) }
refreshAll()
startPolling()
} catch (e: Exception) {
pending?.close()
if (generation == connectionGeneration) {
rpc = null; activeLink = null
if (e !is CancellationException) change { it.copy(identity = null, phase = "未连接", error = e.message ?: "连接失败") }
}
}
}
}
fun disconnect() {
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()
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()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) }
}
fun foreground(value: Boolean) {
foreground = value
releaseJob?.cancel()
if (value) {
if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true)
} else {
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() } }
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)
private fun action(block: suspend () -> Unit) {
if (state.value.busy || rpc == null) return
change { it.copy(busy = true, error = null) }
val generation = connectionGeneration
commandJob = scope.launch {
try { commands.withLock { block() } }
catch (e: DeviceFailure) { change { it.copy(error = e.message) } }
catch (e: Exception) {
if (e !is CancellationException && generation == connectionGeneration) {
connectionLost(e.message ?: "操作结果未确认,请重连后检查设备状态"); pollJob?.cancel()
}
} finally { if (generation == connectionGeneration) change { it.copy(busy = false) } }
}
}
private suspend fun refreshAll() { commands.withLock { refreshStatus(); refreshPage() } }
private suspend fun refreshStatus() {
val result = call("status.get")
val wifi = if (supports("wifi")) call("wifi.get") else state.value.wifi
val signal = try { activeLink?.readSignal() } catch (e: CancellationException) { throw e } catch (_: Exception) { null }
currentCoroutineContext().ensureActive()
change { it.copy(status = result, wifi = wifi, signal = signal) }
}
private suspend fun refreshPage() {
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) }
}
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 fun startPolling() {
pollJob?.cancel()
pollJob = scope.launch {
while (isActive && rpc != null) {
delay(2_000)
if (state.value.busy || !commands.tryLock()) continue
try {
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)
}
}
} catch (e: DeviceFailure) {
// A stale thumbnail or other business rejection does not
// invalidate the authenticated transport.
change { it.copy(error = e.message) }
} catch (e: Exception) {
if (e !is CancellationException) {
connectionLost(e.message ?: "连接已断开,请重试")
}
} finally { commands.unlock() }
}
}
}
private suspend fun refreshFrame() {
val result = call("frame.get", buildJsonObject { frameRevision?.let { put("known_revision", it) } })
frameRevision = result["frame_revision"]?.jsonPrimitive?.content
result["data_base64"]?.jsonPrimitive?.content?.let { image -> change { it.copy(frame = image) } }
change { it.copy(frameCheckedAtMs = Clock.System.now().toEpochMilliseconds()) }
}
fun refresh() = action { refreshStatus(); refreshPage(); if (state.value.page == 2 && supports("frame")) refreshFrame() }
fun thumbnail(item: JsonObject) {
val key = thumbnailKey(item)
if (key !in state.value.thumbnails) {
if (pendingThumbnails.size < 128) pendingThumbnails[key] = item
}
}
fun play(item: JsonObject, default: Boolean = false) = action {
call(if (default) "content.default.set" else "content.play", item); refreshStatus()
if (default) { val value = call("content.default.get"); change { it.copy(defaultContent = value) } }
}
fun playback(changes: JsonObject) = action {
val playback = state.value.status?.get("state")?.jsonObject?.get("animation_playback")?.jsonObject
val session = playback?.get("session_id") ?: throw DeviceFailure("NOT_READY", "当前没有可控制的动图")
call("playback.patch", JsonObject(changes + ("session_id" to session))); refreshStatus()
}
fun setting(key: String, value: JsonElement) = action {
val revision = state.value.settings?.get("revision") ?: return@action
val result = call("settings.patch", buildJsonObject { put("expected_revision", revision); put("changes", JsonObject(mapOf(key to value))) })
change { it.copy(settings = result) }; refreshStatus()
}
fun renameDevice(name: String, id: String? = state.value.identity?.get("device_id")?.jsonPrimitive?.content) {
if (id == null || state.value.identity?.get("device_id")?.jsonPrimitive?.content != id) { reportError("请连接设备"); return }
action {
val value = call("device.rename", buildJsonObject { put("name", name.trim()) })
change { it.copy(identity = value) }
persistRemembered(state.value.remembered.map { if (it.id == id) it.copy(name = value.getValue("device_name").jsonPrimitive.content) else it })
}
}
fun renamePhone(name: String) {
val normalized = name.trim()
if (normalized.isBlank() || normalized.length > 40 || normalized.any { it.code < 32 || it.code == 127 }) {
reportError("名称须为 1 至 40 个字符,不能包含控制字符"); return
}
if (state.value.busy) return
platform.save("client_name", normalized); change { it.copy(clientName = normalized) }
if (rpc != null) action { call("session.rename", buildJsonObject { put("client_name", normalized) }) }
}
fun scanWifi() = action { val result = call("wifi.scan"); change { it.copy(networks = result.getValue("networks").jsonArray.map { n -> n.jsonObject }) } }
fun saveWifi(values: JsonObject) = action {
val revision = state.value.wifi?.get("revision") ?: return@action
val target = values.getValue("ssid").jsonPrimitive.content
val result = call("wifi.set", JsonObject(values + ("expected_revision" to revision)))
change { it.copy(wifiConnecting = true, wifiError = null, wifiTarget = target) }
try {
val task = result["task_id"]
var finished = task == null
for (attempt in 0 until 60) {
if (finished) break
delay(1_000)
val value = call("task.get", buildJsonObject { put("task_id", checkNotNull(task)) })
when (value["state"]?.jsonPrimitive?.content) {
"succeeded" -> finished = true
"failed" -> {
val message = when (value["error_code"]?.jsonPrimitive?.content) {
"AUTH_FAILED" -> "${target}密码错误"
"NETWORK_UNAVAILABLE" -> "WiFi不存在或暂不可达"
"IP_CONFIG_FAILED" -> "无法获取IP,请检查地址设置"
else -> "网络连接失败,请检查设置"
}
change { it.copy(wifiError = message) }
throw DeviceFailure("DEVICE_ERROR", message)
}
}
}
if (!finished) { change { it.copy(wifiError = "连接结果尚未确认,请刷新") }; throw DeviceFailure("TIMEOUT", "网络任务仍在进行,请刷新状态") }
} finally { change { it.copy(wifiConnecting = false) }; refreshStatus() }
refreshPage()
}
}
fun wifiSummary(state: ControllerState): String {
if (state.identity == null) return "未连接"
if (state.wifiConnecting) return "连接中"
state.wifiError?.let { return it }
val active = state.wifi?.get("active")?.jsonObject ?: return "WiFi状态读取中"
if (active["connected"]?.jsonPrimitive?.booleanOrNull != true) return "未连接,WiFi不存在"
return listOfNotNull(active["ssid"]?.jsonPrimitive?.contentOrNull, active["ipv4_address"]?.jsonPrimitive?.contentOrNull).joinToString(" · ")
}
fun thumbnailKey(item: JsonObject): String = listOf("type", "id", "revision")
.joinToString("|") { item.getValue(it).jsonPrimitive.content }
@@ -0,0 +1,74 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.*
data class DiscoveredDevice(val handle: String, val name: String, val signal: Int)
/** Platform owns the GATT connection. Handles are ephemeral, never device identity. */
interface DeviceLink {
val mtu: Int
suspend fun send(fragment: ByteArray)
suspend fun receive(): ByteArray
fun close()
suspend fun readSignal(): Int? = null
}
class DeviceFailure(val code: String, message: String) : Exception(message)
class DeviceTransportFailure(message: String) : Exception(message)
class DeviceRpc(private val link: DeviceLink, private val crypto: SecureChannel, private val requestTimeoutMs: Long = 10_000) {
private val mutex = Mutex()
private var tx = 0L
private var request = 0L
private val receiver = Reassembler()
private var usable = true
private suspend fun send(kind: Int, payload: ByteArray) {
Protocol.encode(kind, tx++, payload, link.mtu).forEach { link.send(it) }
}
private suspend fun receive(): Pair<Int, ByteArray> = withTimeout(15_000) {
var result: Pair<Int, ByteArray>? = null
while (result == null) result = receiver.accept(link.receive())
result
}
suspend fun open(name: String): JsonObject = mutex.withLock {
try {
withTimeout(10_000) {
send(1, crypto.hello)
val (kind, payload) = receive()
require(kind == 2) { "设备未接受握手" }
crypto.finish(payload)
exchange("session.open", buildJsonObject { put("client_name", name); put("receive_mtu", link.mtu) })
}
} catch (e: TimeoutCancellationException) {
close(); throw DeviceTransportFailure("连接握手超时,请重新连接")
} catch (e: Exception) { close(); throw e }
}
suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonObject = mutex.withLock {
check(usable) { "设备连接已关闭" }
try {
withTimeout(if (method == "wifi.scan") 20_000 else requestTimeoutMs) { exchange(method, params) }
} catch (e: TimeoutCancellationException) {
close(); throw DeviceTransportFailure("设备回复超时($method),请重新连接")
} catch (e: DeviceFailure) { throw e }
catch (e: Exception) { close(); throw e }
}
private suspend fun exchange(method: String, params: JsonObject): JsonObject {
val id = (++request).toString()
val raw = buildJsonObject { put("id", id); put("method", method); put("params", params) }
send(3, crypto.encrypt(raw.toString().encodeToByteArray()))
val (kind, record) = receive()
require(kind == 3)
val response = Protocol.json.parseToJsonElement(crypto.decrypt(record).decodeToString(throwOnInvalidSequence = true)).jsonObject
require(response["id"]?.jsonPrimitive?.content == id)
if (!response.getValue("ok").jsonPrimitive.boolean) {
val error = response.getValue("error").jsonObject
throw DeviceFailure(error.getValue("code").jsonPrimitive.content, error.getValue("message").jsonPrimitive.content)
}
return response.getValue("result").jsonObject
}
fun close() { usable = false; link.close() }
}
@@ -0,0 +1,70 @@
package org.qimiaoscreen.core
import kotlinx.serialization.json.Json
object Protocol {
const val SERVICE = "9f57a001-6c31-4c58-bc22-1f728b641001"
const val RX = "9f57a002-6c31-4c58-bc22-1f728b641001"
const val TX = "9f57a003-6c31-4c58-bc22-1f728b641001"
val json = Json { ignoreUnknownKeys = true }
const val MAX_MESSAGE = 65536
fun encode(kind: Int, message: Long, payload: ByteArray, mtu: Int): List<ByteArray> {
require(kind in 1..4 && message in 0..0xffffffffL && payload.size in 1..MAX_MESSAGE)
require(mtu in 23..517)
val size = mtu - 19
require(size > 0)
val count = (payload.size + size - 1) / size
require(count <= 65535)
return (0 until count).map { index ->
val part = payload.copyOfRange(index * size, minOf(payload.size, (index + 1) * size))
ByteArray(16 + part.size).apply {
putInt(0, 0x514d, 2); this[2] = 1; this[3] = kind.toByte()
putInt(4, message, 4); putInt(8, index.toLong(), 2)
putInt(10, count.toLong(), 2); putInt(12, payload.size.toLong(), 4)
part.copyInto(this, 16)
}
}
}
}
fun ByteArray.putInt(offset: Int, value: Long, length: Int) {
for (i in 0 until length) this[offset + i] = (value ushr (8 * (length - i - 1))).toByte()
}
fun ByteArray.readInt(offset: Int, length: Int): Long {
require(offset + length <= size)
var result = 0L
for (i in offset until offset + length) result = (result shl 8) or (this[i].toLong() and 255)
return result
}
class Reassembler {
private var id = -1L
private var count = 0
private var total = 0
private var index = 0
private var kind = 0
private var buffer = ByteArray(0)
private var offset = 0
private var nextMessage = 0L
fun accept(part: ByteArray): Pair<Int, ByteArray>? {
require(part.size in 17..514 && part.readInt(0, 2) == 0x514dL && part[2].toInt() == 1)
val partId = part.readInt(4, 4)
val partIndex = part.readInt(8, 2).toInt()
val partCount = part.readInt(10, 2).toInt()
val partTotal = part.readInt(12, 4).toInt()
val partKind = part[3].toInt()
require(partKind in 1..4 && partCount in 1..partTotal && partTotal in 1..Protocol.MAX_MESSAGE)
if (index == 0) {
require(partIndex == 0 && partId == nextMessage)
id = partId; count = partCount; total = partTotal; kind = partKind
buffer = ByteArray(total); offset = 0
}
require(partId == id && partIndex == index && partCount == count && partTotal == total && partKind == kind)
require(index < count && offset + part.size - 16 <= total)
part.copyInto(buffer, offset, 16); offset += part.size - 16; index++
if (index != count) { require(offset < total); return null }
require(offset == total)
index = 0; nextMessage++
return kind to buffer
}
}
@@ -0,0 +1,9 @@
package org.qimiaoscreen.core
/** Platform crypto boundary; future iOS implementation must pass the same vectors. */
interface SecureChannel {
val hello: ByteArray
fun finish(serverHello: ByteArray)
fun encrypt(payload: ByteArray): ByteArray
fun decrypt(record: ByteArray): ByteArray
}
@@ -0,0 +1,202 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.*
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"
val preferences = mutableMapOf<String, String>()
val links = mutableListOf<SimulatedLink>()
val scanCallbacks = mutableListOf<(DiscoveredDevice) -> Unit>()
override fun load(key: String) = preferences[key]
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").also { links += it }
override fun crypto() = object : SecureChannel {
override val hello = byteArrayOf(1)
override fun finish(serverHello: ByteArray) {}
override fun encrypt(payload: ByteArray) = payload
override fun decrypt(record: ByteArray) = record
}
}
private class SimulatedLink(private val deviceId: String) : DeviceLink {
override val mtu = 247
private val receiver = Reassembler()
private val messages = Channel<ByteArray>(Channel.UNLIMITED)
private var sequence = 0L
var failure: String? = null
var blockStorage = false
var storageEntered = CompletableDeferred<Unit>()
var closed = false
override suspend fun send(fragment: ByteArray) {
check(!closed)
val (kind, payload) = receiver.accept(fragment) ?: return
val response = if (kind == 1) byteArrayOf(2) else {
val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
val method = request.getValue("method").jsonPrimitive.content
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"]}"""
"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"}"""
else -> "{}"
}
buildJsonObject {
put("id", request.getValue("id")); put("ok", true)
put("result", Protocol.json.parseToJsonElement(result))
}.toString().encodeToByteArray()
}
Protocol.encode(if (kind == 1) 2 else 3, sequence++, response, mtu).forEach { messages.send(it) }
}
override suspend fun receive() = messages.receive()
override fun close() { closed = true; messages.close() }
}
class ControllerRecoveryTest {
private val device = DiscoveredDevice("test-handle", "test-device", -40)
@Test fun lossClearsCachedStateAndReconnects() = runBlocking {
val platform = SimulatedPlatform()
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(1)
withTimeout(2_000) { controller.state.first { it.frame != null && !it.busy } }
assertNotNull(controller.state.value.frameCheckedAtMs)
platform.links.last().failure = "status.get"
controller.refresh()
withTimeout(2_000) { controller.state.first { it.phase == "连接中断" } }
assertNull(controller.state.value.frame)
assertNull(controller.state.value.frameCheckedAtMs)
assertNull(controller.state.value.storage)
assertTrue(platform.links.last().closed)
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
assertEquals(2, platform.links.size)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun disconnectCancelsInFlightOperationWithoutCorruptingNextConnection() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
val old = platform.links.last()
old.blockStorage = true
controller.refresh()
withTimeout(2_000) { old.storageEntered.await() }
controller.disconnect()
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
assertTrue(old.closed)
assertFalse(controller.state.value.busy)
assertNull(controller.state.value.error)
assertFalse(platform.links.last().closed)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun oldScanCallbackCannotPopulateNewScan() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.scan(); controller.scan()
platform.scanCallbacks.first()(device)
yield()
assertTrue(controller.state.value.devices.isEmpty())
platform.scanCallbacks.last()(device)
withTimeout(2_000) { controller.state.first { it.devices.size == 1 } }
Unit
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun rememberedDevicesSurviveDisconnectAndForgetPreventsReconnect() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
val remembered = controller.state.value.remembered.single()
assertEquals("模拟屏", remembered.name)
controller.scan()
assertNotNull(controller.state.value.identity)
assertTrue(controller.state.value.scanning)
platform.scanCallbacks.last()(device)
withTimeout(2_000) { controller.state.first { it.devices.isNotEmpty() } }
assertEquals(1, platform.links.size)
controller.disconnect()
assertEquals(remembered.id, Controller(platform, scope).state.value.remembered.single().id)
controller.forget(remembered.id)
controller.foreground(true)
assertFalse(controller.state.value.scanning)
assertTrue(controller.state.value.remembered.isEmpty())
assertEquals("", platform.load("last_device_id"))
controller.renameDevice("不应执行", remembered.id)
assertEquals("请连接设备", controller.state.value.error)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun selectingDeviceReleasesPreviousLinkAndForgettingActiveDisconnects() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
val old = platform.links.single()
controller.connect(device.copy(handle = "second-handle"))
withTimeout(2_000) { controller.state.first { it.status != null } }
assertTrue(old.closed)
assertEquals(2, platform.links.size)
assertEquals(2, controller.state.value.remembered.size)
controller.forget("11111111-0000-4000-8000-000000000001")
assertNull(controller.state.value.identity)
assertTrue(platform.links.last().closed)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun legacyLastDeviceMigratesAfterValidatedHandshake() = runBlocking {
val platform = SimulatedPlatform()
val id = "00000000-0000-4000-8000-000000000001"
platform.preferences["last_device_id"] = id
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.foreground(true)
platform.scanCallbacks.last()(device.copy(name = "QMS-00000000"))
withTimeout(2_000) { controller.state.first { it.status != null } }
assertEquals(id, controller.state.value.remembered.single().id)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun renameUpdatesAuthenticatedIdentityAndRememberedName() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
controller.renameDevice("客厅测试屏")
withTimeout(2_000) { controller.state.first { it.remembered.firstOrNull()?.name == "客厅测试屏" } }
assertEquals("客厅测试屏", controller.state.value.identity?.get("device_name")?.jsonPrimitive?.content)
controller.disconnect()
assertEquals("客厅测试屏", Controller(platform, scope).state.value.remembered.single().name)
} finally { controller.disconnect(); scope.cancel() }
}
}
@@ -0,0 +1,21 @@
package org.qimiaoscreen.core
import kotlin.test.*
class ProtocolTest {
@Test fun minimumMtuRoundTrip() {
val payload = "中文协议".repeat(20).encodeToByteArray()
val rx = Reassembler()
var result: Pair<Int, ByteArray>? = null
Protocol.encode(1, 0, payload, 23).forEach { result = rx.accept(it) }
assertEquals(1, result!!.first)
assertContentEquals(payload, result!!.second)
}
@Test fun rejectsOutOfOrderAndReplayedMessage() {
val frames = Protocol.encode(3, 0, ByteArray(20), 23)
assertFailsWith<IllegalArgumentException> { Reassembler().accept(frames[1]) }
val rx = Reassembler()
frames.forEach { rx.accept(it) }
assertFailsWith<IllegalArgumentException> { rx.accept(frames[0]) }
}
}
@@ -0,0 +1,115 @@
package org.qimiaoscreen.core
import java.math.BigInteger
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.ECParameterSpec
import java.security.spec.ECPoint
import java.security.spec.ECPublicKeySpec
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlinx.serialization.json.*
/** JCA implementation shared by Android and JVM interoperability tests. */
class JavaSecureChannel(
private val keyPair: KeyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair(),
private val random: ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) },
) : SecureChannel {
private val label = "QMS-BLE-1".encodeToByteArray()
private var material: ByteArray? = null
private var transcript = ByteArray(0)
private var tx = 0L
private var rx = 0L
private var failed = false
private fun fixed(value: BigInteger): ByteArray = value.toByteArray().let {
if (it.size >= 32) it.copyOfRange(it.size - 32, it.size) else ByteArray(32 - it.size) + it
}
override val hello: ByteArray
init {
require(random.size == 32)
val point = (keyPair.public as ECPublicKey).w
val public = byteArrayOf(4) + fixed(point.affineX) + fixed(point.affineY)
hello = buildJsonObject {
put("protocol_major", 1); put("protocol_minor", 0)
put("public_key", Base64.getEncoder().encodeToString(public))
put("random", Base64.getEncoder().encodeToString(random))
}.toString().encodeToByteArray()
}
private fun hash(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes)
private fun number(value: Long, length: Int) = ByteArray(length).apply { putInt(0, value, length) }
private fun mac(key: ByteArray, bytes: ByteArray) = Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(key, "HmacSHA256")); doFinal(bytes)
}
override fun finish(serverHello: ByteArray) {
check(material == null && !failed)
try {
require(serverHello.size <= 1024)
val peer = Protocol.json.parseToJsonElement(serverHello.decodeToString(throwOnInvalidSequence = true)).jsonObject
require(peer.getValue("protocol_major").jsonPrimitive.int == 1)
require(peer.getValue("protocol_minor").jsonPrimitive.int >= 0)
val public = Base64.getDecoder().decode(peer.getValue("public_key").jsonPrimitive.content)
val sr = Base64.getDecoder().decode(peer.getValue("random").jsonPrimitive.content)
require(public.size == 65 && public[0] == 4.toByte() && sr.size == 32)
val parameters = AlgorithmParameters.getInstance("EC").apply { init(ECGenParameterSpec("secp256r1")) }
.getParameterSpec(ECParameterSpec::class.java)
val point = ECPoint(BigInteger(1, public.copyOfRange(1, 33)), BigInteger(1, public.copyOfRange(33, 65)))
val peerKey = KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec(point, parameters))
val secret = KeyAgreement.getInstance("ECDH").run { init(keyPair.private); doPhase(peerKey, true); generateSecret() }
transcript = hash(number(hello.size.toLong(), 4) + hello + number(serverHello.size.toLong(), 4) + serverHello)
val prk = mac(hash(random + sr), secret)
var block = ByteArray(0)
var expanded = ByteArray(0)
for (i in 1..3) {
block = mac(prk, block + label + transcript + byteArrayOf(i.toByte()))
expanded += block
}
material = expanded.copyOf(72)
secret.fill(0); prk.fill(0); expanded.fill(0)
} catch (e: Exception) {
failed = true
throw IllegalArgumentException("无法建立加密会话")
}
}
override fun encrypt(payload: ByteArray): ByteArray {
check(!failed && tx >= 0 && tx < Long.MAX_VALUE)
require(payload.size in 1..Protocol.MAX_MESSAGE - 24)
val seq = number(tx, 8)
val result = seq + crypt(Cipher.ENCRYPT_MODE, 0, seq, payload)
tx++
return result
}
override fun decrypt(record: ByteArray): ByteArray {
try {
check(!failed && rx >= 0)
require(record.size in 25..Protocol.MAX_MESSAGE && record.readInt(0, 8) == rx)
val result = crypt(Cipher.DECRYPT_MODE, 1, record.copyOfRange(0, 8), record.copyOfRange(8, record.size))
rx++
return result
} catch (e: Exception) {
failed = true
material?.fill(0)
throw IllegalArgumentException("加密消息校验失败")
}
}
private fun crypt(mode: Int, direction: Int, seq: ByteArray, value: ByteArray): ByteArray {
val keys = checkNotNull(material)
return Cipher.getInstance("AES/GCM/NoPadding").run {
init(mode, SecretKeySpec(keys.copyOfRange(direction * 32, direction * 32 + 32), "AES"),
GCMParameterSpec(128, keys.copyOfRange(64 + direction * 4, 68 + direction * 4) + seq))
updateAAD(label + transcript + byteArrayOf(direction.toByte()) + seq)
doFinal(value)
}
}
}
@@ -0,0 +1,48 @@
package org.qimiaoscreen.core
import java.math.BigInteger
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.KeyPair
import java.security.spec.*
import kotlinx.serialization.json.*
import kotlin.test.*
class CryptoInteropTest {
private val vector = Protocol.json.parseToJsonElement(
checkNotNull(javaClass.getResourceAsStream("/protocol-v1.json")).bufferedReader().use { it.readText() }
).jsonObject
private fun bytes(key: String) = vector.getValue(key).jsonPrimitive.content.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun channel(): JavaSecureChannel {
val params = AlgorithmParameters.getInstance("EC").apply { init(ECGenParameterSpec("secp256r1")) }
.getParameterSpec(ECParameterSpec::class.java)
val factory = KeyFactory.getInstance("EC")
val privateKey = factory.generatePrivate(ECPrivateKeySpec(BigInteger.ONE, params))
val publicKey = factory.generatePublic(ECPublicKeySpec(params.generator, params))
return JavaSecureChannel(KeyPair(publicKey, privateKey), bytes("client_random"))
}
@Test fun pythonGoldenRecordsMatch() {
val channel = channel()
assertContentEquals(bytes("client_hello"), channel.hello)
channel.finish(bytes("server_hello"))
assertContentEquals(bytes("client_record"), channel.encrypt(bytes("request")))
assertContentEquals(bytes("response"), channel.decrypt(bytes("server_record")))
assertFails { channel.decrypt(bytes("server_record")) }
}
@Test fun pythonFragmentVectorMatches() {
val expected = vector.getValue("fragments_mtu23").jsonArray.map {
it.jsonPrimitive.content.chunked(2).map { hex -> hex.toInt(16).toByte() }.toByteArray()
}
val actual = Protocol.encode(3, 0, bytes("client_record"), 23)
assertEquals(expected.size, actual.size)
expected.zip(actual).forEach { (a, b) -> assertContentEquals(a, b) }
}
@Test fun tamperedRecordInvalidatesConnection() {
val channel = channel()
channel.finish(bytes("server_hello"))
val record = bytes("server_record")
record[record.lastIndex] = (record.last().toInt() xor 1).toByte()
assertFails { channel.decrypt(record) }
assertFails { channel.decrypt(bytes("server_record")) }
}
}
@@ -0,0 +1,28 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.*
import kotlin.test.*
class RpcTimeoutTest {
@Test fun timeoutClosesTransportAndIsNotCancellation() = runBlocking {
var closed = false
val link = object : DeviceLink {
override val mtu = 247
override suspend fun send(fragment: ByteArray) {}
override suspend fun receive(): ByteArray { awaitCancellation() }
override fun close() { closed = true }
}
val crypto = object : SecureChannel {
override val hello = byteArrayOf(1)
override fun finish(serverHello: ByteArray) {}
override fun encrypt(payload: ByteArray) = payload
override fun decrypt(record: ByteArray) = record
}
val rpc = DeviceRpc(link, crypto, requestTimeoutMs = 25)
val error = assertFailsWith<DeviceTransportFailure> { rpc.call("status.get") }
assertTrue(error.message!!.contains("status.get"))
assertTrue(closed)
assertFailsWith<IllegalStateException> { rpc.call("status.get") }
Unit
}
}
@@ -0,0 +1,41 @@
{
"note": "Public interoperability fixture only; never use these scalars in real connections.",
"client_scalar": "1",
"server_scalar": "2",
"client_random": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"server_random": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"client_hello": "7b2270726f746f636f6c5f6d616a6f72223a312c2270726f746f636f6c5f6d696e6f72223a302c227075626c69635f6b6579223a224247735830664c684c454a482b4c7a6d35574f6b51504a33413332424c65737a6f5053684f5558596d4d4b57542b4e43347634616635754f352b744b66412b654669764f4d3164724d56374f79375a416144652f5566553d222c2272616e646f6d223a2241414543417751464267634943516f4c4441304f4478415245684d554652595847426b61477877644868383d227d",
"server_hello": "7b2270726f746f636f6c5f6d616a6f72223a312c2270726f746f636f6c5f6d696e6f72223a302c227075626c69635f6b6579223a2242487a796578694e4130392b696c4934417753314773504169576e69642f49624e61594c535078485a706c3442336456454e754f30454170505a72476e3351773237703972655938365949706e6753336e534a346339453d222c2272616e646f6d223a22494345694979516c4a69636f4b536f724c4330754c7a41784d6a4d304e5459334f446b364f7a7739506a383d227d",
"transcript": "9ca718b743dfe549aaba17180599eced0e2bcb680fd7c210e341ae74ccd17b76",
"request": "7b226964223a2231222c226d6574686f64223a2273657373696f6e2e6f70656e222c22706172616d73223a7b22636c69656e745f6e616d65223a22e6b58be8af95e6898be69cba227d7d",
"response": "7b226964223a2231222c226f6b223a747275652c22726573756c74223a7b22616c697665223a747275657d7d",
"client_record": "0000000000000000f2c854e062cd588b6de2066904d362400bf231bdeb046d786fca2a2bb6e1b539d0bd32d225461581655d7dc5066cf89d2d3bea12421e0d6e452f34ba4c39219b3e3efd3826c8461b67b8a74650976db8d47a0edd94e6cf39f526",
"server_record": "0000000000000000fd089bf6a0d3196c601f5dcbefad51b0f130e17cf38d2c76cbb9855a7da88480e53f3e89893dcc35fa175fcfbf01683f575244928462d07d13056a67",
"fragments_mtu23": [
"514d010300000000000000190000006200000000",
"514d010300000000000100190000006200000000",
"514d0103000000000002001900000062f2c854e0",
"514d010300000000000300190000006262cd588b",
"514d01030000000000040019000000626de20669",
"514d010300000000000500190000006204d36240",
"514d01030000000000060019000000620bf231bd",
"514d0103000000000007001900000062eb046d78",
"514d01030000000000080019000000626fca2a2b",
"514d0103000000000009001900000062b6e1b539",
"514d010300000000000a001900000062d0bd32d2",
"514d010300000000000b00190000006225461581",
"514d010300000000000c001900000062655d7dc5",
"514d010300000000000d001900000062066cf89d",
"514d010300000000000e0019000000622d3bea12",
"514d010300000000000f001900000062421e0d6e",
"514d0103000000000010001900000062452f34ba",
"514d01030000000000110019000000624c39219b",
"514d01030000000000120019000000623e3efd38",
"514d010300000000001300190000006226c8461b",
"514d010300000000001400190000006267b8a746",
"514d010300000000001500190000006250976db8",
"514d0103000000000016001900000062d47a0edd",
"514d010300000000001700190000006294e6cf39",
"514d0103000000000018001900000062f526"
]
}