Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20de47228b | ||
|
|
e8a45032f8 |
Generated
-1
@@ -2,6 +2,5 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$/teamspeak-js" vcs="Git" />
|
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</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
|
**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/
|
Go Library → TSBridge (JNI) → ServerViewModel (callbacks) → Repository (state) → ViewModels → Compose Screens
|
||||||
├── MainActivity.kt # Entry point
|
User Actions → Compose UI → ViewModel methods → TSBridge → Go Library
|
||||||
├── 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.
|
**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
|
## 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.
|
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
|
## Common Issues
|
||||||
|
|
||||||
### "javac: 非法字符" or "GBK unmappable character"
|
### "javac: 非法字符" or "GBK unmappable character"
|
||||||
@@ -119,8 +142,13 @@ Missing `-Wl,--hash-style=both` flag. Rebuild AAR with correct flags.
|
|||||||
### "Unresolved reference: teamspeak"
|
### "Unresolved reference: teamspeak"
|
||||||
AAR not compiled or not in `android/app/libs/teamspeak.aar`. Run Step 1 of manual build.
|
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
|
## Testing
|
||||||
|
|
||||||
Run from IDE with connected device or emulator. Check Logcat in IDE for runtime logs.
|
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-runtime-compose:2.8.7")
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-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-service:2.8.7")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
|
||||||
|
|
||||||
// DataStore (替代 localStorage)
|
// DataStore (替代 localStorage)
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
@@ -93,6 +94,9 @@ dependencies {
|
|||||||
// JSON 序列化
|
// JSON 序列化
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
|
||||||
|
// HTTP client (更新检测)
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
||||||
// Coil 图片加载(Compose)
|
// Coil 图片加载(Compose)
|
||||||
implementation("io.coil-kt.coil3:coil-compose:3.0.4")
|
implementation("io.coil-kt.coil3:coil-compose:3.0.4")
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class App : Application() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "App"
|
private const val TAG = "App"
|
||||||
private const val CONNECTION_CHANNEL_ID = "connection_channel"
|
private const val CONNECTION_CHANNEL_ID = "connection_channel"
|
||||||
|
const val POKE_CHANNEL_ID = "poke_channel"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -24,6 +25,9 @@ class App : Application() {
|
|||||||
// 创建通知频道(前台服务需要)
|
// 创建通知频道(前台服务需要)
|
||||||
createConnectionNotificationChannel()
|
createConnectionNotificationChannel()
|
||||||
|
|
||||||
|
// 创建 Poke 通知频道(高优先级,弹横幅)
|
||||||
|
createPokeNotificationChannel()
|
||||||
|
|
||||||
// 全局未捕获异常处理
|
// 全局未捕获异常处理
|
||||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
@@ -54,6 +58,23 @@ class App : Application() {
|
|||||||
nm.createNotificationChannel(channel)
|
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) {
|
private fun writeCrashLog(throwable: Throwable) {
|
||||||
try {
|
try {
|
||||||
val file = File(getExternalFilesDir(null), "crash.log")
|
val file = File(getExternalFilesDir(null), "crash.log")
|
||||||
|
|||||||
@@ -70,8 +70,10 @@ class ConnectionService : LifecycleService() {
|
|||||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceType)
|
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceType)
|
||||||
|
|
||||||
android.util.Log.i(TAG, "Foreground service started")
|
android.util.Log.i(TAG, "Foreground service started")
|
||||||
// START_STICKY: 被系统杀死后会尝试重建
|
// The service exists only to keep the process alive while a TS connection is active.
|
||||||
return Service.START_STICKY
|
// 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() {
|
override fun onDestroy() {
|
||||||
|
|||||||
@@ -18,7 +18,32 @@ import java.lang.reflect.Proxy
|
|||||||
*/
|
*/
|
||||||
object TSBridge {
|
object TSBridge {
|
||||||
|
|
||||||
|
private val connectLock = Any()
|
||||||
private var client: TSClient? = null
|
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) */
|
/** Poke 事件(Kotlin 侧数据类,对应 Go PokeEvent) */
|
||||||
data class PokeEventData(
|
data class PokeEventData(
|
||||||
@@ -56,24 +81,61 @@ object TSBridge {
|
|||||||
defaultChannelPassword: String = "",
|
defaultChannelPassword: String = "",
|
||||||
callbacks: Callbacks,
|
callbacks: Callbacks,
|
||||||
): String {
|
): String {
|
||||||
val tsClient = Teamspeak.newClient()
|
val identity = currentIdentity
|
||||||
client = tsClient
|
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
|
fun connectWithIdentity(
|
||||||
// proxy lets this source compile against both artifacts while routing only the new
|
identity: String,
|
||||||
// mixed-PCM callbacks when the regenerated AAR is installed.
|
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(
|
val callbackProxy = Proxy.newProxyInstance(
|
||||||
EventCallback::class.java.classLoader,
|
EventCallback::class.java.classLoader,
|
||||||
arrayOf(EventCallback::class.java),
|
arrayOf(EventCallback::class.java),
|
||||||
) { _, method, args ->
|
) { _, method, args ->
|
||||||
|
if (captureGeneration() != generation) {
|
||||||
|
return@newProxyInstance null
|
||||||
|
}
|
||||||
val values = args ?: emptyArray()
|
val values = args ?: emptyArray()
|
||||||
when (method.name) {
|
when (method.name) {
|
||||||
"onConnected" -> callbacks.onConnected()
|
"onConnected" -> callbacks.onConnected()
|
||||||
"onDisconnected" -> callbacks.onDisconnected(values[0] as String)
|
"onDisconnected" -> callbacks.onDisconnected(values[0] as String)
|
||||||
"onTextMessage" -> (values[0] as? TextMsg)?.let(callbacks::onTextMessage)
|
"onTextMessage" -> (values[0] as? TextMsg)?.let(callbacks::onTextMessage)
|
||||||
"onClientEnter" -> (values[0] as? Client)?.let(callbacks::onClientEnter)
|
"onClientEnter" -> (values[0] as? Client)?.let(callbacks::onClientEnter)
|
||||||
"onClientLeave" -> callbacks.onClientLeave(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 Long, values[1] as String)
|
"onClientMoved" -> callbacks.onClientMoved((values[0] as Number).toLong(), values[1] as String)
|
||||||
"onKicked" -> callbacks.onKicked(values[0] as String)
|
"onKicked" -> callbacks.onKicked(values[0] as String)
|
||||||
"onMixedVoicePCM" -> (values[0] as? ByteArray)?.let {
|
"onMixedVoicePCM" -> (values[0] as? ByteArray)?.let {
|
||||||
android.util.Log.d("TSVoice", "Kotlin mixed PCM bytes=${it.size}")
|
android.util.Log.d("TSVoice", "Kotlin mixed PCM bytes=${it.size}")
|
||||||
@@ -81,7 +143,7 @@ object TSBridge {
|
|||||||
}
|
}
|
||||||
"onClientSpeaking" -> {
|
"onClientSpeaking" -> {
|
||||||
android.util.Log.i("TSVoice", "Kotlin speaking client=${values[0]} speaking=${values[1]}")
|
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 ->
|
"onPoked" -> (values[0] as? teamspeak.PokeEvent)?.let { event ->
|
||||||
callbacks.onPoked(PokeEventData(
|
callbacks.onPoked(PokeEventData(
|
||||||
@@ -91,7 +153,6 @@ object TSBridge {
|
|||||||
message = event.message,
|
message = event.message,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
// Raw Opus callbacks deliberately have no Kotlin receive fallback.
|
|
||||||
"onVoiceData" -> Unit
|
"onVoiceData" -> Unit
|
||||||
"toString" -> "TSBridge.EventCallback"
|
"toString" -> "TSBridge.EventCallback"
|
||||||
"hashCode" -> System.identityHashCode(callbacks)
|
"hashCode" -> System.identityHashCode(callbacks)
|
||||||
@@ -101,13 +162,21 @@ object TSBridge {
|
|||||||
null
|
null
|
||||||
} as EventCallback
|
} 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() {
|
fun disconnect() {
|
||||||
client?.disconnect()
|
synchronized(connectLock) {
|
||||||
client = null
|
client?.disconnect()
|
||||||
|
client = null
|
||||||
|
connectionGeneration += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否已连接 */
|
/** 是否已连接 */
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package com.tsmobile.app.data
|
package com.tsmobile.app.data
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
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)。
|
* 权威来源:OnTextMessage(对应 notifytextmessage)。
|
||||||
@@ -114,6 +123,7 @@ data class ChatMessage(
|
|||||||
val isSelf: Boolean, // 是否是自己发送的
|
val isSelf: Boolean, // 是否是自己发送的
|
||||||
val deliveryState: MessageDeliveryState = MessageDeliveryState.SENT, // 送达状态
|
val deliveryState: MessageDeliveryState = MessageDeliveryState.SENT, // 送达状态
|
||||||
val fileMeta: FileMessageMeta? = null, // 文件消息元数据(仅文件消息)
|
val fileMeta: FileMessageMeta? = null, // 文件消息元数据(仅文件消息)
|
||||||
|
val messageType: MessageType = MessageType.NORMAL, // 消息类型
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,8 +271,7 @@ enum class VoiceOutputDevice {
|
|||||||
fun parseFileMessageMeta(content: String): FileMessageMeta? {
|
fun parseFileMessageMeta(content: String): FileMessageMeta? {
|
||||||
if (!content.trimStart().startsWith("{")) return null
|
if (!content.trimStart().startsWith("{")) return null
|
||||||
return try {
|
return try {
|
||||||
val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
|
val obj = fileJson.decodeFromString<kotlinx.serialization.json.JsonObject>(content)
|
||||||
val obj = json.decodeFromString<kotlinx.serialization.json.JsonObject>(content)
|
|
||||||
val msgType = obj["msg_type"]?.toString()?.trim('"') ?: return null
|
val msgType = obj["msg_type"]?.toString()?.trim('"') ?: return null
|
||||||
if (!msgType.startsWith("ts.file")) return null
|
if (!msgType.startsWith("ts.file")) return null
|
||||||
val meta = FileMessageMeta(
|
val meta = FileMessageMeta(
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ object Repository {
|
|||||||
// 客户端 Map 索引:ClientID → ClientInfo(O(1) 查找)
|
// 客户端 Map 索引:ClientID → ClientInfo(O(1) 查找)
|
||||||
private var clientMap: Map<Int, ClientInfo> = emptyMap()
|
private var clientMap: Map<Int, ClientInfo> = emptyMap()
|
||||||
|
|
||||||
|
// 客户端 UID 索引:UID → ClientInfo(O(1) UID 反查)
|
||||||
|
private var clientUidMap: Map<String, ClientInfo> = emptyMap()
|
||||||
|
|
||||||
// --- 当前客户端 ID ---
|
// --- 当前客户端 ID ---
|
||||||
private val _selfClientId = MutableStateFlow(0)
|
private val _selfClientId = MutableStateFlow(0)
|
||||||
val selfClientId: StateFlow<Int> = _selfClientId.asStateFlow()
|
val selfClientId: StateFlow<Int> = _selfClientId.asStateFlow()
|
||||||
@@ -138,6 +141,7 @@ object Repository {
|
|||||||
}
|
}
|
||||||
_clients.value = clients
|
_clients.value = clients
|
||||||
clientMap = clients.associateBy { it.id }
|
clientMap = clients.associateBy { it.id }
|
||||||
|
clientUidMap = clients.associateBy { it.uid }
|
||||||
_channelClients.value = clients.groupBy { it.channelId }
|
_channelClients.value = clients.groupBy { it.channelId }
|
||||||
android.util.Log.d("Repository", "refreshClientList: ${clients.size} clients updated")
|
android.util.Log.d("Repository", "refreshClientList: ${clients.size} clients updated")
|
||||||
}
|
}
|
||||||
@@ -149,6 +153,14 @@ object Repository {
|
|||||||
_currentChannelId.value = channelId
|
_currentChannelId.value = channelId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅更新频道列表(不覆盖 clients 相关状态)。
|
||||||
|
* 用于 refreshChannelsIfNeeded 等部分刷新场景。
|
||||||
|
*/
|
||||||
|
fun updateChannels(channels: List<ChannelInfo>) {
|
||||||
|
_channels.value = channels
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新基线数据。
|
* 更新基线数据。
|
||||||
*/
|
*/
|
||||||
@@ -161,6 +173,7 @@ object Repository {
|
|||||||
_channels.value = channels
|
_channels.value = channels
|
||||||
_clients.value = clients
|
_clients.value = clients
|
||||||
clientMap = clients.associateBy { it.id }
|
clientMap = clients.associateBy { it.id }
|
||||||
|
clientUidMap = clients.associateBy { it.uid }
|
||||||
_selfClientId.value = selfClientId
|
_selfClientId.value = selfClientId
|
||||||
_currentChannelId.value = currentChannelId
|
_currentChannelId.value = currentChannelId
|
||||||
|
|
||||||
@@ -171,6 +184,12 @@ object Repository {
|
|||||||
/** 查询成员是否存在 */
|
/** 查询成员是否存在 */
|
||||||
fun hasClient(clientId: Int): Boolean = clientMap.containsKey(clientId)
|
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 }
|
fun hasChannel(channelId: String): Boolean = _channels.value.any { it.id == channelId }
|
||||||
|
|
||||||
@@ -178,13 +197,10 @@ object Repository {
|
|||||||
// 按 (TargetMode, Target) 分组存储,key = "${targetMode}_${targetId}"
|
// 按 (TargetMode, Target) 分组存储,key = "${targetMode}_${targetId}"
|
||||||
private val messageArchives = ConcurrentHashMap<String, MutableList<ChatMessage>>()
|
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
|
@Volatile
|
||||||
private var activeChatKey: String? = null
|
var activeChatKey: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 归档消息(由 OnTextMessage 调用)。
|
* 归档消息(由 OnTextMessage 调用)。
|
||||||
@@ -200,40 +216,32 @@ object Repository {
|
|||||||
archive.removeAt(0)
|
archive.removeAt(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果不是当前查看的会话,增加未读计数
|
|
||||||
if (key != activeChatKey) {
|
|
||||||
val currentCounts = _unreadCounts.value.toMutableMap()
|
|
||||||
currentCounts[key] = (currentCounts[key] ?: 0) + 1
|
|
||||||
_unreadCounts.value = currentCounts
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标记自己发送的消息为已送达(匹配 OnTextMessage 回显)。
|
* 标记自己发送的消息为已送达(匹配 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 key = "${targetMode}_${targetId}"
|
||||||
val archive = messageArchives[key] ?: return false
|
val archive = messageArchives[key] ?: return null
|
||||||
|
|
||||||
synchronized(archive) {
|
synchronized(archive) {
|
||||||
// 从后往前找最近的 PENDING 消息(同内容)
|
|
||||||
// 不匹配 senderId:gomobile 的 TextMsg 不含 InvokerID,
|
|
||||||
// 通过 UID 反查 clientId 可能因客户端列表未同步而得到 0,
|
|
||||||
// 导致与 sendMessage 时记录的 selfClientId 不一致。
|
|
||||||
for (i in archive.indices.reversed()) {
|
for (i in archive.indices.reversed()) {
|
||||||
val msg = archive[i]
|
val msg = archive[i]
|
||||||
if (msg.isSelf &&
|
if (msg.isSelf &&
|
||||||
msg.deliveryState == MessageDeliveryState.PENDING &&
|
msg.deliveryState == MessageDeliveryState.PENDING &&
|
||||||
msg.content == content
|
msg.content == content &&
|
||||||
|
(senderId == 0 || msg.senderId == senderId)
|
||||||
) {
|
) {
|
||||||
archive[i] = msg.copy(deliveryState = MessageDeliveryState.SENT)
|
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()
|
_channels.value = emptyList()
|
||||||
_clients.value = emptyList()
|
_clients.value = emptyList()
|
||||||
clientMap = emptyMap()
|
clientMap = emptyMap()
|
||||||
|
clientUidMap = emptyMap()
|
||||||
_selfClientId.value = 0
|
_selfClientId.value = 0
|
||||||
_currentChannelId.value = "0"
|
_currentChannelId.value = "0"
|
||||||
_serverInfo.value = null
|
_serverInfo.value = null
|
||||||
_channelClients.value = emptyMap()
|
_channelClients.value = emptyMap()
|
||||||
messageArchives.clear()
|
messageArchives.clear()
|
||||||
_unreadCounts.value = emptyMap()
|
|
||||||
activeChatKey = null
|
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.FileDownloadManager
|
||||||
import com.tsmobile.app.data.FileMessageMeta
|
import com.tsmobile.app.data.FileMessageMeta
|
||||||
import com.tsmobile.app.data.MessageDeliveryState
|
import com.tsmobile.app.data.MessageDeliveryState
|
||||||
|
import com.tsmobile.app.data.MessageType
|
||||||
import com.tsmobile.app.data.Repository
|
import com.tsmobile.app.data.Repository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -44,6 +45,33 @@ import java.util.Locale
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageItem(message: ChatMessage) {
|
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) {
|
val timeText = remember(message.timestamp) {
|
||||||
SimpleDateFormat("HH:mm", Locale.getDefault())
|
SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||||
.format(Date(message.timestamp))
|
.format(Date(message.timestamp))
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ fun PokeNotification(
|
|||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
.padding(
|
.padding(
|
||||||
horizontal = UiTokens.Spacing.Large,
|
horizontal = UiTokens.Spacing.Large,
|
||||||
vertical = UiTokens.Spacing.Small,
|
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),
|
.heightIn(min = 280.dp, max = 560.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.ExtraSmall),
|
verticalArrangement = Arrangement.spacedBy(UiTokens.Spacing.ExtraSmall),
|
||||||
) {
|
) {
|
||||||
items(members, key = { it.id }) { member ->
|
items(members.distinctBy { it.id }, key = { it.id }) { member ->
|
||||||
MemberRow(
|
MemberRow(
|
||||||
name = member.nickname,
|
name = member.nickname,
|
||||||
isSelf = member.isSelf,
|
isSelf = member.isSelf,
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ package com.tsmobile.app.ui.navigation
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
import androidx.compose.animation.ExitTransition
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import com.tsmobile.app.data.ConnectionState
|
import com.tsmobile.app.data.ConnectionState
|
||||||
import com.tsmobile.app.data.Repository
|
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.ChannelListScreen
|
||||||
import com.tsmobile.app.ui.screens.ChatScreen
|
import com.tsmobile.app.ui.screens.ChatScreen
|
||||||
import com.tsmobile.app.ui.screens.KickedScreen
|
import com.tsmobile.app.ui.screens.KickedScreen
|
||||||
@@ -50,6 +54,7 @@ fun AppNavGraph(
|
|||||||
serverViewModel.channelViewModel = channelViewModel
|
serverViewModel.channelViewModel = channelViewModel
|
||||||
serverViewModel.chatViewModel = chatViewModel
|
serverViewModel.chatViewModel = chatViewModel
|
||||||
serverViewModel.voiceViewModel = voiceViewModel
|
serverViewModel.voiceViewModel = voiceViewModel
|
||||||
|
channelViewModel.chatViewModel = chatViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// 观察连接状态变化,处理导航
|
// 观察连接状态变化,处理导航
|
||||||
@@ -80,100 +85,113 @@ fun AppNavGraph(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NavHost(
|
// 全局 Poke 气泡通知(覆盖在所有页面上方)
|
||||||
navController = navController,
|
val showPokeNotification by serverViewModel.showPokeNotification.collectAsState()
|
||||||
startDestination = Routes.SERVER_CONFIG,
|
val pokeNotification by serverViewModel.pokeNotification.collectAsState()
|
||||||
// 禁用默认的淡入淡出动画,避免页面切换显得迟钝
|
|
||||||
enterTransition = { EnterTransition.None },
|
|
||||||
exitTransition = { ExitTransition.None },
|
|
||||||
popEnterTransition = { EnterTransition.None },
|
|
||||||
popExitTransition = { ExitTransition.None },
|
|
||||||
) {
|
|
||||||
// 服务器配置页
|
|
||||||
composable(Routes.SERVER_CONFIG) {
|
|
||||||
ServerConfigScreen(
|
|
||||||
viewModel = serverViewModel,
|
|
||||||
onNavigateToChannelList = {
|
|
||||||
navController.navigate(Routes.CHANNEL_LIST) {
|
|
||||||
popUpTo(Routes.SERVER_CONFIG) { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 频道列表页
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
composable(Routes.CHANNEL_LIST) {
|
NavHost(
|
||||||
ChannelListScreen(
|
navController = navController,
|
||||||
channelViewModel = channelViewModel,
|
startDestination = Routes.SERVER_CONFIG,
|
||||||
voiceViewModel = voiceViewModel,
|
// 禁用默认的淡入淡出动画,避免页面切换显得迟钝
|
||||||
serverViewModel = serverViewModel,
|
enterTransition = { EnterTransition.None },
|
||||||
onNavigateToChat = {
|
exitTransition = { ExitTransition.None },
|
||||||
if (navController.currentDestination?.route == Routes.CHANNEL_LIST) {
|
popEnterTransition = { EnterTransition.None },
|
||||||
navController.navigate(Routes.CHAT) {
|
popExitTransition = { ExitTransition.None },
|
||||||
launchSingleTop = true
|
) {
|
||||||
|
// 服务器配置页
|
||||||
|
composable(Routes.SERVER_CONFIG) {
|
||||||
|
ServerConfigScreen(
|
||||||
|
viewModel = serverViewModel,
|
||||||
|
onNavigateToChannelList = {
|
||||||
|
navController.navigate(Routes.CHANNEL_LIST) {
|
||||||
|
popUpTo(Routes.SERVER_CONFIG) { inclusive = true }
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
)
|
||||||
onNavigateToServerConfig = {
|
|
||||||
serverViewModel.disconnect()
|
|
||||||
navController.navigate(Routes.SERVER_CONFIG) {
|
|
||||||
popUpTo(Routes.CHANNEL_LIST) { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 聊天页
|
|
||||||
composable(Routes.CHAT) {
|
|
||||||
// 进入聊天页:加载当前频道的消息
|
|
||||||
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
|
|
||||||
LaunchedEffect(currentChannelId) {
|
|
||||||
var channelIdLong = currentChannelId.toLongOrNull() ?: 0L
|
|
||||||
// 备用方案:如果 Repository.currentChannelId 为 "0"(GetChannelID 失败),
|
|
||||||
// 从客户端列表中获取自身所在的频道 ID
|
|
||||||
if (channelIdLong <= 0L) {
|
|
||||||
channelIdLong = Repository.getSelfChannelId().toLongOrNull() ?: 0L
|
|
||||||
}
|
|
||||||
if (channelIdLong > 0L) {
|
|
||||||
chatViewModel.enterChat(2, channelIdLong)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val leaveChat = {
|
// 频道列表页
|
||||||
if (navController.currentDestination?.route == Routes.CHAT &&
|
composable(Routes.CHANNEL_LIST) {
|
||||||
navController.popBackStack(Routes.CHANNEL_LIST, inclusive = false)
|
ChannelListScreen(
|
||||||
) {
|
channelViewModel = channelViewModel,
|
||||||
chatViewModel.leaveChat()
|
voiceViewModel = voiceViewModel,
|
||||||
}
|
serverViewModel = serverViewModel,
|
||||||
|
onNavigateToChat = {
|
||||||
|
if (navController.currentDestination?.route == Routes.CHANNEL_LIST) {
|
||||||
|
navController.navigate(Routes.CHAT) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateToServerConfig = {
|
||||||
|
serverViewModel.disconnect()
|
||||||
|
navController.navigate(Routes.SERVER_CONFIG) {
|
||||||
|
popUpTo(Routes.CHANNEL_LIST) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
BackHandler(onBack = leaveChat)
|
|
||||||
|
|
||||||
ChatScreen(
|
// 聊天页
|
||||||
chatViewModel = chatViewModel,
|
composable(Routes.CHAT) {
|
||||||
channelViewModel = channelViewModel,
|
// 进入聊天页:加载当前频道的消息
|
||||||
voiceViewModel = voiceViewModel,
|
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
|
||||||
serverViewModel = serverViewModel,
|
LaunchedEffect(currentChannelId) {
|
||||||
onNavigateBack = leaveChat,
|
var channelIdLong = currentChannelId.toLongOrNull() ?: 0L
|
||||||
onOpenChannelDetail = { channelId ->
|
// 备用方案:如果 Repository.currentChannelId 为 "0"(GetChannelID 失败),
|
||||||
channelViewModel.openChannelDetailCard(channelId)
|
// 从客户端列表中获取自身所在的频道 ID
|
||||||
},
|
if (channelIdLong <= 0L) {
|
||||||
)
|
channelIdLong = Repository.getSelfChannelId().toLongOrNull() ?: 0L
|
||||||
}
|
|
||||||
|
|
||||||
// 被踢页面
|
|
||||||
composable(Routes.KICKED) {
|
|
||||||
val kickReason by serverViewModel.kickReason.collectAsState()
|
|
||||||
KickedScreen(
|
|
||||||
reason = kickReason,
|
|
||||||
onReconnect = { serverViewModel.reconnectAfterKick() },
|
|
||||||
onBackToHome = {
|
|
||||||
serverViewModel.backToHome()
|
|
||||||
navController.navigate(Routes.SERVER_CONFIG) {
|
|
||||||
popUpTo(Routes.KICKED) { inclusive = true }
|
|
||||||
}
|
}
|
||||||
},
|
if (channelIdLong > 0L) {
|
||||||
)
|
chatViewModel.enterChat(2, channelIdLong)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val leaveChat = {
|
||||||
|
if (navController.currentDestination?.route == Routes.CHAT &&
|
||||||
|
navController.popBackStack(Routes.CHANNEL_LIST, inclusive = false)
|
||||||
|
) {
|
||||||
|
chatViewModel.leaveChat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackHandler(onBack = leaveChat)
|
||||||
|
|
||||||
|
ChatScreen(
|
||||||
|
chatViewModel = chatViewModel,
|
||||||
|
channelViewModel = channelViewModel,
|
||||||
|
voiceViewModel = voiceViewModel,
|
||||||
|
serverViewModel = serverViewModel,
|
||||||
|
onNavigateBack = leaveChat,
|
||||||
|
onOpenChannelDetail = { channelId ->
|
||||||
|
channelViewModel.openChannelDetailCard(channelId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 被踢页面
|
||||||
|
composable(Routes.KICKED) {
|
||||||
|
val kickReason by serverViewModel.kickReason.collectAsState()
|
||||||
|
KickedScreen(
|
||||||
|
reason = kickReason,
|
||||||
|
onReconnect = { serverViewModel.reconnectAfterKick() },
|
||||||
|
onBackToHome = {
|
||||||
|
serverViewModel.backToHome()
|
||||||
|
navController.navigate(Routes.SERVER_CONFIG) {
|
||||||
|
popUpTo(Routes.KICKED) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Poke 气泡通知(全局覆盖层)
|
||||||
|
PokeNotification(
|
||||||
|
pokeEvent = pokeNotification,
|
||||||
|
isVisible = showPokeNotification,
|
||||||
|
onDismiss = { serverViewModel.dismissPokeNotification() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ fun ChannelListScreen(
|
|||||||
val channelDetail by channelViewModel.channelDetailInfo.collectAsState()
|
val channelDetail by channelViewModel.channelDetailInfo.collectAsState()
|
||||||
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
|
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
|
||||||
|
|
||||||
// Poke 通知
|
|
||||||
val showPokeNotification by serverViewModel.showPokeNotification.collectAsState()
|
|
||||||
val pokeNotification by serverViewModel.pokeNotification.collectAsState()
|
|
||||||
|
|
||||||
// ViewModel 重建保护:如果 syncState 不是 Synchronized(ViewModel 被系统回收后重建),
|
// ViewModel 重建保护:如果 syncState 不是 Synchronized(ViewModel 被系统回收后重建),
|
||||||
// 重新触发首次同步,否则频道列表页会永远停在加载态。
|
// 重新触发首次同步,否则频道列表页会永远停在加载态。
|
||||||
LaunchedEffect(Unit) {
|
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.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import com.tsmobile.app.data.ChannelInfo
|
import com.tsmobile.app.data.ChannelInfo
|
||||||
import com.tsmobile.app.ui.components.ChannelDetailCard
|
import com.tsmobile.app.ui.components.ChannelDetailCard
|
||||||
import com.tsmobile.app.ui.components.ChatHeader
|
import com.tsmobile.app.ui.components.ChatHeader
|
||||||
@@ -48,6 +49,7 @@ fun ChatScreen(
|
|||||||
|
|
||||||
val currentChannel = channels.find { it.id == currentChannelId }
|
val currentChannel = channels.find { it.id == currentChannelId }
|
||||||
val clients = channelClients[currentChannelId] ?: emptyList()
|
val clients = channelClients[currentChannelId] ?: emptyList()
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -83,19 +85,29 @@ fun ChatScreen(
|
|||||||
// 底部:语音控制栏(固定在底部,不受键盘影响)
|
// 底部:语音控制栏(固定在底部,不受键盘影响)
|
||||||
VoiceControlBar(
|
VoiceControlBar(
|
||||||
voiceViewModel = voiceViewModel,
|
voiceViewModel = voiceViewModel,
|
||||||
onExpand = { showVoiceCard = true },
|
onExpand = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
voiceViewModel.openVoiceCard()
|
||||||
|
showVoiceCard = true
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语音卡弹窗(BottomSheet)
|
// 语音卡弹窗(BottomSheet)
|
||||||
if (showVoiceCard) {
|
if (showVoiceCard) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { showVoiceCard = false },
|
onDismissRequest = {
|
||||||
|
showVoiceCard = false
|
||||||
|
voiceViewModel.closeVoiceCard()
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
VoiceCard(
|
VoiceCard(
|
||||||
voiceViewModel = voiceViewModel,
|
voiceViewModel = voiceViewModel,
|
||||||
channelViewModel = channelViewModel,
|
channelViewModel = channelViewModel,
|
||||||
onDismiss = { showVoiceCard = false },
|
onDismiss = {
|
||||||
|
showVoiceCard = false
|
||||||
|
voiceViewModel.closeVoiceCard()
|
||||||
|
},
|
||||||
onPokeClient = { clientId, message ->
|
onPokeClient = { clientId, message ->
|
||||||
serverViewModel.pokeClient(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.LightMode
|
||||||
import androidx.compose.material.icons.filled.SettingsBrightness
|
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||||
import androidx.compose.material.icons.filled.Dns
|
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.ThemeMode
|
||||||
|
import com.tsmobile.app.ui.theme.UiTokens
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -23,6 +25,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tsmobile.app.data.RecentConnection
|
import com.tsmobile.app.data.RecentConnection
|
||||||
import com.tsmobile.app.ui.components.ConnectButton
|
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.ConnectState
|
||||||
import com.tsmobile.app.viewmodel.ServerViewModel
|
import com.tsmobile.app.viewmodel.ServerViewModel
|
||||||
import com.tsmobile.app.viewmodel.formatRelativeTime
|
import com.tsmobile.app.viewmodel.formatRelativeTime
|
||||||
@@ -48,7 +51,9 @@ fun ServerConfigScreen(
|
|||||||
// 上:品牌区
|
// 上:品牌区
|
||||||
BrandSection(
|
BrandSection(
|
||||||
themeMode = themeMode,
|
themeMode = themeMode,
|
||||||
onToggleTheme = { viewModel.toggleTheme() }
|
onToggleTheme = { viewModel.toggleTheme() },
|
||||||
|
isCheckingUpdate = state.isCheckingUpdate,
|
||||||
|
onCheckUpdate = { viewModel.manualCheckUpdate() },
|
||||||
)
|
)
|
||||||
|
|
||||||
// 中:输入区
|
// 中:输入区
|
||||||
@@ -80,18 +85,31 @@ fun ServerConfigScreen(
|
|||||||
onNavigateToChannelList()
|
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
|
@Composable
|
||||||
private fun BrandSection(themeMode: ThemeMode, onToggleTheme: () -> Unit) {
|
private fun BrandSection(
|
||||||
Box(
|
themeMode: ThemeMode,
|
||||||
modifier = Modifier
|
onToggleTheme: () -> Unit,
|
||||||
.fillMaxWidth()
|
isCheckingUpdate: Boolean = false,
|
||||||
.padding(top = 48.dp)
|
onCheckUpdate: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -115,6 +133,29 @@ private fun BrandSection(themeMode: ThemeMode, onToggleTheme: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
IconButton(
|
||||||
onClick = onToggleTheme,
|
onClick = onToggleTheme,
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
// ChatViewModel 引用(由 ServerViewModel 设置)
|
||||||
|
var chatViewModel: ChatViewModel? = null
|
||||||
|
|
||||||
// 频道列表最后刷新时间
|
// 频道列表最后刷新时间
|
||||||
private var lastChannelRefreshTime: Long = 0
|
private var lastChannelRefreshTime: Long = 0
|
||||||
|
|
||||||
@@ -82,10 +85,6 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
private val _expandedChannelIds = MutableStateFlow<Set<String>>(emptySet())
|
private val _expandedChannelIds = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val expandedChannelIds: StateFlow<Set<String>> = _expandedChannelIds.asStateFlow()
|
val expandedChannelIds: StateFlow<Set<String>> = _expandedChannelIds.asStateFlow()
|
||||||
|
|
||||||
// 未读消息标记(当前频道外是否有新消息)
|
|
||||||
private val _hasUnreadMessage = MutableStateFlow(false)
|
|
||||||
val hasUnreadMessage: StateFlow<Boolean> = _hasUnreadMessage.asStateFlow()
|
|
||||||
|
|
||||||
// --- 当前频道 ---
|
// --- 当前频道 ---
|
||||||
val currentChannelId: StateFlow<String> = Repository.currentChannelId
|
val currentChannelId: StateFlow<String> = Repository.currentChannelId
|
||||||
|
|
||||||
@@ -127,7 +126,6 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
fun clearChannels() {
|
fun clearChannels() {
|
||||||
_syncState.value = SyncState.Unsynced
|
_syncState.value = SyncState.Unsynced
|
||||||
_expandedChannelIds.value = emptySet()
|
_expandedChannelIds.value = emptySet()
|
||||||
_hasUnreadMessage.value = false
|
|
||||||
_switchState.value = ChannelSwitchState.Idle
|
_switchState.value = ChannelSwitchState.Idle
|
||||||
_showSwitchDialog.value = false
|
_showSwitchDialog.value = false
|
||||||
_pendingSwitchChannel.value = null
|
_pendingSwitchChannel.value = null
|
||||||
@@ -275,12 +273,7 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
Log.d(TAG, "Channel list stale, refreshing...")
|
Log.d(TAG, "Channel list stale, refreshing...")
|
||||||
val channelsJson = TSBridge.getChannelsJSON()
|
val channelsJson = TSBridge.getChannelsJSON()
|
||||||
val channels = json.decodeFromString<List<ChannelInfo>>(channelsJson)
|
val channels = json.decodeFromString<List<ChannelInfo>>(channelsJson)
|
||||||
Repository.updateBaseline(
|
Repository.updateChannels(channels)
|
||||||
channels,
|
|
||||||
Repository.clients.value,
|
|
||||||
Repository.selfClientId.value,
|
|
||||||
Repository.currentChannelId.value,
|
|
||||||
)
|
|
||||||
lastChannelRefreshTime = System.currentTimeMillis()
|
lastChannelRefreshTime = System.currentTimeMillis()
|
||||||
Log.d(TAG, "Channel list refreshed: ${channels.size} channels")
|
Log.d(TAG, "Channel list refreshed: ${channels.size} channels")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -291,49 +284,80 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理客户端进入事件(增量同步)。
|
* 处理客户端进入事件(增量同步)。
|
||||||
|
* 刷新后对比当前频道成员,检测新加入者并发送系统消息。
|
||||||
*/
|
*/
|
||||||
fun handleClientEnter() {
|
fun handleClientEnter() {
|
||||||
Log.d(TAG, "Client enter: refreshing client list")
|
Log.d(TAG, "Client enter: refreshing client list")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val snapshot = snapshotCurrentChannelClients()
|
||||||
Repository.refreshClientList()
|
Repository.refreshClientList()
|
||||||
|
notifyMemberChanges(snapshot, "加入")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理客户端离开事件。
|
* 处理客户端离开事件。
|
||||||
* 全量刷新客户端列表(人员变动统一用全量更新)。
|
* 刷新后对比当前频道成员,检测离开者并发送系统消息。
|
||||||
*/
|
*/
|
||||||
fun handleClientLeave(clientId: Int, reasonMsg: String) {
|
fun handleClientLeave(clientId: Int, reasonMsg: String) {
|
||||||
Log.d(TAG, "Client leave: $clientId, reason: $reasonMsg")
|
Log.d(TAG, "Client leave: $clientId, reason: $reasonMsg")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val snapshot = snapshotCurrentChannelClients()
|
||||||
Repository.refreshClientList()
|
Repository.refreshClientList()
|
||||||
|
notifyMemberChanges(snapshot, "离开")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理文字消息(未读指示)。
|
* 快照当前频道的成员 ID 和昵称。
|
||||||
* 只需标记"当前频道外有新消息",不追踪具体频道。
|
* 离开事件后 clientMap 会更新,所以需要提前保存昵称。
|
||||||
*/
|
*/
|
||||||
fun onTextMessage(targetMode: Int, targetId: String) {
|
private fun snapshotCurrentChannelClients(): Map<Int, String> {
|
||||||
if (targetMode != 2) return
|
val channelId = Repository.currentChannelId.value
|
||||||
if (targetId != Repository.currentChannelId.value) {
|
return Repository.channelClients.value[channelId]
|
||||||
_hasUnreadMessage.value = true
|
?.associate { it.id to it.nickname }
|
||||||
}
|
?: emptyMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除未读标记。
|
* 对比快照与刷新后的当前频道成员,检测变动并发送系统消息。
|
||||||
|
* @param before 变动前的 {clientId → nickname} 快照
|
||||||
|
* @param action "加入" 或 "离开"
|
||||||
*/
|
*/
|
||||||
fun clearUnread() {
|
private fun notifyMemberChanges(before: Map<Int, String>, action: String) {
|
||||||
_hasUnreadMessage.value = false
|
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) {
|
fun toggleExpand(channelId: String) {
|
||||||
_expandedChannelIds.value = _expandedChannelIds.value.toMutableSet().apply {
|
_expandedChannelIds.update { current ->
|
||||||
if (contains(channelId)) remove(channelId) else add(channelId)
|
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>,
|
clients: List<ClientInfo>,
|
||||||
expandedIds: Set<String>,
|
expandedIds: Set<String>,
|
||||||
): List<ChannelTreeNode> {
|
): List<ChannelTreeNode> {
|
||||||
|
// 预计算:按频道 ID 分组客户端(O(n) 一次,替代递归中 O(n) 每次)
|
||||||
|
val clientsByChannel = clients.groupBy { it.channelId }
|
||||||
|
|
||||||
// 找出自引用的根频道(id == parentId),这些频道需要特殊处理
|
// 找出自引用的根频道(id == parentId),这些频道需要特殊处理
|
||||||
val selfRefRoots = channels.filter { it.id == it.parentId }
|
val selfRefRoots = channels.filter { it.id == it.parentId }
|
||||||
// 排除自引用频道后的正常频道列表
|
// 排除自引用频道后的正常频道列表
|
||||||
@@ -386,7 +413,7 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
return ChannelTreeNode(
|
return ChannelTreeNode(
|
||||||
channel = channel,
|
channel = channel,
|
||||||
children = emptyList(),
|
children = emptyList(),
|
||||||
clients = clients.filter { it.channelId == channel.id },
|
clients = clientsByChannel[channel.id] ?: emptyList(),
|
||||||
isExpanded = expandedIds.contains(channel.id),
|
isExpanded = expandedIds.contains(channel.id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -394,12 +421,11 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
val children = (childrenMap[channel.id] ?: emptyList())
|
val children = (childrenMap[channel.id] ?: emptyList())
|
||||||
.sortedBy { it.order }
|
.sortedBy { it.order }
|
||||||
.map { buildNode(it) }
|
.map { buildNode(it) }
|
||||||
val channelClients = clients.filter { it.channelId == channel.id }
|
|
||||||
|
|
||||||
return ChannelTreeNode(
|
return ChannelTreeNode(
|
||||||
channel = channel,
|
channel = channel,
|
||||||
children = children,
|
children = children,
|
||||||
clients = channelClients,
|
clients = clientsByChannel[channel.id] ?: emptyList(),
|
||||||
isExpanded = expandedIds.contains(channel.id),
|
isExpanded = expandedIds.contains(channel.id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -413,7 +439,7 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
ChannelTreeNode(
|
ChannelTreeNode(
|
||||||
channel = root,
|
channel = root,
|
||||||
children = emptyList(),
|
children = emptyList(),
|
||||||
clients = clients.filter { it.channelId == root.id },
|
clients = clientsByChannel[root.id] ?: emptyList(),
|
||||||
isExpanded = expandedIds.contains(root.id),
|
isExpanded = expandedIds.contains(root.id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -502,6 +528,12 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
if (_switchState.value is ChannelSwitchState.WaitingServerEvent) {
|
if (_switchState.value is ChannelSwitchState.WaitingServerEvent) {
|
||||||
Log.w(TAG, "Channel switch timeout waiting for server event")
|
Log.w(TAG, "Channel switch timeout waiting for server event")
|
||||||
_switchState.value = ChannelSwitchState.Failed("等待服务端确认超时")
|
_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()
|
Repository.refreshClientList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新当前频道 ID 并清除未读
|
// 更新当前频道 ID
|
||||||
Repository.setCurrentChannelId(targetChannelId)
|
Repository.setCurrentChannelId(targetChannelId)
|
||||||
clearUnread()
|
|
||||||
|
|
||||||
when (currentState) {
|
when (currentState) {
|
||||||
is ChannelSwitchState.WaitingServerEvent -> {
|
is ChannelSwitchState.WaitingServerEvent -> {
|
||||||
@@ -582,10 +613,21 @@ class ChannelViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
/**
|
/**
|
||||||
* 处理其他用户的移动事件。
|
* 处理其他用户的移动事件。
|
||||||
* 全量刷新客户端列表(人员变动统一用全量更新)。
|
* 全量刷新客户端列表(人员变动统一用全量更新)。
|
||||||
|
* 根据移动方向检测"加入"或"离开"当前频道。
|
||||||
*/
|
*/
|
||||||
private fun handleOtherClientMoved(clientId: Int, targetChannelId: String) {
|
private fun handleOtherClientMoved(clientId: Int, targetChannelId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val currentChannelId = Repository.currentChannelId.value
|
||||||
|
val snapshot = snapshotCurrentChannelClients()
|
||||||
Repository.refreshClientList()
|
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.ChatMessage
|
||||||
import com.tsmobile.app.data.MessageDeliveryState
|
import com.tsmobile.app.data.MessageDeliveryState
|
||||||
import com.tsmobile.app.data.MessageSendState
|
import com.tsmobile.app.data.MessageSendState
|
||||||
|
import com.tsmobile.app.data.MessageType
|
||||||
import com.tsmobile.app.data.Repository
|
import com.tsmobile.app.data.Repository
|
||||||
import com.tsmobile.app.data.parseFileMessageMeta
|
import com.tsmobile.app.data.parseFileMessageMeta
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -15,7 +16,9 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
@@ -37,12 +40,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private var currentTargetMode: Int = 2
|
private var currentTargetMode: Int = 2
|
||||||
private var currentTargetId: Long = 0
|
private var currentTargetId: Long = 0
|
||||||
|
|
||||||
// 送达确认超时 Job
|
// 送达确认超时 Job(每条消息独立管理,避免快速发送时相互覆盖)
|
||||||
private var deliveryTimeoutJob: Job? = null
|
private val deliveryTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 进入聊天页。
|
* 进入聊天页。
|
||||||
* 加载消息并清除未读标记。
|
* 加载消息并设置活跃会话标识。
|
||||||
*/
|
*/
|
||||||
fun enterChat(targetMode: Int, targetId: Long) {
|
fun enterChat(targetMode: Int, targetId: Long) {
|
||||||
currentTargetMode = targetMode
|
currentTargetMode = targetMode
|
||||||
@@ -50,7 +53,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
Repository.setActiveChat(targetMode, targetId)
|
Repository.setActiveChat(targetMode, targetId)
|
||||||
_messages.value = Repository.getMessages(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}")
|
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() {
|
fun leaveChat() {
|
||||||
Repository.setActiveChat(null, null)
|
Repository.setActiveChat(null, null)
|
||||||
_sendState.value = MessageSendState.Idle()
|
_sendState.value = MessageSendState.Idle()
|
||||||
deliveryTimeoutJob?.cancel()
|
deliveryTimeoutJobs.values.forEach { it.cancel() }
|
||||||
|
deliveryTimeoutJobs.clear()
|
||||||
Log.d(TAG, "Left chat")
|
Log.d(TAG, "Left chat")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,11 +74,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
fun clearMessages() {
|
fun clearMessages() {
|
||||||
_messages.value = emptyList()
|
_messages.value = emptyList()
|
||||||
_sendState.value = MessageSendState.Idle()
|
_sendState.value = MessageSendState.Idle()
|
||||||
deliveryTimeoutJob?.cancel()
|
deliveryTimeoutJobs.values.forEach { it.cancel() }
|
||||||
|
deliveryTimeoutJobs.clear()
|
||||||
currentTargetMode = 2
|
currentTargetMode = 2
|
||||||
currentTargetId = 0
|
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
|
_sendState.value = MessageSendState.Sending
|
||||||
|
|
||||||
val selfId = Repository.selfClientId.value
|
val selfId = Repository.selfClientId.value
|
||||||
val selfName = Repository.clients.value.find { it.id == selfId }?.nickname ?: ""
|
val selfName = Repository.getClient(selfId)?.nickname ?: ""
|
||||||
|
|
||||||
// 生成唯一 ID(用于匹配回显)
|
// 生成唯一 ID(用于匹配回显)
|
||||||
val messageId = "local_${System.currentTimeMillis()}_${selfId}"
|
val messageId = "local_${System.currentTimeMillis()}_${selfId}"
|
||||||
@@ -155,13 +179,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
/**
|
/**
|
||||||
* 启动送达确认超时。
|
* 启动送达确认超时。
|
||||||
* 如果在 DELIVERY_TIMEOUT_MS 内没有收到回显,标记为 FAILED。
|
* 如果在 DELIVERY_TIMEOUT_MS 内没有收到回显,标记为 FAILED。
|
||||||
|
* 每条消息独立管理超时 Job,快速发送多条消息时互不影响。
|
||||||
*/
|
*/
|
||||||
private fun startDeliveryTimeout(messageId: String) {
|
private fun startDeliveryTimeout(messageId: String) {
|
||||||
deliveryTimeoutJob?.cancel()
|
val job = viewModelScope.launch {
|
||||||
deliveryTimeoutJob = viewModelScope.launch {
|
|
||||||
delay(DELIVERY_TIMEOUT_MS)
|
delay(DELIVERY_TIMEOUT_MS)
|
||||||
|
|
||||||
// 超时:检查消息是否仍然是 PENDING
|
// 超时:检查消息是否仍然是 PENDING
|
||||||
|
deliveryTimeoutJobs.remove(messageId)
|
||||||
val messages = _messages.value
|
val messages = _messages.value
|
||||||
val pending = messages.find {
|
val pending = messages.find {
|
||||||
it.id == messageId && it.deliveryState == MessageDeliveryState.PENDING
|
it.id == messageId && it.deliveryState == MessageDeliveryState.PENDING
|
||||||
@@ -171,21 +196,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
markMessageFailed(messageId)
|
markMessageFailed(messageId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
deliveryTimeoutJobs[messageId] = job
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标记指定消息为 FAILED。
|
* 标记指定消息为 FAILED。
|
||||||
*/
|
*/
|
||||||
private fun markMessageFailed(messageId: String) {
|
private fun markMessageFailed(messageId: String) {
|
||||||
// 更新本地 _messages 快照
|
// 清理超时 Job
|
||||||
val updated = _messages.value.map { msg ->
|
deliveryTimeoutJobs.remove(messageId)?.cancel()
|
||||||
if (msg.id == messageId && msg.deliveryState == MessageDeliveryState.PENDING) {
|
// 原子更新本地 _messages 快照(避免与 refreshMessages 并发覆盖)
|
||||||
msg.copy(deliveryState = MessageDeliveryState.FAILED)
|
_messages.update { current ->
|
||||||
} else {
|
current.map { msg ->
|
||||||
msg
|
if (msg.id == messageId && msg.deliveryState == MessageDeliveryState.PENDING) {
|
||||||
|
msg.copy(deliveryState = MessageDeliveryState.FAILED)
|
||||||
|
} else {
|
||||||
|
msg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_messages.value = updated
|
|
||||||
|
|
||||||
// 同步更新 Repository 归档(确保导航离开再回来时状态不丢失)
|
// 同步更新 Repository 归档(确保导航离开再回来时状态不丢失)
|
||||||
Repository.markMessageDeliveryFailed(currentTargetMode, currentTargetId, messageId)
|
Repository.markMessageDeliveryFailed(currentTargetMode, currentTargetId, messageId)
|
||||||
@@ -214,13 +243,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
Log.d(TAG, "handleTextMessage JSON content: $content")
|
Log.d(TAG, "handleTextMessage JSON content: $content")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无论 isSelf,始终尝试匹配 PENDING 消息(送达确认)。
|
// 尝试匹配 PENDING 消息(送达确认)。
|
||||||
// 原因:gomobile 的 TextMsg 不含 InvokerID,senderId 通过 UID 反查 clientId,
|
// AAR 已支持 InvokerID,senderId 可靠。仍保留降级逻辑:senderId=0 时仅匹配 content。
|
||||||
// 如果客户端列表未同步(selfUid=null),senderId 会是 0 ≠ selfId,导致 isSelf=false。
|
val confirmedId = Repository.confirmMessageDelivery(targetMode, targetId, senderId, content)
|
||||||
val confirmed = Repository.confirmMessageDelivery(targetMode, targetId, senderId, content)
|
if (confirmedId != null) {
|
||||||
if (confirmed) {
|
Log.d(TAG, "Delivery confirmed: id=$confirmedId, content=$content")
|
||||||
Log.d(TAG, "Delivery confirmed: content=$content")
|
deliveryTimeoutJobs.remove(confirmedId)?.cancel()
|
||||||
deliveryTimeoutJob?.cancel()
|
|
||||||
if (targetMode == currentTargetMode && targetId == currentTargetId) {
|
if (targetMode == currentTargetMode && targetId == currentTargetId) {
|
||||||
refreshMessages()
|
refreshMessages()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,20 @@ import com.tsmobile.app.data.*
|
|||||||
import com.tsmobile.app.ui.theme.ThemeMode
|
import com.tsmobile.app.ui.theme.ThemeMode
|
||||||
import teamspeak.TextMsg
|
import teamspeak.TextMsg
|
||||||
import teamspeak.Client
|
import teamspeak.Client
|
||||||
|
import teamspeak.Teamspeak
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.delay
|
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.Job
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -46,6 +56,16 @@ data class ServerScreenState(
|
|||||||
val connectState: ConnectState = ConnectState.IDLE,
|
val connectState: ConnectState = ConnectState.IDLE,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val validationErrors: ValidationErrors = ValidationErrors(),
|
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) {
|
class ServerViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
@@ -57,6 +77,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val recentStore = RecentConnectionsStore(application)
|
private val recentStore = RecentConnectionsStore(application)
|
||||||
|
private val identityStore = IdentityStore(application)
|
||||||
|
|
||||||
// ChannelViewModel 引用(由 NavGraph 设置)
|
// ChannelViewModel 引用(由 NavGraph 设置)
|
||||||
var channelViewModel: ChannelViewModel? = null
|
var channelViewModel: ChannelViewModel? = null
|
||||||
@@ -88,6 +109,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
private val _serverInfo = MutableStateFlow<ServerInfo?>(null)
|
private val _serverInfo = MutableStateFlow<ServerInfo?>(null)
|
||||||
val serverInfo: StateFlow<ServerInfo?> = _serverInfo.asStateFlow()
|
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) ──
|
// ── 主题切换(架构 4.4) ──
|
||||||
private val themePreferences = ThemePreferences(application)
|
private val themePreferences = ThemePreferences(application)
|
||||||
|
|
||||||
@@ -127,6 +154,177 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
val defaultChannelPassword: String = "",
|
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) {
|
fun updateAddress(value: String) {
|
||||||
@@ -200,11 +398,27 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
// 异步连接(阻塞调用放到 IO 线程,不阻塞 UI)
|
// 异步连接(阻塞调用放到 IO 线程,不阻塞 UI)
|
||||||
viewModelScope.launch {
|
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) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
TSBridge.connect(
|
TSBridge.connectWithIdentity(
|
||||||
|
identity = identity,
|
||||||
host = config.address,
|
host = config.address,
|
||||||
nickname = config.nickname,
|
nickname = config.nickname,
|
||||||
password = config.password,
|
password = config.password,
|
||||||
|
defaultChannel = config.defaultChannel,
|
||||||
|
defaultChannelPassword = config.defaultChannelPassword,
|
||||||
callbacks = createBridgeCallbacks(),
|
callbacks = createBridgeCallbacks(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -337,7 +551,13 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
// 震动反馈
|
// 震动反馈
|
||||||
triggerVibration()
|
triggerVibration()
|
||||||
|
|
||||||
// 自动隐藏通知(5秒后)
|
// app 在后台时发送系统通知
|
||||||
|
val isInForeground = ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.RESUMED
|
||||||
|
if (!isInForeground) {
|
||||||
|
sendPokeSystemNotification(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动隐藏 app 内气泡(5秒后)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
delay(5000)
|
delay(5000)
|
||||||
dismissPokeNotification()
|
dismissPokeNotification()
|
||||||
@@ -349,6 +569,91 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
_pokeNotification.value = null
|
_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() {
|
private fun triggerVibration() {
|
||||||
try {
|
try {
|
||||||
val vibrator = getApplication<Application>().getSystemService(android.os.Vibrator::class.java)
|
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 params = lastConnectParams ?: break
|
||||||
val error = withContext(Dispatchers.IO) {
|
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,
|
host = params.host,
|
||||||
nickname = params.nickname,
|
nickname = params.nickname,
|
||||||
password = params.password,
|
password = params.password,
|
||||||
@@ -508,7 +816,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val error = withContext(Dispatchers.IO) {
|
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,
|
host = params.host,
|
||||||
nickname = params.nickname,
|
nickname = params.nickname,
|
||||||
password = params.password,
|
password = params.password,
|
||||||
@@ -666,13 +977,17 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
targetIdLong = resolved
|
targetIdLong = resolved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TextMsg 不含 invokerID,通过 UID 查找客户端 ID
|
// 直接使用 Go 侧传来的 invokerID(AAR 已包含此字段)
|
||||||
val selfId = Repository.selfClientId.value
|
val selfId = Repository.selfClientId.value
|
||||||
val selfUid = Repository.clients.value.find { it.id == selfId }?.uid
|
val invokerId = msg.invokerID.toInt()
|
||||||
val senderClient = Repository.clients.value.find { it.uid == msg.invokerUID }
|
val senderId: Int = if (invokerId > 0) invokerId else {
|
||||||
// 优先匹配客户端列表;如果 UID 是自己的,直接使用 selfId
|
// 降级:invokerID 不可用时通过 UID 反查
|
||||||
val senderId = senderClient?.id ?: if (msg.invokerUID == selfUid) selfId else 0
|
val selfUid = Repository.getClient(selfId)?.uid
|
||||||
android.util.Log.d(TAG, "onTextMessage: resolved senderId=$senderId (selfId=$selfId, selfUid=$selfUid, senderClient=${senderClient?.id})")
|
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
|
val cvm = chatViewModel
|
||||||
if (cvm == null) {
|
if (cvm == null) {
|
||||||
android.util.Log.w(TAG, "onTextMessage: chatViewModel is null, message dropped!")
|
android.util.Log.w(TAG, "onTextMessage: chatViewModel is null, message dropped!")
|
||||||
@@ -685,10 +1000,12 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
content = msg.message
|
content = msg.message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// 更新未读标记
|
// 当用户不在对应聊天页时,推送系统通知
|
||||||
channelViewModel?.onTextMessage(
|
sendMessageNotification(
|
||||||
targetMode = msg.targetMode.toInt(),
|
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())
|
private val _speakingClients = MutableStateFlow<Map<Int, Long>>(emptyMap())
|
||||||
val speakingClients: StateFlow<Map<Int, Long>> = _speakingClients
|
val speakingClients: StateFlow<Map<Int, Long>> = _speakingClients
|
||||||
|
|
||||||
|
/** 保护 _speakingClients 和 _remoteAudioSettings 的复合读写操作 */
|
||||||
|
private val audioStateLock = Any()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// 连接 VoiceService 回调到 TSBridge
|
// 连接 VoiceService 回调到 TSBridge
|
||||||
voiceService.onVoiceData = { opusData, codec ->
|
voiceService.onVoiceData = { opusData, codec ->
|
||||||
@@ -225,35 +228,46 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
/** Go owns speaking detection; Kotlin only reflects explicit bridge transitions. */
|
/** Go owns speaking detection; Kotlin only reflects explicit bridge transitions. */
|
||||||
fun handleClientSpeaking(clientID: Long, speaking: Boolean) {
|
fun handleClientSpeaking(clientID: Long, speaking: Boolean) {
|
||||||
val clientId = clientID.toInt()
|
val clientId = clientID.toInt()
|
||||||
val current = _speakingClients.value.toMutableMap()
|
synchronized(audioStateLock) {
|
||||||
if (speaking) current[clientId] = System.currentTimeMillis() else current.remove(clientId)
|
val current = _speakingClients.value.toMutableMap()
|
||||||
_speakingClients.value = current
|
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. */
|
/** Removes Go's timeline and local transient mute/speaking state for this client. */
|
||||||
fun removeRemoteClient(clientID: Long, moved: Boolean = false) {
|
fun removeRemoteClient(clientID: Long, moved: Boolean = false) {
|
||||||
val clientId = clientID.toInt()
|
val clientId = clientID.toInt()
|
||||||
TSBridge.removeRemoteAudioClient(clientId)
|
TSBridge.removeRemoteAudioClient(clientId)
|
||||||
_remoteAudioSettings.value = _remoteAudioSettings.value - clientId
|
synchronized(audioStateLock) {
|
||||||
_speakingClients.value = _speakingClients.value - clientId
|
_remoteAudioSettings.value = _remoteAudioSettings.value - clientId
|
||||||
|
_speakingClients.value = _speakingClients.value - clientId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setRemoteClientMuted(clientId: Int, muted: Boolean) {
|
fun setRemoteClientMuted(clientId: Int, muted: Boolean) {
|
||||||
_remoteAudioSettings.value = _remoteAudioSettings.value + (clientId to RemoteAudioSettings(muted))
|
synchronized(audioStateLock) {
|
||||||
|
_remoteAudioSettings.value = _remoteAudioSettings.value + (clientId to RemoteAudioSettings(muted))
|
||||||
|
}
|
||||||
TSBridge.setRemoteClientMuted(clientId, muted)
|
TSBridge.setRemoteClientMuted(clientId, muted)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleRemoteClientMuted(clientId: Int) {
|
fun toggleRemoteClientMuted(clientId: Int) {
|
||||||
val muted = !(_remoteAudioSettings.value[clientId]?.muted ?: false)
|
synchronized(audioStateLock) {
|
||||||
setRemoteClientMuted(clientId, muted)
|
val muted = !(_remoteAudioSettings.value[clientId]?.muted ?: false)
|
||||||
|
_remoteAudioSettings.value = _remoteAudioSettings.value + (clientId to RemoteAudioSettings(muted))
|
||||||
|
TSBridge.setRemoteClientMuted(clientId, muted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onClientMoved(clientId: Long, targetChannelId: String) {
|
fun onClientMoved(clientId: Long, targetChannelId: String) {
|
||||||
val id = clientId.toInt()
|
val id = clientId.toInt()
|
||||||
if (id == Repository.selfClientId.value) {
|
if (id == Repository.selfClientId.value) {
|
||||||
TSBridge.clearRemoteAudioClients()
|
TSBridge.clearRemoteAudioClients()
|
||||||
_remoteAudioSettings.value = emptyMap()
|
synchronized(audioStateLock) {
|
||||||
_speakingClients.value = emptyMap()
|
_remoteAudioSettings.value = emptyMap()
|
||||||
|
_speakingClients.value = emptyMap()
|
||||||
|
}
|
||||||
} else if (targetChannelId != Repository.currentChannelId.value) {
|
} else if (targetChannelId != Repository.currentChannelId.value) {
|
||||||
removeRemoteClient(clientId, moved = true)
|
removeRemoteClient(clientId, moved = true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ func (c *Client) handleClientEnterView(cmd *commands.Command) {
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.clients[clid] = info
|
c.clients[clid] = info
|
||||||
unescapedNick := commands.Unescape(nick)
|
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.clid = clid
|
||||||
c.handler.SetClientID(clid)
|
c.handler.SetClientID(clid)
|
||||||
}
|
}
|
||||||
@@ -80,11 +83,16 @@ func (c *Client) handleClientLeftView(cmd *commands.Command) {
|
|||||||
if clid != 0 {
|
if clid != 0 {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
isSelf := (clid == c.clid)
|
isSelf := (clid == c.clid)
|
||||||
|
uid := ""
|
||||||
|
if info, ok := c.clients[clid]; ok {
|
||||||
|
uid = info.UID
|
||||||
|
}
|
||||||
delete(c.clients, clid)
|
delete(c.clients, clid)
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
evt := ClientLeftViewEvent{
|
evt := ClientLeftViewEvent{
|
||||||
ID: clid,
|
ID: clid,
|
||||||
|
UID: uid,
|
||||||
ReasonID: reasonID,
|
ReasonID: reasonID,
|
||||||
ReasonMsg: reasonMsg,
|
ReasonMsg: reasonMsg,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type ClientLeftViewEvent struct {
|
|||||||
ReasonMsg string
|
ReasonMsg string
|
||||||
ReasonID int
|
ReasonID int
|
||||||
ID uint16
|
ID uint16
|
||||||
|
UID string
|
||||||
TargetID uint16
|
TargetID uint16
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
-5
@@ -222,6 +222,7 @@ type TextMsg struct {
|
|||||||
InvokerName string
|
InvokerName string
|
||||||
InvokerUID string
|
InvokerUID string
|
||||||
Message string
|
Message string
|
||||||
|
InvokerID int
|
||||||
TargetMode int
|
TargetMode int
|
||||||
TargetID string
|
TargetID string
|
||||||
}
|
}
|
||||||
@@ -261,6 +262,7 @@ type TSClient struct {
|
|||||||
callback EventCallback
|
callback EventCallback
|
||||||
connected bool
|
connected bool
|
||||||
host string // 服务器地址(用于文件传输 TCP 连接)
|
host string // 服务器地址(用于文件传输 TCP 连接)
|
||||||
|
selfUID string // 当前连接的 TeamSpeak UID,用于识别同 identity 的残留 session
|
||||||
|
|
||||||
// 事件队列:保证所有 JNI 回调在同一个协程中顺序执行
|
// 事件队列:保证所有 JNI 回调在同一个协程中顺序执行
|
||||||
evtQueueMu sync.Mutex // protects eventQueue replacement
|
evtQueueMu sync.Mutex // protects eventQueue replacement
|
||||||
@@ -303,6 +305,11 @@ func (c *TSClient) Connect(host, nickname, password, defaultChannel, defaultChan
|
|||||||
return fmt.Sprintf("生成身份失败: %v", err)
|
return fmt.Sprintf("生成身份失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算 TeamSpeak UID 并存储,用于僵尸会话过滤(isStaleSelfSession)。
|
||||||
|
c.mu.Lock()
|
||||||
|
c.selfUID = crypto.GetUidFromPublicKey(identity.PublicKeyBase64())
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
opts := []ts.ClientOption{}
|
opts := []ts.ClientOption{}
|
||||||
if password != "" {
|
if password != "" {
|
||||||
opts = append(opts, ts.WithServerPassword(password))
|
opts = append(opts, ts.WithServerPassword(password))
|
||||||
@@ -1423,6 +1430,25 @@ func (c *TSClient) isCurrentClient(client *ts.Client) bool {
|
|||||||
return c.client == client
|
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) {
|
func (c *TSClient) queueClientEvent(client *ts.Client, evt queuedEvent) {
|
||||||
if c.isCurrentClient(client) {
|
if c.isCurrentClient(client) {
|
||||||
c.queueEvent(evt)
|
c.queueEvent(evt)
|
||||||
@@ -1444,6 +1470,10 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
|||||||
client.OnDisconnected(func(err error) {
|
client.OnDisconnected(func(err error) {
|
||||||
c.StopReceiveAudio()
|
c.StopReceiveAudio()
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
if c.client != client {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
c.connected = false
|
c.connected = false
|
||||||
c.client = nil
|
c.client = nil
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
@@ -1455,12 +1485,13 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
client.OnTextMessage(func(msg ts.TextMessage) {
|
client.OnTextMessage(func(msg ts.TextMessage) {
|
||||||
c.queueEvent(queuedEvent{
|
c.queueClientEvent(client, queuedEvent{
|
||||||
evtType: "textmessage",
|
evtType: "textmessage",
|
||||||
data: &TextMsg{
|
data: &TextMsg{
|
||||||
InvokerName: msg.InvokerName,
|
InvokerName: msg.InvokerName,
|
||||||
InvokerUID: msg.InvokerUID,
|
InvokerUID: msg.InvokerUID,
|
||||||
Message: msg.Message,
|
Message: msg.Message,
|
||||||
|
InvokerID: int(msg.InvokerID),
|
||||||
TargetMode: msg.TargetMode,
|
TargetMode: msg.TargetMode,
|
||||||
TargetID: fmt.Sprintf("%d", msg.TargetID),
|
TargetID: fmt.Sprintf("%d", msg.TargetID),
|
||||||
},
|
},
|
||||||
@@ -1468,7 +1499,11 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
client.OnClientEnter(func(info ts.ClientInfo) {
|
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",
|
evtType: "cliententer",
|
||||||
data: &Client{
|
data: &Client{
|
||||||
ID: int(info.ID),
|
ID: int(info.ID),
|
||||||
@@ -1481,8 +1516,13 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
client.OnClientLeave(func(data ts.ClientLeftViewEvent) {
|
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.RemoveRemoteAudioClient(int(data.ID))
|
||||||
c.queueEvent(queuedEvent{
|
c.queueClientEvent(client, queuedEvent{
|
||||||
evtType: "clientleave",
|
evtType: "clientleave",
|
||||||
data: struct {
|
data: struct {
|
||||||
ID int
|
ID int
|
||||||
@@ -1495,7 +1535,7 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
client.OnClientMoved(func(data ts.ClientMovedEvent) {
|
client.OnClientMoved(func(data ts.ClientMovedEvent) {
|
||||||
c.queueEvent(queuedEvent{
|
c.queueClientEvent(client, queuedEvent{
|
||||||
evtType: "clientmoved",
|
evtType: "clientmoved",
|
||||||
data: struct {
|
data: struct {
|
||||||
ID int
|
ID int
|
||||||
@@ -1510,6 +1550,10 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
|||||||
client.OnKicked(func(reason string) {
|
client.OnKicked(func(reason string) {
|
||||||
c.StopReceiveAudio()
|
c.StopReceiveAudio()
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
if c.client != client {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
c.connected = false
|
c.connected = false
|
||||||
c.client = nil
|
c.client = nil
|
||||||
c.mu.Unlock()
|
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
|
// Keep the existing raw bridge active until an Android libopus decoder
|
||||||
// factory has been supplied and the Go receiver explicitly started.
|
// factory has been supplied and the Go receiver explicitly started.
|
||||||
|
if !c.isCurrentClient(client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
c.queueEvent(queuedEvent{
|
c.queueEvent(queuedEvent{
|
||||||
evtType: "voicedata",
|
evtType: "voicedata",
|
||||||
data: struct {
|
data: struct {
|
||||||
@@ -1545,7 +1592,7 @@ func (c *TSClient) registerEvents(client *ts.Client) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
client.OnPoked(func(evt ts.PokeEvent) {
|
client.OnPoked(func(evt ts.PokeEvent) {
|
||||||
c.queueEvent(queuedEvent{
|
c.queueClientEvent(client, queuedEvent{
|
||||||
evtType: "poked",
|
evtType: "poked",
|
||||||
data: &PokeEvent{
|
data: &PokeEvent{
|
||||||
InvokerID: int(evt.InvokerID),
|
InvokerID: int(evt.InvokerID),
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ func (c *TSClient) ConnectWithIdentity(identityStr, host, nickname, password, de
|
|||||||
return fmt.Sprintf("identity 解析失败: %v", err)
|
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{}
|
opts := []ts.ClientOption{}
|
||||||
if password != "" {
|
if password != "" {
|
||||||
opts = append(opts, ts.WithServerPassword(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)
|
return fmt.Sprintf("连接失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.cleanupDuplicateIdentitySessions(client, identity)
|
// 延迟执行僵尸清理:连接刚建立时客户端在默认频道,ListClients 可能尚未
|
||||||
|
// 收到服务器发来的完整客户端列表。延迟 3 秒确保所有 notifycliententerview
|
||||||
|
// 事件已到达,此时 ListClients 能发现不同频道中的僵尸会话。
|
||||||
|
go func() {
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
if c.isCurrentClient(client) {
|
||||||
|
c.cleanupDuplicateIdentitySessions(client, identity)
|
||||||
|
}
|
||||||
|
}()
|
||||||
return ""
|
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