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
@@ -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)
@@ -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(" · "))
@@ -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" />
@@ -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)
@@ -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()
}
}
@@ -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() }
}