Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions android-native/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
67 changes: 60 additions & 7 deletions android-native/app/src/main/java/cc/trailmate/app/MeshBus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

/**
Expand All @@ -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<Byte, (ByteArray) -> Unit>()
private val peerHandlers = HashMap<String, (Int) -> Unit>()
private val cloudHandlers = HashMap<String, (Int) -> 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
}
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
100 changes: 100 additions & 0 deletions android-native/app/src/main/java/cc/trailmate/app/NetTransport.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion docs/requirements/03-Gap分析与演进规划.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 离线识别,选小票照片自动填最大金额;待真机拍票验收) |
Expand Down
7 changes: 7 additions & 0 deletions tests/mesh-fixture/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Loading