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

This commit is contained in:
2026-09-26 19:21:12 +08:00
parent 912ba432cf
commit 98eadc5c8b
137 changed files with 9274 additions and 85 deletions
@@ -0,0 +1,9 @@
# KMP 工程位置
这里是唯一 Android/共享 KMP 工程根目录。模块为 androidApp、sharedCore、sharedUi,详细职责见相邻 `../安卓开发详细设计.md`。
当前工程骨架已构建 debug APK,界面仍为占位,不能作为控制器交付。Gradle Wrapper 9.5.0 固定发行摘要,版本目录锁定 Kotlin 2.4.20、Compose 1.12.1、AGP 9.3.1。实际构建使用 Android Studio 自带 JBR 25.0.3、Android SDK 37。Android 最低 API 26。
Windows 中文源码路径下编译可继续,但 JVM 测试出现 ClassNotFoundException;同一源码在 ASCII 隔离目录通过。使用 `../构建与发布/build_android.py` 构建,参数与说明见该目录 README。构建产物与密钥不提交。
`sharedCore/src/jvmTest/resources/protocol-v1.json` 是 Python/JVM 共用公开测试向量;固定标量 1、2 仅用于测试,生产会话必须每次生成新的临时密钥。
@@ -0,0 +1,37 @@
import java.time.ZonedDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "org.qimiaoscreen.controller"
compileSdk = 37
defaultConfig {
applicationId = "org.qimiaoscreen.controller"
minSdk = 26
targetSdk = 37
versionCode = 3
versionName = "0.2.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"
}
buildFeatures { compose = true; buildConfig = true }
buildTypes { getByName("debug") { isDebuggable = true } }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(project(":sharedCore"))
implementation(project(":sharedUi"))
implementation(libs.activity.compose)
implementation(libs.coroutines.android)
implementation(libs.serialization.json)
androidTestImplementation("androidx.test:runner:1.7.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
}
@@ -0,0 +1,122 @@
package org.qimiaoscreen.controller
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Semantic UI checks only: no screenshot, camera or display-content changes. */
@RunWith(AndroidJUnit4::class)
class AppLifecycleTest {
@Test fun actualActivityReturnsFromBackground() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val ui = UiDevice.getInstance(instrumentation)
val rootInput = InstrumentationRegistry.getArguments().getString("root_input") == "true"
fun rootCommand(args: String) {
val result = ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $args")
check(result.contains("QMS_INPUT_OK")) { "Authorized root input did not complete" }
}
fun click(node: UiObject2) {
check(rootInput) { "All test clicks require authorized root input" }
check(node.applicationPackage == "org.qimiaoscreen.controller") { "Input target outside App" }
val point = node.visibleCenter
rootCommand("tap ${point.x} ${point.y}")
}
ui.wakeUp()
fun launch() {
// MIUI rejects background Activity starts made by the test's app UID.
// Start the explicit authorized app through the normal shell test API.
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
check(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000)) {
"App did not become foreground"
}
}
launch()
fun scanAndConnect() {
click(checkNotNull(ui.wait(Until.findObject(By.text("扫描设备")), 10_000)))
val name = InstrumentationRegistry.getArguments().getString("device_name")!!
val device = checkNotNull(ui.wait(Until.findObject(By.text(name)), 20_000))
click(checkNotNull(device.parent.parent.findObject(By.text("连接"))))
assertTrue(ui.wait(Until.hasObject(By.text("断开连接")), 30_000))
}
if (!ui.wait(Until.hasObject(By.text("断开连接")), 30_000)) {
scanAndConnect()
}
if (InstrumentationRegistry.getArguments().getString("toggle_bluetooth") == "true") {
check(rootInput)
try {
rootCommand("bluetooth-disable")
assertTrue("Connection state must clear after Bluetooth is disabled",
ui.wait(Until.hasObject(By.text("扫描设备")), 15_000))
} finally { rootCommand("bluetooth-enable") }
Thread.sleep(3_000)
scanAndConnect()
}
click(ui.findObject(By.text("设备状态")))
assertTrue(ui.wait(Until.hasObject(By.text("设备画面")), 15_000))
check(!ui.hasObject(By.text("运行状态")))
click(ui.findObject(By.text("显示内容")))
assertTrue(ui.wait(Until.hasObject(By.text("可播放内容")), 15_000))
click(ui.findObject(By.text("设备设置")))
assertTrue(ui.wait(Until.hasObject(By.text("屏幕方向")), 15_000))
check(!ui.hasObject(By.text("性能模式")))
click(ui.findObject(By.text("连接")))
click(ui.findObject(By.text("⋮")))
click(checkNotNull(ui.wait(Until.findObject(By.text("关于")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.textContains("版本 0.2.0")), 5_000))
click(ui.findObject(By.text("关闭")))
click(ui.findObject(By.text("⋮")))
click(checkNotNull(ui.wait(Until.findObject(By.text("软件设置")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("当前手机昵称")), 5_000))
click(ui.findObject(By.text("取消")))
check(rootInput); rootCommand("home")
Thread.sleep(33_000)
launch()
assertTrue("Activity should reconnect after background release",
ui.wait(Until.hasObject(By.text("断开连接")), 35_000))
fun openRename() {
val marker = checkNotNull(ui.wait(Until.findObject(By.text("已连接")), 5_000))
val point = marker.visibleCenter
rootCommand("swipe ${point.x} ${point.y} ${point.x} ${point.y} 700")
click(checkNotNull(ui.wait(Until.findObject(By.text("重命名设备")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("设备昵称")), 5_000))
}
fun rename(value: String) {
val field = checkNotNull(ui.wait(Until.findObject(By.clazz("android.widget.EditText")), 5_000))
val clipboard = instrumentation.targetContext.getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", value))
try {
click(field)
rootCommand("clear-field ${(field.text.length + 2).coerceIn(2, 512)}")
rootCommand("paste")
rootCommand("hide-keyboard")
} finally { clipboard.clearPrimaryClip() }
click(checkNotNull(ui.wait(Until.findObject(By.text("保存")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:$value")), 15_000))
}
openRename()
val originalName = ui.findObject(By.clazz("android.widget.EditText")).text
var renamed = false
try {
rename("界面验证屏")
renamed = true
click(ui.findObject(By.text("断开连接")))
scanAndConnect()
assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:界面验证屏")), 5_000))
} finally {
if (renamed) { openRename(); rename(originalName) }
}
val again = ui.findObject(By.text("已连接")).visibleCenter
rootCommand("swipe ${again.x} ${again.y} ${again.x} ${again.y} 700")
click(checkNotNull(ui.wait(Until.findObject(By.text("取消记忆")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("暂无已保存设备,扫描后选择即可连接")), 5_000))
scanAndConnect()
click(ui.findObject(By.text("断开连接")))
assertTrue(ui.wait(Until.hasObject(By.text("扫描设备")), 5_000))
}
}
@@ -0,0 +1,240 @@
package org.qimiaoscreen.controller
import android.Manifest
import android.os.Build
import android.os.Bundle
import android.content.Intent
import android.content.pm.PackageManager
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
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.*
/** Read-only by default; an explicit flag can reapply the already active WiFi profile. */
@RunWith(AndroidJUnit4::class)
class BleIntegrationTest {
@Test fun encryptedReadOnlySessionAndReconnect() = runBlocking {
val instrumentation = InstrumentationRegistry.getInstrumentation()
fun stage(name: String) { instrumentation.sendStatus(2, Bundle().apply { putString("stream", "BLE stage: $name\n") }) }
val context = instrumentation.targetContext
val permissions = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
check(permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) {
"Test permissions missing; stop and grant using authorized root runner"
}
val ble = AndroidBle(context)
val expectedName = checkNotNull(InstrumentationRegistry.getArguments().getString("device_name")) { "Explicit test board required" }
suspend fun discover(): DiscoveredDevice {
val found = CompletableDeferred<DiscoveredDevice>()
try {
ble.scan({ if (it.name == expectedName) found.complete(it) }, { found.completeExceptionally(IllegalStateException(it)) })
return withTimeout(20_000) { found.await() }
} finally { ble.stopScan() }
}
try {
val device = discover()
stage("scan complete")
ble.stopScan()
var identity: String? = null
repeat(2) { iteration ->
// Reconnection uses a fresh scan, as production does. A scan
// handle is transient and must not be retained across sessions.
val target = if (iteration == 0) device else discover()
val link = ble.connect(target.handle)
val traced = object : DeviceLink {
override val mtu get() = link.mtu
override suspend fun send(fragment: ByteArray) {
stage("send kind=${fragment[3]} index=${fragment.readInt(8, 2)} count=${fragment.readInt(10, 2)} bytes=${fragment.size} mtu=$mtu")
link.send(fragment)
}
override suspend fun receive(): ByteArray = link.receive().also {
if (it.readInt(8, 2) == 0L) stage("receive kind=${it[3]} count=${it.readInt(10, 2)} bytes=${it.size}")
}
override fun close() = link.close()
}
val rpc = DeviceRpc(traced, JavaSecureChannel())
stage("GATT ready $iteration")
try {
val hello = rpc.open("android-test-a")
stage("encrypted session open")
val id = hello.getValue("device_id").jsonPrimitive.content
if (identity == null) identity = id else assertEquals(identity, id)
assertEquals(1, hello.getValue("protocol_major").jsonPrimitive.int)
val status = rpc.call("status.get")
stage("status received")
assertTrue(status.containsKey("state"))
val settings = rpc.call("settings.get")
stage("settings received")
assertTrue(settings.getValue("values").jsonObject.containsKey("brightness"))
rpc.call("storage.get")
stage("storage received")
val library = rpc.call("library.list")
stage("library received")
assertTrue(library.getValue("items").jsonArray.isNotEmpty())
val expectedOrder = InstrumentationRegistry.getArguments().getString("library_order_hash")
if (expectedOrder != null) {
val all = library.getValue("items").jsonArray.map { it.jsonObject }.toMutableList()
var cursor = library["next_cursor"]?.jsonPrimitive?.contentOrNull
while (cursor != null) {
val page = rpc.call("library.list", buildJsonObject { put("cursor", cursor); put("limit", 50) })
all += page.getValue("items").jsonArray.map { it.jsonObject }
cursor = page["next_cursor"]?.jsonPrimitive?.contentOrNull
}
val canonical = all.filter { it["is_demo"]?.jsonPrimitive?.booleanOrNull != true }
.joinToString("\n") { it.getValue("type").jsonPrimitive.content + "|" + it.getValue("id").jsonPrimitive.content }
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(canonical.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it.toInt() and 255) }
assertEquals("BLE must preserve device library order", expectedOrder, digest)
stage("library persistent order verified")
}
val frame = rpc.call("frame.get")
stage("frame received")
assertEquals("image/png", frame.getValue("mime").jsonPrimitive.content)
val wifi = rpc.call("wifi.get")
val saved = wifi["saved"]
if (saved != null && saved != JsonNull) assertFalse(saved.jsonObject.containsKey("password"))
assertTrue(rpc.call("session.ping").getValue("alive").jsonPrimitive.boolean)
if (iteration == 0) {
val networks = rpc.call("wifi.scan").getValue("networks").jsonArray
assertTrue(networks.size <= 100)
assertTrue(networks.all { !it.jsonObject.containsKey("password") })
stage("WiFi scan completed without logging network names")
try {
rpc.call("settings.patch", buildJsonObject {
put("expected_revision", "deliberately-stale-test-revision")
put("changes", buildJsonObject { put("brightness", settings.getValue("values").jsonObject.getValue("brightness")) })
})
fail("Stale revision must be rejected")
} catch (e: DeviceFailure) { assertEquals("CONFLICT", e.code) }
assertTrue(rpc.call("session.ping").getValue("alive").jsonPrimitive.boolean)
stage("configuration conflict preserves session")
if (InstrumentationRegistry.getArguments().getString("verify_current_controls") == "true") {
// Exercise accepted writes without changing the selected
// content, defaults, brightness, direction or network.
val currentSettings = rpc.call("settings.get")
val sameBrightness = currentSettings.getValue("values").jsonObject.getValue("brightness")
val patched = rpc.call("settings.patch", buildJsonObject {
put("expected_revision", currentSettings.getValue("revision"))
put("changes", buildJsonObject { put("brightness", sameBrightness) })
})
assertEquals(sameBrightness, patched.getValue("values").jsonObject.getValue("brightness"))
val originalDefault = rpc.call("content.default.get")
val currentState = rpc.call("status.get").getValue("state").jsonObject
val originalPlayback = currentState.getValue("animation_playback").jsonObject
if (originalPlayback["active"]?.jsonPrimitive?.booleanOrNull == true) {
val session = originalPlayback.getValue("session_id")
val originalPaused = originalPlayback.getValue("paused")
try {
val paused = rpc.call("playback.patch", buildJsonObject { put("session_id", session); put("paused", true) })
assertTrue(paused.getValue("animation_playback").jsonObject.getValue("paused").jsonPrimitive.boolean)
val held = paused.getValue("animation_playback").jsonObject
rpc.call("playback.patch", buildJsonObject {
put("session_id", session); put("position_ms", held.getValue("position_ms")); put("speed", originalPlayback.getValue("speed"))
})
} finally {
rpc.call("playback.patch", buildJsonObject { put("session_id", session); put("paused", originalPaused) })
}
stage("animation pause, same-position seek and same-speed write; original pause restored")
} else stage("animation write not applicable: no active animation")
assertEquals(originalDefault, rpc.call("content.default.get"))
stage("accepted settings write and default readback; selected content unchanged")
}
if (InstrumentationRegistry.getArguments().getString("reapply_current_wifi") == "true") {
val before = rpc.call("wifi.get")
val savedNetwork = before.getValue("saved").jsonObject
val activeNetwork = before.getValue("active").jsonObject
check(activeNetwork["connected"]?.jsonPrimitive?.boolean == true &&
activeNetwork["ssid"] == savedNetwork["ssid"]) { "Saved network must already be active" }
val security = savedNetwork.getValue("security").jsonPrimitive.content
check(security == "open" || savedNetwork["password_configured"]?.jsonPrimitive?.boolean == true)
val request = buildJsonObject {
for (key in listOf("ssid", "security", "ipv4_mode", "address", "prefix", "gateway", "dns_servers"))
savedNetwork[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 = rpc.call("wifi.set", request).getValue("task_id")
withTimeout(45_000) {
while (true) {
val result = rpc.call("task.get", buildJsonObject { put("task_id", task) })
val taskState = result.getValue("state").jsonPrimitive.content
check(taskState != "failed") { "Current-network activation failed" }
if (taskState == "succeeded") break
delay(1_000)
}
}
val after = rpc.call("wifi.get")
assertTrue("Current network restored", after.getValue("active").jsonObject["ssid"] == activeNetwork["ssid"])
assertTrue("Network is connected", after.getValue("active").jsonObject.getValue("connected").jsonPrimitive.boolean)
assertTrue("Saved fields retained", listOf("ssid", "security", "ipv4_mode", "address", "prefix", "gateway", "dns_servers").all {
after.getValue("saved").jsonObject[it] == savedNetwork[it]
})
stage("current WiFi reapplied with password retained; BLE remained usable")
}
repeat(InstrumentationRegistry.getArguments().getString("poll_cycles")?.toInt() ?: 60) { cycle ->
delay(2_000)
rpc.call("status.get")
rpc.call("frame.get")
if (cycle % 10 == 0) stage("continuous polling cycle $cycle")
}
}
} finally { stage("intentional session close $iteration"); rpc.close() }
if (iteration == 0) delay(3_000)
}
delay(3_000)
val saved = mutableMapOf<String, String>()
val platform = object : MobilePlatform {
override val model = "android-test-a"
override fun load(key: String) = saved[key]
override fun save(key: String, value: String) { saved[key] = value }
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) = ble.scan(onDevice, onError)
override fun stopScan() = ble.stopScan()
override suspend fun connect(handle: String) = ble.connect(handle)
override fun crypto(): SecureChannel = JavaSecureChannel()
}
val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val controller = Controller(platform, controllerScope)
try {
withContext(Dispatchers.Main) { controller.connect(device) }
try {
withTimeout(30_000) { controller.state.first { it.status != null } }
} catch (e: TimeoutCancellationException) {
error("Controller failed: phase=${controller.state.value.phase}, error=${controller.state.value.error}")
}
stage("controller device page ready")
withContext(Dispatchers.Main) { controller.page(2) }
withTimeout(20_000) { controller.state.first { it.library.isNotEmpty() && !it.busy } }
withContext(Dispatchers.Main) { controller.state.value.library.take(3).forEach(controller::thumbnail) }
delay(10_000)
assertNotNull(controller.state.value.identity)
withContext(Dispatchers.Main) { controller.page(3) }
withTimeout(20_000) { controller.state.first { it.wifi != null && !it.busy } }
stage("controller content and settings ready")
withContext(Dispatchers.Main) { controller.foreground(false) }
delay(5_000)
withContext(Dispatchers.Main) { controller.foreground(true) }
delay(27_000)
assertNotNull("Returning before 30 seconds must cancel release", controller.state.value.identity)
withContext(Dispatchers.Main) { controller.foreground(false) }
withTimeout(35_000) { controller.state.first { it.identity == null } }
stage("controller background released")
delay(3_000)
withContext(Dispatchers.Main) { controller.foreground(true) }
withTimeout(35_000) { controller.state.first { it.identity != null && it.wifi != null } }
stage("controller foreground reconnected")
} finally {
withContext(Dispatchers.Main) { controller.disconnect() }
controllerScope.cancel()
}
} finally { ble.stopScan() }
}
}
@@ -0,0 +1,41 @@
package org.qimiaoscreen.controller
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Read semantic bounds; inject all input through the authorized root helper. */
@RunWith(AndroidJUnit4::class)
class LayoutTest {
@Test fun narrowLargeFontAndLandscape() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val ui = UiDevice.getInstance(instrumentation)
fun root(command: String) = ui.executeShellCommand("sh /data/local/tmp/qms-authorized-root.sh $command").trim()
check(root("id -u") == "0") { "Root unavailable; stop testing" }
fun input(command: String) {
check(ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $command").contains("QMS_INPUT_OK"))
Thread.sleep(250)
}
fun click(label: String) {
check(ui.wait(Until.hasObject(By.text(label)), 15_000)) { "Layout control unavailable: $label" }
val node = checkNotNull(ui.findObjects(By.text(label)).filter { !it.visibleBounds.isEmpty }
.maxByOrNull { it.visibleCenter.y }) { "Visible layout target unavailable: $label" }
check(node.applicationPackage == "org.qimiaoscreen.controller")
val bounds = node.visibleBounds
assertTrue(bounds.left >= 0 && bounds.top >= 0 && bounds.right <= ui.displayWidth && bounds.bottom <= ui.displayHeight)
val point = node.visibleCenter
input("tap ${point.x} ${point.y}")
}
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
assertTrue(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000))
for (label in listOf("连接", "设备状态", "显示内容", "设备设置", "连接")) click(label)
click("⋮"); click("关于"); click("关闭")
}
}
@@ -0,0 +1,252 @@
package org.qimiaoscreen.controller
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import android.graphics.Rect
import android.view.accessibility.AccessibilityNodeInfo
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/** Real App WiFi form. Secrets arrive through a short-lived shell-only file, never test arguments or logs. */
@RunWith(AndroidJUnit4::class)
class WifiSwitchTest {
@Test fun wifiFormStage() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val ui = UiDevice.getInstance(instrumentation)
val args = InstrumentationRegistry.getArguments()
val stage = checkNotNull(args.getString("wifi_stage"))
val rootSwipe = true
val config = Json.parseToJsonElement(
ui.executeShellCommand("cat /data/local/tmp/qms-wifi-test-private.json")
).jsonObject
fun value(key: String) = config.getValue(key).jsonPrimitive.content
fun rootCommand(command: String) {
check(ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $command").contains("QMS_INPUT_OK"))
}
fun click(node: UiObject2) {
var target = node
while ((!target.isClickable || target.visibleBounds.isEmpty) && target.parent != null) target = target.parent
check(target.applicationPackage == "org.qimiaoscreen.controller")
check(!target.visibleBounds.isEmpty)
val center = target.visibleCenter
instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream", "UI tap class=${target.className} bounds=${target.visibleBounds}\n") })
rootCommand("tap ${center.x} ${center.y}")
ui.waitForIdle(1_000)
Thread.sleep(250)
}
fun scroll(down: Boolean): Boolean {
val root = checkNotNull(instrumentation.uiAutomation.rootInActiveWindow)
val pending = ArrayDeque<AccessibilityNodeInfo>()
pending.add(root)
var scroller: AccessibilityNodeInfo? = null
var height = 0
while (pending.isNotEmpty()) {
val node = pending.removeFirst()
val bounds = Rect()
node.getBoundsInScreen(bounds)
if (node.isScrollable && bounds.height() > height && bounds.width() > ui.displayWidth / 2) {
scroller = node
height = bounds.height()
}
for (index in 0 until node.childCount) node.getChild(index)?.let(pending::add)
}
checkNotNull(scroller) { "App settings list has no accessibility scroll action" }
if (rootSwipe) {
val bounds = Rect()
scroller.getBoundsInScreen(bounds)
val x = bounds.centerX()
val upper = bounds.top + bounds.height() / 5
val lower = bounds.bottom - bounds.height() / 5
rootCommand("swipe $x ${if (down) lower else upper} $x ${if (down) upper else lower} 350")
Thread.sleep(300)
return true
}
error("Root scrolling is required")
}
fun find(label: String): UiObject2 {
repeat(14) {
ui.wait(Until.findObject(By.text(label)), 1_000)?.let { return it }
scroll(true)
}
repeat(14) {
ui.wait(Until.findObject(By.text(label)), 1_000)?.let { return it }
scroll(false)
}
error("App control unavailable: $label")
}
fun edit(label: String, text: String) {
repeat(5) {
try {
val labelNode = find(label)
val field = generateSequence(labelNode) { it.parent }.first { it.className == "android.widget.EditText" }
repeat(40) { if (!field.isEnabled) Thread.sleep(100) }
check(field.isEnabled) { "App edit field remained disabled" }
val clipboard = instrumentation.targetContext.getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", text))
try {
click(field)
rootCommand("clear-field ${(field.text.length + 2).coerceIn(2, 512)}")
if (text.isNotEmpty()) rootCommand("paste")
rootCommand("hide-keyboard")
} finally { clipboard.clearPrimaryClip() }
return
} catch (_: androidx.test.uiautomator.StaleObjectException) {
Thread.sleep(300)
}
}
error("App edit field kept changing: $label")
}
fun dhcp(enabled: Boolean) {
val label = find("自动获取IP(DHCP)")
val bounds = label.visibleBounds
val toggle = if (label.isCheckable) label else checkNotNull(ui.findObjects(By.checkable(true))
.filter { kotlin.math.abs(it.visibleCenter.y - bounds.centerY()) < bounds.height().coerceAtLeast(48) }
.maxByOrNull { it.visibleCenter.x }) { "DHCP switch unavailable beside its label" }
if (toggle.isChecked != enabled) click(toggle)
}
fun choose(label: String) {
repeat(6) {
try {
val text = find(label)
val chip = generateSequence(text) { it.parent }.first { it.isClickable }
if (chip.isChecked) return
if (chip.isEnabled) click(chip)
Thread.sleep(500)
val updated = generateSequence(find(label)) { it.parent }.first { it.isClickable }
if (updated.isChecked) return
} catch (_: androidx.test.uiautomator.StaleObjectException) {
Thread.sleep(300)
}
}
error("App choice did not become selected: $label")
}
fun findStatus(): String {
repeat(14) {
ui.findObject(By.textStartsWith("当前WiFi:"))?.let { return it.text }
scroll(false)
}
error("Current WiFi status unavailable")
}
fun assertWifi(saved: String, active: String) {
val label = find("WiFi名称")
val field = generateSequence(label) { it.parent }.first { it.className == "android.widget.EditText" }
assertEquals(saved, field.text)
val current = findStatus()
assertTrue(current.contains(active))
val ip = Regex("\\b(?:[0-9]{1,3}\\.){3}[0-9]{1,3}\\b").find(current)?.value
checkNotNull(ip) { "Current WiFi IP unavailable" }
File(instrumentation.targetContext.filesDir, "qms-wifi-test-ip.txt").writeText(ip, Charsets.UTF_8)
}
ui.wakeUp()
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
check(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000))
if (!ui.wait(Until.hasObject(By.text("断开连接")), 25_000)) {
click(find("扫描设备"))
val board = checkNotNull(ui.wait(Until.findObject(By.text(value("board_name"))), 20_000))
click(checkNotNull(board.parent.parent.findObject(By.text("连接"))))
check(ui.wait(Until.hasObject(By.text("断开连接")), 30_000))
}
click(find("设备设置"))
check(ui.wait(Until.hasObject(By.text("屏幕方向")), 15_000))
ui.waitForIdle(1_000)
click(find("WiFi设置 展开"))
check(ui.wait(Until.hasObject(By.text("WiFi设置 收起")), 5_000))
when (stage) {
"validate" -> {
edit("WiFi名称", "")
val field = generateSequence(find("WiFi名称")) { it.parent }.first { it.className == "android.widget.EditText" }
instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream", "UI tap diagnostic: SSID empty=${field.text.isEmpty()}\n") })
val save = find("保存WiFi设置")
assertTrue("Empty SSID must disable save", generateSequence(save) { it.parent }.any { !it.isEnabled })
edit("WiFi名称", value("original_ssid"))
assertWifi(value("original_ssid"), value("original_ssid"))
}
"validate_invalid" -> {
edit("WiFi名称", value("original_ssid"))
dhcp(false)
edit("静态IP地址", "999.999.999.999")
edit("前缀长度(例如24)", "24")
edit("网关", value("original_gateway"))
click(find("保存WiFi设置"))
check(ui.wait(Until.hasObject(By.text("立即生效WiFi设置")), 5_000))
click(find("确定"))
check(ui.wait(Until.hasObject(By.textContains("参数无效")), 12_000)) { "Invalid IPv4 was not rejected" }
click(find("知道了"))
}
"scan" -> {
click(find("扫描WiFi"))
Thread.sleep(12_000)
check(!ui.hasObject(By.text("知道了"))) { "App WiFi scan reported an error" }
}
"wrong", "dhcp", "manual", "dhcp_return", "restore" -> {
val target = if (stage == "restore") value("original_ssid") else value("hotspot_ssid")
edit("WiFi名称", target)
if (stage == "wrong" || stage == "dhcp" || stage == "restore") {
val password = if (stage == "wrong") value("wrong_password") else if (stage == "restore") value("original_password") else value("hotspot_password")
edit("WiFi密码", password)
val show = find("显示输入的密码")
val bounds = show.visibleBounds
val checkbox = if (show.isCheckable) show else checkNotNull(ui.findObjects(By.checkable(true))
.filter { kotlin.math.abs(it.visibleCenter.y - bounds.centerY()) < bounds.height().coerceAtLeast(48) }
.minByOrNull { it.visibleCenter.x })
click(checkbox)
val label = find("WiFi密码")
val field = generateSequence(label) { it.parent }.first { it.className == "android.widget.EditText" }
assertEquals(password, field.text)
}
dhcp(!(stage == "manual" || stage == "restore" && value("original_mode") == "manual"))
if (stage == "manual" || stage == "restore" && value("original_mode") == "manual") {
val prefix = if (stage == "manual") "24" else value("original_prefix")
val address = if (stage == "manual") value("static_address") else value("original_address")
val gateway = if (stage == "manual") value("hotspot_gateway") else value("original_gateway")
val dns = if (stage == "manual") value("hotspot_gateway") else value("original_dns")
edit("静态IP地址", address)
edit("前缀长度(例如24)", prefix)
edit("网关", gateway)
edit("DNS(多个用逗号分隔)", dns)
}
click(find("保存WiFi设置"))
check(ui.wait(Until.hasObject(By.text("立即生效WiFi设置")), 5_000))
click(find("确定"))
if (stage == "wrong") {
Thread.sleep(45_000)
check(listOf("密码错误", "网络连接失败", "WiFi不存在", "无法获取IP").any { ui.hasObject(By.textContains(it)) }) {
"Wrong-password attempt must show a WiFi failure"
}
instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream",
if (ui.hasObject(By.textContains("密码错误"))) "AUTH_FAILED_CONFIRMED\n" else "WIFI_FAILURE_UNCLASSIFIED\n") })
} else {
Thread.sleep(5_000)
}
assertFalse(ui.hasObject(By.text(value("hotspot_password"))))
assertFalse(ui.hasObject(By.text(value("original_password"))))
assertFalse(ui.hasObject(By.text(value("wrong_password"))))
}
"verify" -> assertWifi(value("original_ssid"), value("original_ssid"))
"inspect_failure" -> {
val current = findStatus()
check(current.contains("失败") || current.contains("密码错误") || current.contains("不存在") || current.contains("无法获取IP")) { "Expected human-readable WiFi failure" }
}
else -> error("Unknown WiFi test stage")
}
// Release the authenticated owner before the next instrumentation process.
ui.findObject(By.text("知道了"))?.let(::click)
click(find("连接"))
val disconnect = ui.wait(Until.findObject(By.text("断开连接")), 3_000)
if (disconnect != null) click(disconnect)
Thread.sleep(2_000)
}
}
@@ -0,0 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,179 @@
package org.qimiaoscreen.controller
import android.annotation.SuppressLint
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.os.ParcelUuid
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import org.qimiaoscreen.core.*
import java.util.UUID
/** Permissions are requested by the Activity before calling this adapter. */
@SuppressLint("MissingPermission")
class AndroidBle(private val context: Context) {
private val adapter get() = context.getSystemService(BluetoothManager::class.java).adapter
private var scanning: ScanCallback? = null
fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) {
stopScan()
val scanner = adapter?.bluetoothLeScanner ?: run { onError("请打开手机蓝牙"); return }
val callback = object : ScanCallback() {
override fun onScanResult(type: Int, result: ScanResult) {
onDevice(DiscoveredDevice(result.device.address, result.scanRecord?.deviceName ?: "奇妙小屏幕", result.rssi))
}
override fun onScanFailed(code: Int) { onError("蓝牙扫描失败($code)") }
}
scanning = callback
scanner.startScan(listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(UUID.fromString(Protocol.SERVICE))).build()),
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), callback)
}
fun stopScan() { scanning?.let { adapter?.bluetoothLeScanner?.stopScan(it) }; scanning = null }
suspend fun connect(handle: String): DeviceLink {
stopScan()
// Retry only GATT establishment, before any application request exists.
// Never switch device or replay an ambiguous business mutation.
repeat(2) { attempt ->
val link = GattLink()
try {
val gatt = adapter.getRemoteDevice(handle).connectGatt(context, false, link.callback, BluetoothDevice.TRANSPORT_LE)
link.attach(gatt)
withTimeout(15_000) { link.ready.await() }
return link
} catch (e: Exception) {
link.close()
if (e is CancellationException && e !is TimeoutCancellationException) throw e
if (attempt == 1) {
if (e is TimeoutCancellationException) throw DeviceTransportFailure("蓝牙连接建立超时,请重新扫描")
throw e
}
delay(1_000)
}
}
error("蓝牙连接失败")
}
}
@SuppressLint("MissingPermission")
private class GattLink : DeviceLink {
@Volatile override var mtu = 23
private set
val ready = CompletableDeferred<Unit>()
private val messages = Channel<ByteArray>(256)
private val writes = Mutex()
@Volatile private var pending: CompletableDeferred<Unit>? = null
@Volatile private var gatt: BluetoothGatt? = null
@Volatile private var rx: BluetoothGattCharacteristic? = null
@Volatile private var closed = false
private var signalPending: CompletableDeferred<Int?>? = null
private var discoveryStarted = false
private val handler = Handler(Looper.getMainLooper())
private val discoveryFallback = Runnable { gatt?.let(::discoverOnce) }
@Synchronized private fun discoverOnce(g: BluetoothGatt) {
if (closed || discoveryStarted) return
discoveryStarted = true
handler.removeCallbacks(discoveryFallback)
if (!g.discoverServices()) fail()
}
fun attach(value: BluetoothGatt) { if (closed) value.close() else gatt = value }
private fun fail() {
val error = IllegalStateException("蓝牙连接已中断")
ready.completeExceptionally(error)
pending?.completeExceptionally(error)
messages.close(error)
close()
}
val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, state: Int) {
if (status != BluetoothGatt.GATT_SUCCESS || state == BluetoothProfile.STATE_DISCONNECTED) { fail(); return }
if (state == BluetoothProfile.STATE_CONNECTED) {
attach(g)
if (!g.requestMtu(247)) discoverOnce(g)
else handler.postDelayed(discoveryFallback, 2_000)
}
}
override fun onReadRemoteRssi(g: BluetoothGatt, rssi: Int, status: Int) {
signalPending?.complete(if (status == BluetoothGatt.GATT_SUCCESS) rssi else null)
}
override fun onMtuChanged(g: BluetoothGatt, value: Int, status: Int) {
// Some vendor stacks report 517 and then 247 for one request.
// Keep the advertised receive limit conservative and do not queue
// duplicate service discovery / CCCD writes on those callbacks.
if (status == BluetoothGatt.GATT_SUCCESS) mtu = value.coerceIn(23, 247)
// A cached 517 callback can precede completion of our 247 request.
// Starting discovery on it stalls this vendor stack. Wait for the
// requested size, or the bounded fallback if the stack chooses more.
if (status != BluetoothGatt.GATT_SUCCESS || value <= 247) discoverOnce(g)
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) { fail(); return }
val service = g.getService(UUID.fromString(Protocol.SERVICE))
rx = service?.getCharacteristic(UUID.fromString(Protocol.RX))
val tx = service?.getCharacteristic(UUID.fromString(Protocol.TX))
val descriptor = tx?.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
if (rx == null || tx == null || descriptor == null || !g.setCharacteristicNotification(tx, true)) { fail(); return }
@Suppress("DEPRECATION")
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
if (!g.writeDescriptor(descriptor)) fail()
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) ready.complete(Unit) else fail()
}
@Deprecated("Legacy Android callback")
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
@Suppress("DEPRECATION")
enqueue(characteristic, characteristic.value)
}
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
enqueue(characteristic, value)
}
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) pending?.complete(Unit) else fail()
}
}
private fun enqueue(characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (characteristic.uuid.toString() != Protocol.TX || !messages.trySend(value.copyOf()).isSuccess) fail()
}
override suspend fun send(fragment: ByteArray) = writes.withLock {
check(!closed)
val completion = CompletableDeferred<Unit>()
pending = completion
try {
val characteristic = checkNotNull(rx)
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
characteristic.value = fragment
@Suppress("DEPRECATION")
check(checkNotNull(gatt).writeCharacteristic(characteristic))
withTimeout(5_000) { completion.await() }
} finally { pending = null }
}
override suspend fun receive(): ByteArray = messages.receive()
override suspend fun readSignal(): Int? {
if (closed) return null
val completion = CompletableDeferred<Int?>()
signalPending = completion
return try {
if (gatt?.readRemoteRssi() != true) null else withTimeout(1_000) { completion.await() }
} catch (_: TimeoutCancellationException) { null } finally { signalPending = null }
}
override fun close() {
if (closed) return
closed = true
handler.removeCallbacks(discoveryFallback)
val error = IllegalStateException("蓝牙连接已关闭")
ready.completeExceptionally(error)
pending?.completeExceptionally(error)
messages.close()
gatt?.disconnect(); gatt?.close(); gatt = null
}
}
@@ -0,0 +1,65 @@
package org.qimiaoscreen.controller
import android.os.Bundle
import android.os.Build
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.util.Base64
import android.app.AlertDialog
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.*
import org.qimiaoscreen.core.*
import org.qimiaoscreen.ui.QimiaoApp
import org.qimiaoscreen.ui.AppInfo
class MainActivity : ComponentActivity() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var controller: Controller
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()) {
if (permitted()) controller.scan()
else {
controller.reportError("蓝牙权限未授予,无法扫描和连接设备")
val permanent = permissions.any { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED && !shouldShowRequestPermissionRationale(it) }
AlertDialog.Builder(this).setTitle("需要蓝牙扫描权限")
.setMessage(if (permanent) "请在应用设置中允许附近设备权限;Android 8–11 需要定位权限用于蓝牙扫描。" else "权限只用于发现和连接小屏幕,不读取联系人或手机文件。")
.setNegativeButton("暂不", null)
.setPositiveButton(if (permanent) "打开应用设置" else "重试") { _, _ ->
if (permanent) startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
else requestPermission.launch(permissions)
}.show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val ble = AndroidBle(applicationContext)
val preferences = getSharedPreferences("controller", MODE_PRIVATE)
val platform = object : MobilePlatform {
override val model = Build.MODEL
override fun load(key: String) = preferences.getString(key, null)
override fun save(key: String, value: String) { preferences.edit().putString(key, value).apply() }
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) = ble.scan(onDevice, onError)
override fun stopScan() { if (permitted()) ble.stopScan() }
override suspend fun connect(handle: String) = ble.connect(handle)
override fun crypto(): SecureChannel = JavaSecureChannel()
}
controller = Controller(platform, scope)
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 onStop() { if (::controller.isInitialized) controller.foreground(false); super.onStop() }
override fun onDestroy() { if (::controller.isInitialized) controller.disconnect(); scope.cancel(); super.onDestroy() }
}
@@ -0,0 +1,8 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.kmp) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.compose.compiler) apply false
}
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.workers.max=3
android.useAndroidX=true
kotlin.code.style=official
android.overridePathCheck=true
@@ -0,0 +1,20 @@
[versions]
kotlin = "2.4.20"
agp = "9.3.1"
compose = "1.12.1"
coroutines = "1.10.2"
serialization = "1.9.0"
[libraries]
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
activity-compose = { module = "androidx.activity:activity-compose", version = "1.11.0" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-kmp = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
@@ -0,0 +1,10 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
@@ -0,0 +1,7 @@
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral() }
}
rootProject.name = "QimiaoScreen"
include(":androidApp", ":sharedCore", ":sharedUi")
@@ -0,0 +1,23 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kmp)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
android {
namespace = "org.qimiaoscreen.core"
compileSdk = 37
minSdk = 26
withHostTestBuilder {}
}
jvm()
sourceSets {
getByName("androidMain").kotlin.srcDir("src/javaCryptoMain/kotlin")
getByName("jvmMain").kotlin.srcDir("src/javaCryptoMain/kotlin")
commonMain.dependencies {
implementation(libs.coroutines.core)
implementation(libs.serialization.json)
}
commonTest.dependencies { implementation(kotlin("test")) }
}
}
@@ -0,0 +1,339 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.*
import kotlin.time.Clock
interface MobilePlatform {
val model: String
fun load(key: String): String?
fun save(key: String, value: String)
fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit)
fun stopScan()
suspend fun connect(handle: String): DeviceLink
fun crypto(): SecureChannel
}
data class RememberedDevice(val id: String, val name: String, val shortId: String, val lastConnected: Long)
data class WifiDraft(val ssid: String = "", val security: String = "wpa-psk", val manual: Boolean = false,
val address: String = "", val prefix: String = "24", val gateway: String = "", val dns: String = "")
data class ControllerState(
val remembered: List<RememberedDevice> = emptyList(), val signal: Int? = null,
val wifiConnecting: Boolean = false, val wifiError: String? = null, val wifiTarget: String? = null,
val wifiDraft: WifiDraft? = null,
val page: Int = 0, val phase: String = "未连接", val scanning: Boolean = false,
val busy: Boolean = false, val error: String? = null, val clientName: String = "",
val devices: List<DiscoveredDevice> = emptyList(), val identity: JsonObject? = null,
val status: JsonObject? = null, val storage: JsonObject? = null,
val settings: JsonObject? = null, val wifi: JsonObject? = null,
val library: List<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(),
val thumbnails: Map<String, String> = emptyMap(), val frame: String? = null,
val frameCheckedAtMs: Long? = null, val defaultContent: JsonObject? = null,
)
class Controller(private val platform: MobilePlatform, private val scope: CoroutineScope) {
private val mutable = MutableStateFlow(ControllerState(clientName = platform.load("client_name") ?: platform.model, remembered = loadRemembered()))
val state: StateFlow<ControllerState> = mutable.asStateFlow()
private fun loadRemembered(): List<RememberedDevice> = runCatching {
platform.load("remembered_devices")?.let { Protocol.json.parseToJsonElement(it).jsonArray.map { item ->
val o = item.jsonObject
RememberedDevice(o.getValue("id").jsonPrimitive.content, o.getValue("name").jsonPrimitive.content,
o.getValue("short_id").jsonPrimitive.content, o.getValue("last_connected").jsonPrimitive.long)
} } ?: emptyList()
}.getOrDefault(emptyList())
private fun persistRemembered(items: List<RememberedDevice>) {
platform.save("remembered_devices", JsonArray(items.map { buildJsonObject {
put("id", it.id); put("name", it.name); put("short_id", it.shortId); put("last_connected", it.lastConnected)
} }).toString())
change { it.copy(remembered = items) }
}
fun rememberedFor(device: DiscoveredDevice) = state.value.remembered.firstOrNull { device.name == "QMS-${it.shortId}" }
fun forget(id: String) {
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == id) disconnect()
persistRemembered(state.value.remembered.filterNot { it.id == id })
if (platform.load("last_device_id") == id) platform.save("last_device_id", "")
platform.save("forgotten_devices", JsonArray((forgottenIds() + id).map { JsonPrimitive(it) }).toString())
}
private fun forgottenIds(): Set<String> = runCatching { platform.load("forgotten_devices")?.let {
Protocol.json.parseToJsonElement(it).jsonArray.map { v -> v.jsonPrimitive.content }.toSet()
} ?: emptySet() }.getOrDefault(emptySet())
fun connectRemembered(device: RememberedDevice) {
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == device.id) return
val found = state.value.devices.firstOrNull { it.name == "QMS-${device.shortId}" }
if (found != null) connect(found, device.id) else { requestedDeviceId = device.id; scan() }
}
private var requestedDeviceId: String? = null
private var activeLink: DeviceLink? = null
fun wifiDraft(value: WifiDraft) = change { it.copy(wifiDraft = value) }
private var rpc: DeviceRpc? = null
private val commands = Mutex()
private var scanJob: Job? = null
private var pollJob: Job? = null
private var releaseJob: Job? = null
private var foreground = true
private var frameRevision: String? = null
private var connectionJob: Job? = null
private var commandJob: Job? = null
private var connectionGeneration = 0L
private var scanGeneration = 0L
private val pendingThumbnails = linkedMapOf<String, JsonObject>()
private fun change(update: (ControllerState) -> ControllerState) { mutable.update(update) }
fun dismissError() = change { it.copy(error = null) }
fun reportError(message: String) = change { it.copy(error = message) }
fun scan(auto: Boolean = false) {
if (connectionJob?.isActive == true) return
scanJob?.cancel()
platform.stopScan()
val generation = ++scanGeneration
change { it.copy(scanning = true, devices = emptyList(), error = null, phase = if (it.identity != null) it.phase else if (auto) "正在重连上次设备" else "正在扫描") }
val lastId = platform.load("last_device_id")?.takeIf { it.isNotBlank() && it !in forgottenIds() }
val short = lastId?.replace("-", "")?.take(8)
platform.scan({ device -> scope.launch {
if (!state.value.scanning || generation != scanGeneration) return@launch
change { it.copy(devices = (it.devices.filterNot { old -> old.handle == device.handle } + device).sortedByDescending { d -> d.signal }) }
val requested = requestedDeviceId
if (requested != null && device.name == "QMS-${requested.replace("-", "").take(8)}") {
requestedDeviceId = null; connect(device, requested)
} else if (auto && short != null && device.name == "QMS-$short") connect(device, lastId)
} }, { error -> scope.launch {
if (generation == scanGeneration) change { it.copy(error = error, scanning = false, phase = if (it.identity != null) it.phase else "未连接") }
} })
scanJob = scope.launch {
delay(12_000); if (generation != scanGeneration) return@launch; platform.stopScan(); requestedDeviceId = null
change { it.copy(scanning = false, phase = if (it.identity == null) "未连接" else it.phase,
error = if (auto && it.identity == null && connectionJob?.isActive != true) "未找到上次设备,可稍后重试" else it.error) }
}
}
fun connect(device: DiscoveredDevice, expectedId: String? = null) {
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == expectedId && expectedId != null) return
if (connectionJob?.isActive == true || rpc != null) disconnect()
scanGeneration++; scanJob?.cancel(); platform.stopScan()
val generation = ++connectionGeneration
frameRevision = null; pendingThumbnails.clear()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, devices = it.devices, phase = "正在连接") }
connectionJob = scope.launch {
var pending: DeviceRpc? = null
try {
activeLink = platform.connect(device.handle)
pending = DeviceRpc(checkNotNull(activeLink), platform.crypto())
val identity = pending.open(state.value.clientName)
val id = identity.getValue("device_id").jsonPrimitive.content
require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" }
require(identity.getValue("protocol_major").jsonPrimitive.int == 1) { "通信协议不兼容" }
rpc = pending
platform.save("last_device_id", id)
platform.save("forgotten_devices", JsonArray((forgottenIds() - id).map { JsonPrimitive(it) }).toString())
persistRemembered((state.value.remembered.filterNot { it.id == id } + RememberedDevice(id,
identity.getValue("device_name").jsonPrimitive.content, identity.getValue("short_id").jsonPrimitive.content,
Clock.System.now().toEpochMilliseconds())).sortedByDescending { it.lastConnected })
change { it.copy(identity = identity, phase = "已连接", signal = device.signal) }
refreshAll()
startPolling()
} catch (e: Exception) {
pending?.close()
if (generation == connectionGeneration) {
rpc = null; activeLink = null
if (e !is CancellationException) change { it.copy(identity = null, phase = "未连接", error = e.message ?: "连接失败") }
}
}
}
}
fun disconnect() {
connectionGeneration++; scanGeneration++
connectionJob?.cancel(); commandJob?.cancel(); scanJob?.cancel(); pollJob?.cancel(); releaseJob?.cancel()
platform.stopScan(); rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; requestedDeviceId = null
pendingThumbnails.clear()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered) }
}
private fun connectionLost(message: String) {
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; pendingThumbnails.clear()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) }
}
fun foreground(value: Boolean) {
foreground = value
releaseJob?.cancel()
if (value) {
if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true)
} else {
scanJob?.cancel(); platform.stopScan()
change { it.copy(scanning = false) }
releaseJob = scope.launch { delay(30_000); disconnect() }
}
}
fun page(value: Int) { change { it.copy(page = value) }; if (rpc != null) action { refreshPage() } }
private fun capabilities() = state.value.identity?.get("capabilities")?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()
fun supports(capability: String) = capability in capabilities()
private suspend fun call(method: String, params: JsonObject = buildJsonObject {}) = checkNotNull(rpc).call(method, params)
private fun action(block: suspend () -> Unit) {
if (state.value.busy || rpc == null) return
change { it.copy(busy = true, error = null) }
val generation = connectionGeneration
commandJob = scope.launch {
try { commands.withLock { block() } }
catch (e: DeviceFailure) { change { it.copy(error = e.message) } }
catch (e: Exception) {
if (e !is CancellationException && generation == connectionGeneration) {
connectionLost(e.message ?: "操作结果未确认,请重连后检查设备状态"); pollJob?.cancel()
}
} finally { if (generation == connectionGeneration) change { it.copy(busy = false) } }
}
}
private suspend fun refreshAll() { commands.withLock { refreshStatus(); refreshPage() } }
private suspend fun refreshStatus() {
val result = call("status.get")
val wifi = if (supports("wifi")) call("wifi.get") else state.value.wifi
val signal = try { activeLink?.readSignal() } catch (e: CancellationException) { throw e } catch (_: Exception) { null }
currentCoroutineContext().ensureActive()
change { it.copy(status = result, wifi = wifi, signal = signal) }
}
private suspend fun refreshPage() {
when (state.value.page) {
0 -> Unit
1 -> if (supports("frame")) refreshFrame()
2 -> if (supports("library")) {
var items: List<JsonObject> = emptyList()
for (attempt in 0..1) {
try {
val loaded = mutableListOf<JsonObject>()
var cursor: String? = null
do {
val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } })
loaded += result.getValue("items").jsonArray.map { it.jsonObject }
cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull
} while (cursor != null)
items = loaded
break
} catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e }
}
val defaultContent = call("content.default.get")
change { it.copy(library = items, defaultContent = defaultContent) }
}
3 -> {
if (supports("settings")) { val value = call("settings.get"); change { it.copy(settings = value) } }
if (supports("wifi")) { val value = call("wifi.get"); change { it.copy(wifi = value) } }
}
}
}
private fun startPolling() {
pollJob?.cancel()
pollJob = scope.launch {
while (isActive && rpc != null) {
delay(2_000)
if (state.value.busy || !commands.tryLock()) continue
try {
if (!foreground) { call("session.ping"); continue }
refreshStatus()
if (state.value.page in 1..2 && supports("frame")) refreshFrame()
if (state.value.page == 2 && !state.value.busy && pendingThumbnails.isNotEmpty()) {
val (key, item) = pendingThumbnails.entries.first()
pendingThumbnails.remove(key)
val image = call("library.thumbnail", item).getValue("data_base64").jsonPrimitive.content
change { old ->
val cache = (old.thumbnails + (key to image)).toMutableMap()
while (cache.size > 128 || cache.values.sumOf { it.length } > 8 * 1024 * 1024) cache.remove(cache.keys.first())
old.copy(thumbnails = cache)
}
}
} catch (e: DeviceFailure) {
// A stale thumbnail or other business rejection does not
// invalidate the authenticated transport.
change { it.copy(error = e.message) }
} catch (e: Exception) {
if (e !is CancellationException) {
connectionLost(e.message ?: "连接已断开,请重试")
}
} finally { commands.unlock() }
}
}
}
private suspend fun refreshFrame() {
val result = call("frame.get", buildJsonObject { frameRevision?.let { put("known_revision", it) } })
frameRevision = result["frame_revision"]?.jsonPrimitive?.content
result["data_base64"]?.jsonPrimitive?.content?.let { image -> change { it.copy(frame = image) } }
change { it.copy(frameCheckedAtMs = Clock.System.now().toEpochMilliseconds()) }
}
fun refresh() = action { refreshStatus(); refreshPage(); if (state.value.page == 2 && supports("frame")) refreshFrame() }
fun thumbnail(item: JsonObject) {
val key = thumbnailKey(item)
if (key !in state.value.thumbnails) {
if (pendingThumbnails.size < 128) pendingThumbnails[key] = item
}
}
fun play(item: JsonObject, default: Boolean = false) = action {
call(if (default) "content.default.set" else "content.play", item); refreshStatus()
if (default) { val value = call("content.default.get"); change { it.copy(defaultContent = value) } }
}
fun playback(changes: JsonObject) = action {
val playback = state.value.status?.get("state")?.jsonObject?.get("animation_playback")?.jsonObject
val session = playback?.get("session_id") ?: throw DeviceFailure("NOT_READY", "当前没有可控制的动图")
call("playback.patch", JsonObject(changes + ("session_id" to session))); refreshStatus()
}
fun setting(key: String, value: JsonElement) = action {
val revision = state.value.settings?.get("revision") ?: return@action
val result = call("settings.patch", buildJsonObject { put("expected_revision", revision); put("changes", JsonObject(mapOf(key to value))) })
change { it.copy(settings = result) }; refreshStatus()
}
fun renameDevice(name: String, id: String? = state.value.identity?.get("device_id")?.jsonPrimitive?.content) {
if (id == null || state.value.identity?.get("device_id")?.jsonPrimitive?.content != id) { reportError("请连接设备"); return }
action {
val value = call("device.rename", buildJsonObject { put("name", name.trim()) })
change { it.copy(identity = value) }
persistRemembered(state.value.remembered.map { if (it.id == id) it.copy(name = value.getValue("device_name").jsonPrimitive.content) else it })
}
}
fun renamePhone(name: String) {
val normalized = name.trim()
if (normalized.isBlank() || normalized.length > 40 || normalized.any { it.code < 32 || it.code == 127 }) {
reportError("名称须为 1 至 40 个字符,不能包含控制字符"); return
}
if (state.value.busy) return
platform.save("client_name", normalized); change { it.copy(clientName = normalized) }
if (rpc != null) action { call("session.rename", buildJsonObject { put("client_name", normalized) }) }
}
fun scanWifi() = action { val result = call("wifi.scan"); change { it.copy(networks = result.getValue("networks").jsonArray.map { n -> n.jsonObject }) } }
fun saveWifi(values: JsonObject) = action {
val revision = state.value.wifi?.get("revision") ?: return@action
val target = values.getValue("ssid").jsonPrimitive.content
val result = call("wifi.set", JsonObject(values + ("expected_revision" to revision)))
change { it.copy(wifiConnecting = true, wifiError = null, wifiTarget = target) }
try {
val task = result["task_id"]
var finished = task == null
for (attempt in 0 until 60) {
if (finished) break
delay(1_000)
val value = call("task.get", buildJsonObject { put("task_id", checkNotNull(task)) })
when (value["state"]?.jsonPrimitive?.content) {
"succeeded" -> finished = true
"failed" -> {
val message = when (value["error_code"]?.jsonPrimitive?.content) {
"AUTH_FAILED" -> "${target}密码错误"
"NETWORK_UNAVAILABLE" -> "WiFi不存在或暂不可达"
"IP_CONFIG_FAILED" -> "无法获取IP,请检查地址设置"
else -> "网络连接失败,请检查设置"
}
change { it.copy(wifiError = message) }
throw DeviceFailure("DEVICE_ERROR", message)
}
}
}
if (!finished) { change { it.copy(wifiError = "连接结果尚未确认,请刷新") }; throw DeviceFailure("TIMEOUT", "网络任务仍在进行,请刷新状态") }
} finally { change { it.copy(wifiConnecting = false) }; refreshStatus() }
refreshPage()
}
}
fun wifiSummary(state: ControllerState): String {
if (state.identity == null) return "未连接"
if (state.wifiConnecting) return "连接中"
state.wifiError?.let { return it }
val active = state.wifi?.get("active")?.jsonObject ?: return "WiFi状态读取中"
if (active["connected"]?.jsonPrimitive?.booleanOrNull != true) return "未连接,WiFi不存在"
return listOfNotNull(active["ssid"]?.jsonPrimitive?.contentOrNull, active["ipv4_address"]?.jsonPrimitive?.contentOrNull).joinToString(" · ")
}
fun thumbnailKey(item: JsonObject): String = listOf("type", "id", "revision")
.joinToString("|") { item.getValue(it).jsonPrimitive.content }
@@ -0,0 +1,74 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.*
data class DiscoveredDevice(val handle: String, val name: String, val signal: Int)
/** Platform owns the GATT connection. Handles are ephemeral, never device identity. */
interface DeviceLink {
val mtu: Int
suspend fun send(fragment: ByteArray)
suspend fun receive(): ByteArray
fun close()
suspend fun readSignal(): Int? = null
}
class DeviceFailure(val code: String, message: String) : Exception(message)
class DeviceTransportFailure(message: String) : Exception(message)
class DeviceRpc(private val link: DeviceLink, private val crypto: SecureChannel, private val requestTimeoutMs: Long = 10_000) {
private val mutex = Mutex()
private var tx = 0L
private var request = 0L
private val receiver = Reassembler()
private var usable = true
private suspend fun send(kind: Int, payload: ByteArray) {
Protocol.encode(kind, tx++, payload, link.mtu).forEach { link.send(it) }
}
private suspend fun receive(): Pair<Int, ByteArray> = withTimeout(15_000) {
var result: Pair<Int, ByteArray>? = null
while (result == null) result = receiver.accept(link.receive())
result
}
suspend fun open(name: String): JsonObject = mutex.withLock {
try {
withTimeout(10_000) {
send(1, crypto.hello)
val (kind, payload) = receive()
require(kind == 2) { "设备未接受握手" }
crypto.finish(payload)
exchange("session.open", buildJsonObject { put("client_name", name); put("receive_mtu", link.mtu) })
}
} catch (e: TimeoutCancellationException) {
close(); throw DeviceTransportFailure("连接握手超时,请重新连接")
} catch (e: Exception) { close(); throw e }
}
suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonObject = mutex.withLock {
check(usable) { "设备连接已关闭" }
try {
withTimeout(if (method == "wifi.scan") 20_000 else requestTimeoutMs) { exchange(method, params) }
} catch (e: TimeoutCancellationException) {
close(); throw DeviceTransportFailure("设备回复超时($method),请重新连接")
} catch (e: DeviceFailure) { throw e }
catch (e: Exception) { close(); throw e }
}
private suspend fun exchange(method: String, params: JsonObject): JsonObject {
val id = (++request).toString()
val raw = buildJsonObject { put("id", id); put("method", method); put("params", params) }
send(3, crypto.encrypt(raw.toString().encodeToByteArray()))
val (kind, record) = receive()
require(kind == 3)
val response = Protocol.json.parseToJsonElement(crypto.decrypt(record).decodeToString(throwOnInvalidSequence = true)).jsonObject
require(response["id"]?.jsonPrimitive?.content == id)
if (!response.getValue("ok").jsonPrimitive.boolean) {
val error = response.getValue("error").jsonObject
throw DeviceFailure(error.getValue("code").jsonPrimitive.content, error.getValue("message").jsonPrimitive.content)
}
return response.getValue("result").jsonObject
}
fun close() { usable = false; link.close() }
}
@@ -0,0 +1,70 @@
package org.qimiaoscreen.core
import kotlinx.serialization.json.Json
object Protocol {
const val SERVICE = "9f57a001-6c31-4c58-bc22-1f728b641001"
const val RX = "9f57a002-6c31-4c58-bc22-1f728b641001"
const val TX = "9f57a003-6c31-4c58-bc22-1f728b641001"
val json = Json { ignoreUnknownKeys = true }
const val MAX_MESSAGE = 65536
fun encode(kind: Int, message: Long, payload: ByteArray, mtu: Int): List<ByteArray> {
require(kind in 1..4 && message in 0..0xffffffffL && payload.size in 1..MAX_MESSAGE)
require(mtu in 23..517)
val size = mtu - 19
require(size > 0)
val count = (payload.size + size - 1) / size
require(count <= 65535)
return (0 until count).map { index ->
val part = payload.copyOfRange(index * size, minOf(payload.size, (index + 1) * size))
ByteArray(16 + part.size).apply {
putInt(0, 0x514d, 2); this[2] = 1; this[3] = kind.toByte()
putInt(4, message, 4); putInt(8, index.toLong(), 2)
putInt(10, count.toLong(), 2); putInt(12, payload.size.toLong(), 4)
part.copyInto(this, 16)
}
}
}
}
fun ByteArray.putInt(offset: Int, value: Long, length: Int) {
for (i in 0 until length) this[offset + i] = (value ushr (8 * (length - i - 1))).toByte()
}
fun ByteArray.readInt(offset: Int, length: Int): Long {
require(offset + length <= size)
var result = 0L
for (i in offset until offset + length) result = (result shl 8) or (this[i].toLong() and 255)
return result
}
class Reassembler {
private var id = -1L
private var count = 0
private var total = 0
private var index = 0
private var kind = 0
private var buffer = ByteArray(0)
private var offset = 0
private var nextMessage = 0L
fun accept(part: ByteArray): Pair<Int, ByteArray>? {
require(part.size in 17..514 && part.readInt(0, 2) == 0x514dL && part[2].toInt() == 1)
val partId = part.readInt(4, 4)
val partIndex = part.readInt(8, 2).toInt()
val partCount = part.readInt(10, 2).toInt()
val partTotal = part.readInt(12, 4).toInt()
val partKind = part[3].toInt()
require(partKind in 1..4 && partCount in 1..partTotal && partTotal in 1..Protocol.MAX_MESSAGE)
if (index == 0) {
require(partIndex == 0 && partId == nextMessage)
id = partId; count = partCount; total = partTotal; kind = partKind
buffer = ByteArray(total); offset = 0
}
require(partId == id && partIndex == index && partCount == count && partTotal == total && partKind == kind)
require(index < count && offset + part.size - 16 <= total)
part.copyInto(buffer, offset, 16); offset += part.size - 16; index++
if (index != count) { require(offset < total); return null }
require(offset == total)
index = 0; nextMessage++
return kind to buffer
}
}
@@ -0,0 +1,9 @@
package org.qimiaoscreen.core
/** Platform crypto boundary; future iOS implementation must pass the same vectors. */
interface SecureChannel {
val hello: ByteArray
fun finish(serverHello: ByteArray)
fun encrypt(payload: ByteArray): ByteArray
fun decrypt(record: ByteArray): ByteArray
}
@@ -0,0 +1,202 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.*
import kotlin.test.*
/** Test-only simulated device; never included in production scans or APK code. */
private class SimulatedPlatform : MobilePlatform {
override val model = "simulated-test-phone"
val preferences = mutableMapOf<String, String>()
val links = mutableListOf<SimulatedLink>()
val scanCallbacks = mutableListOf<(DiscoveredDevice) -> Unit>()
override fun load(key: String) = preferences[key]
override fun save(key: String, value: String) { preferences[key] = value }
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) { scanCallbacks += onDevice }
override fun stopScan() {}
override suspend fun connect(handle: String) = SimulatedLink(if (handle == "second-handle") "11111111-0000-4000-8000-000000000001" else "00000000-0000-4000-8000-000000000001").also { links += it }
override fun crypto() = object : SecureChannel {
override val hello = byteArrayOf(1)
override fun finish(serverHello: ByteArray) {}
override fun encrypt(payload: ByteArray) = payload
override fun decrypt(record: ByteArray) = record
}
}
private class SimulatedLink(private val deviceId: String) : DeviceLink {
override val mtu = 247
private val receiver = Reassembler()
private val messages = Channel<ByteArray>(Channel.UNLIMITED)
private var sequence = 0L
var failure: String? = null
var blockStorage = false
var storageEntered = CompletableDeferred<Unit>()
var closed = false
override suspend fun send(fragment: ByteArray) {
check(!closed)
val (kind, payload) = receiver.accept(fragment) ?: return
val response = if (kind == 1) byteArrayOf(2) else {
val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
val method = request.getValue("method").jsonPrimitive.content
if (method == failure) throw DeviceTransportFailure("simulated connection loss")
if (method == "status.get" && blockStorage) { storageEntered.complete(Unit); awaitCancellation() }
val result = when (method) {
"session.open" -> """{"device_id":"$deviceId","device_name":"模拟屏","short_id":"${deviceId.take(8)}","protocol_major":1,"capabilities":["status","frame"]}"""
"device.rename" -> buildJsonObject {
put("device_id", deviceId); put("device_name", request.getValue("params").jsonObject.getValue("name"))
put("short_id", deviceId.take(8)); put("protocol_major", 1); put("capabilities", JsonArray(emptyList()))
}.toString()
"status.get" -> """{"state":{}}"""
"frame.get" -> """{"frame_revision":"test-frame","data_base64":"test-frame-data"}"""
else -> "{}"
}
buildJsonObject {
put("id", request.getValue("id")); put("ok", true)
put("result", Protocol.json.parseToJsonElement(result))
}.toString().encodeToByteArray()
}
Protocol.encode(if (kind == 1) 2 else 3, sequence++, response, mtu).forEach { messages.send(it) }
}
override suspend fun receive() = messages.receive()
override fun close() { closed = true; messages.close() }
}
class ControllerRecoveryTest {
private val device = DiscoveredDevice("test-handle", "test-device", -40)
@Test fun lossClearsCachedStateAndReconnects() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
controller.page(1)
withTimeout(2_000) { controller.state.first { it.frame != null && !it.busy } }
assertNotNull(controller.state.value.frameCheckedAtMs)
platform.links.last().failure = "status.get"
controller.refresh()
withTimeout(2_000) { controller.state.first { it.phase == "连接中断" } }
assertNull(controller.state.value.frame)
assertNull(controller.state.value.frameCheckedAtMs)
assertNull(controller.state.value.storage)
assertTrue(platform.links.last().closed)
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
assertEquals(2, platform.links.size)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun disconnectCancelsInFlightOperationWithoutCorruptingNextConnection() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
val old = platform.links.last()
old.blockStorage = true
controller.refresh()
withTimeout(2_000) { old.storageEntered.await() }
controller.disconnect()
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
assertTrue(old.closed)
assertFalse(controller.state.value.busy)
assertNull(controller.state.value.error)
assertFalse(platform.links.last().closed)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun oldScanCallbackCannotPopulateNewScan() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.scan(); controller.scan()
platform.scanCallbacks.first()(device)
yield()
assertTrue(controller.state.value.devices.isEmpty())
platform.scanCallbacks.last()(device)
withTimeout(2_000) { controller.state.first { it.devices.size == 1 } }
Unit
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun rememberedDevicesSurviveDisconnectAndForgetPreventsReconnect() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
val remembered = controller.state.value.remembered.single()
assertEquals("模拟屏", remembered.name)
controller.scan()
assertNotNull(controller.state.value.identity)
assertTrue(controller.state.value.scanning)
platform.scanCallbacks.last()(device)
withTimeout(2_000) { controller.state.first { it.devices.isNotEmpty() } }
assertEquals(1, platform.links.size)
controller.disconnect()
assertEquals(remembered.id, Controller(platform, scope).state.value.remembered.single().id)
controller.forget(remembered.id)
controller.foreground(true)
assertFalse(controller.state.value.scanning)
assertTrue(controller.state.value.remembered.isEmpty())
assertEquals("", platform.load("last_device_id"))
controller.renameDevice("不应执行", remembered.id)
assertEquals("请连接设备", controller.state.value.error)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun selectingDeviceReleasesPreviousLinkAndForgettingActiveDisconnects() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
val old = platform.links.single()
controller.connect(device.copy(handle = "second-handle"))
withTimeout(2_000) { controller.state.first { it.status != null } }
assertTrue(old.closed)
assertEquals(2, platform.links.size)
assertEquals(2, controller.state.value.remembered.size)
controller.forget("11111111-0000-4000-8000-000000000001")
assertNull(controller.state.value.identity)
assertTrue(platform.links.last().closed)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun legacyLastDeviceMigratesAfterValidatedHandshake() = runBlocking {
val platform = SimulatedPlatform()
val id = "00000000-0000-4000-8000-000000000001"
platform.preferences["last_device_id"] = id
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.foreground(true)
platform.scanCallbacks.last()(device.copy(name = "QMS-00000000"))
withTimeout(2_000) { controller.state.first { it.status != null } }
assertEquals(id, controller.state.value.remembered.single().id)
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun renameUpdatesAuthenticatedIdentityAndRememberedName() = runBlocking {
val platform = SimulatedPlatform()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
controller.renameDevice("客厅测试屏")
withTimeout(2_000) { controller.state.first { it.remembered.firstOrNull()?.name == "客厅测试屏" } }
assertEquals("客厅测试屏", controller.state.value.identity?.get("device_name")?.jsonPrimitive?.content)
controller.disconnect()
assertEquals("客厅测试屏", Controller(platform, scope).state.value.remembered.single().name)
} finally { controller.disconnect(); scope.cancel() }
}
}
@@ -0,0 +1,21 @@
package org.qimiaoscreen.core
import kotlin.test.*
class ProtocolTest {
@Test fun minimumMtuRoundTrip() {
val payload = "中文协议".repeat(20).encodeToByteArray()
val rx = Reassembler()
var result: Pair<Int, ByteArray>? = null
Protocol.encode(1, 0, payload, 23).forEach { result = rx.accept(it) }
assertEquals(1, result!!.first)
assertContentEquals(payload, result!!.second)
}
@Test fun rejectsOutOfOrderAndReplayedMessage() {
val frames = Protocol.encode(3, 0, ByteArray(20), 23)
assertFailsWith<IllegalArgumentException> { Reassembler().accept(frames[1]) }
val rx = Reassembler()
frames.forEach { rx.accept(it) }
assertFailsWith<IllegalArgumentException> { rx.accept(frames[0]) }
}
}
@@ -0,0 +1,115 @@
package org.qimiaoscreen.core
import java.math.BigInteger
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.ECParameterSpec
import java.security.spec.ECPoint
import java.security.spec.ECPublicKeySpec
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlinx.serialization.json.*
/** JCA implementation shared by Android and JVM interoperability tests. */
class JavaSecureChannel(
private val keyPair: KeyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair(),
private val random: ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) },
) : SecureChannel {
private val label = "QMS-BLE-1".encodeToByteArray()
private var material: ByteArray? = null
private var transcript = ByteArray(0)
private var tx = 0L
private var rx = 0L
private var failed = false
private fun fixed(value: BigInteger): ByteArray = value.toByteArray().let {
if (it.size >= 32) it.copyOfRange(it.size - 32, it.size) else ByteArray(32 - it.size) + it
}
override val hello: ByteArray
init {
require(random.size == 32)
val point = (keyPair.public as ECPublicKey).w
val public = byteArrayOf(4) + fixed(point.affineX) + fixed(point.affineY)
hello = buildJsonObject {
put("protocol_major", 1); put("protocol_minor", 0)
put("public_key", Base64.getEncoder().encodeToString(public))
put("random", Base64.getEncoder().encodeToString(random))
}.toString().encodeToByteArray()
}
private fun hash(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes)
private fun number(value: Long, length: Int) = ByteArray(length).apply { putInt(0, value, length) }
private fun mac(key: ByteArray, bytes: ByteArray) = Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(key, "HmacSHA256")); doFinal(bytes)
}
override fun finish(serverHello: ByteArray) {
check(material == null && !failed)
try {
require(serverHello.size <= 1024)
val peer = Protocol.json.parseToJsonElement(serverHello.decodeToString(throwOnInvalidSequence = true)).jsonObject
require(peer.getValue("protocol_major").jsonPrimitive.int == 1)
require(peer.getValue("protocol_minor").jsonPrimitive.int >= 0)
val public = Base64.getDecoder().decode(peer.getValue("public_key").jsonPrimitive.content)
val sr = Base64.getDecoder().decode(peer.getValue("random").jsonPrimitive.content)
require(public.size == 65 && public[0] == 4.toByte() && sr.size == 32)
val parameters = AlgorithmParameters.getInstance("EC").apply { init(ECGenParameterSpec("secp256r1")) }
.getParameterSpec(ECParameterSpec::class.java)
val point = ECPoint(BigInteger(1, public.copyOfRange(1, 33)), BigInteger(1, public.copyOfRange(33, 65)))
val peerKey = KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec(point, parameters))
val secret = KeyAgreement.getInstance("ECDH").run { init(keyPair.private); doPhase(peerKey, true); generateSecret() }
transcript = hash(number(hello.size.toLong(), 4) + hello + number(serverHello.size.toLong(), 4) + serverHello)
val prk = mac(hash(random + sr), secret)
var block = ByteArray(0)
var expanded = ByteArray(0)
for (i in 1..3) {
block = mac(prk, block + label + transcript + byteArrayOf(i.toByte()))
expanded += block
}
material = expanded.copyOf(72)
secret.fill(0); prk.fill(0); expanded.fill(0)
} catch (e: Exception) {
failed = true
throw IllegalArgumentException("无法建立加密会话")
}
}
override fun encrypt(payload: ByteArray): ByteArray {
check(!failed && tx >= 0 && tx < Long.MAX_VALUE)
require(payload.size in 1..Protocol.MAX_MESSAGE - 24)
val seq = number(tx, 8)
val result = seq + crypt(Cipher.ENCRYPT_MODE, 0, seq, payload)
tx++
return result
}
override fun decrypt(record: ByteArray): ByteArray {
try {
check(!failed && rx >= 0)
require(record.size in 25..Protocol.MAX_MESSAGE && record.readInt(0, 8) == rx)
val result = crypt(Cipher.DECRYPT_MODE, 1, record.copyOfRange(0, 8), record.copyOfRange(8, record.size))
rx++
return result
} catch (e: Exception) {
failed = true
material?.fill(0)
throw IllegalArgumentException("加密消息校验失败")
}
}
private fun crypt(mode: Int, direction: Int, seq: ByteArray, value: ByteArray): ByteArray {
val keys = checkNotNull(material)
return Cipher.getInstance("AES/GCM/NoPadding").run {
init(mode, SecretKeySpec(keys.copyOfRange(direction * 32, direction * 32 + 32), "AES"),
GCMParameterSpec(128, keys.copyOfRange(64 + direction * 4, 68 + direction * 4) + seq))
updateAAD(label + transcript + byteArrayOf(direction.toByte()) + seq)
doFinal(value)
}
}
}
@@ -0,0 +1,48 @@
package org.qimiaoscreen.core
import java.math.BigInteger
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.KeyPair
import java.security.spec.*
import kotlinx.serialization.json.*
import kotlin.test.*
class CryptoInteropTest {
private val vector = Protocol.json.parseToJsonElement(
checkNotNull(javaClass.getResourceAsStream("/protocol-v1.json")).bufferedReader().use { it.readText() }
).jsonObject
private fun bytes(key: String) = vector.getValue(key).jsonPrimitive.content.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun channel(): JavaSecureChannel {
val params = AlgorithmParameters.getInstance("EC").apply { init(ECGenParameterSpec("secp256r1")) }
.getParameterSpec(ECParameterSpec::class.java)
val factory = KeyFactory.getInstance("EC")
val privateKey = factory.generatePrivate(ECPrivateKeySpec(BigInteger.ONE, params))
val publicKey = factory.generatePublic(ECPublicKeySpec(params.generator, params))
return JavaSecureChannel(KeyPair(publicKey, privateKey), bytes("client_random"))
}
@Test fun pythonGoldenRecordsMatch() {
val channel = channel()
assertContentEquals(bytes("client_hello"), channel.hello)
channel.finish(bytes("server_hello"))
assertContentEquals(bytes("client_record"), channel.encrypt(bytes("request")))
assertContentEquals(bytes("response"), channel.decrypt(bytes("server_record")))
assertFails { channel.decrypt(bytes("server_record")) }
}
@Test fun pythonFragmentVectorMatches() {
val expected = vector.getValue("fragments_mtu23").jsonArray.map {
it.jsonPrimitive.content.chunked(2).map { hex -> hex.toInt(16).toByte() }.toByteArray()
}
val actual = Protocol.encode(3, 0, bytes("client_record"), 23)
assertEquals(expected.size, actual.size)
expected.zip(actual).forEach { (a, b) -> assertContentEquals(a, b) }
}
@Test fun tamperedRecordInvalidatesConnection() {
val channel = channel()
channel.finish(bytes("server_hello"))
val record = bytes("server_record")
record[record.lastIndex] = (record.last().toInt() xor 1).toByte()
assertFails { channel.decrypt(record) }
assertFails { channel.decrypt(bytes("server_record")) }
}
}
@@ -0,0 +1,28 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.*
import kotlin.test.*
class RpcTimeoutTest {
@Test fun timeoutClosesTransportAndIsNotCancellation() = runBlocking {
var closed = false
val link = object : DeviceLink {
override val mtu = 247
override suspend fun send(fragment: ByteArray) {}
override suspend fun receive(): ByteArray { awaitCancellation() }
override fun close() { closed = true }
}
val crypto = object : SecureChannel {
override val hello = byteArrayOf(1)
override fun finish(serverHello: ByteArray) {}
override fun encrypt(payload: ByteArray) = payload
override fun decrypt(record: ByteArray) = record
}
val rpc = DeviceRpc(link, crypto, requestTimeoutMs = 25)
val error = assertFailsWith<DeviceTransportFailure> { rpc.call("status.get") }
assertTrue(error.message!!.contains("status.get"))
assertTrue(closed)
assertFailsWith<IllegalStateException> { rpc.call("status.get") }
Unit
}
}
@@ -0,0 +1,41 @@
{
"note": "Public interoperability fixture only; never use these scalars in real connections.",
"client_scalar": "1",
"server_scalar": "2",
"client_random": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"server_random": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"client_hello": "7b2270726f746f636f6c5f6d616a6f72223a312c2270726f746f636f6c5f6d696e6f72223a302c227075626c69635f6b6579223a224247735830664c684c454a482b4c7a6d35574f6b51504a33413332424c65737a6f5053684f5558596d4d4b57542b4e43347634616635754f352b744b66412b654669764f4d3164724d56374f79375a416144652f5566553d222c2272616e646f6d223a2241414543417751464267634943516f4c4441304f4478415245684d554652595847426b61477877644868383d227d",
"server_hello": "7b2270726f746f636f6c5f6d616a6f72223a312c2270726f746f636f6c5f6d696e6f72223a302c227075626c69635f6b6579223a2242487a796578694e4130392b696c4934417753314773504169576e69642f49624e61594c535078485a706c3442336456454e754f30454170505a72476e3351773237703972655938365949706e6753336e534a346339453d222c2272616e646f6d223a22494345694979516c4a69636f4b536f724c4330754c7a41784d6a4d304e5459334f446b364f7a7739506a383d227d",
"transcript": "9ca718b743dfe549aaba17180599eced0e2bcb680fd7c210e341ae74ccd17b76",
"request": "7b226964223a2231222c226d6574686f64223a2273657373696f6e2e6f70656e222c22706172616d73223a7b22636c69656e745f6e616d65223a22e6b58be8af95e6898be69cba227d7d",
"response": "7b226964223a2231222c226f6b223a747275652c22726573756c74223a7b22616c697665223a747275657d7d",
"client_record": "0000000000000000f2c854e062cd588b6de2066904d362400bf231bdeb046d786fca2a2bb6e1b539d0bd32d225461581655d7dc5066cf89d2d3bea12421e0d6e452f34ba4c39219b3e3efd3826c8461b67b8a74650976db8d47a0edd94e6cf39f526",
"server_record": "0000000000000000fd089bf6a0d3196c601f5dcbefad51b0f130e17cf38d2c76cbb9855a7da88480e53f3e89893dcc35fa175fcfbf01683f575244928462d07d13056a67",
"fragments_mtu23": [
"514d010300000000000000190000006200000000",
"514d010300000000000100190000006200000000",
"514d0103000000000002001900000062f2c854e0",
"514d010300000000000300190000006262cd588b",
"514d01030000000000040019000000626de20669",
"514d010300000000000500190000006204d36240",
"514d01030000000000060019000000620bf231bd",
"514d0103000000000007001900000062eb046d78",
"514d01030000000000080019000000626fca2a2b",
"514d0103000000000009001900000062b6e1b539",
"514d010300000000000a001900000062d0bd32d2",
"514d010300000000000b00190000006225461581",
"514d010300000000000c001900000062655d7dc5",
"514d010300000000000d001900000062066cf89d",
"514d010300000000000e0019000000622d3bea12",
"514d010300000000000f001900000062421e0d6e",
"514d0103000000000010001900000062452f34ba",
"514d01030000000000110019000000624c39219b",
"514d01030000000000120019000000623e3efd38",
"514d010300000000001300190000006226c8461b",
"514d010300000000001400190000006267b8a746",
"514d010300000000001500190000006250976db8",
"514d0103000000000016001900000062d47a0edd",
"514d010300000000001700190000006294e6cf39",
"514d0103000000000018001900000062f526"
]
}
@@ -0,0 +1,24 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kmp)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
}
kotlin {
android {
namespace = "org.qimiaoscreen.ui"
compileSdk = 37
minSdk = 26
}
sourceSets {
commonMain.dependencies {
implementation(project(":sharedCore"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(libs.coroutines.core)
implementation(libs.serialization.json)
}
}
}
@@ -0,0 +1,283 @@
package org.qimiaoscreen.ui
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.serialization.json.*
import org.qimiaoscreen.core.*
import kotlinx.coroutines.delay
import kotlin.time.Clock
private fun JsonObject.text(key: String, fallback: String = "—") = get(key)?.jsonPrimitive?.contentOrNull ?: fallback
data class AppInfo(val version: String, val build: String, val date: String)
@Composable
fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> ImageBitmap?,
appInfo: AppInfo = AppInfo("—", "—", "—")) {
val state by controller.state.collectAsState()
var menu by remember { mutableStateOf(false) }
var dialog by remember { mutableStateOf<String?>(null) }
MaterialTheme {
Scaffold(bottomBar = { NavigationBar {
listOf("连接", "设备状态", "显示内容", "设备设置").forEachIndexed { index, name ->
NavigationBarItem(state.page == index, { controller.page(index) },
icon = { Text(listOf("◉", "▣", "▦", "⚙")[index]) }, label = { Text(name) })
}
} }) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val identity = state.identity
Text(if (identity == null) "当前连接设备:未连接${if (state.phase == "正在连接") " · 连接中" else ""}"
else "当前连接设备:${identity.text("device_name")}(${identity.text("short_id")}) · ${wifiSummary(state)}",
Modifier.weight(1f), style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
Box {
TextButton({ menu = true }) { Text("⋮") }
DropdownMenu(menu, { menu = false }) {
DropdownMenuItem(text = { Text("关于") }, onClick = { menu = false; dialog = "about" })
DropdownMenuItem(text = { Text("软件设置") }, onClick = { menu = false; dialog = "settings" })
}
}
}
if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
state.error?.let { error -> Card { Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(error, Modifier.weight(1f)); TextButton(controller::dismissError) { Text("知道了") }
} } }
when (state.page) {
0 -> ConnectionPage(state, controller, onScan)
1 -> if (state.identity == null) Text("请先在连接页连接设备") else Frame(state.frame, state.frameCheckedAtMs, decode, controller::refresh, !state.busy)
2 -> ContentPage(state, controller, decode)
3 -> SettingsPage(state, controller)
}
}
}
if (dialog == "about") AlertDialog(onDismissRequest = { dialog = null }, title = { Text("关于奇妙小屏幕") },
text = { Text("版本 ${appInfo.version}\n构建编号 ${appInfo.build}\n打包日期 ${appInfo.date}\n通过蓝牙控制核桃派小屏幕") },
confirmButton = { TextButton({ dialog = null }) { Text("关闭") } })
if (dialog == "settings") {
var name by remember { mutableStateOf(state.clientName) }
AlertDialog(onDismissRequest = { dialog = null }, title = { Text("软件设置") },
text = { OutlinedTextField(name, { name = it.take(40) }, label = { Text("当前手机昵称") }, singleLine = true) },
confirmButton = { TextButton({ controller.renamePhone(name); if (name.trim().isNotEmpty()) dialog = null }, enabled = !state.busy) { Text("保存") } },
dismissButton = { TextButton({ dialog = null }) { Text("取消") } })
}
}
}
@OptIn(ExperimentalFoundationApi::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(verticalArrangement = Arrangement.spacedBy(10.dp)) {
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 "点击连接")
}
}
}
item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(scan, enabled = !state.scanning && state.phase != "正在连接") { Text(if (state.scanning) "扫描中…" else "扫描设备") }
if (state.identity != null) OutlinedButton(c::disconnect) { Text("断开连接") }
} }
item { Text("扫描到的设备", style = MaterialTheme.typography.titleMedium) }
items(state.devices, key = { it.handle }) { device -> Card(Modifier.fillMaxWidth()) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(device.name)
Text("信号 ${device.signal} dBm${if (c.rememberedFor(device) != null) " · 已记忆" else ""}", style = MaterialTheme.typography.bodySmall)
}
Button({ c.connect(device, c.rememberedFor(device)?.id) }, enabled = state.phase != "正在连接") { Text("连接") }
}
} }
if (state.identity != null) item { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("当前连接信息", style = MaterialTheme.typography.titleMedium)
Text("蓝牙信号:${state.signal?.let { "$it dBm" } ?: "暂不可用"}")
Text("设备WiFi:${wifiSummary(state)}")
} } }
}
selected?.let { device ->
if (!renaming) AlertDialog(onDismissRequest = { selected = null }, title = { Text(device.name) },
text = { Text("代号 ${device.shortId}") },
confirmButton = { TextButton({
if (state.identity?.text("device_id") != device.id) { c.reportError("请连接设备"); selected = null }
else { name = device.name; renaming = true }
}) { Text("重命名设备") } },
dismissButton = { Row { TextButton({ c.forget(device.id); selected = null }) { Text("取消记忆") }; TextButton({ selected = null }) { Text("关闭") } } })
else AlertDialog(onDismissRequest = { selected = null }, title = { Text("重命名设备") },
text = { OutlinedTextField(name, { name = it.take(40) }, label = { Text("设备昵称") }, singleLine = true) },
confirmButton = { TextButton({ c.renameDevice(name, device.id); selected = null }, enabled = name.isNotBlank() && !state.busy) { Text("保存") } },
dismissButton = { TextButton({ selected = null }) { Text("取消") } })
}
}
@Composable
private fun Frame(encoded: String?, checkedAt: Long?, decode: (String) -> ImageBitmap?, refresh: () -> Unit, enabled: Boolean) {
val bitmap = remember(encoded) { encoded?.let(decode) }
var now by remember { mutableLongStateOf(Clock.System.now().toEpochMilliseconds()) }
LaunchedEffect(checkedAt) { while (true) { now = Clock.System.now().toEpochMilliseconds(); delay(1_000) } }
Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("设备画面", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium)
TextButton(refresh, enabled = enabled) { Text("手动刷新") }
}
if (bitmap != null) Image(bitmap, "设备当前输出帧", Modifier.size(160.dp)) else Text("等待画面")
if (checkedAt != null && now - checkedAt > 6_000) Text("画面可能已过期,请刷新", style = MaterialTheme.typography.bodySmall)
} }
}
@Composable
private fun ContentPage(state: ControllerState, c: Controller, decode: (String) -> ImageBitmap?) {
if (state.identity == null) { Text("请先在连接页连接小屏幕"); return }
if (!c.supports("library")) { Text("此设备版本不支持内容控制"); return }
val playback = state.status?.get("state")?.jsonObject?.get("animation_playback")?.jsonObject
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
item { Frame(state.frame, state.frameCheckedAtMs, decode, c::refresh, !state.busy) }
item { Text("开机默认:${state.defaultContent?.text("name") ?: "正在读取…"}") }
if (playback?.get("active")?.jsonPrimitive?.booleanOrNull == true) item { Playback(playback, c, !state.busy) }
item { Text("可播放内容", style = MaterialTheme.typography.titleLarge) }
items(state.library, key = { it.text("type") + it.text("id") }) { item ->
val key = thumbnailKey(item)
LaunchedEffect(key) { c.thumbnail(item) }
val encoded = state.thumbnails[key]
val image = remember(encoded) { encoded?.let(decode) }
Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (image != null) Image(image, item.text("name"), Modifier.size(64.dp)); Text(item.text("name")) }
Text((if (item.text("type") == "animation") "动图" else "静态模板") + if (item.text("is_demo") == "true") " · 演示内容" else "")
val enabled = !state.busy && item["playable"]?.jsonPrimitive?.booleanOrNull == true
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button({ c.play(item) }, enabled = enabled) { Text("播放") }
OutlinedButton({ c.play(item, true) }, enabled = enabled) { Text("设为默认并播放") }
}
} }
}
}
}
@Composable
private fun Playback(value: JsonObject, c: Controller, enabled: Boolean) {
val paused = value["paused"]?.jsonPrimitive?.booleanOrNull == true
val position = value["position_ms"]?.jsonPrimitive?.longOrNull ?: 0L
val total = (value["total_duration_ms"]?.jsonPrimitive?.longOrNull ?: 1L).coerceAtLeast(1)
var seek by remember(position) { mutableFloatStateOf(position.toFloat()) }
Text("动图进度 ${position / 1000.0} / ${total / 1000.0} 秒")
Button({ c.playback(buildJsonObject { put("paused", !paused) }) }, enabled = enabled) { Text(if (paused) "继续" else "暂停") }
Slider(seek.coerceIn(0f, total.toFloat()), { seek = it }, enabled = enabled, valueRange = 0f..total.toFloat(), onValueChangeFinished = { c.playback(buildJsonObject { put("position_ms", seek.toLong()) }) })
Choices("播放倍速", value.text("speed"), value["supported_speeds"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList(), enabled) {
c.playback(buildJsonObject { put("speed", it.toDouble()) })
}
}
@Composable
private fun SettingsPage(state: ControllerState, c: Controller) {
if (state.identity == null) { Text("请先在连接页连接设备"); return }
val values = state.settings?.get("values")?.jsonObject
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (values != null) {
item {
var brightness by remember(values.text("brightness")) { mutableFloatStateOf(values.text("brightness", "40").toFloat()) }
Text("亮度 ${brightness.toInt()}%")
Slider(brightness, { brightness = it }, enabled = !state.busy, valueRange = 1f..100f,
onValueChangeFinished = { c.setting("brightness", JsonPrimitive(brightness.toInt())) })
}
item { Text("屏幕方向"); Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("0" to "正常", "90" to "向右90°", "180" to "倒置", "270" to "向左90°").forEach { (value, label) ->
FilterChip(values.text("orientation") == value, { c.setting("orientation", JsonPrimitive(value.toInt())) }, label = { Text(label) }, enabled = !state.busy)
}
} }
} else item { Text(if (c.supports("settings")) "正在读取设置…" else "设备不支持系统设置") }
if (c.supports("wifi")) item { WifiForm(state, c) }
if (values != null) item {
var seconds by remember(values.text("prompt_delay_seconds")) { mutableStateOf(values.text("prompt_delay_seconds")) }
OutlinedTextField(seconds, { seconds = it }, label = { Text("WiFi提示秒数") }, singleLine = true)
TextButton({ seconds.toIntOrNull()?.let { c.setting("prompt_delay_seconds", JsonPrimitive(it)) } }, enabled = !state.busy && seconds.toIntOrNull() != null) { Text("保存提示秒数") }
}
}
}
@Composable
private fun Choices(label: String, selected: String, options: List<String>, enabled: Boolean, select: (String) -> Unit) {
Text(label)
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
options.forEach { value -> FilterChip(selected == value, { select(value) }, label = { Text(value) }, enabled = enabled) }
}
}
@Composable
private fun WifiForm(state: ControllerState, c: Controller) {
val saved = state.wifi?.get("saved")?.takeIf { it != JsonNull }?.jsonObject
val draft = state.wifiDraft ?: WifiDraft(saved?.text("ssid", "") ?: "", saved?.text("security", "wpa-psk") ?: "wpa-psk",
saved?.text("ipv4_mode") == "manual", saved?.text("address", "") ?: "", saved?.text("prefix", "24") ?: "24",
saved?.text("gateway", "") ?: "", saved?.get("dns_servers")?.jsonArray?.joinToString(",") { it.jsonPrimitive.content } ?: "")
var expanded by remember { mutableStateOf(false) }
var networks by remember { mutableStateOf(false) }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var confirm by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
HorizontalDivider(); Text("WiFi网络", style = MaterialTheme.typography.titleMedium)
Text("当前WiFi:${wifiSummary(state)}")
TextButton({ expanded = !expanded; if (!expanded) { password = ""; showPassword = false } }) { Text("WiFi设置 ${if (expanded) "收起" else "展开"}") }
if (expanded) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(draft.ssid, { c.wifiDraft(draft.copy(ssid = it)) }, Modifier.weight(1f), label = { Text("WiFi名称") }, singleLine = true, enabled = !state.busy)
if (c.supports("wifi_scan")) TextButton({ networks = true; c.scanWifi() }, enabled = !state.busy) { Text("扫描WiFi") }
}
if (networks) {
if (state.networks.isEmpty() && !state.busy) Text("未发现WiFi,可手动输入名称")
state.networks.forEach { network -> TextButton({
c.wifiDraft(draft.copy(ssid = network.text("ssid"), security = network.text("security"))); password = ""; networks = false
}, enabled = !state.busy && network.text("security") != "unsupported") {
Text("${network.text("ssid")} · ${network.text("signal_percent")}%${if (network.text("security") == "unsupported") " · 暂不支持" else ""}")
} }
}
Choices("网络类型", if (draft.security == "open") "开放网络" else "密码网络", listOf("密码网络", "开放网络"), !state.busy) {
c.wifiDraft(draft.copy(security = if (it == "开放网络") "open" else "wpa-psk")); password = ""
}
if (draft.security != "open") {
val configured = saved?.get("password_configured")?.jsonPrimitive?.booleanOrNull == true && saved.text("ssid", "") == draft.ssid
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("WiFi密码") },
placeholder = { Text(if (configured) "已设置密码,留空保留" else "请输入密码") },
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), singleLine = true, enabled = !state.busy)
Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(showPassword, { showPassword = it }); Text("显示输入的密码") }
}
Row(verticalAlignment = Alignment.CenterVertically) { Text("自动获取IP(DHCP)", Modifier.weight(1f)); Switch(!draft.manual, { c.wifiDraft(draft.copy(manual = !it)) }, enabled = !state.busy) }
if (draft.manual) {
OutlinedTextField(draft.address, { c.wifiDraft(draft.copy(address = it)) }, label = { Text("静态IP地址") }, singleLine = true, enabled = !state.busy)
OutlinedTextField(draft.prefix, { c.wifiDraft(draft.copy(prefix = it)) }, label = { Text("前缀长度(例如24)") }, singleLine = true, enabled = !state.busy)
OutlinedTextField(draft.gateway, { c.wifiDraft(draft.copy(gateway = it)) }, label = { Text("网关") }, singleLine = true, enabled = !state.busy)
OutlinedTextField(draft.dns, { c.wifiDraft(draft.copy(dns = it)) }, label = { Text("DNS(多个用逗号分隔)") }, singleLine = true, enabled = !state.busy)
}
Button({ confirm = true }, enabled = !state.busy && draft.ssid.isNotBlank() && state.wifi != null) { Text("保存WiFi设置") }
}
}
if (confirm) AlertDialog(onDismissRequest = { confirm = false }, title = { Text("立即生效WiFi设置") },
text = { Text("WiFi设置将立即生效,是否确定?手机仍可通过蓝牙控制设备。") },
dismissButton = { TextButton({ confirm = false }) { Text("取消") } },
confirmButton = { TextButton({
confirm = false
c.saveWifi(buildJsonObject {
put("ssid", draft.ssid); put("security", draft.security)
put("password_action", if (draft.security == "open") "none" else if (password.isEmpty()) "keep" else "replace")
if (password.isNotEmpty() && draft.security != "open") put("password", password)
put("ipv4_mode", if (draft.manual) "manual" else "dhcp")
if (draft.manual) { put("address", draft.address); put("prefix", draft.prefix.toIntOrNull() ?: 0); put("gateway", draft.gateway) }
put("dns_servers", JsonArray(if (draft.manual) draft.dns.split(',').map { it.trim() }.filter { it.isNotEmpty() }.map { JsonPrimitive(it) } else emptyList()))
put("activation", "immediate")
}); password = ""; showPassword = false
}) { Text("确定") } })
}