2026-07-20 19:01:03 +08:00
# 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
2026-07-23 19:37:20 +08:00
### Android MVVM + Repository Pattern
2026-07-20 19:01:03 +08:00
```
2026-07-23 19:37:20 +08:00
Go Library → TSBridge (JNI) → ServerViewModel (callbacks) → Repository (state) → ViewModels → Compose Screens
User Actions → Compose UI → ViewModel methods → TSBridge → Go Library
2026-07-20 19:01:03 +08:00
```
2026-07-23 19:37:20 +08:00
**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
2026-09-10 14:58:15 +08:00
- **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.
2026-07-23 19:37:20 +08:00
- **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
2026-07-20 19:01:03 +08:00
## 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.
2026-07-23 19:37:20 +08:00
### 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.
2026-07-20 19:01:03 +08:00
## 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.
2026-07-23 19:37:20 +08:00
## 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.
2026-07-20 19:01:03 +08:00
## Testing
Run from IDE with connected device or emulator. Check Logcat in IDE for runtime logs.
2026-09-10 14:58:15 +08:00
```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 里另建时间戳——历史上两处时间戳不同步会导致重复刷新请求。