diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts
index 0d1f5db..b6ebb7c 100644
--- a/android-client/app/build.gradle.kts
+++ b/android-client/app/build.gradle.kts
@@ -82,3 +82,19 @@ dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}
+
+val debugApkExportDir = "/Users/solux/Docker/webdav/share/public/apk-release"
+val debugApkExportName = "onlinemsgclient-debug.apk"
+
+val exportDebugApk by tasks.registering(Copy::class) {
+ from(layout.buildDirectory.file("outputs/apk/debug/app-debug.apk"))
+ into(debugApkExportDir)
+ rename { debugApkExportName }
+ doFirst {
+ file(debugApkExportDir).mkdirs()
+ }
+}
+
+tasks.matching { it.name == "assembleDebug" }.configureEach {
+ finalizedBy(exportDebugApk)
+}
diff --git a/android-client/app/src/main/AndroidManifest.xml b/android-client/app/src/main/AndroidManifest.xml
index 025683d..6c539c7 100644
--- a/android-client/app/src/main/AndroidManifest.xml
+++ b/android-client/app/src/main/AndroidManifest.xml
@@ -2,6 +2,9 @@
+
+
+
+
+
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/MainActivity.kt b/android-client/app/src/main/java/com/onlinemsg/client/MainActivity.kt
index a6d327a..defba49 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/MainActivity.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/MainActivity.kt
@@ -1,17 +1,41 @@
package com.onlinemsg.client
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
import com.onlinemsg.client.ui.OnlineMsgApp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ requestNotificationPermissionIfNeeded()
enableEdgeToEdge()
setContent {
OnlineMsgApp()
}
}
+
+ private fun requestNotificationPermissionIfNeeded() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
+ val granted = ContextCompat.checkSelfPermission(
+ this,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
+ if (granted) return
+ ActivityCompat.requestPermissions(
+ this,
+ arrayOf(Manifest.permission.POST_NOTIFICATIONS),
+ REQUEST_NOTIFICATION_PERMISSION
+ )
+ }
+
+ private companion object {
+ const val REQUEST_NOTIFICATION_PERMISSION = 1002
+ }
}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/network/OnlineMsgSocketClient.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/network/OnlineMsgSocketClient.kt
index 83f12db..2480270 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/data/network/OnlineMsgSocketClient.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/data/network/OnlineMsgSocketClient.kt
@@ -6,6 +6,7 @@ import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
+import java.util.concurrent.TimeUnit
class OnlineMsgSocketClient {
@@ -19,6 +20,7 @@ class OnlineMsgSocketClient {
private val client = OkHttpClient.Builder()
.retryOnConnectionFailure(true)
+ .pingInterval(15, TimeUnit.SECONDS)
.build()
@Volatile
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt
index 50e4f85..2ad6213 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt
@@ -20,7 +20,8 @@ data class UserPreferences(
val serverUrls: List,
val currentServerUrl: String,
val showSystemMessages: Boolean,
- val directMode: Boolean
+ val directMode: Boolean,
+ val shouldAutoReconnect: Boolean
)
class UserPreferencesRepository(
@@ -42,7 +43,8 @@ class UserPreferencesRepository(
serverUrls = if (serverUrls.isEmpty()) listOf(ServerUrlFormatter.defaultServerUrl) else serverUrls,
currentServerUrl = currentServer,
showSystemMessages = prefs[KEY_SHOW_SYSTEM_MESSAGES] ?: false,
- directMode = prefs[KEY_DIRECT_MODE] ?: false
+ directMode = prefs[KEY_DIRECT_MODE] ?: false,
+ shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false
)
}
@@ -101,6 +103,12 @@ class UserPreferencesRepository(
}
}
+ suspend fun setShouldAutoReconnect(enabled: Boolean) {
+ context.dataStore.edit { prefs ->
+ prefs[KEY_SHOULD_AUTO_RECONNECT] = enabled
+ }
+ }
+
private fun decodeServerUrls(raw: String?): List {
if (raw.isNullOrBlank()) return listOf(ServerUrlFormatter.defaultServerUrl)
return runCatching {
@@ -119,5 +127,6 @@ class UserPreferencesRepository(
val KEY_CURRENT_SERVER_URL: Preferences.Key = stringPreferencesKey("current_server_url")
val KEY_SHOW_SYSTEM_MESSAGES: Preferences.Key = booleanPreferencesKey("show_system_messages")
val KEY_DIRECT_MODE: Preferences.Key = booleanPreferencesKey("direct_mode")
+ val KEY_SHOULD_AUTO_RECONNECT: Preferences.Key = booleanPreferencesKey("should_auto_reconnect")
}
}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt b/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt
new file mode 100644
index 0000000..0ebaa12
--- /dev/null
+++ b/android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt
@@ -0,0 +1,164 @@
+package com.onlinemsg.client.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.onlinemsg.client.MainActivity
+import com.onlinemsg.client.ui.ChatSessionManager
+import com.onlinemsg.client.ui.ConnectionStatus
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+
+class ChatForegroundService : Service() {
+
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private var statusJob: Job? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ ChatSessionManager.initialize(application)
+ ensureForegroundChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_STOP -> {
+ ChatSessionManager.onForegroundServiceStopped()
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ ACTION_START, null -> {
+ startForeground(
+ FOREGROUND_NOTIFICATION_ID,
+ buildForegroundNotification(ChatSessionManager.uiState.value.status, ChatSessionManager.uiState.value.statusHint)
+ )
+ observeStatusAndRefreshNotification()
+ return START_STICKY
+ }
+
+ else -> return START_STICKY
+ }
+ }
+
+ override fun onDestroy() {
+ statusJob?.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun observeStatusAndRefreshNotification() {
+ if (statusJob != null) return
+ statusJob = serviceScope.launch {
+ ChatSessionManager.uiState
+ .map { it.status to it.statusHint }
+ .distinctUntilChanged()
+ .collect { (status, hint) ->
+ NotificationManagerCompat.from(this@ChatForegroundService).notify(
+ FOREGROUND_NOTIFICATION_ID,
+ buildForegroundNotification(status, hint)
+ )
+ if (status == ConnectionStatus.IDLE && !ChatSessionManager.shouldForegroundServiceRun()) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ }
+ }
+ }
+
+ private fun buildForegroundNotification(status: ConnectionStatus, hint: String): Notification {
+ val openAppIntent = Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ val openAppPendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ openAppIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val stopIntent = Intent(this, ChatForegroundService::class.java).apply {
+ action = ACTION_STOP
+ }
+ val stopPendingIntent = PendingIntent.getService(
+ this,
+ 1,
+ stopIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val title = when (status) {
+ ConnectionStatus.READY -> "OnlineMsg 已保持连接"
+ ConnectionStatus.CONNECTING,
+ ConnectionStatus.HANDSHAKING,
+ ConnectionStatus.AUTHENTICATING -> "OnlineMsg 正在连接"
+ ConnectionStatus.ERROR -> "OnlineMsg 连接异常"
+ ConnectionStatus.IDLE -> "OnlineMsg 后台服务"
+ }
+
+ return NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.stat_notify_sync)
+ .setContentTitle(title)
+ .setContentText(hint.ifBlank { "后台保持连接中" })
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setContentIntent(openAppPendingIntent)
+ .addAction(0, "断开", stopPendingIntent)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+ }
+
+ private fun ensureForegroundChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ FOREGROUND_CHANNEL_ID,
+ "OnlineMsg 后台连接",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "保持 WebSocket 后台长连接"
+ setShowBadge(false)
+ }
+ manager.createNotificationChannel(channel)
+ }
+
+ companion object {
+ private const val ACTION_START = "com.onlinemsg.client.action.START_FOREGROUND_CHAT"
+ private const val ACTION_STOP = "com.onlinemsg.client.action.STOP_FOREGROUND_CHAT"
+ private const val FOREGROUND_CHANNEL_ID = "onlinemsg_foreground"
+ private const val FOREGROUND_NOTIFICATION_ID = 1001
+
+ fun start(context: Context) {
+ val intent = Intent(context, ChatForegroundService::class.java).apply {
+ action = ACTION_START
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, ChatForegroundService::class.java).apply {
+ action = ACTION_STOP
+ }
+ context.startService(intent)
+ }
+ }
+}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt
index 95e5255..714d1e2 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt
@@ -3,6 +3,7 @@ package com.onlinemsg.client.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.imePadding
@@ -14,7 +15,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
@@ -371,48 +374,131 @@ private fun MessageItem(
message: UiMessage,
onCopy: () -> Unit
) {
- val container = when (message.role) {
- MessageRole.SYSTEM -> MaterialTheme.colorScheme.secondaryContainer
- MessageRole.INCOMING -> MaterialTheme.colorScheme.surface
- MessageRole.OUTGOING -> MaterialTheme.colorScheme.primaryContainer
- }
+ BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
+ val maxBubbleWidth = if (message.role == MessageRole.SYSTEM) {
+ maxWidth * 0.9f
+ } else {
+ maxWidth * 0.82f
+ }
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = container)
- ) {
- Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
- if (message.role != MessageRole.SYSTEM) {
- Row(verticalAlignment = Alignment.CenterVertically) {
+ if (message.role == MessageRole.SYSTEM) {
+ Card(
+ modifier = Modifier
+ .widthIn(max = maxBubbleWidth)
+ .align(Alignment.Center),
+ shape = RoundedCornerShape(14.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
Text(
- text = message.sender,
- style = MaterialTheme.typography.labelLarge
+ text = message.content,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
)
- if (message.subtitle.isNotBlank()) {
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = message.subtitle,
- style = MaterialTheme.typography.labelSmall,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
- Spacer(modifier = Modifier.weight(1f))
Text(
text = formatTime(message.timestampMillis),
- style = MaterialTheme.typography.labelSmall
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
)
}
}
+ return@BoxWithConstraints
+ }
- Text(
- text = message.content,
- style = MaterialTheme.typography.bodyMedium
+ val isOutgoing = message.role == MessageRole.OUTGOING
+ val bubbleColor = if (isOutgoing) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceContainer
+ }
+ val bubbleTextColor = if (isOutgoing) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ }
+ val bubbleShape = if (isOutgoing) {
+ RoundedCornerShape(
+ topStart = 18.dp,
+ topEnd = 6.dp,
+ bottomEnd = 18.dp,
+ bottomStart = 18.dp
+ )
+ } else {
+ RoundedCornerShape(
+ topStart = 6.dp,
+ topEnd = 18.dp,
+ bottomEnd = 18.dp,
+ bottomStart = 18.dp
)
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start
+ ) {
+ Card(
+ modifier = Modifier.widthIn(max = maxBubbleWidth),
+ shape = bubbleShape,
+ colors = CardDefaults.cardColors(containerColor = bubbleColor)
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ if (!isOutgoing) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = message.sender,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ if (message.subtitle.isNotBlank()) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = message.subtitle,
+ style = MaterialTheme.typography.labelSmall,
+ color = bubbleTextColor.copy(alpha = 0.75f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ }
+
+ Text(
+ text = message.content,
+ style = MaterialTheme.typography.bodyMedium,
+ color = bubbleTextColor
+ )
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
- IconButton(onClick = onCopy) {
- Icon(Icons.Rounded.ContentCopy, contentDescription = "复制")
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = formatTime(message.timestampMillis),
+ style = MaterialTheme.typography.labelSmall,
+ color = bubbleTextColor.copy(alpha = 0.7f)
+ )
+ IconButton(
+ onClick = onCopy,
+ modifier = Modifier.size(24.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.ContentCopy,
+ contentDescription = "复制",
+ tint = bubbleTextColor.copy(alpha = 0.7f),
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ }
}
}
}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt
new file mode 100644
index 0000000..0ae56f0
--- /dev/null
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt
@@ -0,0 +1,943 @@
+package com.onlinemsg.client.ui
+
+import android.Manifest
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import com.onlinemsg.client.MainActivity
+import com.onlinemsg.client.R
+import com.onlinemsg.client.data.crypto.RsaCryptoManager
+import com.onlinemsg.client.data.network.OnlineMsgSocketClient
+import com.onlinemsg.client.data.preferences.ServerUrlFormatter
+import com.onlinemsg.client.data.preferences.UserPreferencesRepository
+import com.onlinemsg.client.data.protocol.AuthPayloadDto
+import com.onlinemsg.client.data.protocol.EnvelopeDto
+import com.onlinemsg.client.data.protocol.HelloDataDto
+import com.onlinemsg.client.data.protocol.SignedPayloadDto
+import com.onlinemsg.client.data.protocol.asPayloadText
+import com.onlinemsg.client.service.ChatForegroundService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.nio.charset.StandardCharsets
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.encodeToJsonElement
+
+object ChatSessionManager {
+
+ private val json = Json {
+ ignoreUnknownKeys = true
+ }
+
+ private lateinit var app: Application
+ private lateinit var preferencesRepository: UserPreferencesRepository
+ private lateinit var cryptoManager: RsaCryptoManager
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private val socketClient = OnlineMsgSocketClient()
+ private var initialized = false
+
+ private val _uiState = MutableStateFlow(ChatUiState())
+ val uiState = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events = _events.asSharedFlow()
+
+ private val identityMutex = Mutex()
+ private var identity: RsaCryptoManager.Identity? = null
+
+ private var manualClose = false
+ private var fallbackTried = false
+ private var connectedUrl = ""
+ private var serverPublicKey = ""
+ private var helloTimeoutJob: Job? = null
+ private var authTimeoutJob: Job? = null
+ private var reconnectJob: Job? = null
+ private var reconnectAttempt: Int = 0
+ private val systemMessageExpiryJobs: MutableMap = mutableMapOf()
+ private var autoReconnectTriggered = false
+ @Volatile
+ private var keepAliveRequested = false
+ private var notificationIdSeed = 2000
+
+ private val socketListener = object : OnlineMsgSocketClient.Listener {
+ override fun onOpen() {
+ scope.launch {
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.HANDSHAKING,
+ statusHint = "已连接,正在准备聊天..."
+ )
+ }
+ addSystemMessage("连接已建立")
+ startHelloTimeout()
+ }
+ }
+
+ override fun onMessage(text: String) {
+ scope.launch {
+ runCatching {
+ handleIncomingMessage(text)
+ }.onFailure { error ->
+ addSystemMessage("文本帧处理异常:${error.message ?: "unknown"}")
+ }
+ }
+ }
+
+ override fun onBinaryMessage(payload: ByteArray) {
+ scope.launch {
+ if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
+ _uiState.update { it.copy(statusHint = "收到二进制握手帧,正在尝试解析...") }
+ }
+
+ val utf8 = runCatching { String(payload, StandardCharsets.UTF_8) }.getOrNull().orEmpty()
+ if (utf8.isNotBlank()) {
+ runCatching {
+ handleIncomingMessage(utf8)
+ }.onFailure { error ->
+ addSystemMessage("二进制帧处理异常:${error.message ?: "unknown"}")
+ }
+ } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
+ val hexPreview = payload.take(24).joinToString(" ") { byte ->
+ "%02x".format(byte)
+ }
+ addSystemMessage("握手二进制帧无法转为文本,len=${payload.size} hex=$hexPreview")
+ }
+ }
+ }
+
+ override fun onClosed(code: Int, reason: String) {
+ scope.launch {
+ handleSocketClosed(code, reason)
+ }
+ }
+
+ override fun onFailure(throwable: Throwable) {
+ scope.launch {
+ if (manualClose) return@launch
+ val message = throwable.message?.takeIf { it.isNotBlank() } ?: "unknown"
+ addSystemMessage("连接异常:$message")
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.ERROR,
+ statusHint = "连接异常,正在重试"
+ )
+ }
+ scheduleReconnect("连接异常")
+ }
+ }
+ }
+
+ @Synchronized
+ fun initialize(application: Application) {
+ if (initialized) return
+ initialized = true
+ app = application
+ preferencesRepository = UserPreferencesRepository(application, json)
+ cryptoManager = RsaCryptoManager(application)
+ ensureMessageNotificationChannel()
+
+ scope.launch {
+ val pref = preferencesRepository.preferencesFlow.first()
+ keepAliveRequested = pref.shouldAutoReconnect
+ _uiState.update { current ->
+ current.copy(
+ displayName = pref.displayName,
+ serverUrls = pref.serverUrls,
+ serverUrl = pref.currentServerUrl,
+ directMode = pref.directMode,
+ showSystemMessages = pref.showSystemMessages
+ )
+ }
+ if (pref.shouldAutoReconnect && !autoReconnectTriggered) {
+ autoReconnectTriggered = true
+ ChatForegroundService.start(application)
+ connectInternal(isAutoRestore = true)
+ }
+ }
+ }
+
+ fun updateDisplayName(value: String) {
+ val displayName = value.take(64)
+ _uiState.update { it.copy(displayName = displayName) }
+ scope.launch {
+ preferencesRepository.setDisplayName(displayName)
+ }
+ }
+
+ fun updateServerUrl(value: String) {
+ _uiState.update { it.copy(serverUrl = value) }
+ }
+
+ fun updateTargetKey(value: String) {
+ _uiState.update { it.copy(targetKey = value) }
+ }
+
+ fun updateDraft(value: String) {
+ _uiState.update { it.copy(draft = value) }
+ }
+
+ fun toggleDirectMode(enabled: Boolean) {
+ _uiState.update { it.copy(directMode = enabled) }
+ scope.launch {
+ preferencesRepository.setDirectMode(enabled)
+ }
+ }
+
+ fun toggleShowSystemMessages(show: Boolean) {
+ _uiState.update { it.copy(showSystemMessages = show) }
+ scope.launch {
+ preferencesRepository.setShowSystemMessages(show)
+ }
+ }
+
+ fun clearMessages() {
+ cancelSystemMessageExpiryJobs()
+ _uiState.update { it.copy(messages = emptyList()) }
+ }
+
+ fun saveCurrentServerUrl() {
+ val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
+ if (normalized.isBlank()) {
+ scope.launch {
+ _events.emit(UiEvent.ShowSnackbar("请输入有效的服务器地址"))
+ }
+ return
+ }
+
+ val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized)
+ _uiState.update {
+ it.copy(
+ serverUrl = normalized,
+ serverUrls = nextUrls,
+ statusHint = "服务器地址已保存"
+ )
+ }
+
+ scope.launch {
+ preferencesRepository.saveCurrentServerUrl(normalized)
+ _events.emit(UiEvent.ShowSnackbar("服务器地址已保存"))
+ }
+ }
+
+ fun removeCurrentServerUrl() {
+ val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
+ if (normalized.isBlank()) return
+
+ val filtered = _uiState.value.serverUrls.filterNot { it == normalized }
+ val nextUrls = if (filtered.isEmpty()) {
+ listOf(ServerUrlFormatter.defaultServerUrl)
+ } else {
+ filtered
+ }
+
+ _uiState.update {
+ it.copy(
+ serverUrls = nextUrls,
+ serverUrl = nextUrls.first(),
+ statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址"
+ )
+ }
+
+ scope.launch {
+ preferencesRepository.removeCurrentServerUrl(normalized)
+ _events.emit(UiEvent.ShowSnackbar("已更新服务器地址列表"))
+ }
+ }
+
+ fun revealMyPublicKey() {
+ scope.launch {
+ _uiState.update { it.copy(loadingPublicKey = true) }
+ runCatching {
+ ensureIdentity()
+ }.onSuccess { id ->
+ _uiState.update {
+ it.copy(
+ myPublicKey = id.publicKeyBase64,
+ loadingPublicKey = false
+ )
+ }
+ }.onFailure { error ->
+ _uiState.update { it.copy(loadingPublicKey = false) }
+ _events.emit(UiEvent.ShowSnackbar("公钥读取失败:${error.message ?: "unknown"}"))
+ }
+ }
+ }
+
+ fun connect() {
+ connectInternal(isAutoRestore = false)
+ }
+
+ private fun connectInternal(isAutoRestore: Boolean) {
+ if (!initialized) return
+ val state = _uiState.value
+ if (!state.canConnect) return
+
+ val normalized = ServerUrlFormatter.normalize(state.serverUrl)
+ if (normalized.isBlank()) {
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.ERROR,
+ statusHint = "请填写有效服务器地址"
+ )
+ }
+ return
+ }
+
+ manualClose = false
+ keepAliveRequested = true
+ fallbackTried = false
+ connectedUrl = normalized
+ serverPublicKey = ""
+ cancelReconnect()
+ reconnectAttempt = 0
+ cancelHelloTimeout()
+ cancelAuthTimeout()
+
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.CONNECTING,
+ statusHint = "正在连接服务器...",
+ serverUrl = normalized,
+ certFingerprint = ""
+ )
+ }
+
+ scope.launch {
+ preferencesRepository.setCurrentServerUrl(normalized)
+ preferencesRepository.setShouldAutoReconnect(true)
+ }
+
+ ChatForegroundService.start(app)
+ socketClient.connect(normalized, socketListener)
+
+ if (isAutoRestore) {
+ addSystemMessage("已恢复上次会话,正在自动连接")
+ }
+ }
+
+ fun disconnect(stopService: Boolean = true) {
+ manualClose = true
+ cancelReconnect()
+ cancelHelloTimeout()
+ cancelAuthTimeout()
+ socketClient.close(1000, "manual_close")
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.IDLE,
+ statusHint = "连接已关闭"
+ )
+ }
+ autoReconnectTriggered = false
+ keepAliveRequested = false
+ scope.launch {
+ preferencesRepository.setShouldAutoReconnect(false)
+ }
+ if (stopService) {
+ ChatForegroundService.stop(app)
+ }
+ addSystemMessage("已断开连接")
+ }
+
+ fun sendMessage() {
+ val current = _uiState.value
+ if (!current.canSend) return
+
+ scope.launch {
+ val text = _uiState.value.draft.trim()
+ if (text.isBlank()) return@launch
+
+ val key = if (_uiState.value.directMode) _uiState.value.targetKey.trim() else ""
+ if (_uiState.value.directMode && key.isBlank()) {
+ _uiState.update { it.copy(statusHint = "请先填写目标公钥,再发送私聊消息") }
+ return@launch
+ }
+
+ val type = if (key.isBlank()) "broadcast" else "forward"
+ val channel = if (key.isBlank()) MessageChannel.BROADCAST else MessageChannel.PRIVATE
+ val subtitle = if (key.isBlank()) "" else "私聊 ${summarizeKey(key)}"
+
+ _uiState.update { it.copy(sending = true) }
+
+ runCatching {
+ val id = ensureIdentity()
+ val timestamp = cryptoManager.unixSecondsNow()
+ val nonce = cryptoManager.createNonce()
+ val signingInput = listOf(type, key, text, timestamp.toString(), nonce).joinToString("\n")
+ val signature = withContext(Dispatchers.Default) {
+ cryptoManager.signText(id.privateKey, signingInput)
+ }
+
+ val payload = SignedPayloadDto(
+ payload = text,
+ timestamp = timestamp,
+ nonce = nonce,
+ signature = signature
+ )
+ val envelope = EnvelopeDto(
+ type = type,
+ key = key,
+ data = json.encodeToJsonElement(payload)
+ )
+
+ val plain = json.encodeToString(envelope)
+ val cipher = withContext(Dispatchers.Default) {
+ cryptoManager.encryptChunked(serverPublicKey, plain)
+ }
+
+ check(socketClient.send(cipher)) { "连接不可用" }
+ }.onSuccess {
+ addOutgoingMessage(text, subtitle, channel)
+ _uiState.update { it.copy(draft = "", sending = false) }
+ }.onFailure { error ->
+ _uiState.update { it.copy(sending = false) }
+ addSystemMessage("发送失败:${error.message ?: "unknown"}")
+ }
+ }
+ }
+
+ fun onMessageCopied() {
+ scope.launch {
+ _events.emit(UiEvent.ShowSnackbar("已复制"))
+ }
+ }
+
+ private suspend fun ensureIdentity(): RsaCryptoManager.Identity {
+ return identityMutex.withLock {
+ identity ?: withContext(Dispatchers.Default) {
+ cryptoManager.getOrCreateIdentity()
+ }.also { created ->
+ identity = created
+ }
+ }
+ }
+
+ private suspend fun handleIncomingMessage(rawText: String) {
+ if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
+ _uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") }
+ }
+
+ val normalizedText = extractJsonCandidate(rawText)
+ val rootObject = runCatching {
+ json.decodeFromString(normalizedText) as? JsonObject
+ }.getOrNull()
+
+ // 兼容某些代理/中间层直接转发 hello data 对象(没有 envelope 外层)
+ val directHello = rootObject?.let { obj ->
+ val hasPublicKey = obj["publicKey"] != null
+ val hasChallenge = obj["authChallenge"] != null
+ if (hasPublicKey && hasChallenge) {
+ runCatching { json.decodeFromJsonElement(obj) }.getOrNull()
+ } else {
+ null
+ }
+ }
+ if (directHello != null) {
+ cancelHelloTimeout()
+ handleServerHello(directHello)
+ return
+ }
+
+ val plain = runCatching { json.decodeFromString(normalizedText) }.getOrNull()
+ if (plain?.type == "publickey") {
+ cancelHelloTimeout()
+ val hello = plain.data?.let {
+ runCatching { json.decodeFromJsonElement(it) }.getOrNull()
+ }
+ if (hello == null || hello.publicKey.isBlank() || hello.authChallenge.isBlank()) {
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.ERROR,
+ statusHint = "握手失败:服务端响应不完整"
+ )
+ }
+ return
+ }
+ handleServerHello(hello)
+ return
+ }
+
+ if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) {
+ _uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") }
+ addSystemMessage("握手阶段收到非预期消息类型:${plain.type}")
+ } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain == null) {
+ val preview = rawText
+ .replace("\n", " ")
+ .replace("\r", " ")
+ .take(80)
+ _uiState.update { it.copy(statusHint = "握手失败:首包解析失败") }
+ addSystemMessage("握手包解析失败:$preview")
+ }
+
+ val id = ensureIdentity()
+ val decrypted = runCatching {
+ withContext(Dispatchers.Default) {
+ cryptoManager.decryptChunked(id.privateKey, normalizedText)
+ }
+ }.getOrElse {
+ addSystemMessage("收到无法解密的消息")
+ return
+ }
+
+ val secure = runCatching {
+ json.decodeFromString(decrypted)
+ }.getOrNull() ?: return
+
+ handleSecureMessage(secure)
+ }
+
+ private suspend fun handleServerHello(hello: HelloDataDto) {
+ cancelHelloTimeout()
+ serverPublicKey = hello.publicKey
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.AUTHENTICATING,
+ statusHint = "正在完成身份验证...",
+ certFingerprint = hello.certFingerprintSha256.orEmpty()
+ )
+ }
+
+ cancelAuthTimeout()
+ authTimeoutJob = scope.launch {
+ delay(AUTH_TIMEOUT_MS)
+ if (_uiState.value.status == ConnectionStatus.AUTHENTICATING) {
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.ERROR,
+ statusHint = "连接超时,请重试"
+ )
+ }
+ addSystemMessage("认证超时,请检查网络后重试")
+ socketClient.close(1000, "auth_timeout")
+ }
+ }
+
+ runCatching {
+ sendAuth(hello.authChallenge)
+ }.onSuccess {
+ addSystemMessage("已发送认证请求")
+ }.onFailure { error ->
+ cancelAuthTimeout()
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.ERROR,
+ statusHint = "认证失败"
+ )
+ }
+ addSystemMessage("认证发送失败:${error.message ?: "unknown"}")
+ socketClient.close(1000, "auth_failed")
+ }
+ }
+
+ private suspend fun sendAuth(challenge: String) {
+ val id = ensureIdentity()
+ val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() }
+ if (displayName != _uiState.value.displayName) {
+ _uiState.update { it.copy(displayName = displayName) }
+ preferencesRepository.setDisplayName(displayName)
+ }
+
+ val timestamp = cryptoManager.unixSecondsNow()
+ val nonce = cryptoManager.createNonce()
+ val signingInput = listOf(
+ "publickey",
+ displayName,
+ id.publicKeyBase64,
+ challenge,
+ timestamp.toString(),
+ nonce
+ ).joinToString("\n")
+
+ val signature = withContext(Dispatchers.Default) {
+ cryptoManager.signText(id.privateKey, signingInput)
+ }
+
+ val payload = AuthPayloadDto(
+ publicKey = id.publicKeyBase64,
+ challenge = challenge,
+ timestamp = timestamp,
+ nonce = nonce,
+ signature = signature
+ )
+ val envelope = EnvelopeDto(
+ type = "publickey",
+ key = displayName,
+ data = json.encodeToJsonElement(payload)
+ )
+
+ val plain = json.encodeToString(envelope)
+ val cipher = withContext(Dispatchers.Default) {
+ cryptoManager.encryptChunked(serverPublicKey, plain)
+ }
+ check(socketClient.send(cipher)) { "连接不可用" }
+ }
+
+ private fun handleSecureMessage(message: EnvelopeDto) {
+ when (message.type) {
+ "auth_ok" -> {
+ cancelAuthTimeout()
+ cancelReconnect()
+ reconnectAttempt = 0
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.READY,
+ statusHint = "已连接,可以开始聊天"
+ )
+ }
+ addSystemMessage("连接准备完成")
+ }
+
+ "broadcast" -> {
+ val sender = message.key?.takeIf { it.isNotBlank() } ?: "匿名用户"
+ addIncomingMessage(
+ sender = sender,
+ subtitle = "",
+ content = message.data.asPayloadText(),
+ channel = MessageChannel.BROADCAST
+ )
+ }
+
+ "forward" -> {
+ val sourceKey = message.key.orEmpty()
+ addIncomingMessage(
+ sender = "私聊消息",
+ subtitle = sourceKey.takeIf { it.isNotBlank() }?.let { "来自 ${summarizeKey(it)}" }.orEmpty(),
+ content = message.data.asPayloadText(),
+ channel = MessageChannel.PRIVATE
+ )
+ }
+
+ else -> addSystemMessage("收到未识别消息类型:${message.type}")
+ }
+ }
+
+ private fun handleSocketClosed(code: Int, reason: String) {
+ cancelHelloTimeout()
+ cancelAuthTimeout()
+
+ if (manualClose) {
+ return
+ }
+ if (reason == "reconnect") {
+ return
+ }
+ if (reconnectJob?.isActive == true) {
+ return
+ }
+
+ val currentStatus = _uiState.value.status
+
+ val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY
+
+ if (allowFallback) {
+ val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl)
+ if (fallbackUrl.isNotBlank()) {
+ fallbackTried = true
+ connectedUrl = fallbackUrl
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.CONNECTING,
+ statusHint = "正在自动重试连接...",
+ serverUrl = fallbackUrl
+ )
+ }
+ addSystemMessage("连接方式切换中,正在重试")
+ socketClient.connect(fallbackUrl, socketListener)
+ return
+ }
+ }
+
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.ERROR,
+ statusHint = "连接已中断,正在重试"
+ )
+ }
+ addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}")
+ scheduleReconnect("连接已中断")
+ }
+
+ private fun addSystemMessage(content: String) {
+ val message = UiMessage(
+ role = MessageRole.SYSTEM,
+ sender = "系统",
+ subtitle = "",
+ content = content,
+ channel = MessageChannel.BROADCAST
+ )
+ appendMessage(message)
+ scheduleSystemMessageExpiry(message.id)
+ }
+
+ private fun addIncomingMessage(
+ sender: String,
+ subtitle: String,
+ content: String,
+ channel: MessageChannel
+ ) {
+ showIncomingNotification(
+ title = sender,
+ body = content.ifBlank { "收到一条新消息" }
+ )
+ appendMessage(
+ UiMessage(
+ role = MessageRole.INCOMING,
+ sender = sender,
+ subtitle = subtitle,
+ content = content,
+ channel = channel
+ )
+ )
+ }
+
+ private fun addOutgoingMessage(
+ content: String,
+ subtitle: String,
+ channel: MessageChannel
+ ) {
+ appendMessage(
+ UiMessage(
+ role = MessageRole.OUTGOING,
+ sender = "我",
+ subtitle = subtitle,
+ content = content,
+ channel = channel
+ )
+ )
+ }
+
+ private fun appendMessage(message: UiMessage) {
+ _uiState.update { current ->
+ val next = (current.messages + message).takeLast(MAX_MESSAGES)
+ val aliveIds = next.asSequence().map { it.id }.toSet()
+ val removedIds = systemMessageExpiryJobs.keys.filterNot { it in aliveIds }
+ removedIds.forEach { id ->
+ systemMessageExpiryJobs.remove(id)?.cancel()
+ }
+ current.copy(messages = next)
+ }
+ }
+
+ private fun cancelAuthTimeout() {
+ authTimeoutJob?.cancel()
+ authTimeoutJob = null
+ }
+
+ private fun scheduleReconnect(reason: String) {
+ if (manualClose) return
+ if (reconnectJob?.isActive == true) return
+
+ reconnectAttempt += 1
+ val exponential = 1 shl minOf(reconnectAttempt - 1, 5)
+ val delaySeconds = minOf(MAX_RECONNECT_DELAY_SECONDS, exponential)
+ addSystemMessage("$reason,${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)")
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.ERROR,
+ statusHint = "${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)"
+ )
+ }
+
+ reconnectJob = scope.launch {
+ delay(delaySeconds * 1000L)
+ if (manualClose) return@launch
+
+ val target = ServerUrlFormatter.normalize(connectedUrl).ifBlank {
+ ServerUrlFormatter.normalize(_uiState.value.serverUrl)
+ }
+ if (target.isBlank()) {
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.ERROR,
+ statusHint = "重连失败:服务器地址无效"
+ )
+ }
+ return@launch
+ }
+
+ fallbackTried = false
+ serverPublicKey = ""
+ connectedUrl = target
+ cancelHelloTimeout()
+ cancelAuthTimeout()
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.CONNECTING,
+ statusHint = "正在自动重连..."
+ )
+ }
+ socketClient.connect(target, socketListener)
+ }
+ }
+
+ private fun cancelReconnect() {
+ reconnectJob?.cancel()
+ reconnectJob = null
+ }
+
+ private fun scheduleSystemMessageExpiry(messageId: String) {
+ systemMessageExpiryJobs.remove(messageId)?.cancel()
+ systemMessageExpiryJobs[messageId] = scope.launch {
+ delay(SYSTEM_MESSAGE_TTL_MS)
+ _uiState.update { current ->
+ val filtered = current.messages.filterNot { it.id == messageId }
+ current.copy(messages = filtered)
+ }
+ systemMessageExpiryJobs.remove(messageId)
+ }
+ }
+
+ private fun cancelSystemMessageExpiryJobs() {
+ systemMessageExpiryJobs.values.forEach { it.cancel() }
+ systemMessageExpiryJobs.clear()
+ }
+
+ private fun startHelloTimeout() {
+ cancelHelloTimeout()
+ helloTimeoutJob = scope.launch {
+ delay(HELLO_TIMEOUT_MS)
+ if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
+ val currentUrl = connectedUrl.ifBlank { "unknown" }
+ _uiState.update {
+ it.copy(
+ status = ConnectionStatus.ERROR,
+ statusHint = "握手超时,请检查地址路径与反向代理"
+ )
+ }
+ addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl)")
+ socketClient.close(1000, "hello_timeout")
+ }
+ }
+ }
+
+ private fun cancelHelloTimeout() {
+ helloTimeoutJob?.cancel()
+ helloTimeoutJob = null
+ }
+
+ private fun summarizeKey(key: String): String {
+ if (key.length <= 16) return key
+ return "${key.take(8)}...${key.takeLast(8)}"
+ }
+
+ private fun createGuestName(): String {
+ val rand = (100000..999999).random()
+ return "guest-$rand"
+ }
+
+ private fun extractJsonCandidate(rawText: String): String {
+ val trimmed = rawText.trim()
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
+ return trimmed
+ }
+
+ val start = rawText.indexOf('{')
+ val end = rawText.lastIndexOf('}')
+ return if (start in 0 until end) {
+ rawText.substring(start, end + 1)
+ } else {
+ rawText
+ }
+ }
+
+ fun shutdownAll() {
+ cancelSystemMessageExpiryJobs()
+ cancelReconnect()
+ cancelHelloTimeout()
+ cancelAuthTimeout()
+ socketClient.shutdown()
+ }
+
+ fun onForegroundServiceStopped() {
+ keepAliveRequested = false
+ if (_uiState.value.status != ConnectionStatus.IDLE) {
+ disconnect(stopService = false)
+ } else {
+ scope.launch {
+ preferencesRepository.setShouldAutoReconnect(false)
+ }
+ }
+ }
+
+ fun shouldForegroundServiceRun(): Boolean = keepAliveRequested
+
+ private fun ensureMessageNotificationChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ MESSAGE_CHANNEL_ID,
+ "OnlineMsg 消息提醒",
+ NotificationManager.IMPORTANCE_DEFAULT
+ ).apply {
+ description = "收到服务器新消息时提醒"
+ }
+ manager.createNotificationChannel(channel)
+ }
+
+ private fun showIncomingNotification(title: String, body: String) {
+ if (!initialized) return
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(app, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ ) {
+ return
+ }
+
+ val launchIntent = Intent(app, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ app,
+ 0,
+ launchIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification = NotificationCompat.Builder(app, MESSAGE_CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.stat_notify_chat)
+ .setContentTitle(title.ifBlank { "OnlineMsg" })
+ .setContentText(body.take(120))
+ .setStyle(NotificationCompat.BigTextStyle().bigText(body))
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .build()
+
+ NotificationManagerCompat.from(app).notify(nextMessageNotificationId(), notification)
+ }
+
+ @Synchronized
+ private fun nextMessageNotificationId(): Int {
+ notificationIdSeed += 1
+ return notificationIdSeed
+ }
+
+ private const val HELLO_TIMEOUT_MS = 12_000L
+ private const val AUTH_TIMEOUT_MS = 20_000L
+ private const val MAX_MESSAGES = 500
+ private const val MAX_RECONNECT_DELAY_SECONDS = 30
+ private const val SYSTEM_MESSAGE_TTL_MS = 1_000L
+ private const val MESSAGE_CHANNEL_ID = "onlinemsg_messages"
+}
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt
index 97861fa..ee5b93c 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt
@@ -2,837 +2,28 @@ package com.onlinemsg.client.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
-import androidx.lifecycle.viewModelScope
-import com.onlinemsg.client.data.crypto.RsaCryptoManager
-import com.onlinemsg.client.data.network.OnlineMsgSocketClient
-import com.onlinemsg.client.data.preferences.ServerUrlFormatter
-import com.onlinemsg.client.data.preferences.UserPreferencesRepository
-import com.onlinemsg.client.data.protocol.AuthPayloadDto
-import com.onlinemsg.client.data.protocol.EnvelopeDto
-import com.onlinemsg.client.data.protocol.HelloDataDto
-import com.onlinemsg.client.data.protocol.SignedPayloadDto
-import com.onlinemsg.client.data.protocol.asPayloadText
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import java.nio.charset.StandardCharsets
-import kotlinx.serialization.encodeToString
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.JsonElement
-import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.decodeFromJsonElement
-import kotlinx.serialization.json.encodeToJsonElement
class ChatViewModel(application: Application) : AndroidViewModel(application) {
- private val json = Json {
- ignoreUnknownKeys = true
- }
-
- private val preferencesRepository = UserPreferencesRepository(application, json)
- private val cryptoManager = RsaCryptoManager(application)
- private val socketClient = OnlineMsgSocketClient()
-
- private val _uiState = MutableStateFlow(ChatUiState())
- val uiState = _uiState.asStateFlow()
-
- private val _events = MutableSharedFlow()
- val events = _events.asSharedFlow()
-
- private val identityMutex = Mutex()
- private var identity: RsaCryptoManager.Identity? = null
-
- private var manualClose = false
- private var fallbackTried = false
- private var connectedUrl = ""
- private var serverPublicKey = ""
- private var helloTimeoutJob: Job? = null
- private var authTimeoutJob: Job? = null
- private var reconnectJob: Job? = null
- private var reconnectAttempt: Int = 0
- private val systemMessageExpiryJobs: MutableMap = mutableMapOf()
-
- private val socketListener = object : OnlineMsgSocketClient.Listener {
- override fun onOpen() {
- viewModelScope.launch {
- _uiState.update {
- it.copy(
- status = ConnectionStatus.HANDSHAKING,
- statusHint = "已连接,正在准备聊天..."
- )
- }
- addSystemMessage("连接已建立")
- startHelloTimeout()
- }
- }
-
- override fun onMessage(text: String) {
- viewModelScope.launch {
- runCatching {
- handleIncomingMessage(text)
- }.onFailure { error ->
- addSystemMessage("文本帧处理异常:${error.message ?: "unknown"}")
- }
- }
- }
-
- override fun onBinaryMessage(payload: ByteArray) {
- viewModelScope.launch {
- if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
- _uiState.update { it.copy(statusHint = "收到二进制握手帧,正在尝试解析...") }
- }
-
- val utf8 = runCatching { String(payload, StandardCharsets.UTF_8) }.getOrNull().orEmpty()
- if (utf8.isNotBlank()) {
- runCatching {
- handleIncomingMessage(utf8)
- }.onFailure { error ->
- addSystemMessage("二进制帧处理异常:${error.message ?: "unknown"}")
- }
- } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
- val hexPreview = payload.take(24).joinToString(" ") { byte ->
- "%02x".format(byte)
- }
- addSystemMessage("握手二进制帧无法转为文本,len=${payload.size} hex=$hexPreview")
- }
- }
- }
-
- override fun onClosed(code: Int, reason: String) {
- viewModelScope.launch {
- handleSocketClosed(code, reason)
- }
- }
-
- override fun onFailure(throwable: Throwable) {
- viewModelScope.launch {
- if (manualClose) return@launch
- val message = throwable.message?.takeIf { it.isNotBlank() } ?: "unknown"
- addSystemMessage("连接异常:$message")
- if (_uiState.value.status == ConnectionStatus.READY) {
- scheduleReconnect("连接异常")
- } else {
- _uiState.update {
- it.copy(
- status = ConnectionStatus.ERROR,
- statusHint = "连接失败,请检查服务器地址"
- )
- }
- }
- }
- }
- }
-
init {
- viewModelScope.launch {
- val pref = preferencesRepository.preferencesFlow.first()
- _uiState.update { current ->
- current.copy(
- displayName = pref.displayName,
- serverUrls = pref.serverUrls,
- serverUrl = pref.currentServerUrl,
- directMode = pref.directMode,
- showSystemMessages = pref.showSystemMessages
- )
- }
- }
- }
-
- fun updateDisplayName(value: String) {
- val displayName = value.take(64)
- _uiState.update { it.copy(displayName = displayName) }
- viewModelScope.launch {
- preferencesRepository.setDisplayName(displayName)
- }
- }
-
- fun updateServerUrl(value: String) {
- _uiState.update { it.copy(serverUrl = value) }
- }
-
- fun updateTargetKey(value: String) {
- _uiState.update { it.copy(targetKey = value) }
- }
-
- fun updateDraft(value: String) {
- _uiState.update { it.copy(draft = value) }
- }
-
- fun toggleDirectMode(enabled: Boolean) {
- _uiState.update { it.copy(directMode = enabled) }
- viewModelScope.launch {
- preferencesRepository.setDirectMode(enabled)
- }
- }
-
- fun toggleShowSystemMessages(show: Boolean) {
- _uiState.update { it.copy(showSystemMessages = show) }
- viewModelScope.launch {
- preferencesRepository.setShowSystemMessages(show)
- }
- }
-
- fun clearMessages() {
- cancelSystemMessageExpiryJobs()
- _uiState.update { it.copy(messages = emptyList()) }
- }
-
- fun saveCurrentServerUrl() {
- val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
- if (normalized.isBlank()) {
- viewModelScope.launch {
- _events.emit(UiEvent.ShowSnackbar("请输入有效的服务器地址"))
- }
- return
- }
-
- val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized)
- _uiState.update {
- it.copy(
- serverUrl = normalized,
- serverUrls = nextUrls,
- statusHint = "服务器地址已保存"
- )
- }
-
- viewModelScope.launch {
- preferencesRepository.saveCurrentServerUrl(normalized)
- _events.emit(UiEvent.ShowSnackbar("服务器地址已保存"))
- }
- }
-
- fun removeCurrentServerUrl() {
- val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
- if (normalized.isBlank()) return
-
- val filtered = _uiState.value.serverUrls.filterNot { it == normalized }
- val nextUrls = if (filtered.isEmpty()) {
- listOf(ServerUrlFormatter.defaultServerUrl)
- } else {
- filtered
- }
-
- _uiState.update {
- it.copy(
- serverUrls = nextUrls,
- serverUrl = nextUrls.first(),
- statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址"
- )
- }
-
- viewModelScope.launch {
- preferencesRepository.removeCurrentServerUrl(normalized)
- _events.emit(UiEvent.ShowSnackbar("已更新服务器地址列表"))
- }
- }
-
- fun revealMyPublicKey() {
- viewModelScope.launch {
- _uiState.update { it.copy(loadingPublicKey = true) }
- runCatching {
- ensureIdentity()
- }.onSuccess { id ->
- _uiState.update {
- it.copy(
- myPublicKey = id.publicKeyBase64,
- loadingPublicKey = false
- )
- }
- }.onFailure { error ->
- _uiState.update { it.copy(loadingPublicKey = false) }
- _events.emit(UiEvent.ShowSnackbar("公钥读取失败:${error.message ?: "unknown"}"))
- }
- }
- }
-
- fun connect() {
- val state = _uiState.value
- if (!state.canConnect) return
-
- val normalized = ServerUrlFormatter.normalize(state.serverUrl)
- if (normalized.isBlank()) {
- _uiState.update {
- it.copy(
- status = ConnectionStatus.ERROR,
- statusHint = "请填写有效服务器地址"
- )
- }
- return
- }
-
- manualClose = false
- fallbackTried = false
- connectedUrl = normalized
- serverPublicKey = ""
- cancelReconnect()
- reconnectAttempt = 0
- cancelHelloTimeout()
- cancelAuthTimeout()
-
- _uiState.update {
- it.copy(
- status = ConnectionStatus.CONNECTING,
- statusHint = "正在连接服务器...",
- serverUrl = normalized,
- certFingerprint = ""
- )
- }
-
- viewModelScope.launch {
- preferencesRepository.setCurrentServerUrl(normalized)
- }
-
- socketClient.connect(normalized, socketListener)
- }
-
- fun disconnect() {
- manualClose = true
- cancelReconnect()
- cancelHelloTimeout()
- cancelAuthTimeout()
- socketClient.close(1000, "manual_close")
- _uiState.update {
- it.copy(
- status = ConnectionStatus.IDLE,
- statusHint = "连接已关闭"
- )
- }
- addSystemMessage("已断开连接")
- }
-
- fun sendMessage() {
- val current = _uiState.value
- if (!current.canSend) return
-
- viewModelScope.launch {
- val text = _uiState.value.draft.trim()
- if (text.isBlank()) return@launch
-
- val key = if (_uiState.value.directMode) _uiState.value.targetKey.trim() else ""
- if (_uiState.value.directMode && key.isBlank()) {
- _uiState.update { it.copy(statusHint = "请先填写目标公钥,再发送私聊消息") }
- return@launch
- }
-
- val type = if (key.isBlank()) "broadcast" else "forward"
- val channel = if (key.isBlank()) MessageChannel.BROADCAST else MessageChannel.PRIVATE
- val subtitle = if (key.isBlank()) "" else "私聊 ${summarizeKey(key)}"
-
- _uiState.update { it.copy(sending = true) }
-
- runCatching {
- val id = ensureIdentity()
- val timestamp = cryptoManager.unixSecondsNow()
- val nonce = cryptoManager.createNonce()
- val signingInput = listOf(type, key, text, timestamp.toString(), nonce).joinToString("\n")
- val signature = withContext(Dispatchers.Default) {
- cryptoManager.signText(id.privateKey, signingInput)
- }
-
- val payload = SignedPayloadDto(
- payload = text,
- timestamp = timestamp,
- nonce = nonce,
- signature = signature
- )
- val envelope = EnvelopeDto(
- type = type,
- key = key,
- data = json.encodeToJsonElement(payload)
- )
-
- val plain = json.encodeToString(envelope)
- val cipher = withContext(Dispatchers.Default) {
- cryptoManager.encryptChunked(serverPublicKey, plain)
- }
-
- check(socketClient.send(cipher)) { "连接不可用" }
- }.onSuccess {
- addOutgoingMessage(text, subtitle, channel)
- _uiState.update { it.copy(draft = "", sending = false) }
- }.onFailure { error ->
- _uiState.update { it.copy(sending = false) }
- addSystemMessage("发送失败:${error.message ?: "unknown"}")
- }
- }
- }
-
- fun onMessageCopied() {
- viewModelScope.launch {
- _events.emit(UiEvent.ShowSnackbar("已复制"))
- }
- }
-
- private suspend fun ensureIdentity(): RsaCryptoManager.Identity {
- return identityMutex.withLock {
- identity ?: withContext(Dispatchers.Default) {
- cryptoManager.getOrCreateIdentity()
- }.also { created ->
- identity = created
- }
- }
- }
-
- private suspend fun handleIncomingMessage(rawText: String) {
- if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
- _uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") }
- }
-
- val normalizedText = extractJsonCandidate(rawText)
- val rootObject = runCatching {
- json.decodeFromString(normalizedText) as? JsonObject
- }.getOrNull()
-
- // 兼容某些代理/中间层直接转发 hello data 对象(没有 envelope 外层)
- val directHello = rootObject?.let { obj ->
- val hasPublicKey = obj["publicKey"] != null
- val hasChallenge = obj["authChallenge"] != null
- if (hasPublicKey && hasChallenge) {
- runCatching { json.decodeFromJsonElement(obj) }.getOrNull()
- } else {
- null
- }
- }
- if (directHello != null) {
- cancelHelloTimeout()
- handleServerHello(directHello)
- return
- }
-
- val plain = runCatching { json.decodeFromString(normalizedText) }.getOrNull()
- if (plain?.type == "publickey") {
- cancelHelloTimeout()
- val hello = plain.data?.let {
- runCatching { json.decodeFromJsonElement(it) }.getOrNull()
- }
- if (hello == null || hello.publicKey.isBlank() || hello.authChallenge.isBlank()) {
- _uiState.update {
- it.copy(
- status = ConnectionStatus.ERROR,
- statusHint = "握手失败:服务端响应不完整"
- )
- }
- return
- }
- handleServerHello(hello)
- return
- }
-
- if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) {
- _uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") }
- addSystemMessage("握手阶段收到非预期消息类型:${plain.type}")
- } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain == null) {
- val preview = rawText
- .replace("\n", " ")
- .replace("\r", " ")
- .take(80)
- _uiState.update { it.copy(statusHint = "握手失败:首包解析失败") }
- addSystemMessage("握手包解析失败:$preview")
- }
-
- val id = ensureIdentity()
- val decrypted = runCatching {
- withContext(Dispatchers.Default) {
- cryptoManager.decryptChunked(id.privateKey, normalizedText)
- }
- }.getOrElse {
- addSystemMessage("收到无法解密的消息")
- return
- }
-
- val secure = runCatching {
- json.decodeFromString(decrypted)
- }.getOrNull() ?: return
-
- handleSecureMessage(secure)
- }
-
- private suspend fun handleServerHello(hello: HelloDataDto) {
- cancelHelloTimeout()
- serverPublicKey = hello.publicKey
- _uiState.update {
- it.copy(
- status = ConnectionStatus.AUTHENTICATING,
- statusHint = "正在完成身份验证...",
- certFingerprint = hello.certFingerprintSha256.orEmpty()
- )
- }
-
- cancelAuthTimeout()
- authTimeoutJob = viewModelScope.launch {
- delay(AUTH_TIMEOUT_MS)
- if (_uiState.value.status == ConnectionStatus.AUTHENTICATING) {
- _uiState.update {
- it.copy(
- status = ConnectionStatus.ERROR,
- statusHint = "连接超时,请重试"
- )
- }
- addSystemMessage("认证超时,请检查网络后重试")
- socketClient.close(1000, "auth_timeout")
- }
- }
-
- runCatching {
- sendAuth(hello.authChallenge)
- }.onSuccess {
- addSystemMessage("已发送认证请求")
- }.onFailure { error ->
- cancelAuthTimeout()
- _uiState.update {
- it.copy(
- status = ConnectionStatus.ERROR,
- statusHint = "认证失败"
- )
- }
- addSystemMessage("认证发送失败:${error.message ?: "unknown"}")
- socketClient.close(1000, "auth_failed")
- }
- }
-
- private suspend fun sendAuth(challenge: String) {
- val id = ensureIdentity()
- val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() }
- if (displayName != _uiState.value.displayName) {
- _uiState.update { it.copy(displayName = displayName) }
- preferencesRepository.setDisplayName(displayName)
- }
-
- val timestamp = cryptoManager.unixSecondsNow()
- val nonce = cryptoManager.createNonce()
- val signingInput = listOf(
- "publickey",
- displayName,
- id.publicKeyBase64,
- challenge,
- timestamp.toString(),
- nonce
- ).joinToString("\n")
-
- val signature = withContext(Dispatchers.Default) {
- cryptoManager.signText(id.privateKey, signingInput)
- }
-
- val payload = AuthPayloadDto(
- publicKey = id.publicKeyBase64,
- challenge = challenge,
- timestamp = timestamp,
- nonce = nonce,
- signature = signature
- )
- val envelope = EnvelopeDto(
- type = "publickey",
- key = displayName,
- data = json.encodeToJsonElement(payload)
- )
-
- val plain = json.encodeToString(envelope)
- val cipher = withContext(Dispatchers.Default) {
- cryptoManager.encryptChunked(serverPublicKey, plain)
- }
- check(socketClient.send(cipher)) { "连接不可用" }
- }
-
- private fun handleSecureMessage(message: EnvelopeDto) {
- when (message.type) {
- "auth_ok" -> {
- cancelAuthTimeout()
- cancelReconnect()
- reconnectAttempt = 0
- _uiState.update {
- it.copy(
- status = ConnectionStatus.READY,
- statusHint = "已连接,可以开始聊天"
- )
- }
- addSystemMessage("连接准备完成")
- }
-
- "broadcast" -> {
- val sender = message.key?.takeIf { it.isNotBlank() } ?: "匿名用户"
- addIncomingMessage(
- sender = sender,
- subtitle = "",
- content = message.data.asPayloadText(),
- channel = MessageChannel.BROADCAST
- )
- }
-
- "forward" -> {
- val sourceKey = message.key.orEmpty()
- addIncomingMessage(
- sender = "私聊消息",
- subtitle = sourceKey.takeIf { it.isNotBlank() }?.let { "来自 ${summarizeKey(it)}" }.orEmpty(),
- content = message.data.asPayloadText(),
- channel = MessageChannel.PRIVATE
- )
- }
-
- else -> addSystemMessage("收到未识别消息类型:${message.type}")
- }
- }
-
- private fun handleSocketClosed(code: Int, reason: String) {
- cancelHelloTimeout()
- cancelAuthTimeout()
-
- if (manualClose) {
- return
- }
- if (reconnectJob?.isActive == true) {
- return
- }
-
- val currentStatus = _uiState.value.status
-
- if (currentStatus == ConnectionStatus.READY) {
- addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}")
- scheduleReconnect("连接已中断")
- return
- }
-
- val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY
-
- if (allowFallback) {
- val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl)
- if (fallbackUrl.isNotBlank()) {
- fallbackTried = true
- connectedUrl = fallbackUrl
- _uiState.update {
- it.copy(
- status = ConnectionStatus.CONNECTING,
- statusHint = "正在自动重试连接...",
- serverUrl = fallbackUrl
- )
- }
- addSystemMessage("连接方式切换中,正在重试")
- socketClient.connect(fallbackUrl, socketListener)
- return
- }
- }
-
- _uiState.update {
- it.copy(
- status = ConnectionStatus.ERROR,
- statusHint = "连接已中断,请检查网络或服务器地址"
- )
- }
- addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}")
- }
-
- private fun addSystemMessage(content: String) {
- val message = UiMessage(
- role = MessageRole.SYSTEM,
- sender = "系统",
- subtitle = "",
- content = content,
- channel = MessageChannel.BROADCAST
- )
- appendMessage(message)
- scheduleSystemMessageExpiry(message.id)
- }
-
- private fun addIncomingMessage(
- sender: String,
- subtitle: String,
- content: String,
- channel: MessageChannel
- ) {
- appendMessage(
- UiMessage(
- role = MessageRole.INCOMING,
- sender = sender,
- subtitle = subtitle,
- content = content,
- channel = channel
- )
- )
- }
-
- private fun addOutgoingMessage(
- content: String,
- subtitle: String,
- channel: MessageChannel
- ) {
- appendMessage(
- UiMessage(
- role = MessageRole.OUTGOING,
- sender = "我",
- subtitle = subtitle,
- content = content,
- channel = channel
- )
- )
- }
-
- private fun appendMessage(message: UiMessage) {
- _uiState.update { current ->
- val next = (current.messages + message).takeLast(MAX_MESSAGES)
- val aliveIds = next.asSequence().map { it.id }.toSet()
- val removedIds = systemMessageExpiryJobs.keys.filterNot { it in aliveIds }
- removedIds.forEach { id ->
- systemMessageExpiryJobs.remove(id)?.cancel()
- }
- current.copy(messages = next)
- }
- }
-
- private fun cancelAuthTimeout() {
- authTimeoutJob?.cancel()
- authTimeoutJob = null
- }
-
- private fun scheduleReconnect(reason: String) {
- if (manualClose) return
- if (reconnectJob?.isActive == true) return
- if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
- _uiState.update {
- it.copy(
- status = ConnectionStatus.ERROR,
- statusHint = "重连失败,请手动重试"
- )
- }
- addSystemMessage("自动重连已停止:超过最大重试次数")
- return
- }
-
- reconnectAttempt += 1
- val delaySeconds = minOf(30, 1 shl (reconnectAttempt - 1))
- val total = MAX_RECONNECT_ATTEMPTS
- addSystemMessage("$reason,${delaySeconds}s 后自动重连($reconnectAttempt/$total)")
- _uiState.update {
- it.copy(
- status = ConnectionStatus.ERROR,
- statusHint = "${delaySeconds}s 后自动重连($reconnectAttempt/$total)"
- )
- }
-
- reconnectJob = viewModelScope.launch {
- delay(delaySeconds * 1000L)
- if (manualClose) return@launch
-
- val target = ServerUrlFormatter.normalize(connectedUrl).ifBlank {
- ServerUrlFormatter.normalize(_uiState.value.serverUrl)
- }
- if (target.isBlank()) {
- _uiState.update {
- it.copy(
- status = ConnectionStatus.ERROR,
- statusHint = "重连失败:服务器地址无效"
- )
- }
- return@launch
- }
-
- fallbackTried = false
- serverPublicKey = ""
- connectedUrl = target
- cancelHelloTimeout()
- cancelAuthTimeout()
- _uiState.update {
- it.copy(
- status = ConnectionStatus.CONNECTING,
- statusHint = "正在自动重连..."
- )
- }
- socketClient.connect(target, socketListener)
- }
- }
-
- private fun cancelReconnect() {
- reconnectJob?.cancel()
- reconnectJob = null
- }
-
- private fun scheduleSystemMessageExpiry(messageId: String) {
- systemMessageExpiryJobs.remove(messageId)?.cancel()
- systemMessageExpiryJobs[messageId] = viewModelScope.launch {
- delay(SYSTEM_MESSAGE_TTL_MS)
- _uiState.update { current ->
- val filtered = current.messages.filterNot { it.id == messageId }
- current.copy(messages = filtered)
- }
- systemMessageExpiryJobs.remove(messageId)
- }
- }
-
- private fun cancelSystemMessageExpiryJobs() {
- systemMessageExpiryJobs.values.forEach { it.cancel() }
- systemMessageExpiryJobs.clear()
- }
-
- private fun startHelloTimeout() {
- cancelHelloTimeout()
- helloTimeoutJob = viewModelScope.launch {
- delay(HELLO_TIMEOUT_MS)
- if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
- val currentUrl = connectedUrl.ifBlank { "unknown" }
- _uiState.update {
- it.copy(
- status = ConnectionStatus.ERROR,
- statusHint = "握手超时,请检查地址路径与反向代理"
- )
- }
- addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl)")
- socketClient.close(1000, "hello_timeout")
- }
- }
- }
-
- private fun cancelHelloTimeout() {
- helloTimeoutJob?.cancel()
- helloTimeoutJob = null
- }
-
- private fun summarizeKey(key: String): String {
- if (key.length <= 16) return key
- return "${key.take(8)}...${key.takeLast(8)}"
- }
-
- private fun createGuestName(): String {
- val rand = (100000..999999).random()
- return "guest-$rand"
- }
-
- private fun extractJsonCandidate(rawText: String): String {
- val trimmed = rawText.trim()
- if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
- return trimmed
- }
-
- val start = rawText.indexOf('{')
- val end = rawText.lastIndexOf('}')
- return if (start in 0 until end) {
- rawText.substring(start, end + 1)
- } else {
- rawText
- }
- }
-
- override fun onCleared() {
- super.onCleared()
- cancelSystemMessageExpiryJobs()
- cancelReconnect()
- cancelHelloTimeout()
- cancelAuthTimeout()
- socketClient.shutdown()
- }
-
- private companion object {
- const val HELLO_TIMEOUT_MS = 12_000L
- const val AUTH_TIMEOUT_MS = 20_000L
- const val MAX_MESSAGES = 500
- const val MAX_RECONNECT_ATTEMPTS = 5
- const val SYSTEM_MESSAGE_TTL_MS = 1_000L
- }
+ ChatSessionManager.initialize(application)
+ }
+
+ val uiState = ChatSessionManager.uiState
+ val events = ChatSessionManager.events
+
+ fun updateDisplayName(value: String) = ChatSessionManager.updateDisplayName(value)
+ fun updateServerUrl(value: String) = ChatSessionManager.updateServerUrl(value)
+ fun updateTargetKey(value: String) = ChatSessionManager.updateTargetKey(value)
+ fun updateDraft(value: String) = ChatSessionManager.updateDraft(value)
+ fun toggleDirectMode(enabled: Boolean) = ChatSessionManager.toggleDirectMode(enabled)
+ fun toggleShowSystemMessages(show: Boolean) = ChatSessionManager.toggleShowSystemMessages(show)
+ fun clearMessages() = ChatSessionManager.clearMessages()
+ fun saveCurrentServerUrl() = ChatSessionManager.saveCurrentServerUrl()
+ fun removeCurrentServerUrl() = ChatSessionManager.removeCurrentServerUrl()
+ fun revealMyPublicKey() = ChatSessionManager.revealMyPublicKey()
+ fun connect() = ChatSessionManager.connect()
+ fun disconnect() = ChatSessionManager.disconnect()
+ fun sendMessage() = ChatSessionManager.sendMessage()
+ fun onMessageCopied() = ChatSessionManager.onMessageCopied()
}