同步移动端工程、设备控制改进与发布资料
This commit is contained in:
@@ -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")
|
||||
}
|
||||
+122
@@ -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))
|
||||
}
|
||||
}
|
||||
+240
@@ -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() }
|
||||
}
|
||||
}
|
||||
+41
@@ -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("关闭")
|
||||
}
|
||||
}
|
||||
+252
@@ -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>
|
||||
+179
@@ -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
|
||||
}
|
||||
}
|
||||
+65
@@ -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" }
|
||||
Binary file not shown.
@@ -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
|
||||
Vendored
+248
@@ -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" "$@"
|
||||
Vendored
+82
@@ -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")) }
|
||||
}
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlin.time.Clock
|
||||
|
||||
interface MobilePlatform {
|
||||
val model: String
|
||||
fun load(key: String): String?
|
||||
fun save(key: String, value: String)
|
||||
fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit)
|
||||
fun stopScan()
|
||||
suspend fun connect(handle: String): DeviceLink
|
||||
fun crypto(): SecureChannel
|
||||
}
|
||||
|
||||
data class RememberedDevice(val id: String, val name: String, val shortId: String, val lastConnected: Long)
|
||||
data class WifiDraft(val ssid: String = "", val security: String = "wpa-psk", val manual: Boolean = false,
|
||||
val address: String = "", val prefix: String = "24", val gateway: String = "", val dns: String = "")
|
||||
data class ControllerState(
|
||||
val remembered: List<RememberedDevice> = emptyList(), val signal: Int? = null,
|
||||
val wifiConnecting: Boolean = false, val wifiError: String? = null, val wifiTarget: String? = null,
|
||||
val wifiDraft: WifiDraft? = null,
|
||||
val page: Int = 0, val phase: String = "未连接", val scanning: Boolean = false,
|
||||
val busy: Boolean = false, val error: String? = null, val clientName: String = "",
|
||||
val devices: List<DiscoveredDevice> = emptyList(), val identity: JsonObject? = null,
|
||||
val status: JsonObject? = null, val storage: JsonObject? = null,
|
||||
val settings: JsonObject? = null, val wifi: JsonObject? = null,
|
||||
val library: List<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(),
|
||||
val thumbnails: Map<String, String> = emptyMap(), val frame: String? = null,
|
||||
val frameCheckedAtMs: Long? = null, val defaultContent: JsonObject? = null,
|
||||
)
|
||||
|
||||
class Controller(private val platform: MobilePlatform, private val scope: CoroutineScope) {
|
||||
private val mutable = MutableStateFlow(ControllerState(clientName = platform.load("client_name") ?: platform.model, remembered = loadRemembered()))
|
||||
val state: StateFlow<ControllerState> = mutable.asStateFlow()
|
||||
private fun loadRemembered(): List<RememberedDevice> = runCatching {
|
||||
platform.load("remembered_devices")?.let { Protocol.json.parseToJsonElement(it).jsonArray.map { item ->
|
||||
val o = item.jsonObject
|
||||
RememberedDevice(o.getValue("id").jsonPrimitive.content, o.getValue("name").jsonPrimitive.content,
|
||||
o.getValue("short_id").jsonPrimitive.content, o.getValue("last_connected").jsonPrimitive.long)
|
||||
} } ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
private fun persistRemembered(items: List<RememberedDevice>) {
|
||||
platform.save("remembered_devices", JsonArray(items.map { buildJsonObject {
|
||||
put("id", it.id); put("name", it.name); put("short_id", it.shortId); put("last_connected", it.lastConnected)
|
||||
} }).toString())
|
||||
change { it.copy(remembered = items) }
|
||||
}
|
||||
fun rememberedFor(device: DiscoveredDevice) = state.value.remembered.firstOrNull { device.name == "QMS-${it.shortId}" }
|
||||
fun forget(id: String) {
|
||||
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == id) disconnect()
|
||||
persistRemembered(state.value.remembered.filterNot { it.id == id })
|
||||
if (platform.load("last_device_id") == id) platform.save("last_device_id", "")
|
||||
platform.save("forgotten_devices", JsonArray((forgottenIds() + id).map { JsonPrimitive(it) }).toString())
|
||||
}
|
||||
private fun forgottenIds(): Set<String> = runCatching { platform.load("forgotten_devices")?.let {
|
||||
Protocol.json.parseToJsonElement(it).jsonArray.map { v -> v.jsonPrimitive.content }.toSet()
|
||||
} ?: emptySet() }.getOrDefault(emptySet())
|
||||
fun connectRemembered(device: RememberedDevice) {
|
||||
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == device.id) return
|
||||
val found = state.value.devices.firstOrNull { it.name == "QMS-${device.shortId}" }
|
||||
if (found != null) connect(found, device.id) else { requestedDeviceId = device.id; scan() }
|
||||
}
|
||||
private var requestedDeviceId: String? = null
|
||||
private var activeLink: DeviceLink? = null
|
||||
fun wifiDraft(value: WifiDraft) = change { it.copy(wifiDraft = value) }
|
||||
private var rpc: DeviceRpc? = null
|
||||
private val commands = Mutex()
|
||||
private var scanJob: Job? = null
|
||||
private var pollJob: Job? = null
|
||||
private var releaseJob: Job? = null
|
||||
private var foreground = true
|
||||
private var frameRevision: String? = null
|
||||
private var connectionJob: Job? = null
|
||||
private var commandJob: Job? = null
|
||||
private var connectionGeneration = 0L
|
||||
private var scanGeneration = 0L
|
||||
private val pendingThumbnails = linkedMapOf<String, JsonObject>()
|
||||
private fun change(update: (ControllerState) -> ControllerState) { mutable.update(update) }
|
||||
fun dismissError() = change { it.copy(error = null) }
|
||||
fun reportError(message: String) = change { it.copy(error = message) }
|
||||
fun scan(auto: Boolean = false) {
|
||||
if (connectionJob?.isActive == true) return
|
||||
scanJob?.cancel()
|
||||
platform.stopScan()
|
||||
val generation = ++scanGeneration
|
||||
change { it.copy(scanning = true, devices = emptyList(), error = null, phase = if (it.identity != null) it.phase else if (auto) "正在重连上次设备" else "正在扫描") }
|
||||
val lastId = platform.load("last_device_id")?.takeIf { it.isNotBlank() && it !in forgottenIds() }
|
||||
val short = lastId?.replace("-", "")?.take(8)
|
||||
platform.scan({ device -> scope.launch {
|
||||
if (!state.value.scanning || generation != scanGeneration) return@launch
|
||||
change { it.copy(devices = (it.devices.filterNot { old -> old.handle == device.handle } + device).sortedByDescending { d -> d.signal }) }
|
||||
val requested = requestedDeviceId
|
||||
if (requested != null && device.name == "QMS-${requested.replace("-", "").take(8)}") {
|
||||
requestedDeviceId = null; connect(device, requested)
|
||||
} else if (auto && short != null && device.name == "QMS-$short") connect(device, lastId)
|
||||
} }, { error -> scope.launch {
|
||||
if (generation == scanGeneration) change { it.copy(error = error, scanning = false, phase = if (it.identity != null) it.phase else "未连接") }
|
||||
} })
|
||||
scanJob = scope.launch {
|
||||
delay(12_000); if (generation != scanGeneration) return@launch; platform.stopScan(); requestedDeviceId = null
|
||||
change { it.copy(scanning = false, phase = if (it.identity == null) "未连接" else it.phase,
|
||||
error = if (auto && it.identity == null && connectionJob?.isActive != true) "未找到上次设备,可稍后重试" else it.error) }
|
||||
}
|
||||
}
|
||||
fun connect(device: DiscoveredDevice, expectedId: String? = null) {
|
||||
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == expectedId && expectedId != null) return
|
||||
if (connectionJob?.isActive == true || rpc != null) disconnect()
|
||||
scanGeneration++; scanJob?.cancel(); platform.stopScan()
|
||||
val generation = ++connectionGeneration
|
||||
frameRevision = null; pendingThumbnails.clear()
|
||||
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, devices = it.devices, phase = "正在连接") }
|
||||
connectionJob = scope.launch {
|
||||
var pending: DeviceRpc? = null
|
||||
try {
|
||||
activeLink = platform.connect(device.handle)
|
||||
pending = DeviceRpc(checkNotNull(activeLink), platform.crypto())
|
||||
val identity = pending.open(state.value.clientName)
|
||||
val id = identity.getValue("device_id").jsonPrimitive.content
|
||||
require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" }
|
||||
require(identity.getValue("protocol_major").jsonPrimitive.int == 1) { "通信协议不兼容" }
|
||||
rpc = pending
|
||||
platform.save("last_device_id", id)
|
||||
platform.save("forgotten_devices", JsonArray((forgottenIds() - id).map { JsonPrimitive(it) }).toString())
|
||||
persistRemembered((state.value.remembered.filterNot { it.id == id } + RememberedDevice(id,
|
||||
identity.getValue("device_name").jsonPrimitive.content, identity.getValue("short_id").jsonPrimitive.content,
|
||||
Clock.System.now().toEpochMilliseconds())).sortedByDescending { it.lastConnected })
|
||||
change { it.copy(identity = identity, phase = "已连接", signal = device.signal) }
|
||||
refreshAll()
|
||||
startPolling()
|
||||
} catch (e: Exception) {
|
||||
pending?.close()
|
||||
if (generation == connectionGeneration) {
|
||||
rpc = null; activeLink = null
|
||||
if (e !is CancellationException) change { it.copy(identity = null, phase = "未连接", error = e.message ?: "连接失败") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun disconnect() {
|
||||
connectionGeneration++; scanGeneration++
|
||||
connectionJob?.cancel(); commandJob?.cancel(); scanJob?.cancel(); pollJob?.cancel(); releaseJob?.cancel()
|
||||
platform.stopScan(); rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; requestedDeviceId = null
|
||||
pendingThumbnails.clear()
|
||||
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered) }
|
||||
}
|
||||
private fun connectionLost(message: String) {
|
||||
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; pendingThumbnails.clear()
|
||||
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) }
|
||||
}
|
||||
fun foreground(value: Boolean) {
|
||||
foreground = value
|
||||
releaseJob?.cancel()
|
||||
if (value) {
|
||||
if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true)
|
||||
} else {
|
||||
scanJob?.cancel(); platform.stopScan()
|
||||
change { it.copy(scanning = false) }
|
||||
releaseJob = scope.launch { delay(30_000); disconnect() }
|
||||
}
|
||||
}
|
||||
fun page(value: Int) { change { it.copy(page = value) }; if (rpc != null) action { refreshPage() } }
|
||||
private fun capabilities() = state.value.identity?.get("capabilities")?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()
|
||||
fun supports(capability: String) = capability in capabilities()
|
||||
private suspend fun call(method: String, params: JsonObject = buildJsonObject {}) = checkNotNull(rpc).call(method, params)
|
||||
private fun action(block: suspend () -> Unit) {
|
||||
if (state.value.busy || rpc == null) return
|
||||
change { it.copy(busy = true, error = null) }
|
||||
val generation = connectionGeneration
|
||||
commandJob = scope.launch {
|
||||
try { commands.withLock { block() } }
|
||||
catch (e: DeviceFailure) { change { it.copy(error = e.message) } }
|
||||
catch (e: Exception) {
|
||||
if (e !is CancellationException && generation == connectionGeneration) {
|
||||
connectionLost(e.message ?: "操作结果未确认,请重连后检查设备状态"); pollJob?.cancel()
|
||||
}
|
||||
} finally { if (generation == connectionGeneration) change { it.copy(busy = false) } }
|
||||
}
|
||||
}
|
||||
private suspend fun refreshAll() { commands.withLock { refreshStatus(); refreshPage() } }
|
||||
private suspend fun refreshStatus() {
|
||||
val result = call("status.get")
|
||||
val wifi = if (supports("wifi")) call("wifi.get") else state.value.wifi
|
||||
val signal = try { activeLink?.readSignal() } catch (e: CancellationException) { throw e } catch (_: Exception) { null }
|
||||
currentCoroutineContext().ensureActive()
|
||||
change { it.copy(status = result, wifi = wifi, signal = signal) }
|
||||
}
|
||||
private suspend fun refreshPage() {
|
||||
when (state.value.page) {
|
||||
0 -> Unit
|
||||
1 -> if (supports("frame")) refreshFrame()
|
||||
2 -> if (supports("library")) {
|
||||
var items: List<JsonObject> = emptyList()
|
||||
for (attempt in 0..1) {
|
||||
try {
|
||||
val loaded = mutableListOf<JsonObject>()
|
||||
var cursor: String? = null
|
||||
do {
|
||||
val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } })
|
||||
loaded += result.getValue("items").jsonArray.map { it.jsonObject }
|
||||
cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull
|
||||
} while (cursor != null)
|
||||
items = loaded
|
||||
break
|
||||
} catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e }
|
||||
}
|
||||
val defaultContent = call("content.default.get")
|
||||
change { it.copy(library = items, defaultContent = defaultContent) }
|
||||
}
|
||||
3 -> {
|
||||
if (supports("settings")) { val value = call("settings.get"); change { it.copy(settings = value) } }
|
||||
if (supports("wifi")) { val value = call("wifi.get"); change { it.copy(wifi = value) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun startPolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = scope.launch {
|
||||
while (isActive && rpc != null) {
|
||||
delay(2_000)
|
||||
if (state.value.busy || !commands.tryLock()) continue
|
||||
try {
|
||||
if (!foreground) { call("session.ping"); continue }
|
||||
refreshStatus()
|
||||
if (state.value.page in 1..2 && supports("frame")) refreshFrame()
|
||||
if (state.value.page == 2 && !state.value.busy && pendingThumbnails.isNotEmpty()) {
|
||||
val (key, item) = pendingThumbnails.entries.first()
|
||||
pendingThumbnails.remove(key)
|
||||
val image = call("library.thumbnail", item).getValue("data_base64").jsonPrimitive.content
|
||||
change { old ->
|
||||
val cache = (old.thumbnails + (key to image)).toMutableMap()
|
||||
while (cache.size > 128 || cache.values.sumOf { it.length } > 8 * 1024 * 1024) cache.remove(cache.keys.first())
|
||||
old.copy(thumbnails = cache)
|
||||
}
|
||||
}
|
||||
} catch (e: DeviceFailure) {
|
||||
// A stale thumbnail or other business rejection does not
|
||||
// invalidate the authenticated transport.
|
||||
change { it.copy(error = e.message) }
|
||||
} catch (e: Exception) {
|
||||
if (e !is CancellationException) {
|
||||
connectionLost(e.message ?: "连接已断开,请重试")
|
||||
}
|
||||
} finally { commands.unlock() }
|
||||
}
|
||||
}
|
||||
}
|
||||
private suspend fun refreshFrame() {
|
||||
val result = call("frame.get", buildJsonObject { frameRevision?.let { put("known_revision", it) } })
|
||||
frameRevision = result["frame_revision"]?.jsonPrimitive?.content
|
||||
result["data_base64"]?.jsonPrimitive?.content?.let { image -> change { it.copy(frame = image) } }
|
||||
change { it.copy(frameCheckedAtMs = Clock.System.now().toEpochMilliseconds()) }
|
||||
}
|
||||
fun refresh() = action { refreshStatus(); refreshPage(); if (state.value.page == 2 && supports("frame")) refreshFrame() }
|
||||
fun thumbnail(item: JsonObject) {
|
||||
val key = thumbnailKey(item)
|
||||
if (key !in state.value.thumbnails) {
|
||||
if (pendingThumbnails.size < 128) pendingThumbnails[key] = item
|
||||
}
|
||||
}
|
||||
fun play(item: JsonObject, default: Boolean = false) = action {
|
||||
call(if (default) "content.default.set" else "content.play", item); refreshStatus()
|
||||
if (default) { val value = call("content.default.get"); change { it.copy(defaultContent = value) } }
|
||||
}
|
||||
fun playback(changes: JsonObject) = action {
|
||||
val playback = state.value.status?.get("state")?.jsonObject?.get("animation_playback")?.jsonObject
|
||||
val session = playback?.get("session_id") ?: throw DeviceFailure("NOT_READY", "当前没有可控制的动图")
|
||||
call("playback.patch", JsonObject(changes + ("session_id" to session))); refreshStatus()
|
||||
}
|
||||
fun setting(key: String, value: JsonElement) = action {
|
||||
val revision = state.value.settings?.get("revision") ?: return@action
|
||||
val result = call("settings.patch", buildJsonObject { put("expected_revision", revision); put("changes", JsonObject(mapOf(key to value))) })
|
||||
change { it.copy(settings = result) }; refreshStatus()
|
||||
}
|
||||
fun renameDevice(name: String, id: String? = state.value.identity?.get("device_id")?.jsonPrimitive?.content) {
|
||||
if (id == null || state.value.identity?.get("device_id")?.jsonPrimitive?.content != id) { reportError("请连接设备"); return }
|
||||
action {
|
||||
val value = call("device.rename", buildJsonObject { put("name", name.trim()) })
|
||||
change { it.copy(identity = value) }
|
||||
persistRemembered(state.value.remembered.map { if (it.id == id) it.copy(name = value.getValue("device_name").jsonPrimitive.content) else it })
|
||||
}
|
||||
}
|
||||
fun renamePhone(name: String) {
|
||||
val normalized = name.trim()
|
||||
if (normalized.isBlank() || normalized.length > 40 || normalized.any { it.code < 32 || it.code == 127 }) {
|
||||
reportError("名称须为 1 至 40 个字符,不能包含控制字符"); return
|
||||
}
|
||||
if (state.value.busy) return
|
||||
platform.save("client_name", normalized); change { it.copy(clientName = normalized) }
|
||||
if (rpc != null) action { call("session.rename", buildJsonObject { put("client_name", normalized) }) }
|
||||
}
|
||||
fun scanWifi() = action { val result = call("wifi.scan"); change { it.copy(networks = result.getValue("networks").jsonArray.map { n -> n.jsonObject }) } }
|
||||
fun saveWifi(values: JsonObject) = action {
|
||||
val revision = state.value.wifi?.get("revision") ?: return@action
|
||||
val target = values.getValue("ssid").jsonPrimitive.content
|
||||
val result = call("wifi.set", JsonObject(values + ("expected_revision" to revision)))
|
||||
change { it.copy(wifiConnecting = true, wifiError = null, wifiTarget = target) }
|
||||
try {
|
||||
val task = result["task_id"]
|
||||
var finished = task == null
|
||||
for (attempt in 0 until 60) {
|
||||
if (finished) break
|
||||
delay(1_000)
|
||||
val value = call("task.get", buildJsonObject { put("task_id", checkNotNull(task)) })
|
||||
when (value["state"]?.jsonPrimitive?.content) {
|
||||
"succeeded" -> finished = true
|
||||
"failed" -> {
|
||||
val message = when (value["error_code"]?.jsonPrimitive?.content) {
|
||||
"AUTH_FAILED" -> "${target}密码错误"
|
||||
"NETWORK_UNAVAILABLE" -> "WiFi不存在或暂不可达"
|
||||
"IP_CONFIG_FAILED" -> "无法获取IP,请检查地址设置"
|
||||
else -> "网络连接失败,请检查设置"
|
||||
}
|
||||
change { it.copy(wifiError = message) }
|
||||
throw DeviceFailure("DEVICE_ERROR", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!finished) { change { it.copy(wifiError = "连接结果尚未确认,请刷新") }; throw DeviceFailure("TIMEOUT", "网络任务仍在进行,请刷新状态") }
|
||||
} finally { change { it.copy(wifiConnecting = false) }; refreshStatus() }
|
||||
refreshPage()
|
||||
}
|
||||
}
|
||||
|
||||
fun wifiSummary(state: ControllerState): String {
|
||||
if (state.identity == null) return "未连接"
|
||||
if (state.wifiConnecting) return "连接中"
|
||||
state.wifiError?.let { return it }
|
||||
val active = state.wifi?.get("active")?.jsonObject ?: return "WiFi状态读取中"
|
||||
if (active["connected"]?.jsonPrimitive?.booleanOrNull != true) return "未连接,WiFi不存在"
|
||||
return listOfNotNull(active["ssid"]?.jsonPrimitive?.contentOrNull, active["ipv4_address"]?.jsonPrimitive?.contentOrNull).joinToString(" · ")
|
||||
}
|
||||
|
||||
fun thumbnailKey(item: JsonObject): String = listOf("type", "id", "revision")
|
||||
.joinToString("|") { item.getValue(it).jsonPrimitive.content }
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
data class DiscoveredDevice(val handle: String, val name: String, val signal: Int)
|
||||
|
||||
/** Platform owns the GATT connection. Handles are ephemeral, never device identity. */
|
||||
interface DeviceLink {
|
||||
val mtu: Int
|
||||
suspend fun send(fragment: ByteArray)
|
||||
suspend fun receive(): ByteArray
|
||||
fun close()
|
||||
suspend fun readSignal(): Int? = null
|
||||
}
|
||||
|
||||
class DeviceFailure(val code: String, message: String) : Exception(message)
|
||||
class DeviceTransportFailure(message: String) : Exception(message)
|
||||
|
||||
class DeviceRpc(private val link: DeviceLink, private val crypto: SecureChannel, private val requestTimeoutMs: Long = 10_000) {
|
||||
private val mutex = Mutex()
|
||||
private var tx = 0L
|
||||
private var request = 0L
|
||||
private val receiver = Reassembler()
|
||||
private var usable = true
|
||||
private suspend fun send(kind: Int, payload: ByteArray) {
|
||||
Protocol.encode(kind, tx++, payload, link.mtu).forEach { link.send(it) }
|
||||
}
|
||||
private suspend fun receive(): Pair<Int, ByteArray> = withTimeout(15_000) {
|
||||
var result: Pair<Int, ByteArray>? = null
|
||||
while (result == null) result = receiver.accept(link.receive())
|
||||
result
|
||||
}
|
||||
suspend fun open(name: String): JsonObject = mutex.withLock {
|
||||
try {
|
||||
withTimeout(10_000) {
|
||||
send(1, crypto.hello)
|
||||
val (kind, payload) = receive()
|
||||
require(kind == 2) { "设备未接受握手" }
|
||||
crypto.finish(payload)
|
||||
exchange("session.open", buildJsonObject { put("client_name", name); put("receive_mtu", link.mtu) })
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
close(); throw DeviceTransportFailure("连接握手超时,请重新连接")
|
||||
} catch (e: Exception) { close(); throw e }
|
||||
}
|
||||
suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonObject = mutex.withLock {
|
||||
check(usable) { "设备连接已关闭" }
|
||||
try {
|
||||
withTimeout(if (method == "wifi.scan") 20_000 else requestTimeoutMs) { exchange(method, params) }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
close(); throw DeviceTransportFailure("设备回复超时($method),请重新连接")
|
||||
} catch (e: DeviceFailure) { throw e }
|
||||
catch (e: Exception) { close(); throw e }
|
||||
}
|
||||
private suspend fun exchange(method: String, params: JsonObject): JsonObject {
|
||||
val id = (++request).toString()
|
||||
val raw = buildJsonObject { put("id", id); put("method", method); put("params", params) }
|
||||
send(3, crypto.encrypt(raw.toString().encodeToByteArray()))
|
||||
val (kind, record) = receive()
|
||||
require(kind == 3)
|
||||
val response = Protocol.json.parseToJsonElement(crypto.decrypt(record).decodeToString(throwOnInvalidSequence = true)).jsonObject
|
||||
require(response["id"]?.jsonPrimitive?.content == id)
|
||||
if (!response.getValue("ok").jsonPrimitive.boolean) {
|
||||
val error = response.getValue("error").jsonObject
|
||||
throw DeviceFailure(error.getValue("code").jsonPrimitive.content, error.getValue("message").jsonPrimitive.content)
|
||||
}
|
||||
return response.getValue("result").jsonObject
|
||||
}
|
||||
fun close() { usable = false; link.close() }
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object Protocol {
|
||||
const val SERVICE = "9f57a001-6c31-4c58-bc22-1f728b641001"
|
||||
const val RX = "9f57a002-6c31-4c58-bc22-1f728b641001"
|
||||
const val TX = "9f57a003-6c31-4c58-bc22-1f728b641001"
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
const val MAX_MESSAGE = 65536
|
||||
fun encode(kind: Int, message: Long, payload: ByteArray, mtu: Int): List<ByteArray> {
|
||||
require(kind in 1..4 && message in 0..0xffffffffL && payload.size in 1..MAX_MESSAGE)
|
||||
require(mtu in 23..517)
|
||||
val size = mtu - 19
|
||||
require(size > 0)
|
||||
val count = (payload.size + size - 1) / size
|
||||
require(count <= 65535)
|
||||
return (0 until count).map { index ->
|
||||
val part = payload.copyOfRange(index * size, minOf(payload.size, (index + 1) * size))
|
||||
ByteArray(16 + part.size).apply {
|
||||
putInt(0, 0x514d, 2); this[2] = 1; this[3] = kind.toByte()
|
||||
putInt(4, message, 4); putInt(8, index.toLong(), 2)
|
||||
putInt(10, count.toLong(), 2); putInt(12, payload.size.toLong(), 4)
|
||||
part.copyInto(this, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ByteArray.putInt(offset: Int, value: Long, length: Int) {
|
||||
for (i in 0 until length) this[offset + i] = (value ushr (8 * (length - i - 1))).toByte()
|
||||
}
|
||||
fun ByteArray.readInt(offset: Int, length: Int): Long {
|
||||
require(offset + length <= size)
|
||||
var result = 0L
|
||||
for (i in offset until offset + length) result = (result shl 8) or (this[i].toLong() and 255)
|
||||
return result
|
||||
}
|
||||
|
||||
class Reassembler {
|
||||
private var id = -1L
|
||||
private var count = 0
|
||||
private var total = 0
|
||||
private var index = 0
|
||||
private var kind = 0
|
||||
private var buffer = ByteArray(0)
|
||||
private var offset = 0
|
||||
private var nextMessage = 0L
|
||||
fun accept(part: ByteArray): Pair<Int, ByteArray>? {
|
||||
require(part.size in 17..514 && part.readInt(0, 2) == 0x514dL && part[2].toInt() == 1)
|
||||
val partId = part.readInt(4, 4)
|
||||
val partIndex = part.readInt(8, 2).toInt()
|
||||
val partCount = part.readInt(10, 2).toInt()
|
||||
val partTotal = part.readInt(12, 4).toInt()
|
||||
val partKind = part[3].toInt()
|
||||
require(partKind in 1..4 && partCount in 1..partTotal && partTotal in 1..Protocol.MAX_MESSAGE)
|
||||
if (index == 0) {
|
||||
require(partIndex == 0 && partId == nextMessage)
|
||||
id = partId; count = partCount; total = partTotal; kind = partKind
|
||||
buffer = ByteArray(total); offset = 0
|
||||
}
|
||||
require(partId == id && partIndex == index && partCount == count && partTotal == total && partKind == kind)
|
||||
require(index < count && offset + part.size - 16 <= total)
|
||||
part.copyInto(buffer, offset, 16); offset += part.size - 16; index++
|
||||
if (index != count) { require(offset < total); return null }
|
||||
require(offset == total)
|
||||
index = 0; nextMessage++
|
||||
return kind to buffer
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
/** Platform crypto boundary; future iOS implementation must pass the same vectors. */
|
||||
interface SecureChannel {
|
||||
val hello: ByteArray
|
||||
fun finish(serverHello: ByteArray)
|
||||
fun encrypt(payload: ByteArray): ByteArray
|
||||
fun decrypt(record: ByteArray): ByteArray
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlin.test.*
|
||||
|
||||
/** Test-only simulated device; never included in production scans or APK code. */
|
||||
private class SimulatedPlatform : MobilePlatform {
|
||||
override val model = "simulated-test-phone"
|
||||
val preferences = mutableMapOf<String, String>()
|
||||
val links = mutableListOf<SimulatedLink>()
|
||||
val scanCallbacks = mutableListOf<(DiscoveredDevice) -> Unit>()
|
||||
override fun load(key: String) = preferences[key]
|
||||
override fun save(key: String, value: String) { preferences[key] = value }
|
||||
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) { scanCallbacks += onDevice }
|
||||
override fun stopScan() {}
|
||||
override suspend fun connect(handle: String) = SimulatedLink(if (handle == "second-handle") "11111111-0000-4000-8000-000000000001" else "00000000-0000-4000-8000-000000000001").also { links += it }
|
||||
override fun crypto() = object : SecureChannel {
|
||||
override val hello = byteArrayOf(1)
|
||||
override fun finish(serverHello: ByteArray) {}
|
||||
override fun encrypt(payload: ByteArray) = payload
|
||||
override fun decrypt(record: ByteArray) = record
|
||||
}
|
||||
}
|
||||
|
||||
private class SimulatedLink(private val deviceId: String) : DeviceLink {
|
||||
override val mtu = 247
|
||||
private val receiver = Reassembler()
|
||||
private val messages = Channel<ByteArray>(Channel.UNLIMITED)
|
||||
private var sequence = 0L
|
||||
var failure: String? = null
|
||||
var blockStorage = false
|
||||
var storageEntered = CompletableDeferred<Unit>()
|
||||
var closed = false
|
||||
override suspend fun send(fragment: ByteArray) {
|
||||
check(!closed)
|
||||
val (kind, payload) = receiver.accept(fragment) ?: return
|
||||
val response = if (kind == 1) byteArrayOf(2) else {
|
||||
val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
|
||||
val method = request.getValue("method").jsonPrimitive.content
|
||||
if (method == failure) throw DeviceTransportFailure("simulated connection loss")
|
||||
if (method == "status.get" && blockStorage) { storageEntered.complete(Unit); awaitCancellation() }
|
||||
val result = when (method) {
|
||||
"session.open" -> """{"device_id":"$deviceId","device_name":"模拟屏","short_id":"${deviceId.take(8)}","protocol_major":1,"capabilities":["status","frame"]}"""
|
||||
"device.rename" -> buildJsonObject {
|
||||
put("device_id", deviceId); put("device_name", request.getValue("params").jsonObject.getValue("name"))
|
||||
put("short_id", deviceId.take(8)); put("protocol_major", 1); put("capabilities", JsonArray(emptyList()))
|
||||
}.toString()
|
||||
"status.get" -> """{"state":{}}"""
|
||||
"frame.get" -> """{"frame_revision":"test-frame","data_base64":"test-frame-data"}"""
|
||||
else -> "{}"
|
||||
}
|
||||
buildJsonObject {
|
||||
put("id", request.getValue("id")); put("ok", true)
|
||||
put("result", Protocol.json.parseToJsonElement(result))
|
||||
}.toString().encodeToByteArray()
|
||||
}
|
||||
Protocol.encode(if (kind == 1) 2 else 3, sequence++, response, mtu).forEach { messages.send(it) }
|
||||
}
|
||||
override suspend fun receive() = messages.receive()
|
||||
override fun close() { closed = true; messages.close() }
|
||||
}
|
||||
|
||||
class ControllerRecoveryTest {
|
||||
private val device = DiscoveredDevice("test-handle", "test-device", -40)
|
||||
|
||||
@Test fun lossClearsCachedStateAndReconnects() = runBlocking {
|
||||
val platform = SimulatedPlatform()
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
controller.page(1)
|
||||
withTimeout(2_000) { controller.state.first { it.frame != null && !it.busy } }
|
||||
assertNotNull(controller.state.value.frameCheckedAtMs)
|
||||
platform.links.last().failure = "status.get"
|
||||
controller.refresh()
|
||||
withTimeout(2_000) { controller.state.first { it.phase == "连接中断" } }
|
||||
assertNull(controller.state.value.frame)
|
||||
assertNull(controller.state.value.frameCheckedAtMs)
|
||||
assertNull(controller.state.value.storage)
|
||||
assertTrue(platform.links.last().closed)
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
assertEquals(2, platform.links.size)
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
@Test fun disconnectCancelsInFlightOperationWithoutCorruptingNextConnection() = runBlocking {
|
||||
val platform = SimulatedPlatform()
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
val old = platform.links.last()
|
||||
old.blockStorage = true
|
||||
controller.refresh()
|
||||
withTimeout(2_000) { old.storageEntered.await() }
|
||||
controller.disconnect()
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
assertTrue(old.closed)
|
||||
assertFalse(controller.state.value.busy)
|
||||
assertNull(controller.state.value.error)
|
||||
assertFalse(platform.links.last().closed)
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
@Test fun oldScanCallbackCannotPopulateNewScan() = runBlocking {
|
||||
val platform = SimulatedPlatform()
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.scan(); controller.scan()
|
||||
platform.scanCallbacks.first()(device)
|
||||
yield()
|
||||
assertTrue(controller.state.value.devices.isEmpty())
|
||||
platform.scanCallbacks.last()(device)
|
||||
withTimeout(2_000) { controller.state.first { it.devices.size == 1 } }
|
||||
Unit
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
@Test fun rememberedDevicesSurviveDisconnectAndForgetPreventsReconnect() = runBlocking {
|
||||
val platform = SimulatedPlatform()
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
val remembered = controller.state.value.remembered.single()
|
||||
assertEquals("模拟屏", remembered.name)
|
||||
controller.scan()
|
||||
assertNotNull(controller.state.value.identity)
|
||||
assertTrue(controller.state.value.scanning)
|
||||
platform.scanCallbacks.last()(device)
|
||||
withTimeout(2_000) { controller.state.first { it.devices.isNotEmpty() } }
|
||||
assertEquals(1, platform.links.size)
|
||||
controller.disconnect()
|
||||
assertEquals(remembered.id, Controller(platform, scope).state.value.remembered.single().id)
|
||||
controller.forget(remembered.id)
|
||||
controller.foreground(true)
|
||||
assertFalse(controller.state.value.scanning)
|
||||
assertTrue(controller.state.value.remembered.isEmpty())
|
||||
assertEquals("", platform.load("last_device_id"))
|
||||
controller.renameDevice("不应执行", remembered.id)
|
||||
assertEquals("请连接设备", controller.state.value.error)
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
@Test fun selectingDeviceReleasesPreviousLinkAndForgettingActiveDisconnects() = runBlocking {
|
||||
val platform = SimulatedPlatform()
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
val old = platform.links.single()
|
||||
controller.connect(device.copy(handle = "second-handle"))
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
assertTrue(old.closed)
|
||||
assertEquals(2, platform.links.size)
|
||||
assertEquals(2, controller.state.value.remembered.size)
|
||||
controller.forget("11111111-0000-4000-8000-000000000001")
|
||||
assertNull(controller.state.value.identity)
|
||||
assertTrue(platform.links.last().closed)
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
@Test fun legacyLastDeviceMigratesAfterValidatedHandshake() = runBlocking {
|
||||
val platform = SimulatedPlatform()
|
||||
val id = "00000000-0000-4000-8000-000000000001"
|
||||
platform.preferences["last_device_id"] = id
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.foreground(true)
|
||||
platform.scanCallbacks.last()(device.copy(name = "QMS-00000000"))
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
assertEquals(id, controller.state.value.remembered.single().id)
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
@Test fun renameUpdatesAuthenticatedIdentityAndRememberedName() = runBlocking {
|
||||
val platform = SimulatedPlatform()
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.connect(device)
|
||||
withTimeout(2_000) { controller.state.first { it.status != null } }
|
||||
controller.renameDevice("客厅测试屏")
|
||||
withTimeout(2_000) { controller.state.first { it.remembered.firstOrNull()?.name == "客厅测试屏" } }
|
||||
assertEquals("客厅测试屏", controller.state.value.identity?.get("device_name")?.jsonPrimitive?.content)
|
||||
controller.disconnect()
|
||||
assertEquals("客厅测试屏", Controller(platform, scope).state.value.remembered.single().name)
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import kotlin.test.*
|
||||
|
||||
class ProtocolTest {
|
||||
@Test fun minimumMtuRoundTrip() {
|
||||
val payload = "中文协议".repeat(20).encodeToByteArray()
|
||||
val rx = Reassembler()
|
||||
var result: Pair<Int, ByteArray>? = null
|
||||
Protocol.encode(1, 0, payload, 23).forEach { result = rx.accept(it) }
|
||||
assertEquals(1, result!!.first)
|
||||
assertContentEquals(payload, result!!.second)
|
||||
}
|
||||
@Test fun rejectsOutOfOrderAndReplayedMessage() {
|
||||
val frames = Protocol.encode(3, 0, ByteArray(20), 23)
|
||||
assertFailsWith<IllegalArgumentException> { Reassembler().accept(frames[1]) }
|
||||
val rx = Reassembler()
|
||||
frames.forEach { rx.accept(it) }
|
||||
assertFailsWith<IllegalArgumentException> { rx.accept(frames[0]) }
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import java.math.BigInteger
|
||||
import java.security.AlgorithmParameters
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.interfaces.ECPublicKey
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
import java.security.spec.ECParameterSpec
|
||||
import java.security.spec.ECPoint
|
||||
import java.security.spec.ECPublicKeySpec
|
||||
import java.util.Base64
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyAgreement
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
/** JCA implementation shared by Android and JVM interoperability tests. */
|
||||
class JavaSecureChannel(
|
||||
private val keyPair: KeyPair = KeyPairGenerator.getInstance("EC").apply {
|
||||
initialize(ECGenParameterSpec("secp256r1"))
|
||||
}.generateKeyPair(),
|
||||
private val random: ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) },
|
||||
) : SecureChannel {
|
||||
private val label = "QMS-BLE-1".encodeToByteArray()
|
||||
private var material: ByteArray? = null
|
||||
private var transcript = ByteArray(0)
|
||||
private var tx = 0L
|
||||
private var rx = 0L
|
||||
private var failed = false
|
||||
private fun fixed(value: BigInteger): ByteArray = value.toByteArray().let {
|
||||
if (it.size >= 32) it.copyOfRange(it.size - 32, it.size) else ByteArray(32 - it.size) + it
|
||||
}
|
||||
override val hello: ByteArray
|
||||
init {
|
||||
require(random.size == 32)
|
||||
val point = (keyPair.public as ECPublicKey).w
|
||||
val public = byteArrayOf(4) + fixed(point.affineX) + fixed(point.affineY)
|
||||
hello = buildJsonObject {
|
||||
put("protocol_major", 1); put("protocol_minor", 0)
|
||||
put("public_key", Base64.getEncoder().encodeToString(public))
|
||||
put("random", Base64.getEncoder().encodeToString(random))
|
||||
}.toString().encodeToByteArray()
|
||||
}
|
||||
private fun hash(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
private fun number(value: Long, length: Int) = ByteArray(length).apply { putInt(0, value, length) }
|
||||
private fun mac(key: ByteArray, bytes: ByteArray) = Mac.getInstance("HmacSHA256").run {
|
||||
init(SecretKeySpec(key, "HmacSHA256")); doFinal(bytes)
|
||||
}
|
||||
override fun finish(serverHello: ByteArray) {
|
||||
check(material == null && !failed)
|
||||
try {
|
||||
require(serverHello.size <= 1024)
|
||||
val peer = Protocol.json.parseToJsonElement(serverHello.decodeToString(throwOnInvalidSequence = true)).jsonObject
|
||||
require(peer.getValue("protocol_major").jsonPrimitive.int == 1)
|
||||
require(peer.getValue("protocol_minor").jsonPrimitive.int >= 0)
|
||||
val public = Base64.getDecoder().decode(peer.getValue("public_key").jsonPrimitive.content)
|
||||
val sr = Base64.getDecoder().decode(peer.getValue("random").jsonPrimitive.content)
|
||||
require(public.size == 65 && public[0] == 4.toByte() && sr.size == 32)
|
||||
val parameters = AlgorithmParameters.getInstance("EC").apply { init(ECGenParameterSpec("secp256r1")) }
|
||||
.getParameterSpec(ECParameterSpec::class.java)
|
||||
val point = ECPoint(BigInteger(1, public.copyOfRange(1, 33)), BigInteger(1, public.copyOfRange(33, 65)))
|
||||
val peerKey = KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec(point, parameters))
|
||||
val secret = KeyAgreement.getInstance("ECDH").run { init(keyPair.private); doPhase(peerKey, true); generateSecret() }
|
||||
transcript = hash(number(hello.size.toLong(), 4) + hello + number(serverHello.size.toLong(), 4) + serverHello)
|
||||
val prk = mac(hash(random + sr), secret)
|
||||
var block = ByteArray(0)
|
||||
var expanded = ByteArray(0)
|
||||
for (i in 1..3) {
|
||||
block = mac(prk, block + label + transcript + byteArrayOf(i.toByte()))
|
||||
expanded += block
|
||||
}
|
||||
material = expanded.copyOf(72)
|
||||
secret.fill(0); prk.fill(0); expanded.fill(0)
|
||||
} catch (e: Exception) {
|
||||
failed = true
|
||||
throw IllegalArgumentException("无法建立加密会话")
|
||||
}
|
||||
}
|
||||
override fun encrypt(payload: ByteArray): ByteArray {
|
||||
check(!failed && tx >= 0 && tx < Long.MAX_VALUE)
|
||||
require(payload.size in 1..Protocol.MAX_MESSAGE - 24)
|
||||
val seq = number(tx, 8)
|
||||
val result = seq + crypt(Cipher.ENCRYPT_MODE, 0, seq, payload)
|
||||
tx++
|
||||
return result
|
||||
}
|
||||
override fun decrypt(record: ByteArray): ByteArray {
|
||||
try {
|
||||
check(!failed && rx >= 0)
|
||||
require(record.size in 25..Protocol.MAX_MESSAGE && record.readInt(0, 8) == rx)
|
||||
val result = crypt(Cipher.DECRYPT_MODE, 1, record.copyOfRange(0, 8), record.copyOfRange(8, record.size))
|
||||
rx++
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
failed = true
|
||||
material?.fill(0)
|
||||
throw IllegalArgumentException("加密消息校验失败")
|
||||
}
|
||||
}
|
||||
private fun crypt(mode: Int, direction: Int, seq: ByteArray, value: ByteArray): ByteArray {
|
||||
val keys = checkNotNull(material)
|
||||
return Cipher.getInstance("AES/GCM/NoPadding").run {
|
||||
init(mode, SecretKeySpec(keys.copyOfRange(direction * 32, direction * 32 + 32), "AES"),
|
||||
GCMParameterSpec(128, keys.copyOfRange(64 + direction * 4, 68 + direction * 4) + seq))
|
||||
updateAAD(label + transcript + byteArrayOf(direction.toByte()) + seq)
|
||||
doFinal(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import java.math.BigInteger
|
||||
import java.security.AlgorithmParameters
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
import java.security.spec.*
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlin.test.*
|
||||
|
||||
class CryptoInteropTest {
|
||||
private val vector = Protocol.json.parseToJsonElement(
|
||||
checkNotNull(javaClass.getResourceAsStream("/protocol-v1.json")).bufferedReader().use { it.readText() }
|
||||
).jsonObject
|
||||
private fun bytes(key: String) = vector.getValue(key).jsonPrimitive.content.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
private fun channel(): JavaSecureChannel {
|
||||
val params = AlgorithmParameters.getInstance("EC").apply { init(ECGenParameterSpec("secp256r1")) }
|
||||
.getParameterSpec(ECParameterSpec::class.java)
|
||||
val factory = KeyFactory.getInstance("EC")
|
||||
val privateKey = factory.generatePrivate(ECPrivateKeySpec(BigInteger.ONE, params))
|
||||
val publicKey = factory.generatePublic(ECPublicKeySpec(params.generator, params))
|
||||
return JavaSecureChannel(KeyPair(publicKey, privateKey), bytes("client_random"))
|
||||
}
|
||||
@Test fun pythonGoldenRecordsMatch() {
|
||||
val channel = channel()
|
||||
assertContentEquals(bytes("client_hello"), channel.hello)
|
||||
channel.finish(bytes("server_hello"))
|
||||
assertContentEquals(bytes("client_record"), channel.encrypt(bytes("request")))
|
||||
assertContentEquals(bytes("response"), channel.decrypt(bytes("server_record")))
|
||||
assertFails { channel.decrypt(bytes("server_record")) }
|
||||
}
|
||||
@Test fun pythonFragmentVectorMatches() {
|
||||
val expected = vector.getValue("fragments_mtu23").jsonArray.map {
|
||||
it.jsonPrimitive.content.chunked(2).map { hex -> hex.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
val actual = Protocol.encode(3, 0, bytes("client_record"), 23)
|
||||
assertEquals(expected.size, actual.size)
|
||||
expected.zip(actual).forEach { (a, b) -> assertContentEquals(a, b) }
|
||||
}
|
||||
@Test fun tamperedRecordInvalidatesConnection() {
|
||||
val channel = channel()
|
||||
channel.finish(bytes("server_hello"))
|
||||
val record = bytes("server_record")
|
||||
record[record.lastIndex] = (record.last().toInt() xor 1).toByte()
|
||||
assertFails { channel.decrypt(record) }
|
||||
assertFails { channel.decrypt(bytes("server_record")) }
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.test.*
|
||||
|
||||
class RpcTimeoutTest {
|
||||
@Test fun timeoutClosesTransportAndIsNotCancellation() = runBlocking {
|
||||
var closed = false
|
||||
val link = object : DeviceLink {
|
||||
override val mtu = 247
|
||||
override suspend fun send(fragment: ByteArray) {}
|
||||
override suspend fun receive(): ByteArray { awaitCancellation() }
|
||||
override fun close() { closed = true }
|
||||
}
|
||||
val crypto = object : SecureChannel {
|
||||
override val hello = byteArrayOf(1)
|
||||
override fun finish(serverHello: ByteArray) {}
|
||||
override fun encrypt(payload: ByteArray) = payload
|
||||
override fun decrypt(record: ByteArray) = record
|
||||
}
|
||||
val rpc = DeviceRpc(link, crypto, requestTimeoutMs = 25)
|
||||
val error = assertFailsWith<DeviceTransportFailure> { rpc.call("status.get") }
|
||||
assertTrue(error.message!!.contains("status.get"))
|
||||
assertTrue(closed)
|
||||
assertFailsWith<IllegalStateException> { rpc.call("status.get") }
|
||||
Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"note": "Public interoperability fixture only; never use these scalars in real connections.",
|
||||
"client_scalar": "1",
|
||||
"server_scalar": "2",
|
||||
"client_random": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
||||
"server_random": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
|
||||
"client_hello": "7b2270726f746f636f6c5f6d616a6f72223a312c2270726f746f636f6c5f6d696e6f72223a302c227075626c69635f6b6579223a224247735830664c684c454a482b4c7a6d35574f6b51504a33413332424c65737a6f5053684f5558596d4d4b57542b4e43347634616635754f352b744b66412b654669764f4d3164724d56374f79375a416144652f5566553d222c2272616e646f6d223a2241414543417751464267634943516f4c4441304f4478415245684d554652595847426b61477877644868383d227d",
|
||||
"server_hello": "7b2270726f746f636f6c5f6d616a6f72223a312c2270726f746f636f6c5f6d696e6f72223a302c227075626c69635f6b6579223a2242487a796578694e4130392b696c4934417753314773504169576e69642f49624e61594c535078485a706c3442336456454e754f30454170505a72476e3351773237703972655938365949706e6753336e534a346339453d222c2272616e646f6d223a22494345694979516c4a69636f4b536f724c4330754c7a41784d6a4d304e5459334f446b364f7a7739506a383d227d",
|
||||
"transcript": "9ca718b743dfe549aaba17180599eced0e2bcb680fd7c210e341ae74ccd17b76",
|
||||
"request": "7b226964223a2231222c226d6574686f64223a2273657373696f6e2e6f70656e222c22706172616d73223a7b22636c69656e745f6e616d65223a22e6b58be8af95e6898be69cba227d7d",
|
||||
"response": "7b226964223a2231222c226f6b223a747275652c22726573756c74223a7b22616c697665223a747275657d7d",
|
||||
"client_record": "0000000000000000f2c854e062cd588b6de2066904d362400bf231bdeb046d786fca2a2bb6e1b539d0bd32d225461581655d7dc5066cf89d2d3bea12421e0d6e452f34ba4c39219b3e3efd3826c8461b67b8a74650976db8d47a0edd94e6cf39f526",
|
||||
"server_record": "0000000000000000fd089bf6a0d3196c601f5dcbefad51b0f130e17cf38d2c76cbb9855a7da88480e53f3e89893dcc35fa175fcfbf01683f575244928462d07d13056a67",
|
||||
"fragments_mtu23": [
|
||||
"514d010300000000000000190000006200000000",
|
||||
"514d010300000000000100190000006200000000",
|
||||
"514d0103000000000002001900000062f2c854e0",
|
||||
"514d010300000000000300190000006262cd588b",
|
||||
"514d01030000000000040019000000626de20669",
|
||||
"514d010300000000000500190000006204d36240",
|
||||
"514d01030000000000060019000000620bf231bd",
|
||||
"514d0103000000000007001900000062eb046d78",
|
||||
"514d01030000000000080019000000626fca2a2b",
|
||||
"514d0103000000000009001900000062b6e1b539",
|
||||
"514d010300000000000a001900000062d0bd32d2",
|
||||
"514d010300000000000b00190000006225461581",
|
||||
"514d010300000000000c001900000062655d7dc5",
|
||||
"514d010300000000000d001900000062066cf89d",
|
||||
"514d010300000000000e0019000000622d3bea12",
|
||||
"514d010300000000000f001900000062421e0d6e",
|
||||
"514d0103000000000010001900000062452f34ba",
|
||||
"514d01030000000000110019000000624c39219b",
|
||||
"514d01030000000000120019000000623e3efd38",
|
||||
"514d010300000000001300190000006226c8461b",
|
||||
"514d010300000000001400190000006267b8a746",
|
||||
"514d010300000000001500190000006250976db8",
|
||||
"514d0103000000000016001900000062d47a0edd",
|
||||
"514d010300000000001700190000006294e6cf39",
|
||||
"514d0103000000000018001900000062f526"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+283
@@ -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("确定") } })
|
||||
}
|
||||
Reference in New Issue
Block a user