Add authenticated WiFi transport with BLE fallback

This commit is contained in:
2026-10-01 14:32:18 +08:00
parent 071f06d365
commit f011d8357d
55 changed files with 6327 additions and 29 deletions
@@ -8,6 +8,8 @@ import kotlinx.serialization.json.*
import kotlin.time.Clock
interface MobilePlatform {
fun wifi(): WifiPlatform? = null
fun requestWifiPermission() {}
val model: String
val libraryCache: LibraryCache? get() = null
fun load(key: String): String?
@@ -22,6 +24,7 @@ data class RememberedDevice(val id: String, val name: String, val shortId: Strin
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 transport: String = "ble", val transportReason: String? = null,
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,
@@ -71,7 +74,8 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
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 var rpc: HybridRpc? = null
fun requestWifiPermission() = platform.requestWifiPermission()
private val commands = Mutex()
private var scanJob: Job? = null
private var pollJob: Job? = null
@@ -139,7 +143,10 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
val id = identity.getValue("device_id").jsonPrimitive.content
require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" }
require(identity.getValue("protocol_major").jsonPrimitive.int == 1) { "通信协议不兼容" }
rpc = pending
val supportsWifi = identity["capabilities"]?.jsonArray?.any { it.jsonPrimitive.content == "wifi_transport" } == true
rpc = HybridRpc(pending, checkNotNull(activeLink), if (supportsWifi) platform.wifi() else null, scope, id,
changed = { transport, reason -> if (generation == connectionGeneration) change { it.copy(transport = transport, transportReason = reason) } },
lost = { message -> if (generation == connectionGeneration) { connectionLost(message); pollJob?.cancel() } })
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,
@@ -153,6 +160,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
} catch (e: Exception) {
pending?.close()
if (generation == connectionGeneration) {
rpc?.close()
rpc = null; activeLink = null
if (e !is CancellationException) change { it.copy(identity = null, connectedDevice = null, phase = "未连接", error = e.message ?: "连接失败") }
}
@@ -167,11 +175,13 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered) }
}
private fun connectionLost(message: String) {
connectionGeneration++
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; resetLibraryQueue()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) }
}
fun foreground(value: Boolean) {
foreground = value
rpc?.foreground(value)
releaseJob?.cancel()
if (value) {
if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true)
@@ -203,7 +213,14 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
val generation = connectionGeneration
commandJob = scope.launch {
try { commands.withLock { block() } }
catch (e: DeviceFailure) { change { it.copy(error = e.message) } }
catch (e: DeviceFailure) {
change { it.copy(error = e.message) }
if (e.code == "RESULT_UNKNOWN") {
try { refreshStatus() }
catch (_: DeviceFailure) { /* Preserve the uncertain-write message. */ }
catch (failure: Exception) { if (failure !is CancellationException) connectionLost("连接已中断,请重新连接") }
}
}
catch (e: Exception) {
if (e !is CancellationException && generation == connectionGeneration) {
connectionLost(e.message ?: "操作结果未确认,请重连后检查设备状态"); pollJob?.cancel()
@@ -10,6 +10,7 @@ data class DiscoveredDevice(val handle: String, val name: String, val signal: In
/** Platform owns the GATT connection. Handles are ephemeral, never device identity. */
interface DeviceLink {
val disconnected: kotlinx.coroutines.Deferred<Unit>? get() = null
val mtu: Int
suspend fun send(fragment: ByteArray)
suspend fun receive(): ByteArray
@@ -0,0 +1,140 @@
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.*
data class WifiNetworkState(val generation: Long = 0, val available: Boolean = false, val reason: String? = null)
interface WifiPlatform {
val state: StateFlow<WifiNetworkState>
fun start()
fun stop()
suspend fun connect(offer: JsonObject): DeviceLink
fun crypto(offer: JsonObject): SecureChannel
}
/** Complete encrypted records, never BLE-sized fragments. */
class WifiRpc(private val link: DeviceLink, private val crypto: SecureChannel) {
private val mutex = Mutex()
private var request = 0L
suspend fun open(offer: JsonObject) = withTimeout(5_000) {
val deviceId = offer.getValue("device_id").jsonPrimitive.content
val hello = buildJsonObject { put("device_id", deviceId) }.toString().encodeToByteArray()
link.send(offer.getValue("channel_id").jsonPrimitive.content.encodeToByteArray() + crypto.encrypt(hello))
val response = Protocol.json.parseToJsonElement(crypto.decrypt(link.receive()).decodeToString(throwOnInvalidSequence = true)).jsonObject
require(response["device_id"]?.jsonPrimitive?.content == deviceId && response["ready"]?.jsonPrimitive?.boolean == true)
}
suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonObject = mutex.withLock {
withTimeout(5_000) {
val id = (++request).toString()
val raw = buildJsonObject { put("id", id); put("method", method); put("params", params) }
link.send(crypto.encrypt(raw.toString().encodeToByteArray()))
val response = Protocol.json.parseToJsonElement(crypto.decrypt(link.receive()).decodeToString(throwOnInvalidSequence = true)).jsonObject
require(response["id"]?.jsonPrimitive?.content == id)
if (response.getValue("ok").jsonPrimitive.boolean) response.getValue("result").jsonObject
else {
val error = response.getValue("error").jsonObject
throw DeviceFailure(error.getValue("code").jsonPrimitive.content, error.getValue("message").jsonPrimitive.content)
}
}
}
fun close() = link.close()
}
/** BLE owns lifetime; routing changes only at business-request boundaries. */
class HybridRpc(
private val ble: DeviceRpc, private val bleLink: DeviceLink, private val wifi: WifiPlatform?,
parent: CoroutineScope, private val deviceId: String,
private val changed: (String, String?) -> Unit, private val lost: (String) -> Unit,
) {
private val scope = CoroutineScope(parent.coroutineContext + SupervisorJob(parent.coroutineContext[Job]))
private val gate = Mutex()
private var lan: WifiRpc? = null
private var closed = false
private var enabled = true
private val readOnly = setOf("status.get", "storage.get", "settings.get", "library.list", "library.summary",
"library.preview", "library.thumbnail", "content.default.get", "frame.get")
init {
scope.launch {
try { while (isActive) { delay(2_000); ble.call("session.ping") } }
catch (e: Exception) { if (e !is CancellationException) fail() }
}
bleLink.disconnected?.let { signal -> scope.launch { signal.await(); fail() } }
if (wifi != null) {
wifi.start()
scope.launch {
try {
wifi.state.collectLatest { network ->
lan?.close() // Immediately interrupt in-flight IO on old networks.
gate.withLock { fallback(network.reason) }
if (!network.available) return@collectLatest
var attempt = 0
while (currentCoroutineContext().isActive) {
if (!enabled) { delay(1_000); continue }
try {
gate.withLock {
if (closed) return@collectLatest
// Network callback cancellation must not truncate a BLE record.
val offer = withContext(NonCancellable) { ble.call("transport.offer") }
currentCoroutineContext().ensureActive()
require(offer.getValue("device_id").jsonPrimitive.content == deviceId)
var candidate: WifiRpc? = null
try {
withTimeout(5_000) {
candidate = WifiRpc(wifi.connect(offer), wifi.crypto(offer))
candidate!!.open(offer)
}
currentCoroutineContext().ensureActive()
lan = candidate
changed("wifi", null)
} catch (e: Exception) { candidate?.close(); throw e }
}
attempt = 0
while (currentCoroutineContext().isActive) {
delay(5_000)
gate.withLock { checkNotNull(lan).call("transport.ping") }
}
} catch (e: CancellationException) {
if (e !is TimeoutCancellationException) throw e
} catch (_: Exception) { /* Recover through the BLE execution barrier. */ }
gate.withLock { fallback("WiFi 暂不可用,正在使用蓝牙") }
delay(listOf(2_000L, 5_000L, 10_000L, 30_000L)[(attempt++).coerceAtMost(3)])
}
}
} catch (e: Exception) {
if (e !is CancellationException) fail()
}
}
}
}
private fun fail() { if (!closed) { close(); lost("蓝牙连接已中断,请重新连接") } }
private suspend fun fallback(reason: String?) {
lan?.close(); lan = null
if (!closed && wifi != null) withContext(NonCancellable) { ble.call("transport.close") }
if (!closed) changed("ble", reason)
}
suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonObject = gate.withLock {
check(!closed)
val channel = lan
if (channel == null || method.startsWith("session.") || method.startsWith("transport.") ||
method.startsWith("wifi.") || method == "task.get") return@withLock ble.call(method, params)
try { channel.call(method, params) }
catch (e: DeviceFailure) { throw e }
catch (e: Exception) {
if (e is CancellationException && e !is TimeoutCancellationException) throw e
fallback("WiFi 已中断,正在使用蓝牙")
if (method in readOnly) ble.call(method, params)
else {
ble.call("status.get") // Resolve the old execution before subsequent writes.
throw DeviceFailure("RESULT_UNKNOWN", "操作结果未确认,已恢复蓝牙,请检查设备状态后再操作")
}
}
}
fun foreground(value: Boolean) { enabled = value }
fun close() {
if (closed) return
closed = true; scope.cancel(); lan?.close(); lan = null; wifi?.stop(); ble.close()
}
}
@@ -27,12 +27,30 @@ class JavaSecureChannel(
}.generateKeyPair(),
private val random: ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) },
) : SecureChannel {
private val label = "QMS-BLE-1".encodeToByteArray()
private var 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
companion object {
fun wifi(secret: ByteArray, channelId: String): JavaSecureChannel {
require(secret.size == 32 && channelId.matches(Regex("[0-9a-f]{32}")))
return JavaSecureChannel().apply {
label = "QMS-WIFI-1".encodeToByteArray()
transcript = hash(channelId.encodeToByteArray())
val prk = mac(transcript, 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)
prk.fill(0); expanded.fill(0)
}
}
}
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
}
@@ -0,0 +1,136 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.*
import kotlin.test.*
private class PlainCrypto : 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 RpcFixture : WifiPlatform {
override val state = MutableStateFlow(WifiNetworkState(1, true))
var wifiWrites = 0
var bleWrites = 0
var wifiReads = 0
var failRead = false
var failWrite = false
var wrongIdentity = false
var blePings = 0
var failBarrier = false
var stopped = false
val offer = buildJsonObject { put("channel_id", "0".repeat(32)); put("device_id", "test") }
val ble = object : DeviceLink {
override val mtu = 247
override val disconnected = CompletableDeferred<Unit>()
val reassembler = Reassembler()
val replies = Channel<ByteArray>(Channel.UNLIMITED)
var sequence = 0L
override suspend fun send(fragment: ByteArray) {
val (kind, payload) = reassembler.accept(fragment) ?: return
val raw = if (kind == 1) byteArrayOf(2) else {
val r = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
val method = r.getValue("method").jsonPrimitive.content
if (method == "transport.close" && failBarrier) throw DeviceTransportFailure("BLE lost during fallback")
if (method == "content.play") bleWrites++
if (method == "session.ping") blePings++
val result = if (method == "transport.offer") offer else buildJsonObject { put("ble", true) }
buildJsonObject { put("id", r.getValue("id")); put("ok", true); put("result", result) }.toString().encodeToByteArray()
}
Protocol.encode(if (kind == 1) 2 else 3, sequence++, raw, mtu).forEach { replies.send(it) }
}
override suspend fun receive() = replies.receive()
override fun close() { disconnected.complete(Unit); replies.close() }
}
override fun start() {}
override fun stop() { stopped = true }
override fun crypto(offer: JsonObject): SecureChannel = PlainCrypto()
override suspend fun connect(offer: JsonObject): DeviceLink = object : DeviceLink {
override val mtu = 65536
val replies = Channel<ByteArray>(Channel.UNLIMITED)
var opened = false
override suspend fun send(fragment: ByteArray) {
if (!opened) {
opened = true
replies.send(buildJsonObject { put("device_id", if (wrongIdentity) "wrong" else "test"); put("ready", true) }.toString().encodeToByteArray())
return
}
val r = Protocol.json.parseToJsonElement(fragment.decodeToString()).jsonObject
val method = r.getValue("method").jsonPrimitive.content
if (method == "content.play") { wifiWrites++; if (failWrite) throw DeviceTransportFailure("lost reply") }
if (method == "status.get") { wifiReads++; if (failRead) throw DeviceTransportFailure("lost read") }
replies.send(buildJsonObject { put("id", r.getValue("id")); put("ok", true); put("result", buildJsonObject { put("wifi", true) }) }.toString().encodeToByteArray())
}
override suspend fun receive() = replies.receive()
override fun close() { replies.close() }
}
}
class HybridRpcTest {
private suspend fun awaitTrue(predicate: () -> Boolean) = withTimeout(3_000) { while (!predicate()) delay(10) }
@Test fun bleFailureDuringNetworkFallbackIsHandled() = runBlocking {
val f = RpcFixture(); val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var mode = "ble"; var lost = false; var uncaught = false
val parent = CoroutineScope(coroutineContext + CoroutineExceptionHandler { _, _ -> uncaught = true })
val router = HybridRpc(ble, f.ble, f, parent, "test", { t, _ -> mode = t }, { lost = true })
try {
awaitTrue { mode == "wifi" }
f.failBarrier = true
f.state.value = WifiNetworkState(2, false)
awaitTrue { lost }
delay(50)
assertFalse(uncaught)
} finally { router.close() }
}
@Test fun upgradesReadFailureFallsBackAndWriteIsNeverReplayed() = runBlocking {
val f = RpcFixture(); val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var transport = "ble"
val router = HybridRpc(ble, f.ble, f, this, "test", { t, _ -> transport = t }, { fail(it) })
try {
awaitTrue { transport == "wifi" }
assertTrue(router.call("status.get").containsKey("wifi"))
f.failWrite = true
val error = assertFailsWith<DeviceFailure> { router.call("content.play") }
assertEquals("RESULT_UNKNOWN", error.code)
assertEquals(1, f.wifiWrites); assertEquals(0, f.bleWrites)
assertEquals("ble", transport)
assertTrue(router.call("status.get").containsKey("ble"))
} finally { router.close() }
assertTrue(f.stopped)
}
@Test fun readRetriesOnBleAndNetworkChangeDoesNotCloseBle() = runBlocking {
val f = RpcFixture(); val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var transport = "ble"; var lost = false
val router = HybridRpc(ble, f.ble, f, this, "test", { t, _ -> transport = t }, { lost = true })
try {
awaitTrue { transport == "wifi" }
f.failRead = true
assertTrue(router.call("status.get").containsKey("ble"))
assertEquals(1, f.wifiReads)
f.state.value = WifiNetworkState(2, false)
delay(100)
assertFalse(lost)
f.failRead = false; f.state.value = WifiNetworkState(3, true)
awaitTrue { transport == "wifi" }
delay(2_100)
assertTrue(f.blePings > 0)
f.ble.close()
awaitTrue { lost }
assertTrue(f.stopped)
assertFails { router.call("status.get") }
Unit
} finally { router.close() }
}
@Test fun identityMismatchNeverUpgrades() = runBlocking {
val f = RpcFixture(); f.wrongIdentity = true
val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var transport = "ble"
val router = HybridRpc(ble, f.ble, f, this, "test", { t, _ -> transport = t }, { fail(it) })
try { delay(200); assertEquals("ble", transport); assertTrue(router.call("status.get").containsKey("ble")) }
finally { router.close() }
}
}
@@ -0,0 +1,23 @@
package org.qimiaoscreen.core
import kotlinx.serialization.json.*
import kotlin.test.*
class WifiCryptoTest {
private val vector = Protocol.json.parseToJsonElement(checkNotNull(javaClass.getResourceAsStream("/protocol-wifi-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 cipher() = JavaSecureChannel.wifi(bytes("secret"), vector.getValue("channel_id").jsonPrimitive.content)
@Test fun pythonGoldenRecordsAndReplay() {
val c = cipher()
assertContentEquals(bytes("client_record"), c.encrypt(bytes("request")))
assertContentEquals(bytes("response"), c.decrypt(bytes("server_record")))
assertFails { c.decrypt(bytes("server_record")) }
}
@Test fun tamperAndWrongChannel() {
val record = bytes("server_record"); record[record.lastIndex] = (record.last().toInt() xor 1).toByte()
assertFails { cipher().decrypt(record) }
val c = JavaSecureChannel.wifi(bytes("secret"), "f".repeat(32))
assertFails { c.decrypt(bytes("server_record")) }
}
}
@@ -0,0 +1,8 @@
{
"secret": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"channel_id": "0123456789abcdef0123456789abcdef",
"request": "7b226465766963655f6964223a227075626c69632d746573742d646576696365227d",
"response": "7b226465766963655f6964223a227075626c69632d746573742d646576696365222c227265616479223a747275657d",
"client_record": "0000000000000000d92ca79a81950869034538790ce815813153b6f67fda4c7c925fd2d3e8cca821df751e3a9a8ea0c9365dba2533f004114c73",
"server_record": "0000000000000000d38868d91e24a0ce8cdd64ec485ab485312aac9f2c2342131544eb3c1a4c3b2827a823d361f12492a3d5f806004ddf6e097b37d0673ddbf203d07ccbb53981"
}