首次推送
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
# 步骤 04:连接与首次同步
|
||||
|
||||
> 实现完整的连接流程:Identity 管理、Connect、WaitConnected、首次同步。
|
||||
|
||||
---
|
||||
|
||||
## 一、目标
|
||||
|
||||
- [ ] Identity 生成与持久化
|
||||
- [ ] ClientOption 组装
|
||||
- [ ] 事件处理器注册
|
||||
- [ ] Connect + WaitConnected 流程
|
||||
- [ ] 首次同步(ListChannels + ListClients + ClientID)
|
||||
- [ ] 连接状态与错误处理
|
||||
|
||||
---
|
||||
|
||||
## 二、任务清单
|
||||
|
||||
### 4.1 Identity 管理
|
||||
|
||||
**目标**:实现 TeamSpeak 加密身份的生成与持久化存储。
|
||||
|
||||
**任务**:
|
||||
|
||||
1. **生成 Identity**
|
||||
```kotlin
|
||||
// Go 侧通过 Bridge 暴露生成接口
|
||||
// Kotlin 侧调用生成并获取 Identity 字符串
|
||||
val identity = TSBridge.generateIdentity()
|
||||
```
|
||||
|
||||
2. **持久化存储**
|
||||
- 使用 `SharedPreferences` 或 `DataStore` 存储 Identity
|
||||
- Key 建议:`ts_identity`
|
||||
- 首次启动时生成,后续启动时读取
|
||||
|
||||
3. **读取与恢复**
|
||||
```kotlin
|
||||
fun loadOrCreateIdentity(context: Context): String {
|
||||
val prefs = context.getSharedPreferences("ts_config", Context.MODE_PRIVATE)
|
||||
return prefs.getString("ts_identity", null)
|
||||
?: TSBridge.generateIdentity().also {
|
||||
prefs.edit().putString("ts_identity", it).apply()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
- Identity 是客户端加密身份,必须持久化,否则每次连接会被服务器视为新用户
|
||||
- 生成后不可更改,丢失需重新生成(会丢失服务器端的权限关联)
|
||||
|
||||
### 4.2 连接流程实现
|
||||
|
||||
**目标**:实现完整的连接流程,从配置组装到连接成功。
|
||||
|
||||
**任务**:
|
||||
|
||||
1. **连接参数数据类**
|
||||
```kotlin
|
||||
data class ConnectionConfig(
|
||||
val address: String, // 服务器地址(IP/域名/TSDNS)
|
||||
val nickname: String, // 显示昵称
|
||||
val password: String? = null, // 服务器密码(可选)
|
||||
val defaultChannel: String? = null, // 默认频道(可选)
|
||||
val defaultChannelPassword: String? = null // 默认频道密码(可选)
|
||||
)
|
||||
```
|
||||
|
||||
2. **Bridge 层连接接口封装**
|
||||
```kotlin
|
||||
// TSBridge.kt 中新增
|
||||
fun connect(config: ConnectionConfig): Result<Unit> {
|
||||
return try {
|
||||
val identity = loadOrCreateIdentity(context)
|
||||
// 调用 Go 侧 NewClient + Connect
|
||||
tsClient.newClient(identity, config.address, config.nickname)
|
||||
if (config.password != null) {
|
||||
tsClient.setServerPassword(config.password)
|
||||
}
|
||||
if (config.defaultChannel != null) {
|
||||
tsClient.setDefaultChannel(config.defaultChannel)
|
||||
if (config.defaultChannelPassword != null) {
|
||||
tsClient.setDefaultChannelPassword(config.defaultChannelPassword)
|
||||
}
|
||||
}
|
||||
tsClient.connect()
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **WaitConnected 实现**
|
||||
```kotlin
|
||||
suspend fun waitConnected(timeout: Duration = 30.seconds): Result<Unit> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Go 侧阻塞等待,支持 context 取消
|
||||
tsClient.waitConnected(timeout.inWholeMilliseconds)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **完整连接流程**
|
||||
```kotlin
|
||||
// ServerViewModel.kt
|
||||
suspend fun connect(config: ConnectionConfig) {
|
||||
_connectionState.value = ConnectionState.Connecting
|
||||
|
||||
// 1. 注册事件处理器(Connect 前必须完成)
|
||||
registerEventHandlers()
|
||||
|
||||
// 2. 发起连接
|
||||
val connectResult = TSBridge.connect(config)
|
||||
if (connectResult.isFailure) {
|
||||
_connectionState.value = ConnectionState.Failed(connectResult.exceptionOrNull()!!)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 等待连接就绪
|
||||
val waitResult = TSBridge.waitConnected()
|
||||
if (waitResult.isFailure) {
|
||||
_connectionState.value = ConnectionState.Failed(waitResult.exceptionOrNull()!!)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 连接成功,等待 OnConnected 事件触发首次同步
|
||||
}
|
||||
```
|
||||
|
||||
**连接状态枚举**:
|
||||
```kotlin
|
||||
sealed class ConnectionState {
|
||||
object Disconnected : ConnectionState()
|
||||
object Connecting : ConnectionState()
|
||||
object Connected : ConnectionState() // WaitConnected 成功
|
||||
object Syncing : ConnectionState() // 首次同步中
|
||||
object Ready : ConnectionState() // 业务就绪
|
||||
data class Failed(val error: Throwable) : ConnectionState()
|
||||
}
|
||||
```
|
||||
|
||||
**时序约束**:
|
||||
- `Connect()` 成功只表示连接流程已启动
|
||||
- 发送业务命令前必须等待 `WaitConnected()` 成功
|
||||
- 事件处理器必须在 `Connect()` 前注册,避免早期事件丢失
|
||||
|
||||
### 4.3 事件注册
|
||||
|
||||
**目标**:在 Connect 前注册所有必要的事件处理器,确保不遗漏早期服务端推送。
|
||||
|
||||
**任务**:
|
||||
|
||||
1. **事件处理器注册(Go Bridge 侧)**
|
||||
```go
|
||||
// bridge.go 中暴露注册接口
|
||||
func (b *Bridge) RegisterEventHandlers() {
|
||||
b.client.OnConnected(func() {
|
||||
b.notifyEvent("connected", nil)
|
||||
})
|
||||
|
||||
b.client.OnDisconnected(func(err error) {
|
||||
b.notifyEvent("disconnected", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
})
|
||||
|
||||
b.client.OnClientEnter(func(info ClientInfo) {
|
||||
data, _ := json.Marshal(info)
|
||||
b.notifyEvent("client_enter", string(data))
|
||||
})
|
||||
|
||||
b.client.OnClientLeave(func(event ClientLeftViewEvent) {
|
||||
data, _ := json.Marshal(event)
|
||||
b.notifyEvent("client_leave", string(data))
|
||||
})
|
||||
|
||||
b.client.OnClientMoved(func(event ClientMovedEvent) {
|
||||
data, _ := json.Marshal(event)
|
||||
b.notifyEvent("client_moved", string(data))
|
||||
})
|
||||
|
||||
b.client.OnTextMessage(func(msg TextMessage) {
|
||||
data, _ := json.Marshal(msg)
|
||||
b.notifyEvent("text_message", string(data))
|
||||
})
|
||||
|
||||
b.client.OnPoked(func(event PokeEvent) {
|
||||
data, _ := json.Marshal(event)
|
||||
b.notifyEvent("poked", string(data))
|
||||
})
|
||||
|
||||
b.client.OnKicked(func(reason string) {
|
||||
b.notifyEvent("kicked", reason)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
2. **Kotlin 侧事件监听**
|
||||
```kotlin
|
||||
// TSBridge.kt
|
||||
fun setEventListener(listener: (String, String) -> Unit) {
|
||||
// 接收 Go 侧通过 JNI 回调的事件
|
||||
eventCallback = listener
|
||||
}
|
||||
|
||||
// ServerViewModel.kt
|
||||
fun registerEventHandlers() {
|
||||
TSBridge.setEventListener { event, data ->
|
||||
when (event) {
|
||||
"connected" -> handleConnected()
|
||||
"disconnected" -> handleDisconnected(data)
|
||||
"client_enter" -> handleClientEnter(data)
|
||||
"client_leave" -> handleClientLeave(data)
|
||||
"client_moved" -> handleClientMoved(data)
|
||||
"text_message" -> handleTextMessage(data)
|
||||
"poked" -> handlePoked(data)
|
||||
"kicked" -> handleKicked(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **事件处理器职责**
|
||||
|
||||
| 事件 | 处理器 | 职责 |
|
||||
|------|--------|------|
|
||||
| `connected` | `handleConnected()` | 触发首次同步流程 |
|
||||
| `disconnected` | `handleDisconnected()` | 清理会话状态,更新 UI |
|
||||
| `client_enter` | `handleClientEnter()` | 增量同步:添加成员到基线 |
|
||||
| `client_leave` | `handleClientLeave()` | 增量同步:从基线移除成员 |
|
||||
| `client_moved` | `handleClientMoved()` | 增量同步:更新成员频道位置 |
|
||||
| `text_message` | `handleTextMessage()` | 消息归档:按 TargetMode 存储 |
|
||||
| `poked` | `handlePoked()` | 显示 Poke 通知 |
|
||||
| `kicked` | `handleKicked()` | 处理踢出,清理状态 |
|
||||
|
||||
**注意事项**:
|
||||
- 事件处理器必须在 `Connect()` 之前注册
|
||||
- 事件回调在 Go 的事件循环 goroutine 中串行执行,不要做耗时操作
|
||||
- 需要通过事件队列串行化 JNI 回调,避免并发问题
|
||||
|
||||
### 4.4 首次同步逻辑
|
||||
|
||||
**目标**:连接成功后,建立完整的频道基线和成员基线。
|
||||
|
||||
**触发时机**:收到 `OnConnected` 事件后立即执行。
|
||||
|
||||
**任务**:
|
||||
|
||||
1. **并行请求三个数据源**
|
||||
```kotlin
|
||||
// ServerViewModel.kt
|
||||
private suspend fun performInitialSync() {
|
||||
_connectionState.value = ConnectionState.Syncing
|
||||
|
||||
try {
|
||||
// 并行请求频道列表、成员列表、自身 ID
|
||||
val channelsDeferred = async { TSBridge.listChannels() }
|
||||
val clientsDeferred = async { TSBridge.listClients() }
|
||||
val selfIdDeferred = async { TSBridge.getClientId() }
|
||||
|
||||
val channels = channelsDeferred.await()
|
||||
val clients = clientsDeferred.await()
|
||||
val selfId = selfIdDeferred.await()
|
||||
|
||||
// 原子提交到状态仓库
|
||||
repository.updateBaseline(
|
||||
channels = channels,
|
||||
clients = clients,
|
||||
selfClientId = selfId
|
||||
)
|
||||
|
||||
_connectionState.value = ConnectionState.Ready
|
||||
} catch (e: Exception) {
|
||||
_connectionState.value = ConnectionState.SyncFailed(e)
|
||||
// 允许重试,不进入业务就绪
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **数据模型定义**
|
||||
```kotlin
|
||||
// data/Models.kt
|
||||
data class ChannelInfo(
|
||||
val id: Long,
|
||||
val parentId: Long,
|
||||
val name: String,
|
||||
val order: Long = 0,
|
||||
val isPassword: Boolean = false,
|
||||
val isPermanent: Boolean = false,
|
||||
val maxClients: Int = -1
|
||||
)
|
||||
|
||||
data class ClientInfo(
|
||||
val id: Int,
|
||||
val nickname: String,
|
||||
val channelId: Long,
|
||||
val uid: String,
|
||||
val type: Int = 0,
|
||||
val serverGroups: List<String> = emptyList()
|
||||
)
|
||||
```
|
||||
|
||||
3. **状态仓库实现**
|
||||
```kotlin
|
||||
// data/Repository.kt
|
||||
class ChannelRepository {
|
||||
private val _channels = MutableStateFlow<List<ChannelInfo>>(emptyList())
|
||||
val channels: StateFlow<List<ChannelInfo>> = _channels
|
||||
|
||||
private val _clients = MutableStateFlow<List<ClientInfo>>(emptyList())
|
||||
val clients: StateFlow<List<ClientInfo>> = _clients
|
||||
|
||||
private val _selfClientId = MutableStateFlow<Int?>(null)
|
||||
val selfClientId: StateFlow<Int?> = _selfClientId
|
||||
|
||||
// 按频道 ID 索引的成员列表
|
||||
private val _channelClients = MutableStateFlow<Map<Long, List<ClientInfo>>>(emptyMap())
|
||||
val channelClients: StateFlow<Map<Long, List<ClientInfo>>> = _channelClients
|
||||
|
||||
fun updateBaseline(channels: List<ChannelInfo>, clients: List<ClientInfo>, selfClientId: Int) {
|
||||
_channels.value = channels
|
||||
_clients.value = clients
|
||||
_selfClientId.value = selfClientId
|
||||
|
||||
// 建立频道-成员索引
|
||||
_channelClients.value = clients.groupBy { it.channelId }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Bridge 层查询**(通过 TsClient 封装)
|
||||
```kotlin
|
||||
// TSBridge.kt — 直接返回 Kotlin 友好类型,无需 JSON 解析
|
||||
fun getChannelList(): List<TsChannel> = TsClient.getChannelList()
|
||||
fun getClientList(): List<TsClientInfo> = TsClient.getClientList()
|
||||
fun getClientId(): Long = TsClient.getClientId()
|
||||
```
|
||||
|
||||
**同步状态机**:
|
||||
```kotlin
|
||||
sealed class SyncState {
|
||||
object Unsynced : SyncState() // 已连接但尚无完整数据
|
||||
object Syncing : SyncState() // 调用 ListChannels 和 ListClients
|
||||
object Synchronized : SyncState() // 列表基线可供 UI 使用
|
||||
data class SyncFailed(val error: Throwable) : SyncState() // 同步失败
|
||||
}
|
||||
```
|
||||
|
||||
**原子提交原则**:
|
||||
- 频道列表、成员列表、自身 ID 必须全部成功才能提交
|
||||
- 任一失败则不进入业务就绪状态
|
||||
- 允许重试,避免在不完整数据上执行业务操作
|
||||
|
||||
### 4.5 错误处理与状态反馈
|
||||
|
||||
**目标**:实现完整的错误处理机制,确保用户能获得清晰的状态反馈。
|
||||
|
||||
**任务**:
|
||||
|
||||
1. **连接错误分类**
|
||||
```kotlin
|
||||
sealed class ConnectionError : Exception() {
|
||||
object InvalidAddress : ConnectionError() // 地址解析失败
|
||||
object AuthenticationFailed : ConnectionError() // 密码错误
|
||||
object ServerFull : ConnectionError() // 服务器满员
|
||||
object Banned : ConnectionError() // 被封禁
|
||||
object NetworkError : ConnectionError() // 网络问题
|
||||
object Timeout : ConnectionError() // 连接超时
|
||||
data class Other(val message: String) : ConnectionError()
|
||||
}
|
||||
```
|
||||
|
||||
2. **错误映射与处理**
|
||||
```kotlin
|
||||
fun mapConnectionError(error: Exception): ConnectionError {
|
||||
val message = error.message?.lowercase() ?: ""
|
||||
return when {
|
||||
message.contains("resolve") || message.contains("address") ->
|
||||
ConnectionError.InvalidAddress
|
||||
message.contains("password") || message.contains("auth") ->
|
||||
ConnectionError.AuthenticationFailed
|
||||
message.contains("full") || message.contains("limit") ->
|
||||
ConnectionError.ServerFull
|
||||
message.contains("ban") ->
|
||||
ConnectionError.Banned
|
||||
message.contains("timeout") ->
|
||||
ConnectionError.Timeout
|
||||
message.contains("network") || message.contains("connection") ->
|
||||
ConnectionError.NetworkError
|
||||
else -> ConnectionError.Other(error.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **状态反馈 UI**
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ConnectionStatusIndicator(state: ConnectionState) {
|
||||
when (state) {
|
||||
ConnectionState.Disconnected -> {
|
||||
Text("未连接", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
ConnectionState.Connecting -> {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Text("正在连接...")
|
||||
}
|
||||
ConnectionState.Connected -> {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Text("已连接,正在同步...")
|
||||
}
|
||||
ConnectionState.Syncing -> {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Text("正在同步数据...")
|
||||
}
|
||||
ConnectionState.Ready -> {
|
||||
Icon(Icons.Default.CheckCircle, tint = Color.Green)
|
||||
Text("就绪")
|
||||
}
|
||||
is ConnectionState.Failed -> {
|
||||
Icon(Icons.Default.Error, tint = Color.Red)
|
||||
Text("连接失败: ${state.error.getLocalizedMessage()}",
|
||||
color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = { /* 重试 */ }) {
|
||||
Text("重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **同步失败重试机制**
|
||||
```kotlin
|
||||
// ServerViewModel.kt
|
||||
private suspend fun performInitialSyncWithRetry(maxRetries: Int = 3) {
|
||||
var retryCount = 0
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
performInitialSync()
|
||||
return // 成功则退出
|
||||
} catch (e: Exception) {
|
||||
retryCount++
|
||||
if (retryCount >= maxRetries) {
|
||||
_connectionState.value = ConnectionState.SyncFailed(e)
|
||||
return
|
||||
}
|
||||
// 等待后重试
|
||||
delay(1000L * retryCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **断开连接清理**
|
||||
```kotlin
|
||||
fun disconnect() {
|
||||
// 1. 停止语音(如有)
|
||||
voiceViewModel.stopVoice()
|
||||
|
||||
// 2. 清理会话状态
|
||||
repository.clearSession()
|
||||
|
||||
// 3. 调用 SDK 断开
|
||||
TSBridge.disconnect()
|
||||
|
||||
// 4. 更新状态
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
```
|
||||
|
||||
**错误日志记录**:
|
||||
```kotlin
|
||||
private fun logConnectionError(error: ConnectionError) {
|
||||
Log.e(TAG, "Connection error: ${error::class.simpleName}", error)
|
||||
// 可选:上报到崩溃分析服务
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、验收标准
|
||||
|
||||
### 功能验收
|
||||
|
||||
- [ ] **Identity 持久化**
|
||||
- 首次启动自动生成 Identity 并存储
|
||||
- 后续启动读取已有 Identity,不重复生成
|
||||
- 清除应用数据后能重新生成
|
||||
|
||||
- [ ] **连接流程**
|
||||
- 输入有效地址、昵称后能成功连接服务器
|
||||
- 输入错误密码时显示明确错误提示
|
||||
- 连接超时时(30秒)显示超时错误
|
||||
- 无网络时显示网络错误
|
||||
|
||||
- [ ] **首次同步**
|
||||
- 连接成功后自动执行首次同步
|
||||
- 频道列表正确显示(包含所有频道)
|
||||
- 成员列表正确显示(包含所有在线用户)
|
||||
- 自己的客户端 ID 正确识别
|
||||
|
||||
- [ ] **状态反馈**
|
||||
- 连接过程中显示加载状态
|
||||
- 同步过程中显示同步状态
|
||||
- 就绪后显示就绪状态
|
||||
- 错误时显示错误信息和重试按钮
|
||||
|
||||
- [ ] **断开连接**
|
||||
- 主动断开后状态正确重置
|
||||
- 被动断开(网络中断)能检测并提示
|
||||
- 被踢出时显示踢出原因
|
||||
|
||||
### 性能验收
|
||||
|
||||
- [ ] 首次同步在 3 秒内完成(标准服务器,< 100 频道,< 500 用户)
|
||||
- [ ] 连接建立时间 < 5 秒(正常网络环境)
|
||||
|
||||
### 代码质量验收
|
||||
|
||||
- [ ] 所有网络操作在 IO 线程执行
|
||||
- [ ] 事件回调通过事件队列串行化
|
||||
- [ ] 无内存泄漏(正确取消协程)
|
||||
- [ ] 错误处理覆盖所有已知异常场景
|
||||
|
||||
### 测试用例
|
||||
|
||||
| 场景 | 输入 | 预期结果 |
|
||||
|------|------|----------|
|
||||
| 正常连接 | 有效地址、昵称、无密码 | 连接成功,频道列表显示 |
|
||||
| 密码保护服务器 | 有效地址、昵称、正确密码 | 连接成功 |
|
||||
| 错误密码 | 有效地址、昵称、错误密码 | 显示"密码错误"提示 |
|
||||
| 无效地址 | 无效地址 | 显示"地址解析失败"提示 |
|
||||
| 网络断开 | 断开网络后连接 | 显示"网络错误"提示 |
|
||||
| 服务器满员 | 满员服务器 | 显示"服务器已满"提示 |
|
||||
| 被封禁 | 被封禁的 UID | 显示"已被封禁"提示 |
|
||||
| 连接超时 | 阻断 UDP 30 秒 | 显示"连接超时"提示 |
|
||||
| 断开重连 | 断开后重新连接 | 状态正确重置,可重新连接 |
|
||||
|
||||
---
|
||||
|
||||
## 四、参考文档
|
||||
|
||||
- `docs/流程/01_连接服务器.md` - 完整生命周期、时序图
|
||||
- `docs/流程/08_状态同步.md` - ① 首次同步
|
||||
- `docs/sdk文档-go.md` - 连接管理 API
|
||||
Reference in New Issue
Block a user