From 8e9cf677c5dd5246ca04f082a3de158da699da25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:22:43 +0000 Subject: [PATCH] feat(android): dual-channel BLE + network relay client (BR-1.1/1.3, E4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android now runs the network relay alongside BLE, closing the convoy distance gap on the Android side. Design keeps the team code off the wire entirely: - MeshBus.send() computes the ciphertext once, then sends the full [kind|teamLen|team|cipher] frame over BLE AND relays just roomId(team)+kind+base64(cipher) over the network — team plaintext never leaves the device (BR-1.2) - Receive path is shared: dispatchSealed() decrypts with the local team and drops on MAC failure, so a wrong-room hash collision from the network is discarded exactly like a wrong-team BLE frame - NetTransport.kt (OkHttp WebSocket): relay-join with roomId+token, exponential-backoff reconnect, silent degradation — relayUrl empty means bluetooth-only, behaviourally identical to today (BR-1.4). Now uses the previously-dead INTERNET permission (resolves KI-10) - BR-1.3 dedup is free: business layer is already idempotent (chat/ voice by mid, position overwrites by device, acks are a Set), so a frame arriving via both channels is harmless — documented, no extra dedup layer - cloudCount tracked separately from BLE peerCount (BR-1.5 data; visible UI wiring is the remaining Android piece) MeshCrypto.roomId added for parity with meshcrypto.mjs. Fixture at 57 assertions: adds Android roomId salt drift check and a NetTransport 'ciphertext-only' shape check. iOS network channel deferred pending decision D-2 (long-poll HTTPS vs hand-rolled WebSocket — iOS 12 has no URLSessionWebSocketTask). ③ G-NET-1 marked in-progress. --- android-native/app/build.gradle | 2 + .../src/main/java/cc/trailmate/app/MeshBus.kt | 67 ++++++++++-- .../main/java/cc/trailmate/app/MeshCrypto.kt | 9 ++ .../java/cc/trailmate/app/NetTransport.kt | 100 ++++++++++++++++++ ...24\350\277\233\350\247\204\345\210\222.md" | 2 +- tests/mesh-fixture/run.mjs | 7 ++ 6 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 android-native/app/src/main/java/cc/trailmate/app/NetTransport.kt diff --git a/android-native/app/build.gradle b/android-native/app/build.gradle index 02f2c0d..a86fc15 100644 --- a/android-native/app/build.gradle +++ b/android-native/app/build.gradle @@ -40,4 +40,6 @@ dependencies { implementation 'com.journeyapps:zxing-android-embedded:4.3.0' // 小票 OCR:ML Kit 文字识别(模型内置离线可用,无需 Google 服务;数字金额用拉丁模型足够) implementation 'com.google.mlkit:text-recognition:16.0.1' + // 网络中继通道(BR-1.1):WebSocket 客户端,与 BLE 并行;relayUrl 为空则完全不启用 + implementation 'com.squareup.okhttp3:okhttp:4.12.0' } diff --git a/android-native/app/src/main/java/cc/trailmate/app/MeshBus.kt b/android-native/app/src/main/java/cc/trailmate/app/MeshBus.kt index d1559e8..bd78c7d 100644 --- a/android-native/app/src/main/java/cc/trailmate/app/MeshBus.kt +++ b/android-native/app/src/main/java/cc/trailmate/app/MeshBus.kt @@ -50,6 +50,21 @@ object Identity { fun setGhost(ctx: Context, v: Boolean) { ctx.getSharedPreferences(SP, Context.MODE_PRIVATE).edit().putBoolean("ghost", v).apply() } + + // 网络中继(BR-1.1):relayUrl 为空 = 只用蓝牙(默认,等同今日行为)。token 对应服务端 SIGNAL_TOKEN。 + fun relayUrl(ctx: Context): String = + ctx.getSharedPreferences(SP, Context.MODE_PRIVATE).getString("relayUrl", "") ?: "" + + fun setRelayUrl(ctx: Context, url: String) { + ctx.getSharedPreferences(SP, Context.MODE_PRIVATE).edit().putString("relayUrl", url.trim()).apply() + } + + fun relayToken(ctx: Context): String = + ctx.getSharedPreferences(SP, Context.MODE_PRIVATE).getString("relayToken", "") ?: "" + + fun setRelayToken(ctx: Context, t: String) { + ctx.getSharedPreferences(SP, Context.MODE_PRIVATE).edit().putString("relayToken", t.trim()).apply() + } } /** @@ -66,26 +81,50 @@ object MeshBus : BleMesh.Listener { private var appCtx: Context? = null private var mesh: BleMesh? = null + private var net: NetTransport? = null // 每类型单 handler:后注册者替换前者。Fragment 切 Tab 重建时新实例自动顶替旧实例, // 避免向单例累积 lambda 造成旧 Fragment 泄漏与消息重复处理。 private val handlers = HashMap Unit>() private val peerHandlers = HashMap Unit>() + private val cloudHandlers = HashMap Unit>() var peerCount = 0 private set + var cloudCount = 0 // 云端在线数(BR-1.5:与蓝牙邻居分开显示,不混淆) + private set /** 幂等启动(需已授蓝牙权限)。返回是否成功开启。 */ fun start(ctx: Context): Boolean { val app = ctx.applicationContext appCtx = app val m = mesh ?: BleMesh(app).also { it.listener = this; mesh = it } - return m.start() + val ok = m.start() + startNet(app) // 网络中继与 BLE 并行(BR-1.1);relayUrl 为空则空转 + return ok + } + + // 启动/重启网络通道。队伍码变化或首次配置 URL 时调用。 + fun startNet(ctx: Context) { + val app = ctx.applicationContext + appCtx = app + net?.stop(); net = null + cloudCount = 0 + val url = Identity.relayUrl(app) + if (url.isEmpty()) { cloudHandlers.values.forEach { it(0) }; return } + net = NetTransport( + url = url, + room = MeshCrypto.roomId(Identity.team(app)), + token = Identity.relayToken(app), + onFrame = { kind, sealed -> dispatchSealed(kind, sealed) }, // 网络收帧:用本机队伍码解密 + onCloud = { n -> cloudCount = n; cloudHandlers.values.forEach { it(n) } }, + ).also { it.start() } } fun send(kind: Byte, payload: ByteArray) { val ctx = appCtx ?: return val teamStr = Identity.team(ctx) - // 端到端加密(G-CM-3):信封 team 明文分房,业务负载全密文 + // 端到端加密(G-CM-3):信封 team 明文仅供 BLE 分房,业务负载全密文 val sealed = MeshCrypto.encrypt(teamStr, payload) + // BLE 通道:完整信封帧 val team = teamStr.toByteArray(Charsets.UTF_8).let { if (it.size > 32) it.copyOfRange(0, 32) else it } @@ -95,6 +134,8 @@ object MeshBus : BleMesh.Listener { System.arraycopy(team, 0, frame, 2, team.size) System.arraycopy(sealed, 0, frame, 2 + team.size, sealed.size) mesh?.send(frame) + // 网络通道:只发 roomId + kind + 密文,**队伍码不上网**(BR-1.2) + net?.relay(kind, sealed) } fun subscribe(kind: Byte, handler: (ByteArray) -> Unit) { @@ -106,6 +147,21 @@ object MeshBus : BleMesh.Listener { handler(peerCount) } + fun onCloud(tag: String, handler: (Int) -> Unit) { + cloudHandlers[tag] = handler + handler(cloudCount) + } + + // 解密并分发(两条通道共用)。BR-1.3 双通道去重:业务层已幂等—— + // 聊天/语音按 mid appendIfNew、位置按设备 id 覆盖、回执用 Set,故同帧双至无害,无需额外去重层。 + private fun dispatchSealed(kind: Byte, sealed: ByteArray) { + val ctx = appCtx ?: return + val team = Identity.team(ctx) + // 解密并验 MAC(G-CM-3):错队伍码/被篡改/旧版明文一律丢弃(网络侧亦借此天然处理房间哈希碰撞) + val body = MeshCrypto.decrypt(team, sealed) ?: return + handlers[kind]?.invoke(body) + } + // MARK: - BleMesh.Listener(主线程) override fun onMeshMessage(payload: ByteArray) { if (payload.size < 2) return @@ -114,11 +170,8 @@ object MeshBus : BleMesh.Listener { if (payload.size < 2 + tlen) return val ctx = appCtx ?: return val team = String(payload, 2, tlen, Charsets.UTF_8) - if (team != Identity.team(ctx)) return // 队伍过滤 - val sealed = payload.copyOfRange(2 + tlen, payload.size) - // 解密并验 MAC(G-CM-3):错队伍码/被篡改/旧版明文一律丢弃 - val body = MeshCrypto.decrypt(team, sealed) ?: return - handlers[kind]?.invoke(body) + if (team != Identity.team(ctx)) return // BLE 侧队伍过滤(明文 team) + dispatchSealed(kind, payload.copyOfRange(2 + tlen, payload.size)) } override fun onMeshPeers(count: Int) { diff --git a/android-native/app/src/main/java/cc/trailmate/app/MeshCrypto.kt b/android-native/app/src/main/java/cc/trailmate/app/MeshCrypto.kt index a637e1a..72d51be 100644 --- a/android-native/app/src/main/java/cc/trailmate/app/MeshCrypto.kt +++ b/android-native/app/src/main/java/cc/trailmate/app/MeshCrypto.kt @@ -87,4 +87,13 @@ object MeshCrypto { } return out.copyOf(keyLen) } + + // 中继房间哈希(BR-1.2):SHA256("trailmate-room-v1|" + 队伍码)。 + // 与加密派生盐 trailmate-mesh-v1 不同做域分离;上网的只有它,队伍码绝不上传。 + // 与 tests/mesh-fixture/meshcrypto.mjs 的 roomId 一致。 + fun roomId(team: String): String { + val md = java.security.MessageDigest.getInstance("SHA-256") + val h = md.digest("trailmate-room-v1|$team".toByteArray(Charsets.UTF_8)) + return h.joinToString("") { "%02x".format(it) } + } } diff --git a/android-native/app/src/main/java/cc/trailmate/app/NetTransport.kt b/android-native/app/src/main/java/cc/trailmate/app/NetTransport.kt new file mode 100644 index 0000000..7e51bd2 --- /dev/null +++ b/android-native/app/src/main/java/cc/trailmate/app/NetTransport.kt @@ -0,0 +1,100 @@ +package cc.trailmate.app + +import android.os.Handler +import android.os.Looper +import android.util.Base64 +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +/** + * 网络中继通道(BR-1.1):与 BLE 并行的第二条传输路,补上"超 BLE 距离即失效"(G-NET-1)。 + * + * 只发密文:上行的是 `roomId(队伍码)` + `kind` + base64(端到端密文),**队伍码绝不上传** + * (见 server/signaling.js 与《④ 后台服务端规划》§3.3)。收端用自己的队伍码解密,MAC 不过即丢。 + * + * 鲁棒性(BR-1.4):连接失败/断线只做指数退避重连,绝不抛错、绝不阻塞 UI; + * relayUrl 为空即完全不启用——此时行为与今日纯 BLE 版一致。 + */ +class NetTransport( + private val url: String, + private val room: String, + private val token: String, + private val onFrame: (Byte, ByteArray) -> Unit, // 收到中继帧:kind + 密文 + private val onCloud: (Int) -> Unit, // 云端在线数变化(BR-1.5) +) { + private val client = OkHttpClient.Builder() + .pingInterval(20, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + private val main = Handler(Looper.getMainLooper()) + private var ws: WebSocket? = null + private var closed = false + private var backoff = 1000L + @Volatile var cloudCount = 0 + private set + + fun start() { closed = false; connect() } + + fun stop() { + closed = true + runCatching { ws?.close(1000, null) } + ws = null + setCloud(0) + } + + fun relay(kind: Byte, sealed: ByteArray) { + val w = ws ?: return + val msg = JSONObject() + .put("type", "relay").put("room", room) + .put("kind", kind.toInt() and 0xFF) + .put("body", Base64.encodeToString(sealed, Base64.NO_WRAP)) + runCatching { w.send(msg.toString()) } + } + + private fun setCloud(n: Int) { + cloudCount = n + main.post { onCloud(n) } + } + + private fun connect() { + if (closed) return + val req = Request.Builder().url(url).build() + ws = client.newWebSocket(req, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + backoff = 1000L + val join = JSONObject().put("type", "relay-join").put("room", room) + if (token.isNotEmpty()) join.put("token", token) + runCatching { webSocket.send(join.toString()) } + } + + override fun onMessage(webSocket: WebSocket, text: String) { + try { + val o = JSONObject(text) + when (o.optString("type")) { + "relay-joined" -> setCloud(o.optInt("peers", 0)) + "relay" -> { + val kind = (o.getInt("kind") and 0xFF).toByte() + val sealed = Base64.decode(o.getString("body"), Base64.DEFAULT) + main.post { onFrame(kind, sealed) } + } + } + } catch (_: Exception) {} + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = scheduleReconnect() + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = scheduleReconnect() + }) + } + + private fun scheduleReconnect() { + setCloud(0) + if (closed) return + main.postDelayed({ connect() }, backoff) + backoff = (backoff * 2).coerceAtMost(16000L) + } +} diff --git "a/docs/requirements/03-Gap\345\210\206\346\236\220\344\270\216\346\274\224\350\277\233\350\247\204\345\210\222.md" "b/docs/requirements/03-Gap\345\210\206\346\236\220\344\270\216\346\274\224\350\277\233\350\247\204\345\210\222.md" index f5defa5..25c572f 100644 --- "a/docs/requirements/03-Gap\345\210\206\346\236\220\344\270\216\346\274\224\350\277\233\350\247\204\345\210\222.md" +++ "b/docs/requirements/03-Gap\345\210\206\346\236\220\344\270\216\346\274\224\350\277\233\350\247\204\345\210\222.md" @@ -195,7 +195,7 @@ E0 恒为稳定性/可测性收口——在补自检与夹具之前堆新功能 | G-DR-1 | L3 | ①FR-2.3 | PR-P1-2 | E1 / v0.3.0 | 已实现(iOS/Android 跟车页蓝牙关/无邻居横幅,待真机关蓝牙验收) | | G-DR-2 | L4 | ②§3.1 | PR-P2-1 | E2 / v0.4.0 | 已实现(双端:15s 未更新标记变淡、60s 移除;待真机拔电验收) | | G-DR-3 | L4 | ②§7 | PR-P2-4 | E3 / v0.5.0 | 已实现(双端跟车页隐身开关,开启即停止广播并横幅提示) | -| G-NET-1 | **L1** | ④§1.1 S#1–S#6 | BR-1.x | E4 / v0.6.0 | 未启动(已规划,见《④ 后台服务端规划》) | +| G-NET-1 | **L1** | ④§1.1 S#1–S#6 | BR-1.x | E4 / v0.6.0 | 进行中(服务端密文中继 + Android 双通道已落地,夹具 57 项验收;iOS 待决策 D-2;均待真机跨距验收) | | G-LG-1 | L3 | ①FR-4.8 | PR-P1-5 | E1 / v0.3.0 | 已实现(Web 往返验收通过;iOS/Android 分享导出+粘贴导入,待真机卸载重装验收) | | G-LG-2 | L4 | B#3 | PR-P1-6 | E0 / v0.2.0 | 已关闭(验收通过) | | G-LG-3 | L4 | ①FR-4.7 | PR-P2-3 | E2 / v0.4.0 | 已实现(ML Kit 离线识别,选小票照片自动填最大金额;待真机拍票验收) | diff --git a/tests/mesh-fixture/run.mjs b/tests/mesh-fixture/run.mjs index 061bd29..697731f 100644 --- a/tests/mesh-fixture/run.mjs +++ b/tests/mesh-fixture/run.mjs @@ -419,6 +419,13 @@ console.log('[12] 服务端业务帧中继(哑中继)') check('房间哈希不可反推为密钥:与 PBKDF2 盐做了域分离', mc.ROOM_SALT !== mc.SALT) check('密文里检索不到业务明文(坐标)', !cipher.toString('binary').includes('120.13')) + // roomId 三端防漂移:Android/iOS 源码用同一 SHA256 域分离串 + const ktCrypto = readFileSync(join(ROOT, 'android-native/app/src/main/java/cc/trailmate/app/MeshCrypto.kt'), 'utf8') + check('Android roomId 用同一域分离串 trailmate-room-v1|', ktCrypto.includes('trailmate-room-v1|$team')) + check('Android NetTransport 只上传密文(relay 带 room+kind+base64 body)', + readFileSync(join(ROOT, 'android-native/app/src/main/java/cc/trailmate/app/NetTransport.kt'), 'utf8') + .match(/put\("type", "relay"\)[\s\S]*put\("body"/) != null) + for (const c of [a, b, c3, sneak, bad]) { try { c.close() } catch {} } srv.kill() }