依据 docs/项目工程改进方案.md 实施(第五梯队 CI/协作除外)。
安全性
- Release 剥离 Log.v/d/i/w(-assumenosideeffects),保留 Log.e
- 全量频道/成员 dump、消息正文、发送者 UID 改为 BuildConfig.DEBUG 保护
(实测 release dex 中 "Channel: id=" / "Client: id=" / selfId= 等均为 0 命中)
构建产物体积
- .gitignore 补 *.a(保留 .opus/lib 四个 ABI 预编译库)、.opus/install、
app/libs/*.aar、dnn/torch
- git rm --cached 除名 AAR、.gradle、.opus/{build,install}、dnn/torch
跟踪体积 50MB → 17.4MB,工作区文件不受影响
跨语言契约(本方案核心)
- 新增 go/teamspeak/contract.go:字段名常量 + BridgeContractVersion 单一事实来源
- 新增契约测试:反射断言常量与 struct tag 一致;Go/Kotlin 共用 golden 样本
- 新增 BridgeContract.kt:启动校验 AAR 契约版本,不匹配则阻止连接
- 修正文档:channel_order 是前驱频道 ID(链表指针)而非排序权重,
按它数值排序会打乱频道树;ChannelInfo.order 在 Go 侧不存在
状态模型
- ConnectionState 增 Idle 取代 null 编码,connectionState 不再可空
- applyClients 改为按频道差分更新,避免全量刷新导致频道树整体重组
- 频道数据 freshness 统一由 Repository 维护,修复 ViewModel 与 Repository
两份时间戳不同步导致的重复刷新
UI
- 修复 collectAsState() 在参数位置调用导致的 isSelf 快照失效
- 消除 9 处 !! 断言(ChannelListScreen)
- 硬编码 24.5/14.5dp 与 depth*24 收敛到 UiTokens.Spacing,缩进加 4 层上限
测试
- Kotlin 测试 7 → 106;Go 契约测试新增 24 个用例
- 各覆盖 InputValidator、消息送达确认、僵尸会话过滤、频道差分、
频道顺序语义、错误分类
其他修复
- classifyError 提取为纯函数并补测试;修复 too many clones (id=521)
未识别导致英文原文直接暴露给用户
版本号提升至 1.0.13(versionCode 13)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 KiB
Go ↔ Kotlin Bridge 契约
状态:权威 | 最后核对:2026-09-10 | 契约版本:
1单一事实来源:
go/teamspeak/contract.go(字段名常量 +BridgeContractVersion) Kotlin 侧守卫:android/app/src/main/java/com/tsmobile/app/data/BridgeContract.kt契约测试:go/teamspeak/contract_test.go、contract_golden_test.go、android/app/src/test/java/com/tsmobile/app/data/BridgeContractTest.kt
为什么需要这份文档
gomobile 有三条硬限制(CLAUDE.md 已记录):不能导出 []string、[]*T、Go error。
项目用 JSON 字符串绕过它们,代价是:
Go 侧改一个字段名,Kotlin 侧不会编译失败,只会在运行时静默拿到默认值。
叠加 Repository.kt 的 Json { ignoreUnknownKeys = true },字段对不上时
ChannelInfo 直接填 "" / 0 / false:频道列表看起来是空的,
或者密码频道显示成公开频道,而日志里什么都没有。
本契约用三道机制钉住这个边界:
| 机制 | 防止的问题 |
|---|---|
contract.go 字段名常量 |
集中声明,marshal 处不写字面量 |
contract_test.go 反射断言 |
常量与 struct tag 漂移 → 测试红 |
| golden 样本(Go/Kotlin 共用) | 样本与 tag 漂移 → 两侧测试都红 |
BridgeContract.verifyOrDescribeError() |
AAR 与 App 版本不匹配 → 连接前明确报错 |
边界数据类型分级
不是所有跨边界数据都同样脆弱。改代码前先确认属于哪一类:
| 类别 | 保护强度 | 类型 | 说明 |
|---|---|---|---|
| A 类 | 编译期检查 | TextMsg、PokeEvent、Channel、Client |
gomobile 导出的 struct,字段是真实属性。Go 改名 → Kotlin 编译失败。无需常量保护。 |
| B 类 | 无(靠契约测试) | channelJSON、clientJSON、serverInfoJSON、文件下载 JSON |
JSON 字符串。本契约的主要保护对象。 |
| C 类 | 无(无消费方) | channelDetailedJSON、clientDetailedInfoJSON、initialSyncJSON |
已导出但 Kotlin 未解析。字段名已声明以钉住形状;将来要消费时先跑契约测试。 |
B 类契约明细
图例:✓ = 已有 Kotlin 消费方 | — = 当前未消费
1. 频道列表 — GetChannelsJSON() → Repository.fetchChannels()
Go:channelJSON(bridge.go)| Kotlin:ChannelInfo(Models.kt)
| Go 字段 | JSON 字段 | Kotlin 字段 | 类型 | 可空 | 默认值语义 |
|---|---|---|---|---|---|
ID |
id |
id |
String |
否 | "" — 字符串化数字,见下方注意 |
Name |
name |
name |
String |
否 | "" |
ParentID |
parentId |
parentId |
String |
否 | "0" — 顶级频道的约定值 |
Description |
description |
description |
String |
否 | ""(Go 侧实际不填充) |
IsPassword |
isPassword |
isPassword |
Boolean |
否 | false |
⚠️
id/parentId是字符串不是数字。 TeamSpeak 的频道 ID 是无符号 64 位, 超出 KotlinInt范围。contract_test.go的TestChannelJSON_IDIsStringType钉住了这一点——改成数值类型会让 Kotlin 在运行时抛序列化异常。
⚠️
ChannelInfo.order(Int)在 Go 侧不存在。channelJSON没有order字段, 所以该值恒为默认0,ChannelViewModel.buildChannelTree的.sortedBy { it.order }实际是不产生效果的 no-op(sortedBy稳定,全等值等于保持原序)。频道顺序目前由服务器返回顺序决定,且这是正确的——TS3 的
channellist响应按频道树顺序返回,子频道紧跟父频道。显示顺序经真机验证与响应顺序一致。📌 不要"按 order 排序":
channel_order不是排序权重,而是 前驱频道的 ID(链表指针,0= 排在本层最前)。见docs/teamspeak-sdk-3.5.2/doc/client/channel-sort.html:The channel order is the ID of the predecessor channel after which the given channel should be sorted. An order of 0 means the channel is sorted on the top of its hirarchy.
官方示例:
Subsubchannel_2 ( ID = 7 , order = 6 )表示"排在 ID=6 之后",Subchannel_2 ( ID = 5 , order = 4 )表示"排在 ID=4 之后"。 若按该值数值排序会得到1,4,6,2,3,5,7,正好把树结构打乱。若将来确实需要在客户端重建顺序(例如不再依赖响应顺序), 正确做法是消费
channelDetailedJSON.order后按前驱指针串链, 而不是排序。注意 API 侧ChannelDetailInfo里pid取自item["channel_order"](api.go:265),该处取值可疑,使用前需先核实。
2. 客户端列表 — GetClientsJSON() → Repository.fetchClients()
Go:clientJSON | Kotlin:ClientInfo
| Go 字段 | JSON 字段 | Kotlin 字段 | 类型 | 可空 | 默认值语义 |
|---|---|---|---|---|---|
ID |
id |
id |
Int |
否 | 0 |
Nickname |
nickname |
nickname |
String |
否 | "" |
UID |
uid |
uid |
String |
否 | "" — 永久身份标识,等同账号 ID |
ChannelID |
channelId |
channelId |
String |
否 | "0" — 字符串化 |
ServerGroups |
serverGroups |
serverGroups |
List<String> |
否 | [] |
IsSelf |
isSelf |
isSelf |
Boolean |
否 | false |
uid是敏感数据:Repository.performInitialSync中的全量 dump 已改为BuildConfig.DEBUG保护 +proguard-rules.pro剥离Log.d。
3. 服务器信息 — GetServerInfoJSON() → ServerViewModel / Repository
Go:serverInfoJSON | Kotlin:ServerInfo
| Go 字段 | JSON 字段 | Kotlin 字段 | 类型 | 可空 | 默认值语义 |
|---|---|---|---|---|---|
Name |
name |
name |
String |
否 | "" |
WelcomeMessage |
welcomeMessage |
welcomeMessage |
String |
否 | "" |
MaxClients |
maxClients |
maxClients |
Int |
否 | 0 |
ClientsOnline |
clientsOnline |
clientsOnline |
Int |
否 | 0 |
ChannelsOnline |
channelsOnline |
channelsOnline |
Int |
否 | 0 |
Uptime |
uptime |
uptime |
String |
否 | "" — 字符串化秒数 |
Version |
version |
version |
String |
否 | "" |
Platform |
platform |
platform |
String |
否 | "" |
Created |
created |
created |
String |
否 | "" — 字符串化秒级时间戳 |
IconID |
iconId |
iconId |
String |
否 | "" |
DefaultServerGroup |
defaultServerGroup |
defaultServerGroup |
Int |
否 | 0 |
DefaultChannelGroup |
defaultChannelGroup |
defaultChannelGroup |
Int |
否 | 0 |
⚠️
uptime/created是字符串。TestGolden_ServerInfoTypes钉住了类型—— 改成数值会让 Kotlin 的String字段静默变空。
4. 文件下载 — DownloadFileBytesJSON() → FileDownloadManager
Go:kotlin_api.go(内联 map)| Kotlin:TSBridge.downloadFileBytes()
| JSON 字段 | 类型 | 说明 |
|---|---|---|
data |
String |
base64 编码的文件内容 |
size |
Int |
解码后字节数 |
失败约定与其它接口不同:失败返回
"{}"(而非"[]"),消费方以data为空判定失败。 空字符串返回表示「未连接」,"{}"表示「连接正常但下载失败」——两者不可混为一谈。
事件契约(A 类,编译期检查)
这些类型由 gomobile 直接导出为 Java 类,字段访问经编译器校验,不需要也不应该 为它们添加 JSON 字段常量。
TextMsg(EventCallback.OnTextMessage)
| 字段 | 类型 | 说明 |
|---|---|---|
TargetMode |
Int |
见下方 targetMode 表 |
TargetID |
String |
目标 ID(字符串化) |
InvokerID |
Int |
发送者 clid;旧 AAR 可能为 0 |
InvokerName |
String |
发送者昵称 |
InvokerUID |
String |
发送者 UID |
Message |
String |
消息内容(纯文本或文件消息 JSON) |
PokeEvent(EventCallback.OnPoked)
| 字段 | 类型 |
|---|---|
InvokerID |
Int |
InvokerName |
String |
InvokerUID |
String |
Message |
String |
targetMode 收发能力对照(重要)
TargetMode 的接收与发送能力不对称,读代码时极易误判为「三种都支持」:
| 值 | 含义 | 接收 | 发送 |
|---|---|---|---|
1 |
私聊 | ✅ 支持 | ❌ 未实现 |
2 |
频道 | ✅ 支持 | ✅ 支持 |
3 |
服务器 | ✅ 支持 | ❌ 未实现 |
TSBridge.sendTextMessage 仅实现 mode=2;其余分支返回显式错误串
"暂不支持该消息类型"(按桥接约定:非空 = 错误),由 ChatViewModel 映射为
MessageSendState.Failed 并将消息标记为 FAILED。
当前状态:接收侧支持三种会话(通知 deep link 可跳转到指定会话),发送侧只有频道消息。 私聊能收到、能弹通知,但无法在 App 内回复。
版本升级流程
任何不兼容变更(字段改名、类型变更、字段删除)必须:
- 递增
go/teamspeak/contract.go的BridgeContractVersion - 同步
BridgeContract.kt的EXPECTED_VERSION - 更新本文档
- 重编 AAR(
build.bat第 1 步)
只新增可选字段(Kotlin 侧有默认值)不需要递增版本。
版本校验不匹配时,
ServerViewModel.connect()会以ConnectState.FAILED明确报错并阻止连接——而不是带着错配的字段名继续跑。 旧 AAR(未导出getContractVersion())不阻断,仅记录 warning。
跑契约测试
# Go 侧:字段名常量 + golden 样本一致性
cd go && go test ./teamspeak/ -run "Contract|Golden" -v
# Kotlin 侧:反序列化 + 失效模式
cd android && ./gradlew testDebugUnitTest --tests '*BridgeContractTest*'
golden 样本位于 android/app/src/test/resources/contract/,
由 Go 与 Kotlin 共用:Go 测试断言样本字段集合 == struct tag,
Kotlin 测试断言样本能正确反序列化。
字段漂移时两侧都会变红——这是「改了字段名却无人发现」的兜底。