From 4ea64e8f8aac4ba36bfbcab20ac44b992672d7c9 Mon Sep 17 00:00:00 2001 From: alimu Date: Thu, 5 Mar 2026 23:37:12 +0400 Subject: [PATCH] Sync from upstream (squashed) --- android-client/app/build.gradle.kts | 16 + .../app/src/main/AndroidManifest.xml | 8 + .../java/com/onlinemsg/client/MainActivity.kt | 24 + .../data/network/OnlineMsgSocketClient.kt | 2 + .../preferences/UserPreferencesRepository.kt | 13 +- .../client/service/ChatForegroundService.kt | 164 +++ .../com/onlinemsg/client/ui/ChatScreen.kt | 148 ++- .../onlinemsg/client/ui/ChatSessionManager.kt | 943 ++++++++++++++++++ .../com/onlinemsg/client/ui/ChatViewModel.kt | 849 +--------------- 9 files changed, 1305 insertions(+), 862 deletions(-) create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/service/ChatForegroundService.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt 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() }