同步移动端工程、设备控制改进与发布资料
This commit is contained in:
+339
@@ -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 }
|
||||
+74
@@ -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() }
|
||||
}
|
||||
+70
@@ -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
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+202
@@ -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() }
|
||||
}
|
||||
|
||||
}
|
||||
+21
@@ -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]) }
|
||||
}
|
||||
}
|
||||
+115
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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")) }
|
||||
}
|
||||
}
|
||||
+28
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user