Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f286e407 | ||
|
|
20de47228b | ||
|
|
e8a45032f8 |
Generated
-1
@@ -2,6 +2,5 @@
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/teamspeak-js" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,126 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.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 Layer Structure
|
||||
|
||||
```
|
||||
android/app/src/main/java/com/tsmobile/app/
|
||||
├── MainActivity.kt # Entry point
|
||||
├── TSBridge.kt # Go bridge wrapper
|
||||
├── ui/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── screens/ # Top-level screens
|
||||
│ ├── navigation/NavGraph.kt # App navigation
|
||||
│ └── theme/ # Material Design 3 theme
|
||||
└── viewmodel/ # State management
|
||||
├── ChannelViewModel.kt # Channel list & navigation
|
||||
├── ChatViewModel.kt # Text messaging
|
||||
├── ServerViewModel.kt # Connection & server info
|
||||
└── VoiceViewModel.kt # Voice communication
|
||||
```
|
||||
|
||||
State flows from ViewModels → Screens via `StateFlow`. User actions flow back via ViewModel methods.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Testing
|
||||
|
||||
Run from IDE with connected device or emulator. Check Logcat in IDE for runtime logs.
|
||||
|
||||
No automated test suite currently exists.
|
||||
@@ -60,25 +60,44 @@ Communication flows through two bridge layers:
|
||||
|
||||
**Event flow**: Go library → event queue → single consumer goroutine → JNI callback → Kotlin callback → ViewModel
|
||||
|
||||
### Android Layer Structure
|
||||
### Android MVVM + Repository Pattern
|
||||
|
||||
```
|
||||
android/app/src/main/java/com/tsmobile/app/
|
||||
├── MainActivity.kt # Entry point
|
||||
├── TSBridge.kt # Go bridge wrapper
|
||||
├── ui/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── screens/ # Top-level screens
|
||||
│ ├── navigation/NavGraph.kt # App navigation
|
||||
│ └── theme/ # Material Design 3 theme
|
||||
└── viewmodel/ # State management
|
||||
├── ChannelViewModel.kt # Channel list & navigation
|
||||
├── ChatViewModel.kt # Text messaging
|
||||
├── ServerViewModel.kt # Connection & server info
|
||||
└── VoiceViewModel.kt # Voice communication
|
||||
Go Library → TSBridge (JNI) → ServerViewModel (callbacks) → Repository (state) → ViewModels → Compose Screens
|
||||
User Actions → Compose UI → ViewModel methods → TSBridge → Go Library
|
||||
```
|
||||
|
||||
State flows from ViewModels → Screens via `StateFlow`. User actions flow back via ViewModel methods.
|
||||
**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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -102,6 +121,10 @@ The `go/_patches/github.com/honeybbq/teamspeak-go/` directory contains modified
|
||||
|
||||
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"
|
||||
@@ -119,8 +142,13 @@ 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.
|
||||
|
||||
No automated test suite currently exists.
|
||||
Go unit tests exist for the audio receive pipeline (`go/teamspeak/receive_audio_test.go`): `cd go && go test ./teamspeak/`
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Soul
|
||||
|
||||
> This file defines who you are. Update it as your personality evolves.
|
||||
|
||||
## Personality
|
||||
|
||||
|
||||
## Tone & Communication Style
|
||||
|
||||
|
||||
## Core Principles
|
||||
|
||||
|
||||
## Boundaries
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# User Profile
|
||||
|
||||
> This file describes the user you serve. Update it as you learn more.
|
||||
|
||||
## Name
|
||||
|
||||
|
||||
## Preferences
|
||||
|
||||
|
||||
## Timezone
|
||||
|
||||
|
||||
## Context
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -83,6 +83,7 @@ dependencies {
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
|
||||
|
||||
// DataStore (替代 localStorage)
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
@@ -93,6 +94,9 @@ dependencies {
|
||||
// JSON 序列化
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
|
||||
// HTTP client (更新检测)
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// Coil 图片加载(Compose)
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.0.4")
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class App : Application() {
|
||||
companion object {
|
||||
private const val TAG = "App"
|
||||
private const val CONNECTION_CHANNEL_ID = "connection_channel"
|
||||
const val POKE_CHANNEL_ID = "poke_channel"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -24,6 +25,9 @@ class App : Application() {
|
||||
// 创建通知频道(前台服务需要)
|
||||
createConnectionNotificationChannel()
|
||||
|
||||
// 创建 Poke 通知频道(高优先级,弹横幅)
|
||||
createPokeNotificationChannel()
|
||||
|
||||
// 全局未捕获异常处理
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
@@ -54,6 +58,23 @@ class App : Application() {
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Poke 通知频道。
|
||||
* IMPORTANCE_HIGH:弹出横幅、发出声音、锁屏可见。
|
||||
*/
|
||||
private fun createPokeNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
POKE_CHANNEL_ID,
|
||||
"戳一戳 通知",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "收到其他用户的 戳一戳 通知"
|
||||
setShowBadge(true)
|
||||
}
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun writeCrashLog(throwable: Throwable) {
|
||||
try {
|
||||
val file = File(getExternalFilesDir(null), "crash.log")
|
||||
|
||||
@@ -70,8 +70,10 @@ class ConnectionService : LifecycleService() {
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceType)
|
||||
|
||||
android.util.Log.i(TAG, "Foreground service started")
|
||||
// START_STICKY: 被系统杀死后会尝试重建
|
||||
return Service.START_STICKY
|
||||
// The service exists only to keep the process alive while a TS connection is active.
|
||||
// If Android kills it, there is no reliable way to restore the TS session, so don't
|
||||
// ask the system to recreate it with a misleading "connected" notification.
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@@ -18,7 +18,32 @@ import java.lang.reflect.Proxy
|
||||
*/
|
||||
object TSBridge {
|
||||
|
||||
private val connectLock = Any()
|
||||
private var client: TSClient? = null
|
||||
private var connectionGeneration: Long = 0
|
||||
private var currentIdentity: String = ""
|
||||
|
||||
private fun beginConnection(identity: String): Pair<TSClient, Long> = synchronized(connectLock) {
|
||||
client?.disconnect()
|
||||
client = null
|
||||
connectionGeneration += 1
|
||||
currentIdentity = identity
|
||||
val tsClient = Teamspeak.newClient()
|
||||
client = tsClient
|
||||
tsClient to connectionGeneration
|
||||
}
|
||||
|
||||
private fun endConnection(tsClient: TSClient) = synchronized(connectLock) {
|
||||
if (client === tsClient) {
|
||||
client = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureGeneration(): Long = synchronized(connectLock) { connectionGeneration }
|
||||
|
||||
fun currentGeneration(): Long = synchronized(connectLock) { connectionGeneration }
|
||||
|
||||
fun currentIdentity(): String = synchronized(connectLock) { currentIdentity }
|
||||
|
||||
/** Poke 事件(Kotlin 侧数据类,对应 Go PokeEvent) */
|
||||
data class PokeEventData(
|
||||
@@ -56,24 +81,61 @@ object TSBridge {
|
||||
defaultChannelPassword: String = "",
|
||||
callbacks: Callbacks,
|
||||
): String {
|
||||
val tsClient = Teamspeak.newClient()
|
||||
client = tsClient
|
||||
val identity = currentIdentity
|
||||
val (tsClient, generation) = beginConnection(identity)
|
||||
val error = doConnect(tsClient, generation, callbacks) { proxy ->
|
||||
tsClient.connect(host, nickname, password, defaultChannel, defaultChannelPassword, proxy)
|
||||
}
|
||||
if (error.isNotEmpty()) endConnection(tsClient)
|
||||
return error
|
||||
}
|
||||
|
||||
// The checked-in AAR may lag the approved Go contract during migration. A dynamic
|
||||
// proxy lets this source compile against both artifacts while routing only the new
|
||||
// mixed-PCM callbacks when the regenerated AAR is installed.
|
||||
fun connectWithIdentity(
|
||||
identity: String,
|
||||
host: String,
|
||||
nickname: String,
|
||||
password: String = "",
|
||||
defaultChannel: String = "",
|
||||
defaultChannelPassword: String = "",
|
||||
callbacks: Callbacks,
|
||||
): String {
|
||||
val (tsClient, generation) = beginConnection(identity)
|
||||
val error = doConnect(tsClient, generation, callbacks) { proxy ->
|
||||
tsClient.connectWithIdentity(
|
||||
identity,
|
||||
host,
|
||||
nickname,
|
||||
password,
|
||||
defaultChannel,
|
||||
defaultChannelPassword,
|
||||
proxy,
|
||||
)
|
||||
}
|
||||
if (error.isNotEmpty()) endConnection(tsClient)
|
||||
return error
|
||||
}
|
||||
|
||||
private fun doConnect(
|
||||
tsClient: TSClient,
|
||||
generation: Long,
|
||||
callbacks: Callbacks,
|
||||
connect: (EventCallback) -> String,
|
||||
): String {
|
||||
val callbackProxy = Proxy.newProxyInstance(
|
||||
EventCallback::class.java.classLoader,
|
||||
arrayOf(EventCallback::class.java),
|
||||
) { _, method, args ->
|
||||
if (captureGeneration() != generation) {
|
||||
return@newProxyInstance null
|
||||
}
|
||||
val values = args ?: emptyArray()
|
||||
when (method.name) {
|
||||
"onConnected" -> callbacks.onConnected()
|
||||
"onDisconnected" -> callbacks.onDisconnected(values[0] as String)
|
||||
"onTextMessage" -> (values[0] as? TextMsg)?.let(callbacks::onTextMessage)
|
||||
"onClientEnter" -> (values[0] as? Client)?.let(callbacks::onClientEnter)
|
||||
"onClientLeave" -> callbacks.onClientLeave(values[0] as Long, values[1] as String)
|
||||
"onClientMoved" -> callbacks.onClientMoved(values[0] as Long, values[1] as String)
|
||||
"onClientLeave" -> callbacks.onClientLeave((values[0] as Number).toLong(), values[1] as String)
|
||||
"onClientMoved" -> callbacks.onClientMoved((values[0] as Number).toLong(), values[1] as String)
|
||||
"onKicked" -> callbacks.onKicked(values[0] as String)
|
||||
"onMixedVoicePCM" -> (values[0] as? ByteArray)?.let {
|
||||
android.util.Log.d("TSVoice", "Kotlin mixed PCM bytes=${it.size}")
|
||||
@@ -81,7 +143,7 @@ object TSBridge {
|
||||
}
|
||||
"onClientSpeaking" -> {
|
||||
android.util.Log.i("TSVoice", "Kotlin speaking client=${values[0]} speaking=${values[1]}")
|
||||
callbacks.onClientSpeaking(values[0] as Long, values[1] as Boolean)
|
||||
callbacks.onClientSpeaking((values[0] as Number).toLong(), values[1] as Boolean)
|
||||
}
|
||||
"onPoked" -> (values[0] as? teamspeak.PokeEvent)?.let { event ->
|
||||
callbacks.onPoked(PokeEventData(
|
||||
@@ -91,7 +153,6 @@ object TSBridge {
|
||||
message = event.message,
|
||||
))
|
||||
}
|
||||
// Raw Opus callbacks deliberately have no Kotlin receive fallback.
|
||||
"onVoiceData" -> Unit
|
||||
"toString" -> "TSBridge.EventCallback"
|
||||
"hashCode" -> System.identityHashCode(callbacks)
|
||||
@@ -101,13 +162,21 @@ object TSBridge {
|
||||
null
|
||||
} as EventCallback
|
||||
|
||||
return tsClient.connect(host, nickname, password, defaultChannel, defaultChannelPassword, callbackProxy)
|
||||
return try {
|
||||
connect(callbackProxy)
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w("TSBridge", "connect threw", t)
|
||||
"连接失败"
|
||||
}
|
||||
}
|
||||
|
||||
/** 断开连接 */
|
||||
fun disconnect() {
|
||||
synchronized(connectLock) {
|
||||
client?.disconnect()
|
||||
client = null
|
||||
connectionGeneration += 1
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否已连接 */
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.tsmobile.app.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** 共享 Json 实例,避免 parseFileMessageMeta 每次调用都创建新实例 */
|
||||
private val fileJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* 服务器连接配置。
|
||||
@@ -99,6 +103,11 @@ data class FileMessageMeta(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息类型。
|
||||
*/
|
||||
enum class MessageType { NORMAL, SYSTEM }
|
||||
|
||||
/**
|
||||
* 聊天消息实体。
|
||||
* 权威来源:OnTextMessage(对应 notifytextmessage)。
|
||||
@@ -114,6 +123,7 @@ data class ChatMessage(
|
||||
val isSelf: Boolean, // 是否是自己发送的
|
||||
val deliveryState: MessageDeliveryState = MessageDeliveryState.SENT, // 送达状态
|
||||
val fileMeta: FileMessageMeta? = null, // 文件消息元数据(仅文件消息)
|
||||
val messageType: MessageType = MessageType.NORMAL, // 消息类型
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -261,8 +271,7 @@ enum class VoiceOutputDevice {
|
||||
fun parseFileMessageMeta(content: String): FileMessageMeta? {
|
||||
if (!content.trimStart().startsWith("{")) return null
|
||||
return try {
|
||||
val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
|
||||
val obj = json.decodeFromString<kotlinx.serialization.json.JsonObject>(content)
|
||||
val obj = fileJson.decodeFromString<kotlinx.serialization.json.JsonObject>(content)
|
||||
val msgType = obj["msg_type"]?.toString()?.trim('"') ?: return null
|
||||
if (!msgType.startsWith("ts.file")) return null
|
||||
val meta = FileMessageMeta(
|
||||
|
||||
@@ -28,6 +28,9 @@ object Repository {
|
||||
// 客户端 Map 索引:ClientID → ClientInfo(O(1) 查找)
|
||||
private var clientMap: Map<Int, ClientInfo> = emptyMap()
|
||||
|
||||
// 客户端 UID 索引:UID → ClientInfo(O(1) UID 反查)
|
||||
private var clientUidMap: Map<String, ClientInfo> = emptyMap()
|
||||
|
||||
// --- 当前客户端 ID ---
|
||||
private val _selfClientId = MutableStateFlow(0)
|
||||
val selfClientId: StateFlow<Int> = _selfClientId.asStateFlow()
|
||||
@@ -138,6 +141,7 @@ object Repository {
|
||||
}
|
||||
_clients.value = clients
|
||||
clientMap = clients.associateBy { it.id }
|
||||
clientUidMap = clients.associateBy { it.uid }
|
||||
_channelClients.value = clients.groupBy { it.channelId }
|
||||
android.util.Log.d("Repository", "refreshClientList: ${clients.size} clients updated")
|
||||
}
|
||||
@@ -149,6 +153,14 @@ object Repository {
|
||||
_currentChannelId.value = channelId
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅更新频道列表(不覆盖 clients 相关状态)。
|
||||
* 用于 refreshChannelsIfNeeded 等部分刷新场景。
|
||||
*/
|
||||
fun updateChannels(channels: List<ChannelInfo>) {
|
||||
_channels.value = channels
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新基线数据。
|
||||
*/
|
||||
@@ -161,6 +173,7 @@ object Repository {
|
||||
_channels.value = channels
|
||||
_clients.value = clients
|
||||
clientMap = clients.associateBy { it.id }
|
||||
clientUidMap = clients.associateBy { it.uid }
|
||||
_selfClientId.value = selfClientId
|
||||
_currentChannelId.value = currentChannelId
|
||||
|
||||
@@ -171,6 +184,12 @@ object Repository {
|
||||
/** 查询成员是否存在 */
|
||||
fun hasClient(clientId: Int): Boolean = clientMap.containsKey(clientId)
|
||||
|
||||
/** 查询成员信息 */
|
||||
fun getClient(clientId: Int): ClientInfo? = clientMap[clientId]
|
||||
|
||||
/** 通过 UID 查询成员信息(O(1)) */
|
||||
fun getClientByUid(uid: String): ClientInfo? = clientUidMap[uid]
|
||||
|
||||
/** 查询频道是否存在 */
|
||||
fun hasChannel(channelId: String): Boolean = _channels.value.any { it.id == channelId }
|
||||
|
||||
@@ -178,13 +197,10 @@ object Repository {
|
||||
// 按 (TargetMode, Target) 分组存储,key = "${targetMode}_${targetId}"
|
||||
private val messageArchives = ConcurrentHashMap<String, MutableList<ChatMessage>>()
|
||||
|
||||
// 未读状态:key = "${targetMode}_${targetId}", value = 未读数
|
||||
private val _unreadCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val unreadCounts: StateFlow<Map<String, Int>> = _unreadCounts.asStateFlow()
|
||||
|
||||
// 当前查看的会话(用于判断是否需要未读提示)
|
||||
// 当前查看的会话(用于判断是否需要推送系统通知)
|
||||
@Volatile
|
||||
private var activeChatKey: String? = null
|
||||
var activeChatKey: String? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* 归档消息(由 OnTextMessage 调用)。
|
||||
@@ -200,40 +216,32 @@ object Repository {
|
||||
archive.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是当前查看的会话,增加未读计数
|
||||
if (key != activeChatKey) {
|
||||
val currentCounts = _unreadCounts.value.toMutableMap()
|
||||
currentCounts[key] = (currentCounts[key] ?: 0) + 1
|
||||
_unreadCounts.value = currentCounts
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记自己发送的消息为已送达(匹配 OnTextMessage 回显)。
|
||||
* @return true 表示找到并更新了对应的 PENDING 消息
|
||||
* 优先用 invokerID + content 双重匹配(AAR 已支持 InvokerID),
|
||||
* 降级到仅 content 匹配(senderId=0 时)。
|
||||
* @return 已确认消息的 ID,未匹配返回 null
|
||||
*/
|
||||
fun confirmMessageDelivery(targetMode: Int, targetId: Long, senderId: Int, content: String): Boolean {
|
||||
fun confirmMessageDelivery(targetMode: Int, targetId: Long, senderId: Int, content: String): String? {
|
||||
val key = "${targetMode}_${targetId}"
|
||||
val archive = messageArchives[key] ?: return false
|
||||
val archive = messageArchives[key] ?: return null
|
||||
|
||||
synchronized(archive) {
|
||||
// 从后往前找最近的 PENDING 消息(同内容)
|
||||
// 不匹配 senderId:gomobile 的 TextMsg 不含 InvokerID,
|
||||
// 通过 UID 反查 clientId 可能因客户端列表未同步而得到 0,
|
||||
// 导致与 sendMessage 时记录的 selfClientId 不一致。
|
||||
for (i in archive.indices.reversed()) {
|
||||
val msg = archive[i]
|
||||
if (msg.isSelf &&
|
||||
msg.deliveryState == MessageDeliveryState.PENDING &&
|
||||
msg.content == content
|
||||
msg.content == content &&
|
||||
(senderId == 0 || msg.senderId == senderId)
|
||||
) {
|
||||
archive[i] = msg.copy(deliveryState = MessageDeliveryState.SENT)
|
||||
return true
|
||||
return msg.id
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,16 +305,6 @@ object Repository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定会话的未读标记。
|
||||
*/
|
||||
fun clearUnread(targetMode: Int, targetId: Long) {
|
||||
val key = "${targetMode}_${targetId}"
|
||||
val currentCounts = _unreadCounts.value.toMutableMap()
|
||||
currentCounts.remove(key)
|
||||
_unreadCounts.value = currentCounts
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理会话数据。
|
||||
*/
|
||||
@@ -314,12 +312,12 @@ object Repository {
|
||||
_channels.value = emptyList()
|
||||
_clients.value = emptyList()
|
||||
clientMap = emptyMap()
|
||||
clientUidMap = emptyMap()
|
||||
_selfClientId.value = 0
|
||||
_currentChannelId.value = "0"
|
||||
_serverInfo.value = null
|
||||
_channelClients.value = emptyMap()
|
||||
messageArchives.clear()
|
||||
_unreadCounts.value = emptyMap()
|
||||
activeChatKey = null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.tsmobile.app.data
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Gitea 发布信息。
|
||||
*/
|
||||
data class ReleaseInfo(
|
||||
val versionName: String,
|
||||
val releaseUrl: String,
|
||||
val tagName: String,
|
||||
val body: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* GitHub/Gitea API 发布响应(部分字段)。
|
||||
*/
|
||||
@Serializable
|
||||
private data class ReleaseResponse(
|
||||
val tag_name: String = "",
|
||||
val name: String = "",
|
||||
val html_url: String = "",
|
||||
val body: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新检测器。
|
||||
*
|
||||
* 通过 Gitea REST API 获取最新发布的版本信息,
|
||||
* 用 semver 方式(x.y.z)与本地 versionName 比较判断是否有新版本。
|
||||
*/
|
||||
object UpdateChecker {
|
||||
|
||||
private const val TAG = "UpdateChecker"
|
||||
|
||||
/** Gitea 实例地址 */
|
||||
private const val GITEA_HOST = "rep.sansenhoshi.top"
|
||||
|
||||
/** 仓库路径 */
|
||||
private const val REPO_PATH = "sansenhoshi/ts-mobile-go"
|
||||
|
||||
/** Gitea API: 获取最新发布 */
|
||||
private val API_URL =
|
||||
"https://$GITEA_HOST/api/v1/repos/$REPO_PATH/releases/latest"
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val client: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.addInterceptor(UserAgentInterceptor())
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入 User-Agent 头(Gitea API 建议携带)。
|
||||
*/
|
||||
private class UserAgentInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
|
||||
val request: Request = chain.request().newBuilder()
|
||||
.header("User-Agent", "TSMobile-UpdateChecker")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新发布信息。
|
||||
*
|
||||
* @return [ReleaseInfo] 若成功获取并解析;null 表示请求失败或格式异常。
|
||||
*/
|
||||
suspend fun fetchLatestRelease(): ReleaseInfo? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(API_URL)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
Log.w(TAG, "API returned ${response.code}: ${response.message}")
|
||||
response.close()
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val body = response.body?.string() ?: run {
|
||||
response.close()
|
||||
return@withContext null
|
||||
}
|
||||
response.close()
|
||||
|
||||
val release = json.decodeFromString<ReleaseResponse>(body)
|
||||
|
||||
// 从 tag_name 解析版本号,失败则从 name 解析
|
||||
val versionName = parseVersionName(release.tag_name)
|
||||
?: parseVersionName(release.name)
|
||||
if (versionName == null) {
|
||||
Log.w(TAG, "Cannot parse version from tag_name='${release.tag_name}' or name='${release.name}'")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
Log.d(TAG, "Parsed release: version=$versionName, tag=${release.tag_name}, name=${release.name}")
|
||||
|
||||
ReleaseInfo(
|
||||
versionName = versionName,
|
||||
releaseUrl = release.html_url.ifBlank {
|
||||
"https://$GITEA_HOST/$REPO_PATH/releases"
|
||||
},
|
||||
tagName = release.tag_name,
|
||||
body = release.body,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch latest release", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个 semver 版本号。
|
||||
*
|
||||
* @return 正数表示 a > b,负数表示 a < b,0 表示相等。
|
||||
*
|
||||
* 示例:
|
||||
* - compareVersion("1.0.2", "1.0.0") → 1
|
||||
* - compareVersion("2.0.0", "1.9.9") → 1
|
||||
* - compareVersion("1.0.0", "1.0.0") → 0
|
||||
* - compareVersion("1.0", "1.0.0") → -1
|
||||
*/
|
||||
fun compareVersion(a: String, b: String): Int {
|
||||
val partsA = a.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val partsB = b.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val maxLen = maxOf(partsA.size, partsB.size)
|
||||
|
||||
for (i in 0 until maxLen) {
|
||||
val numA = partsA.getOrElse(i) { 0 }
|
||||
val numB = partsB.getOrElse(i) { 0 }
|
||||
if (numA != numB) return numA - numB
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 tag_name 解析版本号(semver)。
|
||||
*
|
||||
* 支持格式:
|
||||
* - "v1.0.2" / "V1.0.2" → "1.0.2"
|
||||
* - "v2.0" → "2.0"
|
||||
* - "1.0.2" → "1.0.2"
|
||||
* - 无法解析则返回 null
|
||||
*/
|
||||
private fun parseVersionName(tag: String): String? {
|
||||
if (tag.isBlank()) return null
|
||||
|
||||
// 去掉前缀 v/V
|
||||
val version = tag.trimStart('v', 'V')
|
||||
|
||||
// 提取数字和点号组成的版本段(如 "1.0.2")
|
||||
val match = Regex("""^\d+(\.\d+)*""").find(version) ?: return null
|
||||
return match.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.tsmobile.app.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.updateDataStore by preferencesDataStore(name = "update_settings")
|
||||
|
||||
/**
|
||||
* 更新检测偏好持久化。
|
||||
*
|
||||
* 记录已忽略的版本号和上次检查时间戳,
|
||||
* 避免重复提示同一版本和频繁请求API。
|
||||
*/
|
||||
class UpdatePreferences(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val DISMISSED_VERSION_KEY = stringPreferencesKey("dismissed_version")
|
||||
private val LAST_CHECK_TIMESTAMP_KEY = longPreferencesKey("last_check_timestamp")
|
||||
}
|
||||
|
||||
/**
|
||||
* 已忽略的版本号(默认空字符串表示未忽略任何版本)。
|
||||
*/
|
||||
val dismissedVersion: Flow<String> = context.updateDataStore.data.map { prefs ->
|
||||
prefs[DISMISSED_VERSION_KEY] ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* 上次检查时间戳(默认0表示从未检查过)。
|
||||
*/
|
||||
val lastCheckTimestamp: Flow<Long> = context.updateDataStore.data.map { prefs ->
|
||||
prefs[LAST_CHECK_TIMESTAMP_KEY] ?: 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存已忽略的版本号。
|
||||
*/
|
||||
suspend fun setDismissedVersion(version: String) {
|
||||
context.updateDataStore.edit { prefs ->
|
||||
prefs[DISMISSED_VERSION_KEY] = version
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存上次检查时间戳。
|
||||
*/
|
||||
suspend fun setLastCheckTimestamp(ts: Long) {
|
||||
context.updateDataStore.edit { prefs ->
|
||||
prefs[LAST_CHECK_TIMESTAMP_KEY] = ts
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import com.tsmobile.app.data.ChatMessage
|
||||
import com.tsmobile.app.data.FileDownloadManager
|
||||
import com.tsmobile.app.data.FileMessageMeta
|
||||
import com.tsmobile.app.data.MessageDeliveryState
|
||||
import com.tsmobile.app.data.MessageType
|
||||
import com.tsmobile.app.data.Repository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -44,6 +45,33 @@ import java.util.Locale
|
||||
*/
|
||||
@Composable
|
||||
fun MessageItem(message: ChatMessage) {
|
||||
// 系统消息:居中显示,无气泡
|
||||
if (message.messageType == MessageType.SYSTEM) {
|
||||
val timeText = remember(message.timestamp) {
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(message.timestamp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp, horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = timeText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val timeText = remember(message.timestamp) {
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
.format(Date(message.timestamp))
|
||||
|
||||
@@ -36,6 +36,7 @@ fun PokeNotification(
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(
|
||||
horizontal = UiTokens.Spacing.Large,
|
||||
vertical = UiTokens.Spacing.Small,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.tsmobile.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.NewReleases
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.tsmobile.app.ui.theme.UiTokens
|
||||
import com.tsmobile.app.ui.theme.semanticColors
|
||||
|
||||
/**
|
||||
* 更新横幅 — 显示在服务器配置页顶部。
|
||||
*
|
||||
* 当检测到新版本时显示,提供"下载更新"和"忽略此版本"两个操作。
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateBanner(
|
||||
versionName: String,
|
||||
releaseUrl: String,
|
||||
onDownload: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val semanticColors = MaterialTheme.semanticColors
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = semanticColors.infoContainer,
|
||||
contentColor = semanticColors.onInfoContainer,
|
||||
tonalElevation = UiTokens.Elevation.Subtle,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = UiTokens.Spacing.Large,
|
||||
vertical = UiTokens.Spacing.Medium,
|
||||
),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.NewReleases,
|
||||
contentDescription = null,
|
||||
tint = semanticColors.info,
|
||||
modifier = Modifier.size(UiTokens.Size.IconMedium),
|
||||
)
|
||||
Spacer(Modifier.width(UiTokens.Spacing.Small))
|
||||
Text(
|
||||
text = "发现新版本",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(UiTokens.Spacing.ExtraSmall))
|
||||
Text(
|
||||
text = "新版本 $versionName 已发布,点击下载更新",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(UiTokens.Spacing.Small))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = semanticColors.onInfoContainer.copy(alpha = 0.7f),
|
||||
),
|
||||
) {
|
||||
Text("忽略此版本")
|
||||
}
|
||||
Spacer(Modifier.width(UiTokens.Spacing.Small))
|
||||
TextButton(onClick = { onDownload(releaseUrl) }) {
|
||||
Text("下载更新")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.tsmobile.app.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.NewReleases
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tsmobile.app.ui.theme.UiTokens
|
||||
import com.tsmobile.app.viewmodel.UpdateInfo
|
||||
|
||||
/**
|
||||
* 手动检查更新的结果状态。
|
||||
*/
|
||||
sealed class UpdateCheckResult {
|
||||
/** 正在检查中 */
|
||||
data object Checking : UpdateCheckResult()
|
||||
/** 发现新版本 */
|
||||
data class UpdateAvailable(val info: UpdateInfo) : UpdateCheckResult()
|
||||
/** 已经是最新版 */
|
||||
data object NoUpdate : UpdateCheckResult()
|
||||
/** 检查失败 */
|
||||
data class Error(val message: String) : UpdateCheckResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新检查弹窗。
|
||||
*
|
||||
* 点击"检查更新"后弹出,显示检查过程和结果。
|
||||
* - 检查中:旋转进度 + "正在检查更新..."
|
||||
* - 有新版本:版本号 + 下载/忽略按钮
|
||||
* - 无更新:绿色对勾 + "已经是最新版"
|
||||
* - 失败:错误图标 + 错误信息 + 重试/关闭按钮
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateCheckDialog(
|
||||
result: UpdateCheckResult,
|
||||
onDownload: (String) -> Unit,
|
||||
onDismissVersion: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (result !is UpdateCheckResult.Checking) onClose() },
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = UiTokens.Elevation.Floating,
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
when (result) {
|
||||
is UpdateCheckResult.Checking -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(UiTokens.Size.IconMedium),
|
||||
strokeWidth = UiTokens.Border.Emphasized,
|
||||
)
|
||||
Spacer(Modifier.width(UiTokens.Spacing.Medium))
|
||||
Text("检查更新")
|
||||
}
|
||||
is UpdateCheckResult.UpdateAvailable -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.NewReleases,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(UiTokens.Size.IconMedium),
|
||||
)
|
||||
Spacer(Modifier.width(UiTokens.Spacing.Medium))
|
||||
Text("发现新版本")
|
||||
}
|
||||
is UpdateCheckResult.NoUpdate -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(UiTokens.Size.IconMedium),
|
||||
)
|
||||
Spacer(Modifier.width(UiTokens.Spacing.Medium))
|
||||
Text("检查完成")
|
||||
}
|
||||
is UpdateCheckResult.Error -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(UiTokens.Size.IconMedium),
|
||||
)
|
||||
Spacer(Modifier.width(UiTokens.Spacing.Medium))
|
||||
Text("检查失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = result is UpdateCheckResult.Checking,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Text(
|
||||
text = "正在连接服务器检查更新...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = result is UpdateCheckResult.UpdateAvailable,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
val info = (result as? UpdateCheckResult.UpdateAvailable)?.info
|
||||
if (info != null) {
|
||||
Column {
|
||||
Text(
|
||||
text = "新版本 ${info.versionName} 已发布",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(UiTokens.Spacing.ExtraSmall))
|
||||
Text(
|
||||
text = "建议下载最新版本以获得更好的体验。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// 更新内容(支持滚动)
|
||||
if (info.body.isNotBlank()) {
|
||||
Spacer(Modifier.height(UiTokens.Spacing.Medium))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 200.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(UiTokens.Spacing.Medium),
|
||||
) {
|
||||
Text(
|
||||
text = "更新内容",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(UiTokens.Spacing.ExtraSmall))
|
||||
Text(
|
||||
text = info.body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = result is UpdateCheckResult.NoUpdate,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Text(
|
||||
text = "已经是最新版,无需更新。",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = result is UpdateCheckResult.Error,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
val msg = (result as? UpdateCheckResult.Error)?.message ?: ""
|
||||
Text(
|
||||
text = msg.ifBlank { "网络连接失败,请稍后重试" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
when (result) {
|
||||
is UpdateCheckResult.Checking -> {
|
||||
// 检查中不显示按钮
|
||||
}
|
||||
is UpdateCheckResult.UpdateAvailable -> {
|
||||
TextButton(
|
||||
onClick = { onDismissVersion() },
|
||||
) {
|
||||
Text("忽略此版本")
|
||||
}
|
||||
Spacer(Modifier.width(UiTokens.Spacing.Small))
|
||||
Button(
|
||||
onClick = { onDownload(result.info.releaseUrl) },
|
||||
) {
|
||||
Text("下载更新")
|
||||
}
|
||||
}
|
||||
is UpdateCheckResult.NoUpdate -> {
|
||||
Button(onClick = onClose) {
|
||||
Text("确定")
|
||||
}
|
||||
}
|
||||
is UpdateCheckResult.Error -> {
|
||||
TextButton(onClick = onClose) {
|
||||
Text("关闭")
|
||||
}
|
||||
Spacer(Modifier.width(UiTokens.Spacing.Small))
|
||||
Button(onClick = onRetry) {
|
||||
Text("重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (result is UpdateCheckResult.UpdateAvailable ||
|
||||
result is UpdateCheckResult.Error
|
||||
) {
|
||||
// 关闭按钮在 confirmButton 区域已处理
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ fun VoiceCard(
|
||||
.heightIn(min = 280.dp, max = 560.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.ExtraSmall),
|
||||
) {
|
||||
items(members, key = { it.id }) { member ->
|
||||
items(members.distinctBy { it.id }, key = { it.id }) { member ->
|
||||
MemberRow(
|
||||
name = member.nickname,
|
||||
isSelf = member.isSelf,
|
||||
|
||||
@@ -3,16 +3,20 @@ package com.tsmobile.app.ui.navigation
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.tsmobile.app.data.ConnectionState
|
||||
import com.tsmobile.app.data.Repository
|
||||
import com.tsmobile.app.ui.components.PokeNotification
|
||||
import com.tsmobile.app.ui.screens.ChannelListScreen
|
||||
import com.tsmobile.app.ui.screens.ChatScreen
|
||||
import com.tsmobile.app.ui.screens.KickedScreen
|
||||
@@ -50,6 +54,7 @@ fun AppNavGraph(
|
||||
serverViewModel.channelViewModel = channelViewModel
|
||||
serverViewModel.chatViewModel = chatViewModel
|
||||
serverViewModel.voiceViewModel = voiceViewModel
|
||||
channelViewModel.chatViewModel = chatViewModel
|
||||
}
|
||||
|
||||
// 观察连接状态变化,处理导航
|
||||
@@ -80,6 +85,11 @@ fun AppNavGraph(
|
||||
}
|
||||
}
|
||||
|
||||
// 全局 Poke 气泡通知(覆盖在所有页面上方)
|
||||
val showPokeNotification by serverViewModel.showPokeNotification.collectAsState()
|
||||
val pokeNotification by serverViewModel.pokeNotification.collectAsState()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.SERVER_CONFIG,
|
||||
@@ -175,5 +185,13 @@ fun AppNavGraph(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Poke 气泡通知(全局覆盖层)
|
||||
PokeNotification(
|
||||
pokeEvent = pokeNotification,
|
||||
isVisible = showPokeNotification,
|
||||
onDismiss = { serverViewModel.dismissPokeNotification() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,10 +83,6 @@ fun ChannelListScreen(
|
||||
val channelDetail by channelViewModel.channelDetailInfo.collectAsState()
|
||||
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
|
||||
|
||||
// Poke 通知
|
||||
val showPokeNotification by serverViewModel.showPokeNotification.collectAsState()
|
||||
val pokeNotification by serverViewModel.pokeNotification.collectAsState()
|
||||
|
||||
// ViewModel 重建保护:如果 syncState 不是 Synchronized(ViewModel 被系统回收后重建),
|
||||
// 重新触发首次同步,否则频道列表页会永远停在加载态。
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -189,14 +185,6 @@ fun ChannelListScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 全局浮动通知 ───
|
||||
|
||||
// Poke 通知(顶部气泡)
|
||||
PokeNotification(
|
||||
pokeEvent = pokeNotification,
|
||||
isVisible = showPokeNotification,
|
||||
onDismiss = { serverViewModel.dismissPokeNotification() },
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 弹窗层 ───
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import com.tsmobile.app.data.ChannelInfo
|
||||
import com.tsmobile.app.ui.components.ChannelDetailCard
|
||||
import com.tsmobile.app.ui.components.ChatHeader
|
||||
@@ -48,6 +49,7 @@ fun ChatScreen(
|
||||
|
||||
val currentChannel = channels.find { it.id == currentChannelId }
|
||||
val clients = channelClients[currentChannelId] ?: emptyList()
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -83,19 +85,29 @@ fun ChatScreen(
|
||||
// 底部:语音控制栏(固定在底部,不受键盘影响)
|
||||
VoiceControlBar(
|
||||
voiceViewModel = voiceViewModel,
|
||||
onExpand = { showVoiceCard = true },
|
||||
onExpand = {
|
||||
focusManager.clearFocus()
|
||||
voiceViewModel.openVoiceCard()
|
||||
showVoiceCard = true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 语音卡弹窗(BottomSheet)
|
||||
if (showVoiceCard) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showVoiceCard = false },
|
||||
onDismissRequest = {
|
||||
showVoiceCard = false
|
||||
voiceViewModel.closeVoiceCard()
|
||||
},
|
||||
) {
|
||||
VoiceCard(
|
||||
voiceViewModel = voiceViewModel,
|
||||
channelViewModel = channelViewModel,
|
||||
onDismiss = { showVoiceCard = false },
|
||||
onDismiss = {
|
||||
showVoiceCard = false
|
||||
voiceViewModel.closeVoiceCard()
|
||||
},
|
||||
onPokeClient = { clientId, message ->
|
||||
serverViewModel.pokeClient(clientId, message)
|
||||
},
|
||||
|
||||
@@ -10,7 +10,9 @@ import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.SystemUpdate
|
||||
import com.tsmobile.app.ui.theme.ThemeMode
|
||||
import com.tsmobile.app.ui.theme.UiTokens
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -23,6 +25,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tsmobile.app.data.RecentConnection
|
||||
import com.tsmobile.app.ui.components.ConnectButton
|
||||
import com.tsmobile.app.ui.components.UpdateCheckDialog
|
||||
import com.tsmobile.app.viewmodel.ConnectState
|
||||
import com.tsmobile.app.viewmodel.ServerViewModel
|
||||
import com.tsmobile.app.viewmodel.formatRelativeTime
|
||||
@@ -48,7 +51,9 @@ fun ServerConfigScreen(
|
||||
// 上:品牌区
|
||||
BrandSection(
|
||||
themeMode = themeMode,
|
||||
onToggleTheme = { viewModel.toggleTheme() }
|
||||
onToggleTheme = { viewModel.toggleTheme() },
|
||||
isCheckingUpdate = state.isCheckingUpdate,
|
||||
onCheckUpdate = { viewModel.manualCheckUpdate() },
|
||||
)
|
||||
|
||||
// 中:输入区
|
||||
@@ -80,18 +85,31 @@ fun ServerConfigScreen(
|
||||
onNavigateToChannelList()
|
||||
}
|
||||
}
|
||||
|
||||
// 手动检查更新弹窗
|
||||
val updateCheckResult by viewModel.updateCheckResult.collectAsState()
|
||||
updateCheckResult?.let { result ->
|
||||
UpdateCheckDialog(
|
||||
result = result,
|
||||
onDownload = { url -> viewModel.openReleaseUrl(url) },
|
||||
onDismissVersion = { viewModel.dismissUpdate() },
|
||||
onRetry = { viewModel.manualCheckUpdate() },
|
||||
onClose = { viewModel.closeUpdateDialog() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 品牌区组件。
|
||||
*/
|
||||
@Composable
|
||||
private fun BrandSection(themeMode: ThemeMode, onToggleTheme: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 48.dp)
|
||||
private fun BrandSection(
|
||||
themeMode: ThemeMode,
|
||||
onToggleTheme: () -> Unit,
|
||||
isCheckingUpdate: Boolean = false,
|
||||
onCheckUpdate: () -> Unit = {},
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -115,6 +133,29 @@ private fun BrandSection(themeMode: ThemeMode, onToggleTheme: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// 检查更新按钮(左上角)
|
||||
IconButton(
|
||||
onClick = onCheckUpdate,
|
||||
enabled = !isCheckingUpdate,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = 8.dp),
|
||||
) {
|
||||
if (isCheckingUpdate) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(UiTokens.Size.IconMedium),
|
||||
strokeWidth = UiTokens.Border.Emphasized,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SystemUpdate,
|
||||
contentDescription = "检查更新",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 主题切换按钮(右上角)
|
||||
IconButton(
|
||||
onClick = onToggleTheme,
|
||||
|
||||
@@ -65,6 +65,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// ChatViewModel 引用(由 ServerViewModel 设置)
|
||||
var chatViewModel: ChatViewModel? = null
|
||||
|
||||
// 频道列表最后刷新时间
|
||||
private var lastChannelRefreshTime: Long = 0
|
||||
|
||||
@@ -82,10 +85,6 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
private val _expandedChannelIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val expandedChannelIds: StateFlow<Set<String>> = _expandedChannelIds.asStateFlow()
|
||||
|
||||
// 未读消息标记(当前频道外是否有新消息)
|
||||
private val _hasUnreadMessage = MutableStateFlow(false)
|
||||
val hasUnreadMessage: StateFlow<Boolean> = _hasUnreadMessage.asStateFlow()
|
||||
|
||||
// --- 当前频道 ---
|
||||
val currentChannelId: StateFlow<String> = Repository.currentChannelId
|
||||
|
||||
@@ -127,7 +126,6 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
fun clearChannels() {
|
||||
_syncState.value = SyncState.Unsynced
|
||||
_expandedChannelIds.value = emptySet()
|
||||
_hasUnreadMessage.value = false
|
||||
_switchState.value = ChannelSwitchState.Idle
|
||||
_showSwitchDialog.value = false
|
||||
_pendingSwitchChannel.value = null
|
||||
@@ -275,12 +273,7 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
Log.d(TAG, "Channel list stale, refreshing...")
|
||||
val channelsJson = TSBridge.getChannelsJSON()
|
||||
val channels = json.decodeFromString<List<ChannelInfo>>(channelsJson)
|
||||
Repository.updateBaseline(
|
||||
channels,
|
||||
Repository.clients.value,
|
||||
Repository.selfClientId.value,
|
||||
Repository.currentChannelId.value,
|
||||
)
|
||||
Repository.updateChannels(channels)
|
||||
lastChannelRefreshTime = System.currentTimeMillis()
|
||||
Log.d(TAG, "Channel list refreshed: ${channels.size} channels")
|
||||
} catch (e: Exception) {
|
||||
@@ -291,51 +284,82 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
/**
|
||||
* 处理客户端进入事件(增量同步)。
|
||||
* 刷新后对比当前频道成员,检测新加入者并发送系统消息。
|
||||
*/
|
||||
fun handleClientEnter() {
|
||||
Log.d(TAG, "Client enter: refreshing client list")
|
||||
viewModelScope.launch {
|
||||
val snapshot = snapshotCurrentChannelClients()
|
||||
Repository.refreshClientList()
|
||||
notifyMemberChanges(snapshot, "加入")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端离开事件。
|
||||
* 全量刷新客户端列表(人员变动统一用全量更新)。
|
||||
* 刷新后对比当前频道成员,检测离开者并发送系统消息。
|
||||
*/
|
||||
fun handleClientLeave(clientId: Int, reasonMsg: String) {
|
||||
Log.d(TAG, "Client leave: $clientId, reason: $reasonMsg")
|
||||
viewModelScope.launch {
|
||||
val snapshot = snapshotCurrentChannelClients()
|
||||
Repository.refreshClientList()
|
||||
notifyMemberChanges(snapshot, "离开")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文字消息(未读指示)。
|
||||
* 只需标记"当前频道外有新消息",不追踪具体频道。
|
||||
* 快照当前频道的成员 ID 和昵称。
|
||||
* 离开事件后 clientMap 会更新,所以需要提前保存昵称。
|
||||
*/
|
||||
fun onTextMessage(targetMode: Int, targetId: String) {
|
||||
if (targetMode != 2) return
|
||||
if (targetId != Repository.currentChannelId.value) {
|
||||
_hasUnreadMessage.value = true
|
||||
}
|
||||
private fun snapshotCurrentChannelClients(): Map<Int, String> {
|
||||
val channelId = Repository.currentChannelId.value
|
||||
return Repository.channelClients.value[channelId]
|
||||
?.associate { it.id to it.nickname }
|
||||
?: emptyMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除未读标记。
|
||||
* 对比快照与刷新后的当前频道成员,检测变动并发送系统消息。
|
||||
* @param before 变动前的 {clientId → nickname} 快照
|
||||
* @param action "加入" 或 "离开"
|
||||
*/
|
||||
fun clearUnread() {
|
||||
_hasUnreadMessage.value = false
|
||||
private fun notifyMemberChanges(before: Map<Int, String>, action: String) {
|
||||
val channelId = Repository.currentChannelId.value
|
||||
val after = Repository.channelClients.value[channelId]
|
||||
?.map { it.id }
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
|
||||
val changedIds = if (action == "加入") {
|
||||
after - before.keys
|
||||
} else {
|
||||
before.keys - after
|
||||
}
|
||||
|
||||
if (changedIds.isEmpty()) return
|
||||
|
||||
val cvm = chatViewModel ?: return
|
||||
for (id in changedIds) {
|
||||
val nickname = if (action == "加入") {
|
||||
Repository.getClient(id)?.nickname ?: id.toString()
|
||||
} else {
|
||||
before[id] ?: id.toString()
|
||||
}
|
||||
cvm.addSystemMessage("$nickname $action 当前频道")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换频道展开/折叠状态。
|
||||
*/
|
||||
fun toggleExpand(channelId: String) {
|
||||
_expandedChannelIds.value = _expandedChannelIds.value.toMutableSet().apply {
|
||||
_expandedChannelIds.update { current ->
|
||||
current.toMutableSet().apply {
|
||||
if (contains(channelId)) remove(channelId) else add(channelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动展开有成员的频道。
|
||||
@@ -356,7 +380,7 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
}
|
||||
|
||||
_expandedChannelIds.value = expandedWithParents
|
||||
_expandedChannelIds.update { expandedWithParents }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -367,6 +391,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
clients: List<ClientInfo>,
|
||||
expandedIds: Set<String>,
|
||||
): List<ChannelTreeNode> {
|
||||
// 预计算:按频道 ID 分组客户端(O(n) 一次,替代递归中 O(n) 每次)
|
||||
val clientsByChannel = clients.groupBy { it.channelId }
|
||||
|
||||
// 找出自引用的根频道(id == parentId),这些频道需要特殊处理
|
||||
val selfRefRoots = channels.filter { it.id == it.parentId }
|
||||
// 排除自引用频道后的正常频道列表
|
||||
@@ -386,7 +413,7 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
return ChannelTreeNode(
|
||||
channel = channel,
|
||||
children = emptyList(),
|
||||
clients = clients.filter { it.channelId == channel.id },
|
||||
clients = clientsByChannel[channel.id] ?: emptyList(),
|
||||
isExpanded = expandedIds.contains(channel.id),
|
||||
)
|
||||
}
|
||||
@@ -394,12 +421,11 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
val children = (childrenMap[channel.id] ?: emptyList())
|
||||
.sortedBy { it.order }
|
||||
.map { buildNode(it) }
|
||||
val channelClients = clients.filter { it.channelId == channel.id }
|
||||
|
||||
return ChannelTreeNode(
|
||||
channel = channel,
|
||||
children = children,
|
||||
clients = channelClients,
|
||||
clients = clientsByChannel[channel.id] ?: emptyList(),
|
||||
isExpanded = expandedIds.contains(channel.id),
|
||||
)
|
||||
}
|
||||
@@ -413,7 +439,7 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
ChannelTreeNode(
|
||||
channel = root,
|
||||
children = emptyList(),
|
||||
clients = clients.filter { it.channelId == root.id },
|
||||
clients = clientsByChannel[root.id] ?: emptyList(),
|
||||
isExpanded = expandedIds.contains(root.id),
|
||||
)
|
||||
}
|
||||
@@ -502,6 +528,12 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
if (_switchState.value is ChannelSwitchState.WaitingServerEvent) {
|
||||
Log.w(TAG, "Channel switch timeout waiting for server event")
|
||||
_switchState.value = ChannelSwitchState.Failed("等待服务端确认超时")
|
||||
// 8 秒后自动清除失败状态
|
||||
delay(8_000)
|
||||
if (_switchState.value is ChannelSwitchState.Failed) {
|
||||
_switchState.value = ChannelSwitchState.Idle
|
||||
_pendingSwitchChannel.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,9 +582,8 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
Repository.refreshClientList()
|
||||
}
|
||||
|
||||
// 更新当前频道 ID 并清除未读
|
||||
// 更新当前频道 ID
|
||||
Repository.setCurrentChannelId(targetChannelId)
|
||||
clearUnread()
|
||||
|
||||
when (currentState) {
|
||||
is ChannelSwitchState.WaitingServerEvent -> {
|
||||
@@ -582,10 +613,21 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
||||
/**
|
||||
* 处理其他用户的移动事件。
|
||||
* 全量刷新客户端列表(人员变动统一用全量更新)。
|
||||
* 根据移动方向检测"加入"或"离开"当前频道。
|
||||
*/
|
||||
private fun handleOtherClientMoved(clientId: Int, targetChannelId: String) {
|
||||
viewModelScope.launch {
|
||||
val currentChannelId = Repository.currentChannelId.value
|
||||
val snapshot = snapshotCurrentChannelClients()
|
||||
Repository.refreshClientList()
|
||||
|
||||
if (targetChannelId == currentChannelId) {
|
||||
// 移入当前频道 → 检测"加入"
|
||||
notifyMemberChanges(snapshot, "加入")
|
||||
} else if (snapshot.containsKey(clientId)) {
|
||||
// 从当前频道移出 → 检测"离开"
|
||||
notifyMemberChanges(snapshot, "离开")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.tsmobile.app.TSBridge
|
||||
import com.tsmobile.app.data.ChatMessage
|
||||
import com.tsmobile.app.data.MessageDeliveryState
|
||||
import com.tsmobile.app.data.MessageSendState
|
||||
import com.tsmobile.app.data.MessageType
|
||||
import com.tsmobile.app.data.Repository
|
||||
import com.tsmobile.app.data.parseFileMessageMeta
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -15,7 +16,9 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
@@ -37,12 +40,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var currentTargetMode: Int = 2
|
||||
private var currentTargetId: Long = 0
|
||||
|
||||
// 送达确认超时 Job
|
||||
private var deliveryTimeoutJob: Job? = null
|
||||
// 送达确认超时 Job(每条消息独立管理,避免快速发送时相互覆盖)
|
||||
private val deliveryTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
/**
|
||||
* 进入聊天页。
|
||||
* 加载消息并清除未读标记。
|
||||
* 加载消息并设置活跃会话标识。
|
||||
*/
|
||||
fun enterChat(targetMode: Int, targetId: Long) {
|
||||
currentTargetMode = targetMode
|
||||
@@ -50,7 +53,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
Repository.setActiveChat(targetMode, targetId)
|
||||
_messages.value = Repository.getMessages(targetMode, targetId)
|
||||
Repository.clearUnread(targetMode, targetId)
|
||||
|
||||
Log.d(TAG, "Entered chat: mode=$targetMode, target=$targetId, messages=${_messages.value.size}")
|
||||
}
|
||||
@@ -61,7 +63,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun leaveChat() {
|
||||
Repository.setActiveChat(null, null)
|
||||
_sendState.value = MessageSendState.Idle()
|
||||
deliveryTimeoutJob?.cancel()
|
||||
deliveryTimeoutJobs.values.forEach { it.cancel() }
|
||||
deliveryTimeoutJobs.clear()
|
||||
Log.d(TAG, "Left chat")
|
||||
}
|
||||
|
||||
@@ -71,11 +74,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun clearMessages() {
|
||||
_messages.value = emptyList()
|
||||
_sendState.value = MessageSendState.Idle()
|
||||
deliveryTimeoutJob?.cancel()
|
||||
deliveryTimeoutJobs.values.forEach { it.cancel() }
|
||||
deliveryTimeoutJobs.clear()
|
||||
currentTargetMode = 2
|
||||
currentTargetId = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加系统消息(频道成员变动等提示)。
|
||||
* 归档到 Repository 并刷新当前列表。
|
||||
*/
|
||||
fun addSystemMessage(content: String) {
|
||||
val message = ChatMessage(
|
||||
id = "sys_${System.currentTimeMillis()}",
|
||||
targetMode = 2,
|
||||
targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L,
|
||||
senderId = 0,
|
||||
senderName = "",
|
||||
content = content,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isSelf = false,
|
||||
messageType = MessageType.SYSTEM,
|
||||
)
|
||||
Repository.archiveMessage(message)
|
||||
refreshMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本消息。
|
||||
*
|
||||
@@ -97,7 +121,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_sendState.value = MessageSendState.Sending
|
||||
|
||||
val selfId = Repository.selfClientId.value
|
||||
val selfName = Repository.clients.value.find { it.id == selfId }?.nickname ?: ""
|
||||
val selfName = Repository.getClient(selfId)?.nickname ?: ""
|
||||
|
||||
// 生成唯一 ID(用于匹配回显)
|
||||
val messageId = "local_${System.currentTimeMillis()}_${selfId}"
|
||||
@@ -155,13 +179,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
/**
|
||||
* 启动送达确认超时。
|
||||
* 如果在 DELIVERY_TIMEOUT_MS 内没有收到回显,标记为 FAILED。
|
||||
* 每条消息独立管理超时 Job,快速发送多条消息时互不影响。
|
||||
*/
|
||||
private fun startDeliveryTimeout(messageId: String) {
|
||||
deliveryTimeoutJob?.cancel()
|
||||
deliveryTimeoutJob = viewModelScope.launch {
|
||||
val job = viewModelScope.launch {
|
||||
delay(DELIVERY_TIMEOUT_MS)
|
||||
|
||||
// 超时:检查消息是否仍然是 PENDING
|
||||
deliveryTimeoutJobs.remove(messageId)
|
||||
val messages = _messages.value
|
||||
val pending = messages.find {
|
||||
it.id == messageId && it.deliveryState == MessageDeliveryState.PENDING
|
||||
@@ -171,21 +196,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
markMessageFailed(messageId)
|
||||
}
|
||||
}
|
||||
deliveryTimeoutJobs[messageId] = job
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记指定消息为 FAILED。
|
||||
*/
|
||||
private fun markMessageFailed(messageId: String) {
|
||||
// 更新本地 _messages 快照
|
||||
val updated = _messages.value.map { msg ->
|
||||
// 清理超时 Job
|
||||
deliveryTimeoutJobs.remove(messageId)?.cancel()
|
||||
// 原子更新本地 _messages 快照(避免与 refreshMessages 并发覆盖)
|
||||
_messages.update { current ->
|
||||
current.map { msg ->
|
||||
if (msg.id == messageId && msg.deliveryState == MessageDeliveryState.PENDING) {
|
||||
msg.copy(deliveryState = MessageDeliveryState.FAILED)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
_messages.value = updated
|
||||
}
|
||||
|
||||
// 同步更新 Repository 归档(确保导航离开再回来时状态不丢失)
|
||||
Repository.markMessageDeliveryFailed(currentTargetMode, currentTargetId, messageId)
|
||||
@@ -214,13 +243,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
Log.d(TAG, "handleTextMessage JSON content: $content")
|
||||
}
|
||||
|
||||
// 无论 isSelf,始终尝试匹配 PENDING 消息(送达确认)。
|
||||
// 原因:gomobile 的 TextMsg 不含 InvokerID,senderId 通过 UID 反查 clientId,
|
||||
// 如果客户端列表未同步(selfUid=null),senderId 会是 0 ≠ selfId,导致 isSelf=false。
|
||||
val confirmed = Repository.confirmMessageDelivery(targetMode, targetId, senderId, content)
|
||||
if (confirmed) {
|
||||
Log.d(TAG, "Delivery confirmed: content=$content")
|
||||
deliveryTimeoutJob?.cancel()
|
||||
// 尝试匹配 PENDING 消息(送达确认)。
|
||||
// AAR 已支持 InvokerID,senderId 可靠。仍保留降级逻辑:senderId=0 时仅匹配 content。
|
||||
val confirmedId = Repository.confirmMessageDelivery(targetMode, targetId, senderId, content)
|
||||
if (confirmedId != null) {
|
||||
Log.d(TAG, "Delivery confirmed: id=$confirmedId, content=$content")
|
||||
deliveryTimeoutJobs.remove(confirmedId)?.cancel()
|
||||
if (targetMode == currentTargetMode && targetId == currentTargetId) {
|
||||
refreshMessages()
|
||||
}
|
||||
|
||||
@@ -9,10 +9,20 @@ import com.tsmobile.app.data.*
|
||||
import com.tsmobile.app.ui.theme.ThemeMode
|
||||
import teamspeak.TextMsg
|
||||
import teamspeak.Client
|
||||
import teamspeak.Teamspeak
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.tsmobile.app.App
|
||||
import com.tsmobile.app.MainActivity
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -46,6 +56,16 @@ data class ServerScreenState(
|
||||
val connectState: ConnectState = ConnectState.IDLE,
|
||||
val errorMessage: String? = null,
|
||||
val validationErrors: ValidationErrors = ValidationErrors(),
|
||||
val isCheckingUpdate: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新检测信息。
|
||||
*/
|
||||
data class UpdateInfo(
|
||||
val versionName: String,
|
||||
val releaseUrl: String,
|
||||
val body: String = "",
|
||||
)
|
||||
|
||||
class ServerViewModel(application: Application) : AndroidViewModel(application) {
|
||||
@@ -57,6 +77,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
|
||||
private val recentStore = RecentConnectionsStore(application)
|
||||
private val identityStore = IdentityStore(application)
|
||||
|
||||
// ChannelViewModel 引用(由 NavGraph 设置)
|
||||
var channelViewModel: ChannelViewModel? = null
|
||||
@@ -88,6 +109,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
private val _serverInfo = MutableStateFlow<ServerInfo?>(null)
|
||||
val serverInfo: StateFlow<ServerInfo?> = _serverInfo.asStateFlow()
|
||||
|
||||
// ── 更新检测 ──
|
||||
private val updatePreferences = UpdatePreferences(application)
|
||||
|
||||
private val _updateCheckResult = MutableStateFlow<com.tsmobile.app.ui.components.UpdateCheckResult?>(null)
|
||||
val updateCheckResult: StateFlow<com.tsmobile.app.ui.components.UpdateCheckResult?> = _updateCheckResult.asStateFlow()
|
||||
|
||||
// ── 主题切换(架构 4.4) ──
|
||||
private val themePreferences = ThemePreferences(application)
|
||||
|
||||
@@ -127,6 +154,177 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
val defaultChannelPassword: String = "",
|
||||
)
|
||||
|
||||
init {
|
||||
// 启动时自动检查更新
|
||||
checkForUpdate()
|
||||
}
|
||||
|
||||
// --- 更新检测 ---
|
||||
|
||||
/**
|
||||
* 检查 GitHub/Gitea 是否有新版本。
|
||||
* 用 semver 方式比较版本号,每次启动最多检查一次(1小时去重)。
|
||||
*/
|
||||
fun checkForUpdate() {
|
||||
if (_state.value.isCheckingUpdate) {
|
||||
android.util.Log.d(TAG, "Update check: already in progress, skipping")
|
||||
return
|
||||
}
|
||||
android.util.Log.d(TAG, "Update check: starting...")
|
||||
_state.update { it.copy(isCheckingUpdate = true) }
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// 去重:1小时内不重复检查
|
||||
val lastCheckTime = updatePreferences.lastCheckTimestamp.first()
|
||||
val oneHourMs = 60 * 60 * 1000L
|
||||
val elapsed = System.currentTimeMillis() - lastCheckTime
|
||||
if (elapsed < oneHourMs) {
|
||||
val waitMinutes = (oneHourMs - elapsed) / 60_000
|
||||
android.util.Log.d(TAG, "Update check: debounced (last check was ${elapsed / 60_000}min ago, can retry in ${waitMinutes}min)")
|
||||
_state.update { it.copy(isCheckingUpdate = false) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 获取本地版本
|
||||
val app = getApplication<android.app.Application>()
|
||||
@Suppress("DEPRECATION")
|
||||
val pi = app.packageManager.getPackageInfo(app.packageName, 0)
|
||||
val localVersion = pi.versionName ?: "0"
|
||||
android.util.Log.d(TAG, "Update check: local version=$localVersion")
|
||||
|
||||
// 获取远程版本
|
||||
val release = UpdateChecker.fetchLatestRelease()
|
||||
updatePreferences.setLastCheckTimestamp(System.currentTimeMillis())
|
||||
|
||||
if (release == null) {
|
||||
android.util.Log.d(TAG, "Update check: no release info from server (network error or no release yet)")
|
||||
} else if (UpdateChecker.compareVersion(release.versionName, localVersion) > 0) {
|
||||
android.util.Log.d(TAG, "Update check: new version available! remote=${release.versionName} > local=$localVersion")
|
||||
// 检查是否已被用户忽略(仅首次启动自动弹窗时检查)
|
||||
val dismissed = updatePreferences.dismissedVersion.first()
|
||||
if (release.versionName != dismissed) {
|
||||
android.util.Log.d(TAG, "Update check: showing update dialog for version ${release.versionName}")
|
||||
_updateCheckResult.value =
|
||||
com.tsmobile.app.ui.components.UpdateCheckResult.UpdateAvailable(
|
||||
UpdateInfo(
|
||||
versionName = release.versionName,
|
||||
releaseUrl = release.releaseUrl,
|
||||
body = release.body,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
android.util.Log.d(TAG, "Update check: version ${release.versionName} was previously dismissed, skipping")
|
||||
}
|
||||
} else {
|
||||
android.util.Log.d(TAG, "Update check: no newer version (remote=${release.versionName} <= local=$localVersion)")
|
||||
}
|
||||
_state.update { it.copy(isCheckingUpdate = false) }
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Update check: failed", e)
|
||||
_state.update { it.copy(isCheckingUpdate = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动检查更新(弹出对话框展示结果)。
|
||||
* 不走去重逻辑,每次点击都会实际请求。
|
||||
*/
|
||||
fun manualCheckUpdate() {
|
||||
if (_updateCheckResult.value is com.tsmobile.app.ui.components.UpdateCheckResult.Checking) return
|
||||
|
||||
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.Checking
|
||||
_state.update { it.copy(isCheckingUpdate = true) }
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// 获取本地版本
|
||||
val app = getApplication<android.app.Application>()
|
||||
@Suppress("DEPRECATION")
|
||||
val pi = app.packageManager.getPackageInfo(app.packageName, 0)
|
||||
val localVersion = pi.versionName ?: "0"
|
||||
android.util.Log.d(TAG, "Manual update check: local version=$localVersion")
|
||||
|
||||
// 获取远程版本
|
||||
val release = UpdateChecker.fetchLatestRelease()
|
||||
updatePreferences.setLastCheckTimestamp(System.currentTimeMillis())
|
||||
|
||||
if (release == null) {
|
||||
android.util.Log.d(TAG, "Manual update check: server returned no release info")
|
||||
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.Error("")
|
||||
} else if (UpdateChecker.compareVersion(release.versionName, localVersion) > 0) {
|
||||
// 检查是否已被忽略
|
||||
val dismissed = updatePreferences.dismissedVersion.first()
|
||||
if (release.versionName != dismissed) {
|
||||
android.util.Log.d(TAG, "Manual update check: new version ${release.versionName} available!")
|
||||
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.UpdateAvailable(
|
||||
UpdateInfo(
|
||||
versionName = release.versionName,
|
||||
releaseUrl = release.releaseUrl,
|
||||
body = release.body,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// 已被忽略但用户主动检查,也显示有新版本(覆盖忽略状态)
|
||||
android.util.Log.d(TAG, "Manual update check: version ${release.versionName} available (previously dismissed, showing anyway)")
|
||||
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.UpdateAvailable(
|
||||
UpdateInfo(
|
||||
versionName = release.versionName,
|
||||
releaseUrl = release.releaseUrl,
|
||||
body = release.body,
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.d(TAG, "Manual update check: already latest (remote=${release.versionName} <= local=$localVersion)")
|
||||
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.NoUpdate
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Manual update check: failed", e)
|
||||
_updateCheckResult.value = com.tsmobile.app.ui.components.UpdateCheckResult.Error(
|
||||
e.localizedMessage ?: "网络连接失败"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭更新检查弹窗。
|
||||
*/
|
||||
fun closeUpdateDialog() {
|
||||
_updateCheckResult.value = null
|
||||
_state.update { it.copy(isCheckingUpdate = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 在浏览器中打开发布页面。
|
||||
*/
|
||||
fun openReleaseUrl(url: String) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
getApplication<android.app.Application>().startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to open release URL", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 忽略当前版本的更新提示(持久化,同版本不再提示)。
|
||||
*/
|
||||
fun dismissUpdate() {
|
||||
val dialogResult = _updateCheckResult.value
|
||||
if (dialogResult is com.tsmobile.app.ui.components.UpdateCheckResult.UpdateAvailable) {
|
||||
viewModelScope.launch {
|
||||
updatePreferences.setDismissedVersion(dialogResult.info.versionName)
|
||||
}
|
||||
_updateCheckResult.value = null
|
||||
_state.update { it.copy(isCheckingUpdate = false) }
|
||||
}
|
||||
}
|
||||
|
||||
// --- 输入更新 ---
|
||||
|
||||
fun updateAddress(value: String) {
|
||||
@@ -200,11 +398,27 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
// 异步连接(阻塞调用放到 IO 线程,不阻塞 UI)
|
||||
viewModelScope.launch {
|
||||
val identity = withContext(Dispatchers.IO) {
|
||||
identityStore.getOrCreate { Teamspeak.generateIdentity(8) }
|
||||
}
|
||||
if (identity.isBlank()) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
connectState = ConnectState.FAILED,
|
||||
errorMessage = "身份生成失败",
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
TSBridge.connect(
|
||||
TSBridge.connectWithIdentity(
|
||||
identity = identity,
|
||||
host = config.address,
|
||||
nickname = config.nickname,
|
||||
password = config.password,
|
||||
defaultChannel = config.defaultChannel,
|
||||
defaultChannelPassword = config.defaultChannelPassword,
|
||||
callbacks = createBridgeCallbacks(),
|
||||
)
|
||||
}
|
||||
@@ -337,7 +551,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
// 震动反馈
|
||||
triggerVibration()
|
||||
|
||||
// 自动隐藏通知(5秒后)
|
||||
// app 在后台时发送系统通知
|
||||
val isInForeground = ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.RESUMED
|
||||
if (!isInForeground) {
|
||||
sendPokeSystemNotification(event)
|
||||
}
|
||||
|
||||
// 自动隐藏 app 内气泡(5秒后)
|
||||
viewModelScope.launch {
|
||||
delay(5000)
|
||||
dismissPokeNotification()
|
||||
@@ -349,6 +569,91 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
_pokeNotification.value = null
|
||||
}
|
||||
|
||||
private var pokeNotificationId = 1000
|
||||
|
||||
/**
|
||||
* 发送 Poke 系统通知(app 在后台时调用)。
|
||||
*/
|
||||
private fun sendPokeSystemNotification(event: com.tsmobile.app.data.PokeEvent) {
|
||||
try {
|
||||
val context = getApplication<Application>()
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val text = if (event.message.isNotEmpty()) {
|
||||
"${event.invokerName} 戳了你一下:${event.message}"
|
||||
} else {
|
||||
"${event.invokerName} 戳了你一下"
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(context, App.POKE_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("TeamSpeak Poke")
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
val nm = context.getSystemService(NotificationManager::class.java)
|
||||
nm.notify(pokeNotificationId++, notification)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Failed to send poke notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息系统通知。
|
||||
* 仅在 app 前台且正在查看对应聊天页时跳过,其余情况均推送。
|
||||
*/
|
||||
private fun sendMessageNotification(targetMode: Int, targetId: Long, senderName: String, content: String) {
|
||||
val activeKey = Repository.activeChatKey
|
||||
val msgKey = "${targetMode}_${targetId}"
|
||||
val isInForeground = ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.RESUMED
|
||||
|
||||
// app 在前台且正在查看该会话 → 跳过(消息已直接显示在 UI 中)
|
||||
if (isInForeground && activeKey == msgKey) return
|
||||
|
||||
try {
|
||||
val context = getApplication<Application>()
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val title = if (targetMode == 2) "频道消息" else "私聊消息"
|
||||
val text = "$senderName: $content"
|
||||
|
||||
val notification = NotificationCompat.Builder(context, App.POKE_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
// 使用 targetMode_targetId 作为通知 ID,不同会话各自覆盖
|
||||
val notificationId = 2000 + (msgKey.hashCode() and 0xFFFF)
|
||||
val nm = context.getSystemService(NotificationManager::class.java)
|
||||
nm.notify(notificationId, notification)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Failed to send message notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerVibration() {
|
||||
try {
|
||||
val vibrator = getApplication<Application>().getSystemService(android.os.Vibrator::class.java)
|
||||
@@ -466,7 +771,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
val params = lastConnectParams ?: break
|
||||
val error = withContext(Dispatchers.IO) {
|
||||
TSBridge.connect(
|
||||
val identity = identityStore.getOrCreate { Teamspeak.generateIdentity(8) }
|
||||
if (identity.isBlank()) return@withContext "身份生成失败"
|
||||
TSBridge.connectWithIdentity(
|
||||
identity = identity,
|
||||
host = params.host,
|
||||
nickname = params.nickname,
|
||||
password = params.password,
|
||||
@@ -508,7 +816,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
viewModelScope.launch {
|
||||
val error = withContext(Dispatchers.IO) {
|
||||
TSBridge.connect(
|
||||
val identity = identityStore.getOrCreate { Teamspeak.generateIdentity(8) }
|
||||
if (identity.isBlank()) return@withContext "身份生成失败"
|
||||
TSBridge.connectWithIdentity(
|
||||
identity = identity,
|
||||
host = params.host,
|
||||
nickname = params.nickname,
|
||||
password = params.password,
|
||||
@@ -666,13 +977,17 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
targetIdLong = resolved
|
||||
}
|
||||
}
|
||||
// TextMsg 不含 invokerID,通过 UID 查找客户端 ID
|
||||
// 直接使用 Go 侧传来的 invokerID(AAR 已包含此字段)
|
||||
val selfId = Repository.selfClientId.value
|
||||
val selfUid = Repository.clients.value.find { it.id == selfId }?.uid
|
||||
val senderClient = Repository.clients.value.find { it.uid == msg.invokerUID }
|
||||
// 优先匹配客户端列表;如果 UID 是自己的,直接使用 selfId
|
||||
val senderId = senderClient?.id ?: if (msg.invokerUID == selfUid) selfId else 0
|
||||
android.util.Log.d(TAG, "onTextMessage: resolved senderId=$senderId (selfId=$selfId, selfUid=$selfUid, senderClient=${senderClient?.id})")
|
||||
val invokerId = msg.invokerID.toInt()
|
||||
val senderId: Int = if (invokerId > 0) invokerId else {
|
||||
// 降级:invokerID 不可用时通过 UID 反查
|
||||
val selfUid = Repository.getClient(selfId)?.uid
|
||||
val senderClient = Repository.getClientByUid(msg.invokerUID)
|
||||
senderClient?.id ?: if (msg.invokerUID == selfUid) selfId else 0
|
||||
}
|
||||
val isSelf = senderId == selfId
|
||||
android.util.Log.d(TAG, "onTextMessage: senderId=$senderId (invokerID=${msg.invokerID}, isSelf=$isSelf)")
|
||||
val cvm = chatViewModel
|
||||
if (cvm == null) {
|
||||
android.util.Log.w(TAG, "onTextMessage: chatViewModel is null, message dropped!")
|
||||
@@ -685,10 +1000,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
content = msg.message
|
||||
)
|
||||
}
|
||||
// 更新未读标记
|
||||
channelViewModel?.onTextMessage(
|
||||
// 当用户不在对应聊天页时,推送系统通知
|
||||
sendMessageNotification(
|
||||
targetMode = msg.targetMode.toInt(),
|
||||
targetId = targetIdLong.toString(),
|
||||
targetId = targetIdLong,
|
||||
senderName = msg.invokerName,
|
||||
content = msg.message,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _speakingClients = MutableStateFlow<Map<Int, Long>>(emptyMap())
|
||||
val speakingClients: StateFlow<Map<Int, Long>> = _speakingClients
|
||||
|
||||
/** 保护 _speakingClients 和 _remoteAudioSettings 的复合读写操作 */
|
||||
private val audioStateLock = Any()
|
||||
|
||||
init {
|
||||
// 连接 VoiceService 回调到 TSBridge
|
||||
voiceService.onVoiceData = { opusData, codec ->
|
||||
@@ -225,35 +228,46 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
/** Go owns speaking detection; Kotlin only reflects explicit bridge transitions. */
|
||||
fun handleClientSpeaking(clientID: Long, speaking: Boolean) {
|
||||
val clientId = clientID.toInt()
|
||||
synchronized(audioStateLock) {
|
||||
val current = _speakingClients.value.toMutableMap()
|
||||
if (speaking) current[clientId] = System.currentTimeMillis() else current.remove(clientId)
|
||||
_speakingClients.value = current
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes Go's timeline and local transient mute/speaking state for this client. */
|
||||
fun removeRemoteClient(clientID: Long, moved: Boolean = false) {
|
||||
val clientId = clientID.toInt()
|
||||
TSBridge.removeRemoteAudioClient(clientId)
|
||||
synchronized(audioStateLock) {
|
||||
_remoteAudioSettings.value = _remoteAudioSettings.value - clientId
|
||||
_speakingClients.value = _speakingClients.value - clientId
|
||||
}
|
||||
}
|
||||
|
||||
fun setRemoteClientMuted(clientId: Int, muted: Boolean) {
|
||||
synchronized(audioStateLock) {
|
||||
_remoteAudioSettings.value = _remoteAudioSettings.value + (clientId to RemoteAudioSettings(muted))
|
||||
}
|
||||
TSBridge.setRemoteClientMuted(clientId, muted)
|
||||
}
|
||||
|
||||
fun toggleRemoteClientMuted(clientId: Int) {
|
||||
synchronized(audioStateLock) {
|
||||
val muted = !(_remoteAudioSettings.value[clientId]?.muted ?: false)
|
||||
setRemoteClientMuted(clientId, muted)
|
||||
_remoteAudioSettings.value = _remoteAudioSettings.value + (clientId to RemoteAudioSettings(muted))
|
||||
TSBridge.setRemoteClientMuted(clientId, muted)
|
||||
}
|
||||
}
|
||||
|
||||
fun onClientMoved(clientId: Long, targetChannelId: String) {
|
||||
val id = clientId.toInt()
|
||||
if (id == Repository.selfClientId.value) {
|
||||
TSBridge.clearRemoteAudioClients()
|
||||
synchronized(audioStateLock) {
|
||||
_remoteAudioSettings.value = emptyMap()
|
||||
_speakingClients.value = emptyMap()
|
||||
}
|
||||
} else if (targetChannelId != Repository.currentChannelId.value) {
|
||||
removeRemoteClient(clientId, moved = true)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ class VoiceService(
|
||||
private var playbackGeneration = 0L
|
||||
private var activePlaybackTrack: AudioTrack? = null
|
||||
@Volatile private var receiveVolume = 1.0f
|
||||
@Volatile private var receiveGain = 2.5f
|
||||
|
||||
private val audioManager = context.getSystemService(AudioManager::class.java)
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
@@ -129,6 +130,11 @@ class VoiceService(
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置 PCM 预增益(1.0 = 原始音量,2.5 = 默认) */
|
||||
fun setReceiveGain(gain: Float) {
|
||||
receiveGain = gain.coerceIn(0.5f, 5.0f)
|
||||
}
|
||||
|
||||
fun enableReceivePlayback() {
|
||||
if (speakerEnabled) forceLoudspeaker()
|
||||
}
|
||||
@@ -176,10 +182,12 @@ class VoiceService(
|
||||
synchronized(playbackLock) { activePlaybackTrack = track }
|
||||
}
|
||||
val activeTrack = track ?: continue
|
||||
// 应用 PCM 预增益
|
||||
val gainedFrame = applyPcmGain(frame, receiveGain)
|
||||
if (acceptedFrames <= 5 || acceptedFrames % 250L == 0L) {
|
||||
Log.i(TAG, "Writing mixed PCM bytes=${frame.size} trackState=${activeTrack.state} playState=${activeTrack.playState}")
|
||||
Log.i(TAG, "Writing mixed PCM bytes=${gainedFrame.size} gain=$receiveGain trackState=${activeTrack.state}")
|
||||
}
|
||||
val written = activeTrack.write(frame, 0, frame.size, AudioTrack.WRITE_BLOCKING)
|
||||
val written = activeTrack.write(gainedFrame, 0, gainedFrame.size, AudioTrack.WRITE_BLOCKING)
|
||||
if (written != frame.size) {
|
||||
trackWriteFailures++
|
||||
Log.w(TAG, "AudioTrack.write incomplete: $written/${frame.size}")
|
||||
@@ -341,4 +349,21 @@ class VoiceService(
|
||||
selfSpeakingJob?.cancel(); selfSpeakingJob = null
|
||||
Log.d(TAG, "VoiceService destroyed")
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 PCM16 字节数组应用增益,clamp 到 int16 范围防止溢出。
|
||||
* PCM 格式:小端序 interleaved stereo。
|
||||
*/
|
||||
private fun applyPcmGain(pcm: ByteArray, gain: Float): ByteArray {
|
||||
if (gain == 1.0f) return pcm
|
||||
val result = pcm.copyOf()
|
||||
for (i in result.indices step 2) {
|
||||
// 小端序:低字节在前
|
||||
val sample = ((result[i + 1].toInt() shl 8) or (result[i].toInt() and 0xFF)).toShort()
|
||||
val gained = (sample.toFloat() * gain).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
|
||||
result[i] = (gained and 0xFF).toByte()
|
||||
result[i + 1] = (gained shr 8).toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,10 @@ func (c *Client) handleClientEnterView(cmd *commands.Command) {
|
||||
c.mu.Lock()
|
||||
c.clients[clid] = info
|
||||
unescapedNick := commands.Unescape(nick)
|
||||
if isAutoNicknameMatch(c.nickname, unescapedNick) {
|
||||
// Only allow clid override from nickname match if we haven't received initserver yet.
|
||||
// After initserver, the clid is authoritative and should not be overwritten by
|
||||
// stale same-UID sessions that happen to have matching nicknames.
|
||||
if c.clid == 0 && isAutoNicknameMatch(c.nickname, unescapedNick) {
|
||||
c.clid = clid
|
||||
c.handler.SetClientID(clid)
|
||||
}
|
||||
@@ -80,11 +83,16 @@ func (c *Client) handleClientLeftView(cmd *commands.Command) {
|
||||
if clid != 0 {
|
||||
c.mu.Lock()
|
||||
isSelf := (clid == c.clid)
|
||||
uid := ""
|
||||
if info, ok := c.clients[clid]; ok {
|
||||
uid = info.UID
|
||||
}
|
||||
delete(c.clients, clid)
|
||||
c.mu.Unlock()
|
||||
|
||||
evt := ClientLeftViewEvent{
|
||||
ID: clid,
|
||||
UID: uid,
|
||||
ReasonID: reasonID,
|
||||
ReasonMsg: reasonMsg,
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type ClientLeftViewEvent struct {
|
||||
ReasonMsg string
|
||||
ReasonID int
|
||||
ID uint16
|
||||
UID string
|
||||
TargetID uint16
|
||||
}
|
||||
|
||||
|
||||
+52
-5
@@ -222,6 +222,7 @@ type TextMsg struct {
|
||||
InvokerName string
|
||||
InvokerUID string
|
||||
Message string
|
||||
InvokerID int
|
||||
TargetMode int
|
||||
TargetID string
|
||||
}
|
||||
@@ -261,6 +262,7 @@ type TSClient struct {
|
||||
callback EventCallback
|
||||
connected bool
|
||||
host string // 服务器地址(用于文件传输 TCP 连接)
|
||||
selfUID string // 当前连接的 TeamSpeak UID,用于识别同 identity 的残留 session
|
||||
|
||||
// 事件队列:保证所有 JNI 回调在同一个协程中顺序执行
|
||||
evtQueueMu sync.Mutex // protects eventQueue replacement
|
||||
@@ -303,6 +305,11 @@ func (c *TSClient) Connect(host, nickname, password, defaultChannel, defaultChan
|
||||
return fmt.Sprintf("生成身份失败: %v", err)
|
||||
}
|
||||
|
||||
// 计算 TeamSpeak UID 并存储,用于僵尸会话过滤(isStaleSelfSession)。
|
||||
c.mu.Lock()
|
||||
c.selfUID = crypto.GetUidFromPublicKey(identity.PublicKeyBase64())
|
||||
c.mu.Unlock()
|
||||
|
||||
opts := []ts.ClientOption{}
|
||||
if password != "" {
|
||||
opts = append(opts, ts.WithServerPassword(password))
|
||||
@@ -1423,6 +1430,25 @@ func (c *TSClient) isCurrentClient(client *ts.Client) bool {
|
||||
return c.client == client
|
||||
}
|
||||
|
||||
// isStaleSelfSession checks if a remote client event belongs to a stale session
|
||||
// using the same identity as the current connection. This happens when the previous
|
||||
// process crashed without sending clientdisconnect, leaving a "zombie" session on
|
||||
// the server that the new connection must isolate from.
|
||||
func (c *TSClient) isStaleSelfSession(uid string, clid int) bool {
|
||||
c.mu.Lock()
|
||||
selfUID := c.selfUID
|
||||
selfClid := 0
|
||||
if c.client != nil {
|
||||
selfClid = int(c.client.ClientID())
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if selfUID == "" || uid == "" || uid != selfUID {
|
||||
return false
|
||||
}
|
||||
return clid != selfClid
|
||||
}
|
||||
|
||||
func (c *TSClient) queueClientEvent(client *ts.Client, evt queuedEvent) {
|
||||
if c.isCurrentClient(client) {
|
||||
c.queueEvent(evt)
|
||||
@@ -1444,6 +1470,10 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
||||
client.OnDisconnected(func(err error) {
|
||||
c.StopReceiveAudio()
|
||||
c.mu.Lock()
|
||||
if c.client != client {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.connected = false
|
||||
c.client = nil
|
||||
c.mu.Unlock()
|
||||
@@ -1455,12 +1485,13 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
||||
})
|
||||
|
||||
client.OnTextMessage(func(msg ts.TextMessage) {
|
||||
c.queueEvent(queuedEvent{
|
||||
c.queueClientEvent(client, queuedEvent{
|
||||
evtType: "textmessage",
|
||||
data: &TextMsg{
|
||||
InvokerName: msg.InvokerName,
|
||||
InvokerUID: msg.InvokerUID,
|
||||
Message: msg.Message,
|
||||
InvokerID: int(msg.InvokerID),
|
||||
TargetMode: msg.TargetMode,
|
||||
TargetID: fmt.Sprintf("%d", msg.TargetID),
|
||||
},
|
||||
@@ -1468,7 +1499,11 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
||||
})
|
||||
|
||||
client.OnClientEnter(func(info ts.ClientInfo) {
|
||||
c.queueEvent(queuedEvent{
|
||||
if c.isStaleSelfSession(info.UID, int(info.ID)) {
|
||||
log.Printf("[TSBridge] ignoring stale self session enter clid=%d uid=%s", info.ID, info.UID)
|
||||
return
|
||||
}
|
||||
c.queueClientEvent(client, queuedEvent{
|
||||
evtType: "cliententer",
|
||||
data: &Client{
|
||||
ID: int(info.ID),
|
||||
@@ -1481,8 +1516,13 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
||||
})
|
||||
|
||||
client.OnClientLeave(func(data ts.ClientLeftViewEvent) {
|
||||
// 不过滤僵尸的 leave 事件:GetClientsJSON 初始同步可能已包含僵尸,
|
||||
// 需要 leave 事件来更新 UI 将其移除。同时确保音频解码器被清理。
|
||||
if c.isStaleSelfSession(data.UID, int(data.ID)) {
|
||||
log.Printf("[TSBridge] stale self session leave clid=%d uid=%s (allowing for UI cleanup)", data.ID, data.UID)
|
||||
}
|
||||
c.RemoveRemoteAudioClient(int(data.ID))
|
||||
c.queueEvent(queuedEvent{
|
||||
c.queueClientEvent(client, queuedEvent{
|
||||
evtType: "clientleave",
|
||||
data: struct {
|
||||
ID int
|
||||
@@ -1495,7 +1535,7 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
||||
})
|
||||
|
||||
client.OnClientMoved(func(data ts.ClientMovedEvent) {
|
||||
c.queueEvent(queuedEvent{
|
||||
c.queueClientEvent(client, queuedEvent{
|
||||
evtType: "clientmoved",
|
||||
data: struct {
|
||||
ID int
|
||||
@@ -1510,6 +1550,10 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
||||
client.OnKicked(func(reason string) {
|
||||
c.StopReceiveAudio()
|
||||
c.mu.Lock()
|
||||
if c.client != client {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.connected = false
|
||||
c.client = nil
|
||||
c.mu.Unlock()
|
||||
@@ -1526,6 +1570,9 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
||||
}
|
||||
// Keep the existing raw bridge active until an Android libopus decoder
|
||||
// factory has been supplied and the Go receiver explicitly started.
|
||||
if !c.isCurrentClient(client) {
|
||||
return
|
||||
}
|
||||
c.queueEvent(queuedEvent{
|
||||
evtType: "voicedata",
|
||||
data: struct {
|
||||
@@ -1545,7 +1592,7 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
||||
})
|
||||
|
||||
client.OnPoked(func(evt ts.PokeEvent) {
|
||||
c.queueEvent(queuedEvent{
|
||||
c.queueClientEvent(client, queuedEvent{
|
||||
evtType: "poked",
|
||||
data: &PokeEvent{
|
||||
InvokerID: int(evt.InvokerID),
|
||||
|
||||
@@ -70,6 +70,13 @@ func (c *TSClient) ConnectWithIdentity(identityStr, host, nickname, password, de
|
||||
return fmt.Sprintf("identity 解析失败: %v", err)
|
||||
}
|
||||
|
||||
// 计算 TeamSpeak UID(base64(SHA1(publicKey))),用于僵尸会话过滤。
|
||||
// 不能直接存储 identity 字符串,因为 UID 是公钥的哈希,格式完全不同。
|
||||
uid := crypto.GetUidFromPublicKey(identity.PublicKeyBase64())
|
||||
c.mu.Lock()
|
||||
c.selfUID = uid
|
||||
c.mu.Unlock()
|
||||
|
||||
opts := []ts.ClientOption{}
|
||||
if password != "" {
|
||||
opts = append(opts, ts.WithServerPassword(password))
|
||||
@@ -102,7 +109,15 @@ func (c *TSClient) ConnectWithIdentity(identityStr, host, nickname, password, de
|
||||
return fmt.Sprintf("连接失败: %v", err)
|
||||
}
|
||||
|
||||
// 延迟执行僵尸清理:连接刚建立时客户端在默认频道,ListClients 可能尚未
|
||||
// 收到服务器发来的完整客户端列表。延迟 3 秒确保所有 notifycliententerview
|
||||
// 事件已到达,此时 ListClients 能发现不同频道中的僵尸会话。
|
||||
go func() {
|
||||
time.Sleep(3 * time.Second)
|
||||
if c.isCurrentClient(client) {
|
||||
c.cleanupDuplicateIdentitySessions(client, identity)
|
||||
}
|
||||
}()
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
|
||||
ts官方sdk路径 `E:\MyProject\ts-mobile-go\docs\teamspeak-sdk-3.5.2`
|
||||
Reference in New Issue
Block a user