Files
ts-mobile-go/CLAUDE.md
T
sansenandClaude Fable 5 8716b391f6 工程改进:跨语言契约固化、日志脱敏、状态模型收敛
依据 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>
2026-09-10 14:58:15 +08:00

9.0 KiB
Raw Blame History

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 → compiled to .aar via gomobile
  • UI layer: Kotlin + Jetpack Compose + Material Design 3

Build Commands

# Windows
build.bat

# Linux/macOS
./build.sh

Manual Build (Two Steps Required)

Step 1: Compile Go → AAR

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

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:

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.

# 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 里另建时间戳——历史上两处时间戳不同步会导致重复刷新请求。