依据 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>
189 lines
9.0 KiB
Markdown
189 lines
9.0 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## Project Overview
|
||
|
||
TeamSpeak Android native client with a two-layer architecture:
|
||
|
||
- **Protocol layer**: Go + [teamspeak-go](https://github.com/honeybbq/teamspeak-go) → compiled to `.aar` via gomobile
|
||
- **UI layer**: Kotlin + Jetpack Compose + Material Design 3
|
||
|
||
## Build Commands
|
||
|
||
### Full Build (Recommended)
|
||
```bash
|
||
# Windows
|
||
build.bat
|
||
|
||
# Linux/macOS
|
||
./build.sh
|
||
```
|
||
|
||
### Manual Build (Two Steps Required)
|
||
|
||
**Step 1: Compile Go → AAR**
|
||
```bash
|
||
cd go
|
||
set JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 # Windows only
|
||
gomobile bind -target=android -androidapi=26 -ldflags="-linkmode=external -extldflags=-Wl,--hash-style=both" -o ../android/app/libs/teamspeak.aar ./teamspeak
|
||
```
|
||
|
||
**Step 2: Build Android APK**
|
||
```bash
|
||
cd android
|
||
gradlew.bat assembleDebug # Windows
|
||
./gradlew assembleDebug # Linux/macOS
|
||
```
|
||
|
||
### Development Workflow
|
||
|
||
- **Modified Go code** (`go/`): Re-run Step 1, then run from IDE
|
||
- **Modified Kotlin code** (`android/`): Just run from IDE (IntelliJ/Android Studio)
|
||
|
||
Open the `android/` directory (not root) in IntelliJ IDEA or Android Studio.
|
||
|
||
## Architecture
|
||
|
||
### Go ↔ Kotlin Bridge
|
||
|
||
Communication flows through two bridge layers:
|
||
|
||
1. **Go side** (`go/teamspeak/bridge.go`): Exports `TSClient` class via gomobile
|
||
2. **Kotlin side** (`android/app/src/main/java/com/tsmobile/app/TSBridge.kt`): Wraps gomobile API
|
||
|
||
**Key constraints** (gomobile limitations):
|
||
- Cannot export `[]string`, `[]*T`, or Go `error` types
|
||
- Complex data passed as JSON strings (channels, clients)
|
||
- Events delivered via callback interfaces
|
||
- All JNI callbacks serialized through an event queue to avoid threading issues
|
||
|
||
**Event flow**: Go library → event queue → single consumer goroutine → JNI callback → Kotlin callback → ViewModel
|
||
|
||
### Android MVVM + Repository Pattern
|
||
|
||
```
|
||
Go Library → TSBridge (JNI) → ServerViewModel (callbacks) → Repository (state) → ViewModels → Compose Screens
|
||
User Actions → Compose UI → ViewModel methods → TSBridge → Go Library
|
||
```
|
||
|
||
**Repository** (`data/Repository.kt`): Singleton object, single source of truth for shared state — channels, clients, channel→clients mapping, current channel, unread counts. Uses `ConcurrentHashMap` for thread-safe message archives (max 500 per session, keyed by `targetMode_targetId`).
|
||
|
||
**ViewModels**: Cross-references set by `NavGraph` (e.g. `serverViewModel.channelViewModel = channelViewModel`). `ServerViewModel` dispatches all bridge callbacks to other ViewModels.
|
||
|
||
**State machines** (sealed classes in `data/Models.kt`): `ConnectionState`, `MessageSendState`, `MessageDeliveryState`, `VoiceState`, `SyncState`, `ChannelSwitchState` — use `when` for pattern matching.
|
||
|
||
### Voice System Ownership Split
|
||
|
||
- **Go side** owns the full decode/mix pipeline: per-client jitter buffers, Opus decoders, stereo mixing. Delivers mixed PCM16 as 20ms frames (48kHz, interleaved stereo) via `onPCM` callback. Speaking detection: 400ms silence timeout.
|
||
- **Kotlin side** owns capture: `AudioRecord` (mono, 48kHz) → `OpusEncoder` → `TSBridge.sendVoice()`. Noise suppressor support. Speaking detection via RMS threshold (0.015).
|
||
|
||
### Message Delivery Confirmation
|
||
|
||
Messages appear immediately as `PENDING` in UI. Server echo (`OnTextMessage` with matching content) confirms → `SENT`. 10-second timeout → `FAILED`. Duplicate detection prevents self-message re-archival.
|
||
|
||
### Navigation Routes
|
||
|
||
Four routes in `NavGraph.kt`: `server_config` (connection form) → `channel_list` (channel tree + members) → `chat` (text chat) → `kicked` (kick notification). Navigation driven by `ConnectionState` changes via `LaunchedEffect`.
|
||
|
||
### Known SDK Limitations
|
||
|
||
- **No channel CRUD events**: The SDK does not fire events for channel create/update/delete. Channel list is refreshed after 5 minutes of staleness or before channel switch operations. Staleness is tracked by `Repository.channelsFetchedAt` and surfaced in the UI via `StaleChannelBanner`.
|
||
- **Unreliable ChannelID in enter events**: `notifycliententerview` ChannelID is not effective per SDK docs. Client enter/leave events trigger a full `clientlist` refresh instead of incremental updates. To keep this from rebuilding the whole channel tree, `Repository.applyClients` does a **per-channel differential update** (`diffChannelClients`) that reuses unchanged list instances.
|
||
- **Auto-reconnect disabled by default**: Frequent reconnect attempts cause server-side rate limiting/bans.
|
||
|
||
### Thread Safety
|
||
|
||
- `TSClient.mu` mutex protects Go-side client access
|
||
- `TSBridge.connectLock` synchronizes Kotlin-side connection lifecycle
|
||
- `connectionGeneration` counter prevents stale callbacks from reaching current session
|
||
- Event queue PCM events capped at 12 with oldest eviction to prevent buildup
|
||
|
||
## Critical Build Details
|
||
|
||
### Required Flags for gomobile
|
||
|
||
The `-ldflags` are **mandatory** to avoid runtime crashes:
|
||
|
||
1. **`-linkmode=external`**: Use NDK's external linker instead of Go's built-in linker
|
||
- **Why**: Prevents `SIGSEGV` crashes due to signal handling conflicts between Go runtime and Android
|
||
- Go uses SIGSEGV for GC and goroutine scheduling; Android's memory protection blocks this
|
||
|
||
2. **`-extldflags=-Wl,--hash-style=both`**: Generate compatible ELF hash tables
|
||
- **Why**: Go 1.24+ uses `DT_SUNW_HASH` by default; Android requires `DT_HASH` or `DT_GNU_HASH`
|
||
- Without this: `dlopen failed: empty/missing DT_HASH/DT_GNU_HASH` error
|
||
|
||
### Local Patches
|
||
|
||
The `go/_patches/github.com/honeybbq/teamspeak-go/` directory contains modified upstream code:
|
||
- Fixes 32-bit integer overflow issues (`math.MaxUint32` → platform-specific limits)
|
||
- Applied via `go.mod` replace directive: `replace github.com/honeybbq/teamspeak-go => ./_patches/github.com/honeybbq/teamspeak-go`
|
||
|
||
Do not remove this directory or the replace directive.
|
||
|
||
### Go Module and CGo
|
||
|
||
Go module name is `tsmobile`. Unlike upstream teamspeak-go (zero CGO), this project enables CGO for the Android libopus decoder. Pre-built static libraries live in `go/teamspeak/.opus/lib/{abi}/libopus.a` for all four Android ABIs.
|
||
|
||
## Common Issues
|
||
|
||
### "javac: 非法字符" or "GBK unmappable character"
|
||
Set encoding before running gomobile on Windows:
|
||
```bash
|
||
set JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8
|
||
```
|
||
|
||
### App crashes with "SIGSEGV" on startup
|
||
Missing `-linkmode=external` flag. Rebuild AAR with correct flags.
|
||
|
||
### App crashes with "empty/missing DT_HASH"
|
||
Missing `-Wl,--hash-style=both` flag. Rebuild AAR with correct flags.
|
||
|
||
### "Unresolved reference: teamspeak"
|
||
AAR not compiled or not in `android/app/libs/teamspeak.aar`. Run Step 1 of manual build.
|
||
|
||
## Error Conventions
|
||
|
||
- **Go bridge**: Empty string return = success, non-empty = error message. All IDs are strings for JSON transport.
|
||
- **Kotlin**: Chinese-language user-facing errors. `ServerViewModel.classifyError()` maps raw errors to friendly messages. `ChannelViewModel.mapMoveError()` handles channel switch errors.
|
||
|
||
## Testing
|
||
|
||
Run from IDE with connected device or emulator. Check Logcat in IDE for runtime logs.
|
||
|
||
```bash
|
||
# Go:音频接收管线 + 跨语言契约
|
||
cd go && go test ./teamspeak/
|
||
|
||
# Kotlin:契约反序列化 + 纯逻辑(无需设备)
|
||
cd android && ./gradlew testDebugUnitTest
|
||
```
|
||
|
||
### Cross-language contract
|
||
|
||
Go ↔ Kotlin 的 JSON 字段约定是**编译器无法校验**的边界(改字段名只会静默拿到默认值)。
|
||
三道机制钉住它,改动 bridge 字段时必须一起维护:
|
||
|
||
| 文件 | 作用 |
|
||
| --- | --- |
|
||
| `go/teamspeak/contract.go` | 字段名常量的单一事实来源 + `BridgeContractVersion` |
|
||
| `go/teamspeak/contract_test.go` / `contract_golden_test.go` | 反射断言常量与 struct tag 一致;golden 样本一致性 |
|
||
| `android/.../data/BridgeContract.kt` | 启动时校验 AAR 契约版本,不匹配则阻止连接 |
|
||
| `android/app/src/test/resources/contract/` | **Go 与 Kotlin 共用**的 golden 样本 |
|
||
| `docs/bridge-contract.md` | 逐字段对照表与 targetMode 收发能力矩阵 |
|
||
|
||
不兼容变更(字段改名/类型变更/删除)必须递增 `BridgeContractVersion` 并同步
|
||
`BridgeContract.EXPECTED_VERSION` 与该文档。
|
||
|
||
### Known gotchas fixed here
|
||
|
||
- **`ChannelInfo.order` 在 Go 侧不存在**(`channelJSON` 无 `order` 字段),
|
||
恒为默认 `0`,`buildChannelTree` 的 `sortedBy { it.order }` 是 no-op。
|
||
频道显示顺序由 TS3 `channellist` 的响应顺序决定(已真机验证与显示一致),这是正确的。
|
||
- **`channel_order` 不是排序权重,是前驱频道的 ID**(链表指针,`0` = 本层最前)。
|
||
见 `docs/teamspeak-sdk-3.5.2/doc/client/channel-sort.html`。
|
||
**不要按它数值排序**——那会打乱频道树。若要在客户端重建顺序,需按前驱指针串链。
|
||
- **私聊(targetMode=1)只能收、不能发**。`TSBridge.sendTextMessage` 仅实现 `mode=2`。
|
||
- **频道陈旧判定统一走 `Repository.isChannelDataStale()`**。
|
||
不要在 ViewModel 里另建时间戳——历史上两处时间戳不同步会导致重复刷新请求。
|