Add authenticated WiFi transport with BLE fallback
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "org.qimiaoscreen.controller"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 5
|
||||
versionName = "0.2.2"
|
||||
versionCode = 6
|
||||
versionName = "0.3.0"
|
||||
buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"")
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -26,6 +26,7 @@ android {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation(project(":sharedCore"))
|
||||
implementation(project(":sharedUi"))
|
||||
implementation(libs.activity.compose)
|
||||
|
||||
+4
@@ -108,6 +108,10 @@ class LayoutTest {
|
||||
try {
|
||||
stage("initial-connect")
|
||||
if (visible(By.res("connected_scan_disconnect"), 35_000, scroll = true) == null) connectScanned()
|
||||
if (InstrumentationRegistry.getArguments().getString("scenario") == "wifi-connection-entry") {
|
||||
checkNotNull(visible(By.textContains("连接状态:WiFi"), 25_000, scroll = true)) { "WiFi transport indicator unavailable" }
|
||||
stage("WiFi connection indicator verified")
|
||||
}
|
||||
val summary = tagged("connection_summary")
|
||||
assertTrue(summary.text.startsWith("当前连接设备:"))
|
||||
assertTrue(summary.text.contains(" · "))
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package org.qimiaoscreen.controller
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.*
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.*
|
||||
import org.junit.runner.RunWith
|
||||
import org.qimiaoscreen.core.*
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
/** Real registered BLE + WiFi path; no screen content writes or camera use. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class WifiTransportTest {
|
||||
@Test fun wifiUpgradeAndRecovery() = runBlocking {
|
||||
val i = InstrumentationRegistry.getInstrumentation()
|
||||
val args = InstrumentationRegistry.getArguments()
|
||||
val scenario = args.getString("scenario")!!
|
||||
val context = i.targetContext
|
||||
val ui = UiDevice.getInstance(i)
|
||||
fun stage(s: String) = i.sendStatus(2, Bundle().apply { putString("stream", "WiFi transport: $s\n") })
|
||||
fun root(command: String) = ui.executeShellCommand("sh /data/local/tmp/qms-authorized-root.sh $command").trim()
|
||||
val ble = AndroidBle(context)
|
||||
val expected = args.getString("device_name")!!
|
||||
val found = CompletableDeferred<DiscoveredDevice>()
|
||||
var router: HybridRpc? = null
|
||||
var rpc: DeviceRpc? = null
|
||||
var lan: WifiRpc? = null
|
||||
val wifi = AndroidWifi(context)
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
val transport = kotlinx.coroutines.flow.MutableStateFlow("ble")
|
||||
val lost = CompletableDeferred<Unit>()
|
||||
var wifiChanged = false
|
||||
var bluetoothChanged = false
|
||||
try {
|
||||
ble.scan({ if (it.name == expected) found.complete(it) }, { found.completeExceptionally(IllegalStateException(it)) })
|
||||
val device = withTimeout(20_000) { found.await() }; ble.stopScan()
|
||||
val link = ble.connect(device.handle)
|
||||
rpc = DeviceRpc(link, JavaSecureChannel())
|
||||
val identity = rpc.open("android-test-a")
|
||||
assertTrue(identity.getValue("capabilities").jsonArray.any { it.jsonPrimitive.content == "wifi_transport" })
|
||||
stage("BLE ready")
|
||||
wifi.start()
|
||||
withTimeout(15_000) { wifi.state.first { it.available } }
|
||||
// Same uncached image request on both transports, with byte equality.
|
||||
val item = rpc.call("library.list").getValue("items").jsonArray.first().jsonObject
|
||||
val params = buildJsonObject { for (key in listOf("type", "id", "revision")) put(key, item.getValue(key)) }
|
||||
var bleImage = ""
|
||||
val bleMs = measureTimeMillis { bleImage = rpc.call("library.thumbnail", params).getValue("data_base64").jsonPrimitive.content }
|
||||
val offer = rpc.call("transport.offer")
|
||||
lan = WifiRpc(wifi.connect(offer), wifi.crypto(offer)); lan.open(offer)
|
||||
var wifiImage = ""
|
||||
val wifiMs = measureTimeMillis { wifiImage = lan.call("library.thumbnail", params).getValue("data_base64").jsonPrimitive.content }
|
||||
assertEquals(bleImage, wifiImage)
|
||||
val status = lan.call("status.get")
|
||||
assertEquals("wifi", status.getValue("mobile").jsonObject.getValue("active_transport").jsonPrimitive.content)
|
||||
stage("image_equal=true base64_bytes=${wifiImage.length} ble_ms=$bleMs wifi_ms=$wifiMs")
|
||||
val beforeCounters = status.getValue("mobile").jsonObject
|
||||
val driverBefore = status.getValue("screen").jsonObject.getValue("driver_status").jsonObject
|
||||
repeat(3) {
|
||||
assertEquals(wifiImage, lan.call("library.thumbnail", params).getValue("data_base64").jsonPrimitive.content)
|
||||
lan.call("frame.get")
|
||||
}
|
||||
val afterRead = lan.call("status.get")
|
||||
val afterCounters = afterRead.getValue("mobile").jsonObject
|
||||
val bleDelta = afterCounters.getValue("transport").jsonObject.getValue("rx_bytes").jsonPrimitive.long -
|
||||
beforeCounters.getValue("transport").jsonObject.getValue("rx_bytes").jsonPrimitive.long
|
||||
val wifiDelta = afterCounters.getValue("wifi_counters").jsonObject.getValue("requests").jsonPrimitive.long -
|
||||
beforeCounters.getValue("wifi_counters").jsonObject.getValue("requests").jsonPrimitive.long
|
||||
assertEquals(0L, bleDelta)
|
||||
assertTrue(wifiDelta >= 6)
|
||||
val driverAfter = afterRead.getValue("screen").jsonObject.getValue("driver_status").jsonObject
|
||||
assertTrue(driverAfter["running"]?.jsonPrimitive?.boolean == true)
|
||||
assertTrue(driverAfter["last_error"] == JsonNull || driverAfter["last_error"]?.jsonPrimitive?.contentOrNull.isNullOrEmpty())
|
||||
stage("WiFi image/frame reads=$wifiDelta BLE_rx_delta=$bleDelta driver_hz=${driverAfter["actual_refresh_rate_hz"]} deadline_before=${driverBefore["deadline_misses"]} deadline_after=${driverAfter["deadline_misses"]}")
|
||||
lan.close(); lan = null; rpc.call("transport.close"); wifi.stop()
|
||||
router = HybridRpc(rpc, link, wifi, scope, identity.getValue("device_id").jsonPrimitive.content,
|
||||
{ mode, reason -> transport.value = mode; stage("route=$mode reason=${reason ?: "ready"}") },
|
||||
{ stage("BLE session lost"); lost.complete(Unit) })
|
||||
withTimeout(20_000) { transport.first { it == "wifi" } }
|
||||
stage("automatic upgrade verified")
|
||||
assertTrue(router.call("status.get").containsKey("state"))
|
||||
if (scenario == "wifi-transport-restart") {
|
||||
val oldInstance = router.call("status.get").getValue("service").jsonObject.getValue("instance_id")
|
||||
router.call("session.rename", buildJsonObject { put("client_name", "android-test-restart-ready") })
|
||||
stage("ready for coordinated real service restart")
|
||||
withTimeout(75_000) { lost.await() }
|
||||
stage("service restart closed both transports")
|
||||
delay(4_000)
|
||||
val rediscovered = CompletableDeferred<DiscoveredDevice>()
|
||||
ble.scan({ if (it.name == expected) rediscovered.complete(it) }, { rediscovered.completeExceptionally(IllegalStateException(it)) })
|
||||
val next = withTimeout(25_000) { rediscovered.await() }; ble.stopScan()
|
||||
val newLink = ble.connect(next.handle)
|
||||
rpc = DeviceRpc(newLink, JavaSecureChannel())
|
||||
val fresh = rpc.open("android-test-a")
|
||||
assertEquals(identity.getValue("device_id"), fresh.getValue("device_id"))
|
||||
transport.value = "ble"
|
||||
router = HybridRpc(rpc, newLink, wifi, scope, fresh.getValue("device_id").jsonPrimitive.content,
|
||||
{ mode, _ -> transport.value = mode }, { error("New session unexpectedly lost") })
|
||||
withTimeout(25_000) { transport.first { it == "wifi" } }
|
||||
assertNotEquals(oldInstance, router.call("status.get").getValue("service").jsonObject.getValue("instance_id"))
|
||||
stage("new BLE handshake and WiFi upgrade verified after real service restart")
|
||||
}
|
||||
if (scenario == "wifi-transport-network") {
|
||||
val before = router.call("wifi.get")
|
||||
val saved = before.getValue("saved").jsonObject
|
||||
val active = before.getValue("active").jsonObject
|
||||
check(active["connected"]?.jsonPrimitive?.boolean == true && active["ssid"] == saved["ssid"])
|
||||
val security = saved.getValue("security").jsonPrimitive.content
|
||||
check(security == "open" || saved["password_configured"]?.jsonPrimitive?.boolean == true)
|
||||
val change = buildJsonObject {
|
||||
for (key in listOf("ssid", "security", "ipv4_mode", "address", "prefix", "gateway", "dns_servers")) saved[key]?.let { put(key, it) }
|
||||
put("expected_revision", before.getValue("revision"))
|
||||
put("password_action", if (security == "open") "none" else "keep"); put("activation", "immediate")
|
||||
}
|
||||
val task = router.call("wifi.set", change).getValue("task_id")
|
||||
withTimeout(45_000) {
|
||||
while (true) {
|
||||
val state = router.call("task.get", buildJsonObject { put("task_id", task) }).getValue("state").jsonPrimitive.content
|
||||
check(state != "failed")
|
||||
if (state == "succeeded") break
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
assertFalse(lost.isCompleted)
|
||||
val after = router.call("wifi.get")
|
||||
assertEquals(saved, after.getValue("saved"))
|
||||
assertEquals(active["ssid"], after.getValue("active").jsonObject["ssid"])
|
||||
withTimeout(45_000) {
|
||||
while (true) {
|
||||
val current = router.call("status.get").getValue("mobile").jsonObject
|
||||
if (current["active_transport"]?.jsonPrimitive?.content == "wifi") break
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
stage("board current-network reactivation and WiFi recovery verified; settings retained")
|
||||
}
|
||||
if (scenario == "wifi-transport-recovery") {
|
||||
wifiChanged = true
|
||||
root("svc wifi disable")
|
||||
withTimeout(15_000) { transport.first { it == "ble" } }
|
||||
assertFalse(lost.isCompleted)
|
||||
assertTrue(router.call("status.get").containsKey("state"))
|
||||
stage("WiFi off -> BLE fallback verified")
|
||||
delay(3_000) // Let the platform complete radio shutdown before enabling again.
|
||||
root("svc wifi enable")
|
||||
withTimeout(30_000) { wifi.state.first { it.available } }
|
||||
stage("phone WiFi network available after restore")
|
||||
withTimeout(45_000) { transport.first { it == "wifi" } }
|
||||
stage("WiFi restored -> automatic upgrade verified")
|
||||
bluetoothChanged = true
|
||||
root("cmd bluetooth_manager disable")
|
||||
withTimeout(12_000) { lost.await() }
|
||||
assertTrue(runCatching { router.call("status.get") }.isFailure)
|
||||
stage("BLE off -> whole session closed verified")
|
||||
root("cmd bluetooth_manager enable")
|
||||
}
|
||||
stage("passed")
|
||||
} finally {
|
||||
router?.close(); lan?.close(); rpc?.close(); wifi.stop(); ble.stopScan(); scope.cancel()
|
||||
if (wifiChanged) root("svc wifi enable")
|
||||
if (bluetoothChanged) root("cmd bluetooth_manager enable")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<application android:label="奇妙小屏幕" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false" android:supportsRtl="true">
|
||||
<application android:label="奇妙小屏幕" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false" android:supportsRtl="true" android:usesCleartextTraffic="true">
|
||||
<activity android:name=".MainActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
+2
@@ -64,6 +64,7 @@ class AndroidBle(private val context: Context) {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private class GattLink : DeviceLink {
|
||||
override val disconnected = CompletableDeferred<Unit>()
|
||||
@Volatile override var mtu = 23
|
||||
private set
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
@@ -167,6 +168,7 @@ private class GattLink : DeviceLink {
|
||||
} catch (_: TimeoutCancellationException) { null } finally { signalPending = null }
|
||||
}
|
||||
override fun close() {
|
||||
disconnected.complete(Unit)
|
||||
if (closed) return
|
||||
closed = true
|
||||
handler.removeCallbacks(discoveryFallback)
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package org.qimiaoscreen.controller
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.*
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.serialization.json.*
|
||||
import okhttp3.*
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.qimiaoscreen.core.*
|
||||
import org.qimiaoscreen.core.Protocol
|
||||
import java.net.Proxy
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AndroidWifi(private val context: Context) : WifiPlatform {
|
||||
private val manager = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val mutable = MutableStateFlow(WifiNetworkState())
|
||||
override val state: StateFlow<WifiNetworkState> = mutable
|
||||
private var callback: ConnectivityManager.NetworkCallback? = null
|
||||
private var network: Network? = null
|
||||
private var link: LanLink? = null
|
||||
private var addresses = ""
|
||||
fun permitted() = Build.VERSION.SDK_INT < 37 || context.checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK") == PackageManager.PERMISSION_GRANTED
|
||||
@Synchronized fun refresh() {
|
||||
val available = permitted() && network != null
|
||||
if (!available) link?.close()
|
||||
mutable.value = WifiNetworkState(mutable.value.generation + 1, available,
|
||||
if (!permitted()) "未允许局域网访问,仍可使用蓝牙" else if (network == null) "手机未连接 WiFi,正在使用蓝牙" else null)
|
||||
}
|
||||
@Synchronized override fun start() {
|
||||
if (callback != null) return
|
||||
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onCapabilitiesChanged(n: Network, caps: NetworkCapabilities) {
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && !caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||
synchronized(this@AndroidWifi) {
|
||||
if (callback !== this) return
|
||||
if (network != n) { link?.close(); network = n; addresses = ""; refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onLinkPropertiesChanged(n: Network, properties: LinkProperties) {
|
||||
synchronized(this@AndroidWifi) {
|
||||
if (callback !== this) return
|
||||
if (network == n) {
|
||||
val current = properties.linkAddresses.toString() + properties.routes.toString()
|
||||
if (current != addresses) { link?.close(); addresses = current; refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onLost(n: Network) {
|
||||
synchronized(this@AndroidWifi) { if (callback === this && network == n) { network = null; refresh() } }
|
||||
}
|
||||
}
|
||||
callback = cb
|
||||
try {
|
||||
manager.registerNetworkCallback(NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build(), cb)
|
||||
refresh()
|
||||
} catch (_: Exception) { callback = null; mutable.value = WifiNetworkState(reason = "无法监听 WiFi,正在使用蓝牙") }
|
||||
}
|
||||
@Synchronized override fun stop() {
|
||||
callback?.let { runCatching { manager.unregisterNetworkCallback(it) } }; callback = null
|
||||
link?.close(); link = null; network = null; addresses = ""
|
||||
mutable.value = WifiNetworkState(mutable.value.generation + 1)
|
||||
}
|
||||
override fun crypto(offer: JsonObject) = JavaSecureChannel.wifi(
|
||||
Base64.getDecoder().decode(offer.getValue("secret").jsonPrimitive.content), offer.getValue("channel_id").jsonPrimitive.content)
|
||||
override suspend fun connect(offer: JsonObject): DeviceLink {
|
||||
check(permitted())
|
||||
val selected = synchronized(this) { checkNotNull(network) }
|
||||
val host = offer.getValue("host").jsonPrimitive.content
|
||||
require(host.split('.').let { it.size == 4 && it.all { part -> part.toIntOrNull() in 0..255 } })
|
||||
require(offer.getValue("port").jsonPrimitive.int == 8080 && offer.getValue("path").jsonPrimitive.content == "/ws/mobile")
|
||||
val client = OkHttpClient.Builder().socketFactory(selected.socketFactory).proxy(Proxy.NO_PROXY)
|
||||
.connectTimeout(5, TimeUnit.SECONDS).readTimeout(0, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(false).build()
|
||||
val candidate = LanLink(client)
|
||||
try {
|
||||
synchronized(this) { check(network == selected); link?.close(); link = candidate }
|
||||
candidate.socket = client.newWebSocket(Request.Builder().url("ws://$host:8080/ws/mobile").build(), candidate.listener)
|
||||
withTimeout(5_000) { candidate.ready.await() }
|
||||
return candidate
|
||||
} catch (e: Exception) { candidate.close(); throw e }
|
||||
}
|
||||
}
|
||||
|
||||
private class LanLink(private val client: OkHttpClient) : DeviceLink {
|
||||
override val mtu = 65536
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
override val disconnected = CompletableDeferred<Unit>()
|
||||
private val incoming = Channel<ByteArray>(4)
|
||||
@Volatile var socket: WebSocket? = null
|
||||
@Volatile private var closed = false
|
||||
val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) { if (closed) webSocket.cancel() else ready.complete(Unit) }
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
if (bytes.size > Protocol.MAX_MESSAGE || !incoming.trySend(bytes.toByteArray()).isSuccess) close()
|
||||
}
|
||||
override fun onMessage(webSocket: WebSocket, text: String) { close() }
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { close() }
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { close() }
|
||||
}
|
||||
override suspend fun send(fragment: ByteArray) {
|
||||
check(!closed && fragment.size <= Protocol.MAX_MESSAGE + 32)
|
||||
check(socket?.send(fragment.toByteString()) == true)
|
||||
}
|
||||
override suspend fun receive() = incoming.receive()
|
||||
@Synchronized override fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
ready.completeExceptionally(DeviceTransportFailure("WiFi 连接不可用"))
|
||||
incoming.close(DeviceTransportFailure("WiFi 连接已中断"))
|
||||
disconnected.complete(Unit); socket?.cancel()
|
||||
client.connectionPool.evictAll(); client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -23,6 +23,15 @@ import org.qimiaoscreen.ui.AppInfo
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private lateinit var controller: Controller
|
||||
private lateinit var wifi: AndroidWifi
|
||||
private val requestLanPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { wifi.refresh() }
|
||||
private fun requestLan(manual: Boolean = true) {
|
||||
if (Build.VERSION.SDK_INT >= 37 && !wifi.permitted()) {
|
||||
if (manual && !shouldShowRequestPermissionRationale("android.permission.ACCESS_LOCAL_NETWORK")) {
|
||||
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
|
||||
} else requestLanPermission.launch("android.permission.ACCESS_LOCAL_NETWORK")
|
||||
} else wifi.refresh()
|
||||
}
|
||||
private val permissions get() = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
private fun permitted() = permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||
private val requestPermission: ActivityResultLauncher<Array<String>> = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
@@ -42,8 +51,11 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val ble = AndroidBle(applicationContext)
|
||||
wifi = AndroidWifi(applicationContext)
|
||||
val preferences = getSharedPreferences("controller", MODE_PRIVATE)
|
||||
val platform = object : MobilePlatform {
|
||||
override fun wifi(): WifiPlatform = wifi
|
||||
override fun requestWifiPermission() { requestLan() }
|
||||
override val model = Build.MODEL
|
||||
override val libraryCache: LibraryCache = AndroidLibraryCache(applicationContext)
|
||||
override fun load(key: String) = preferences.getString(key, null)
|
||||
@@ -54,13 +66,17 @@ class MainActivity : ComponentActivity() {
|
||||
override fun crypto(): SecureChannel = JavaSecureChannel()
|
||||
}
|
||||
controller = Controller(platform, scope)
|
||||
if (Build.VERSION.SDK_INT >= 37 && !wifi.permitted() && !preferences.getBoolean("lan_permission_requested", false)) {
|
||||
preferences.edit().putBoolean("lan_permission_requested", true).apply()
|
||||
requestLan(manual = false)
|
||||
}
|
||||
setContent { QimiaoApp(controller, onScan = {
|
||||
if (permitted()) controller.scan() else requestPermission.launch(permissions)
|
||||
}, appInfo = AppInfo(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toString(), BuildConfig.BUILD_DATE), decode = { encoded ->
|
||||
runCatching { val bytes = Base64.decode(encoded, Base64.DEFAULT); BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() }.getOrNull()
|
||||
}) }
|
||||
}
|
||||
override fun onStart() { super.onStart(); if (::controller.isInitialized && permitted()) controller.foreground(true) }
|
||||
override fun onStart() { super.onStart(); if (::wifi.isInitialized) wifi.refresh(); if (::controller.isInitialized && permitted()) controller.foreground(true) }
|
||||
override fun onStop() { if (::controller.isInitialized) controller.foreground(false); super.onStop() }
|
||||
override fun onDestroy() { if (::controller.isInitialized) controller.disconnect(); scope.cancel(); super.onDestroy() }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+20
-3
@@ -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()
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+140
@@ -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()
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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
|
||||
}
|
||||
|
||||
+136
@@ -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() }
|
||||
}
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+12
-3
@@ -93,20 +93,29 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Unit) {
|
||||
var selected by remember { mutableStateOf<RememberedDevice?>(null) }
|
||||
var renaming by remember { mutableStateOf(false) }
|
||||
var name by remember { mutableStateOf("") }
|
||||
LazyColumn(Modifier.fillMaxWidth().testTag("connection_list"), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (state.identity != null && state.transportReason != null) item {
|
||||
Text(state.transportReason.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||
if (state.transportReason.orEmpty().contains("局域网")) TextButton(c::requestWifiPermission) { Text("允许局域网访问") }
|
||||
}
|
||||
item { Text("已保存的设备", style = MaterialTheme.typography.titleMedium) }
|
||||
if (state.remembered.isEmpty()) item { Text("暂无已保存设备,扫描后选择即可连接") }
|
||||
items(state.remembered, key = { it.id }) { device ->
|
||||
Card(Modifier.fillMaxWidth().combinedClickable(onClick = { c.connectRemembered(device) }, onLongClick = { selected = device; renaming = false })) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) { Text(device.name); Text("代号 ${device.shortId}", style = MaterialTheme.typography.bodySmall) }
|
||||
Text(if (state.identity?.text("device_id") == device.id) "已连接" else "点击连接")
|
||||
if (state.identity?.text("device_id") == device.id) {
|
||||
FlowRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("连接状态:${if (state.transport == "wifi") "WiFi" else "蓝牙"}")
|
||||
Text("已连接")
|
||||
}
|
||||
} else Text("点击连接")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +129,7 @@ private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Un
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(if (connected) state.identity!!.text("device_name") else device.name)
|
||||
Text(if (connected) "已连接 · 蓝牙信号:${state.signal?.let { "$it dBm" } ?: "暂不可用"}"
|
||||
Text(if (connected) "连接状态:${if (state.transport == "wifi") "WiFi" else "蓝牙"} · 已连接 · 蓝牙信号:${state.signal?.let { "$it dBm" } ?: "暂不可用"}"
|
||||
else "扫描信号 ${device.signal} dBm${if (c.rememberedFor(device) != null) " · 已记忆" else ""}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Button({ if (connected) c.disconnect() else c.connect(device, c.rememberedFor(device)?.id) },
|
||||
|
||||
+36
-1
@@ -22,6 +22,9 @@ class FixtureActivity : ComponentActivity() {
|
||||
override fun onDestroy() { controller.disconnect(); scope.cancel(); super.onDestroy() }
|
||||
}
|
||||
class FixturePlatform : MobilePlatform {
|
||||
var wifiEnabled = false
|
||||
val fixtureWifi = FixtureWifi()
|
||||
override fun wifi(): WifiPlatform? = if (wifiEnabled) fixtureWifi else null
|
||||
override val model = "模拟测试手机"
|
||||
private val saved = mutableMapOf<String, String>()
|
||||
val calls = mutableListOf<String>()
|
||||
@@ -56,7 +59,9 @@ private class FixtureLink(val platform: FixturePlatform) : DeviceLink {
|
||||
val method = request.getValue("method").jsonPrimitive.content
|
||||
platform.calls += method
|
||||
val result = when (method) {
|
||||
"session.open" -> """{"device_id":"00000000-0000-4000-8000-000000000001","device_name":"模拟小屏幕","short_id":"00000000","protocol_major":1,"capabilities":["status","frame","library","library_progressive","settings","wifi","wifi_scan"]}"""
|
||||
"session.open" -> """{"device_id":"00000000-0000-4000-8000-000000000001","device_name":"模拟小屏幕","short_id":"00000000","protocol_major":1,"capabilities":["status","frame","library","library_progressive","settings","wifi","wifi_scan"${if (platform.wifiEnabled) ",\"wifi_transport\"" else ""}]}"""
|
||||
"transport.offer" -> """{"device_id":"00000000-0000-4000-8000-000000000001","channel_id":"00000000000000000000000000000000"}"""
|
||||
"transport.close" -> """{"closed":true}"""
|
||||
"session.ping" -> """{"alive":true}"""
|
||||
"status.get" -> """{"state":{"animation_playback":{"active":false}}}"""
|
||||
"frame.get" -> """{"frame_revision":"fixture","data_base64":"fixture-image"}"""
|
||||
@@ -77,3 +82,33 @@ private class FixtureLink(val platform: FixturePlatform) : DeviceLink {
|
||||
override suspend fun receive() = messages.receive()
|
||||
override fun close() { messages.close() }
|
||||
}
|
||||
|
||||
class FixtureWifi : WifiPlatform {
|
||||
override val state = kotlinx.coroutines.flow.MutableStateFlow(WifiNetworkState(1, true))
|
||||
override fun start() {}
|
||||
override fun stop() {}
|
||||
override fun crypto(offer: JsonObject) = object : SecureChannel {
|
||||
override val hello = byteArrayOf(1)
|
||||
override fun finish(serverHello: ByteArray) {}
|
||||
override fun encrypt(payload: ByteArray) = payload
|
||||
override fun decrypt(record: ByteArray) = record
|
||||
}
|
||||
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("""{"device_id":"00000000-0000-4000-8000-000000000001","ready":true}""".encodeToByteArray())
|
||||
} else {
|
||||
val request = Protocol.json.parseToJsonElement(fragment.decodeToString()).jsonObject
|
||||
replies.send(buildJsonObject { put("id", request.getValue("id")); put("ok", true)
|
||||
put("result", buildJsonObject { put("state", buildJsonObject {}); put("alive", true) })
|
||||
}.toString().encodeToByteArray())
|
||||
}
|
||||
}
|
||||
override suspend fun receive() = replies.receive()
|
||||
override fun close() { replies.close() }
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -7,6 +7,19 @@ import org.junit.Test
|
||||
import org.junit.Assert.*
|
||||
|
||||
class IsolatedUiTest {
|
||||
@Test fun wifiTransport() {
|
||||
ui.runOnIdle { ui.activity.platform.wifiEnabled = true }
|
||||
connect()
|
||||
ui.waitUntil(5_000) { ui.activity.controller.state.value.transport == "wifi" }
|
||||
ui.onNodeWithText("连接状态:WiFi", substring = false).assertExists()
|
||||
ui.onNodeWithText("连接状态:WiFi · 已连接", substring = true).assertExists()
|
||||
ui.runOnIdle { ui.activity.platform.fixtureWifi.state.value = org.qimiaoscreen.core.WifiNetworkState(2, false, "未允许局域网访问,仍可使用蓝牙") }
|
||||
ui.waitUntil(5_000) { ui.activity.controller.state.value.transport == "ble" }
|
||||
ui.onNodeWithText("允许局域网访问").performScrollTo().assertExists()
|
||||
ui.onNodeWithText("连接状态:蓝牙", substring = false).performScrollTo().assertExists()
|
||||
ui.runOnIdle { ui.activity.controller.disconnect() }
|
||||
ui.onNodeWithText("连接状态:蓝牙", substring = false).assertDoesNotExist()
|
||||
}
|
||||
@get:Rule val ui = createAndroidComposeRule<FixtureActivity>()
|
||||
private fun click(text: String) { ui.onNodeWithText(text).performClick() }
|
||||
private fun connect() {
|
||||
@@ -24,6 +37,7 @@ class IsolatedUiTest {
|
||||
ui.onNodeWithTag("controller_error").assertDoesNotExist()
|
||||
ui.runOnIdle { ui.activity.platform.scanError = false }
|
||||
connect()
|
||||
ui.onNodeWithText("连接状态:蓝牙", substring = false).assertExists()
|
||||
ui.onNodeWithTag("connected_scan_disconnect").performScrollTo().assertIsEnabled().performClick()
|
||||
ui.waitUntil(5_000) { ui.activity.controller.state.value.identity == null }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user