diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts
index a769b8f..320a3f3 100644
--- a/android-client/app/build.gradle.kts
+++ b/android-client/app/build.gradle.kts
@@ -13,7 +13,7 @@ android {
minSdk = 26
targetSdk = 34
versionCode = 1
- versionName = "1.0.0"
+ versionName = "1.0.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
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 2ad6213..e039044 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
@@ -21,7 +21,9 @@ data class UserPreferences(
val currentServerUrl: String,
val showSystemMessages: Boolean,
val directMode: Boolean,
- val shouldAutoReconnect: Boolean
+ val shouldAutoReconnect: Boolean,
+ val themeId: String = "default",
+ val useDynamicColor: Boolean = true
)
class UserPreferencesRepository(
@@ -44,10 +46,25 @@ class UserPreferencesRepository(
currentServerUrl = currentServer,
showSystemMessages = prefs[KEY_SHOW_SYSTEM_MESSAGES] ?: false,
directMode = prefs[KEY_DIRECT_MODE] ?: false,
- shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false
+ shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false,
+ themeId = prefs[KEY_THEME_ID] ?: "default",
+ useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true
)
}
+ suspend fun setThemeId(themeId: String) {
+ context.dataStore.edit { prefs ->
+ prefs[KEY_THEME_ID] = themeId
+ }
+ }
+
+
+ suspend fun setUseDynamicColor(enabled: Boolean) {
+ context.dataStore.edit { prefs ->
+ prefs[KEY_USE_DYNAMIC_COLOR] = enabled
+ }
+ }
+
suspend fun setDisplayName(name: String) {
context.dataStore.edit { prefs ->
prefs[KEY_DISPLAY_NAME] = name.trim().take(64)
@@ -128,5 +145,7 @@ class UserPreferencesRepository(
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")
+ val KEY_THEME_ID: Preferences.Key = stringPreferencesKey("theme_id")
+ val KEY_USE_DYNAMIC_COLOR: Preferences.Key = booleanPreferencesKey("use_dynamic_color")
}
}
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 714d1e2..e641d90 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
@@ -65,26 +65,45 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.compose.foundation.isSystemInDarkTheme
+import android.os.Build
import com.onlinemsg.client.ui.theme.OnlineMsgTheme
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
+import com.onlinemsg.client.ui.theme.themeOptions
+import com.onlinemsg.client.ui.theme.ThemeOption
+
+/**
+ * 主界面底部导航栏的选项卡枚举。
+ * @property label 选项卡显示的文本
+ */
private enum class MainTab(val label: String) {
CHAT("聊天"),
SETTINGS("设置")
}
+/**
+ * 应用程序的根可组合函数。
+ * 集成 ViewModel、主题、Scaffold 以及选项卡切换逻辑。
+ * @param viewModel 由 [viewModel] 自动提供的 [ChatViewModel] 实例
+ */
@Composable
-fun OnlineMsgApp(
- viewModel: ChatViewModel = viewModel()
-) {
- OnlineMsgTheme {
+fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ OnlineMsgTheme(
+ darkTheme = isSystemInDarkTheme(), // 仍可跟随系统
+ themeId = state.themeId,
+ useDynamicColor = state.useDynamicColor
+ )
+ {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current
val snackbarHostState = remember { SnackbarHostState() }
var tab by rememberSaveable { mutableStateOf(MainTab.CHAT) }
+ // 监听 ViewModel 发送的 UI 事件(如 Snackbar 消息)
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
@@ -164,7 +183,9 @@ fun OnlineMsgApp(
},
onConnect = viewModel::connect,
onDisconnect = viewModel::disconnect,
- onClearMessages = viewModel::clearMessages
+ onClearMessages = viewModel::clearMessages,
+ onThemeChange = viewModel::updateTheme,
+ onUseDynamicColorChange = viewModel::updateUseDynamicColor
)
}
}
@@ -172,6 +193,11 @@ fun OnlineMsgApp(
}
}
+/**
+ * 应用程序顶部栏,显示标题和当前连接状态徽章。
+ * @param statusText 状态文本(如“已连接”)
+ * @param statusColor 状态指示点的颜色
+ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppTopBar(
@@ -203,6 +229,20 @@ private fun AppTopBar(
)
}
+/**
+ * 聊天选项卡的主界面。
+ * 包含连接控制、模式切换、消息列表和输入区域。
+ * @param modifier 修饰符
+ * @param state 当前的 UI 状态
+ * @param onToggleDirectMode 切换广播/私聊模式
+ * @param onTargetKeyChange 私聊目标公钥改变
+ * @param onDraftChange 草稿消息改变
+ * @param onSend 发送消息
+ * @param onConnect 连接服务器
+ * @param onDisconnect 断开连接
+ * @param onClearMessages 清空所有消息
+ * @param onCopyMessage 复制消息内容
+ */
@Composable
private fun ChatTab(
modifier: Modifier,
@@ -218,6 +258,7 @@ private fun ChatTab(
) {
val listState = rememberLazyListState()
+ // 当消息列表新增消息时,自动滚动到底部
LaunchedEffect(state.visibleMessages.size) {
if (state.visibleMessages.isNotEmpty()) {
listState.animateScrollToItem(state.visibleMessages.lastIndex)
@@ -230,6 +271,7 @@ private fun ChatTab(
.imePadding()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
+ // 连接状态卡片
ConnectionRow(
statusHint = state.statusHint,
canConnect = state.canConnect,
@@ -241,6 +283,7 @@ private fun ChatTab(
Spacer(modifier = Modifier.height(8.dp))
+ // 广播/私聊模式切换按钮
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FilterChip(
selected = !state.directMode,
@@ -254,6 +297,7 @@ private fun ChatTab(
)
}
+ // 私聊模式下显示目标公钥输入框
if (state.directMode) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
@@ -268,6 +312,7 @@ private fun ChatTab(
Spacer(modifier = Modifier.height(8.dp))
+ // 消息列表
LazyColumn(
modifier = Modifier
.weight(1f)
@@ -277,6 +322,7 @@ private fun ChatTab(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (state.visibleMessages.isEmpty()) {
+ // 无消息时显示提示卡片
item {
Card(
colors = CardDefaults.cardColors(
@@ -302,6 +348,7 @@ private fun ChatTab(
Spacer(modifier = Modifier.height(8.dp))
+ // 消息输入区域
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -331,6 +378,15 @@ private fun ChatTab(
}
}
+/**
+ * 连接状态控制卡片,显示当前状态提示并提供连接/断开/清空按钮。
+ * @param statusHint 详细的状态提示文本
+ * @param canConnect 是否可连接
+ * @param canDisconnect 是否可断开
+ * @param onConnect 连接回调
+ * @param onDisconnect 断开回调
+ * @param onClearMessages 清空消息回调
+ */
@Composable
private fun ConnectionRow(
statusHint: String,
@@ -369,6 +425,12 @@ private fun ConnectionRow(
}
}
+/**
+ * 单个消息气泡组件。
+ * 根据消息角色(系统、发出、接收)显示不同的样式。
+ * @param message 要显示的消息数据
+ * @param onCopy 复制消息内容的回调
+ */
@Composable
private fun MessageItem(
message: UiMessage,
@@ -381,6 +443,7 @@ private fun MessageItem(
maxWidth * 0.82f
}
+ // 系统消息居中显示
if (message.role == MessageRole.SYSTEM) {
Card(
modifier = Modifier
@@ -451,6 +514,7 @@ private fun MessageItem(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
+ // 接收消息时显示发送者信息
if (!isOutgoing) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -471,12 +535,14 @@ private fun MessageItem(
}
}
+ // 消息正文
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = bubbleTextColor
)
+ // 时间戳和复制按钮
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
@@ -505,6 +571,22 @@ private fun MessageItem(
}
}
+/**
+ * 设置选项卡界面,包含个人设置、服务器管理、身份安全和诊断信息。
+ * @param modifier 修饰符
+ * @param state 当前的 UI 状态
+ * @param onDisplayNameChange 显示名称变更
+ * @param onServerUrlChange 服务器地址变更
+ * @param onSaveServer 保存当前服务器地址
+ * @param onRemoveServer 删除当前服务器地址
+ * @param onSelectServer 选择历史服务器地址
+ * @param onToggleShowSystem 切换显示系统消息
+ * @param onRevealPublicKey 显示/生成公钥
+ * @param onCopyPublicKey 复制公钥
+ * @param onConnect 连接服务器
+ * @param onDisconnect 断开连接
+ * @param onClearMessages 清空消息
+ */
@Composable
private fun SettingsTab(
modifier: Modifier,
@@ -519,14 +601,17 @@ private fun SettingsTab(
onCopyPublicKey: () -> Unit,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
- onClearMessages: () -> Unit
+ onClearMessages: () -> Unit,
+ onThemeChange: (String) -> Unit,
+ onUseDynamicColorChange: (Boolean) -> Unit
) {
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
+ // 个人设置卡片
item {
Card {
Column(
@@ -557,6 +642,7 @@ private fun SettingsTab(
}
}
+ // 服务器管理卡片
item {
Card {
Column(
@@ -602,6 +688,7 @@ private fun SettingsTab(
}
}
+ // 身份与安全卡片
item {
Card {
Column(
@@ -636,6 +723,54 @@ private fun SettingsTab(
}
}
+ // 主题设置卡片
+ item {
+ Card {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text("主题", style = MaterialTheme.typography.titleMedium)
+
+ // 动态颜色开关(仅 Android 12+ 显示)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Switch(
+ checked = state.useDynamicColor,
+ onCheckedChange = onUseDynamicColorChange
+ )
+ Text("使用动态颜色(跟随系统)")
+ }
+ }
+
+ // 当动态颜色关闭时,显示预设主题选择
+ if (!state.useDynamicColor || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ Text("预设主题", style = MaterialTheme.typography.labelLarge)
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(themeOptions) { option ->
+ FilterChip(
+ selected = state.themeId == option.id,
+ onClick = { onThemeChange(option.id) },
+ label = { Text(option.name) },
+ leadingIcon = {
+ Box(
+ modifier = Modifier
+ .size(16.dp)
+ .background(option.primary, RoundedCornerShape(4.dp))
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 诊断信息卡片
item {
Card {
Column(
@@ -666,10 +801,15 @@ private fun SettingsTab(
}
}
+/**
+ * 将时间戳格式化为本地时间的小时:分钟(如 "14:30")。
+ * @param tsMillis 毫秒时间戳
+ * @return 格式化后的时间字符串
+ */
private fun formatTime(tsMillis: Long): String {
val formatter = DateTimeFormatter.ofPattern("HH:mm")
return Instant.ofEpochMilli(tsMillis)
.atZone(ZoneId.systemDefault())
.toLocalTime()
.format(formatter)
-}
+}
\ No newline at end of file
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
index 0ae56f0..f8ce61d 100644
--- 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
@@ -47,6 +47,10 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
+/**
+ * 单例管理类,负责整个聊天会话的生命周期、网络连接、消息收发、状态维护和持久化。
+ * 所有公开方法均通过 ViewModel 代理调用,内部使用协程处理异步操作。
+ */
object ChatSessionManager {
private val json = Json {
@@ -60,29 +64,34 @@ object ChatSessionManager {
private val socketClient = OnlineMsgSocketClient()
private var initialized = false
+ // 状态流,供 UI 层订阅
private val _uiState = MutableStateFlow(ChatUiState())
val uiState = _uiState.asStateFlow()
+ // 事件流(如 Snackbar 消息)
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 manualClose = false // 是否为手动断开
+ private var fallbackTried = false // 是否已尝试切换 ws/wss
+ 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 keepAliveRequested = false // 是否应保活(前台服务标志)
private var notificationIdSeed = 2000
+ // WebSocket 事件监听器
private val socketListener = object : OnlineMsgSocketClient.Listener {
override fun onOpen() {
scope.launch {
@@ -151,6 +160,10 @@ object ChatSessionManager {
}
}
+ /**
+ * 初始化管理器,必须在应用启动时调用一次。
+ * @param application Application 实例
+ */
@Synchronized
fun initialize(application: Application) {
if (initialized) return
@@ -169,9 +182,12 @@ object ChatSessionManager {
serverUrls = pref.serverUrls,
serverUrl = pref.currentServerUrl,
directMode = pref.directMode,
- showSystemMessages = pref.showSystemMessages
+ showSystemMessages = pref.showSystemMessages,
+ themeId = pref.themeId, // 假设 preferences 中有 themeId
+ useDynamicColor = pref.useDynamicColor
)
}
+ // 如果上次会话启用了自动重连,则自动恢复连接
if (pref.shouldAutoReconnect && !autoReconnectTriggered) {
autoReconnectTriggered = true
ChatForegroundService.start(application)
@@ -180,6 +196,32 @@ object ChatSessionManager {
}
}
+ /**
+ * 更新主题
+ * @param themeId 主题名
+ */
+ fun updateTheme(themeId: String) {
+ _uiState.update { it.copy(themeId = themeId) }
+ scope.launch {
+ preferencesRepository.setThemeId(themeId)
+ }
+ }
+
+ /**
+ * 更改使用动态颜色
+ * @param enabled 主题名
+ */
+ fun updateUseDynamicColor(enabled: Boolean) {
+ _uiState.update { it.copy(useDynamicColor = enabled) }
+ scope.launch {
+ preferencesRepository.setUseDynamicColor(enabled)
+ }
+ }
+
+ /**
+ * 更新显示名称并持久化。
+ * @param value 新名称(自动截断至 64 字符)
+ */
fun updateDisplayName(value: String) {
val displayName = value.take(64)
_uiState.update { it.copy(displayName = displayName) }
@@ -188,18 +230,34 @@ object ChatSessionManager {
}
}
+ /**
+ * 更新当前输入的服务器地址(不持久化)。
+ * @param value 新地址
+ */
fun updateServerUrl(value: String) {
_uiState.update { it.copy(serverUrl = value) }
}
+ /**
+ * 更新私聊目标公钥。
+ * @param value 公钥字符串
+ */
fun updateTargetKey(value: String) {
_uiState.update { it.copy(targetKey = value) }
}
+ /**
+ * 更新消息草稿。
+ * @param value 草稿内容
+ */
fun updateDraft(value: String) {
_uiState.update { it.copy(draft = value) }
}
+ /**
+ * 切换广播/私聊模式并持久化。
+ * @param enabled true 为私聊模式
+ */
fun toggleDirectMode(enabled: Boolean) {
_uiState.update { it.copy(directMode = enabled) }
scope.launch {
@@ -207,6 +265,10 @@ object ChatSessionManager {
}
}
+ /**
+ * 切换是否显示系统消息并持久化。
+ * @param show true 显示
+ */
fun toggleShowSystemMessages(show: Boolean) {
_uiState.update { it.copy(showSystemMessages = show) }
scope.launch {
@@ -214,11 +276,17 @@ object ChatSessionManager {
}
}
+ /**
+ * 清空所有消息,并取消系统消息的过期任务。
+ */
fun clearMessages() {
cancelSystemMessageExpiryJobs()
_uiState.update { it.copy(messages = emptyList()) }
}
+ /**
+ * 保存当前服务器地址到历史列表并持久化。
+ */
fun saveCurrentServerUrl() {
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
if (normalized.isBlank()) {
@@ -243,6 +311,10 @@ object ChatSessionManager {
}
}
+ /**
+ * 从历史列表中移除当前服务器地址。
+ * 如果列表清空则恢复默认地址。
+ */
fun removeCurrentServerUrl() {
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
if (normalized.isBlank()) return
@@ -268,6 +340,9 @@ object ChatSessionManager {
}
}
+ /**
+ * 加载或生成本地身份密钥对,并将公钥显示到 UI。
+ */
fun revealMyPublicKey() {
scope.launch {
_uiState.update { it.copy(loadingPublicKey = true) }
@@ -287,10 +362,17 @@ object ChatSessionManager {
}
}
+ /**
+ * 主动连接服务器(由用户点击连接触发)。
+ */
fun connect() {
connectInternal(isAutoRestore = false)
}
+ /**
+ * 内部连接逻辑,区分自动恢复和手动连接。
+ * @param isAutoRestore 是否为应用启动时的自动恢复连接
+ */
private fun connectInternal(isAutoRestore: Boolean) {
if (!initialized) return
val state = _uiState.value
@@ -339,6 +421,10 @@ object ChatSessionManager {
}
}
+ /**
+ * 主动断开连接。
+ * @param stopService 是否同时停止前台服务(默认 true)
+ */
fun disconnect(stopService: Boolean = true) {
manualClose = true
cancelReconnect()
@@ -362,6 +448,10 @@ object ChatSessionManager {
addSystemMessage("已断开连接")
}
+ /**
+ * 发送消息(广播或私聊)。
+ * 执行签名、加密并发送。
+ */
fun sendMessage() {
val current = _uiState.value
if (!current.canSend) return
@@ -419,12 +509,19 @@ object ChatSessionManager {
}
}
+ /**
+ * 消息复制成功后的回调,显示“已复制”提示。
+ */
fun onMessageCopied() {
scope.launch {
_events.emit(UiEvent.ShowSnackbar("已复制"))
}
}
+ /**
+ * 确保本地身份已加载或创建。
+ * @return 本地身份对象
+ */
private suspend fun ensureIdentity(): RsaCryptoManager.Identity {
return identityMutex.withLock {
identity ?: withContext(Dispatchers.Default) {
@@ -435,6 +532,10 @@ object ChatSessionManager {
}
}
+ /**
+ * 处理收到的原始文本消息(可能是握手包或加密消息)。
+ * @param rawText 原始文本
+ */
private suspend fun handleIncomingMessage(rawText: String) {
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
_uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") }
@@ -445,7 +546,7 @@ object ChatSessionManager {
json.decodeFromString(normalizedText) as? JsonObject
}.getOrNull()
- // 兼容某些代理/中间层直接转发 hello data 对象(没有 envelope 外层)
+ // 尝试直接解析为 HelloDataDto(某些服务器可能直接发送,不带外层)
val directHello = rootObject?.let { obj ->
val hasPublicKey = obj["publicKey"] != null
val hasChallenge = obj["authChallenge"] != null
@@ -461,6 +562,7 @@ object ChatSessionManager {
return
}
+ // 尝试解析为带外层的 EnvelopeDto
val plain = runCatching { json.decodeFromString(normalizedText) }.getOrNull()
if (plain?.type == "publickey") {
cancelHelloTimeout()
@@ -480,6 +582,7 @@ object ChatSessionManager {
return
}
+ // 握手阶段收到非预期消息则报错
if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) {
_uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") }
addSystemMessage("握手阶段收到非预期消息类型:${plain.type}")
@@ -492,6 +595,7 @@ object ChatSessionManager {
addSystemMessage("握手包解析失败:$preview")
}
+ // 尝试解密(若已握手完成,收到的应是加密消息)
val id = ensureIdentity()
val decrypted = runCatching {
withContext(Dispatchers.Default) {
@@ -509,6 +613,10 @@ object ChatSessionManager {
handleSecureMessage(secure)
}
+ /**
+ * 处理服务端发来的握手 Hello 数据。
+ * @param hello 服务端公钥和挑战
+ */
private suspend fun handleServerHello(hello: HelloDataDto) {
cancelHelloTimeout()
serverPublicKey = hello.publicKey
@@ -552,6 +660,10 @@ object ChatSessionManager {
}
}
+ /**
+ * 发送认证消息(包含签名后的身份信息)。
+ * @param challenge 服务端提供的挑战值
+ */
private suspend fun sendAuth(challenge: String) {
val id = ensureIdentity()
val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() }
@@ -595,6 +707,10 @@ object ChatSessionManager {
check(socketClient.send(cipher)) { "连接不可用" }
}
+ /**
+ * 处理安全通道建立后的业务消息(广播、私聊、认证结果等)。
+ * @param message 解密后的 EnvelopeDto
+ */
private fun handleSecureMessage(message: EnvelopeDto) {
when (message.type) {
"auth_ok" -> {
@@ -634,6 +750,11 @@ object ChatSessionManager {
}
}
+ /**
+ * 处理 WebSocket 连接关闭事件。
+ * @param code 关闭状态码
+ * @param reason 关闭原因
+ */
private fun handleSocketClosed(code: Int, reason: String) {
cancelHelloTimeout()
cancelAuthTimeout()
@@ -652,6 +773,7 @@ object ChatSessionManager {
val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY
+ // 尝试切换 ws/wss 协议重试(仅限非就绪状态)
if (allowFallback) {
val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl)
if (fallbackUrl.isNotBlank()) {
@@ -680,6 +802,10 @@ object ChatSessionManager {
scheduleReconnect("连接已中断")
}
+ /**
+ * 添加一条系统消息(自动按 TTL 过期)。
+ * @param content 消息内容
+ */
private fun addSystemMessage(content: String) {
val message = UiMessage(
role = MessageRole.SYSTEM,
@@ -692,6 +818,13 @@ object ChatSessionManager {
scheduleSystemMessageExpiry(message.id)
}
+ /**
+ * 添加一条接收到的用户消息。
+ * @param sender 发送者名称
+ * @param subtitle 附加说明(如私聊来源)
+ * @param content 消息内容
+ * @param channel 消息通道(广播/私聊)
+ */
private fun addIncomingMessage(
sender: String,
subtitle: String,
@@ -713,6 +846,12 @@ object ChatSessionManager {
)
}
+ /**
+ * 添加一条发出的消息。
+ * @param content 消息内容
+ * @param subtitle 附加说明(如私聊目标)
+ * @param channel 消息通道
+ */
private fun addOutgoingMessage(
content: String,
subtitle: String,
@@ -729,6 +868,10 @@ object ChatSessionManager {
)
}
+ /**
+ * 将消息追加到列表尾部,并清理超出数量限制的消息。
+ * @param message 要追加的消息
+ */
private fun appendMessage(message: UiMessage) {
_uiState.update { current ->
val next = (current.messages + message).takeLast(MAX_MESSAGES)
@@ -741,11 +884,18 @@ object ChatSessionManager {
}
}
+ /**
+ * 取消认证超时任务。
+ */
private fun cancelAuthTimeout() {
authTimeoutJob?.cancel()
authTimeoutJob = null
}
+ /**
+ * 安排自动重连(指数退避)。
+ * @param reason 触发重连的原因
+ */
private fun scheduleReconnect(reason: String) {
if (manualClose) return
if (reconnectJob?.isActive == true) return
@@ -793,11 +943,18 @@ object ChatSessionManager {
}
}
+ /**
+ * 取消自动重连任务。
+ */
private fun cancelReconnect() {
reconnectJob?.cancel()
reconnectJob = null
}
+ /**
+ * 为系统消息安排过期自动删除。
+ * @param messageId 消息唯一 ID
+ */
private fun scheduleSystemMessageExpiry(messageId: String) {
systemMessageExpiryJobs.remove(messageId)?.cancel()
systemMessageExpiryJobs[messageId] = scope.launch {
@@ -810,11 +967,17 @@ object ChatSessionManager {
}
}
+ /**
+ * 取消所有系统消息的过期任务。
+ */
private fun cancelSystemMessageExpiryJobs() {
systemMessageExpiryJobs.values.forEach { it.cancel() }
systemMessageExpiryJobs.clear()
}
+ /**
+ * 启动握手超时计时器。
+ */
private fun startHelloTimeout() {
cancelHelloTimeout()
helloTimeoutJob = scope.launch {
@@ -833,21 +996,38 @@ object ChatSessionManager {
}
}
+ /**
+ * 取消握手超时任务。
+ */
private fun cancelHelloTimeout() {
helloTimeoutJob?.cancel()
helloTimeoutJob = null
}
+ /**
+ * 缩写显示公钥(取前后各8字符)。
+ * @param key 完整公钥
+ * @return 缩写字符串
+ */
private fun summarizeKey(key: String): String {
if (key.length <= 16) return key
return "${key.take(8)}...${key.takeLast(8)}"
}
+ /**
+ * 生成访客名称(如 guest-123456)。
+ * @return 随机名称
+ */
private fun createGuestName(): String {
val rand = (100000..999999).random()
return "guest-$rand"
}
+ /**
+ * 从可能包含前缀的原始文本中提取 JSON 对象部分。
+ * @param rawText 原始文本
+ * @return 最外层的 JSON 字符串
+ */
private fun extractJsonCandidate(rawText: String): String {
val trimmed = rawText.trim()
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
@@ -863,6 +1043,9 @@ object ChatSessionManager {
}
}
+ /**
+ * 关闭所有资源(用于应用退出时)。
+ */
fun shutdownAll() {
cancelSystemMessageExpiryJobs()
cancelReconnect()
@@ -871,6 +1054,9 @@ object ChatSessionManager {
socketClient.shutdown()
}
+ /**
+ * 前台服务停止时的回调。
+ */
fun onForegroundServiceStopped() {
keepAliveRequested = false
if (_uiState.value.status != ConnectionStatus.IDLE) {
@@ -882,8 +1068,15 @@ object ChatSessionManager {
}
}
+ /**
+ * 判断前台服务是否应该运行。
+ * @return true 表示应保持服务运行
+ */
fun shouldForegroundServiceRun(): Boolean = keepAliveRequested
+ /**
+ * 创建消息通知渠道(Android O+)。
+ */
private fun ensureMessageNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -897,6 +1090,11 @@ object ChatSessionManager {
manager.createNotificationChannel(channel)
}
+ /**
+ * 显示新消息到达的通知。
+ * @param title 通知标题
+ * @param body 通知正文
+ */
private fun showIncomingNotification(title: String, body: String) {
if (!initialized) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
@@ -928,16 +1126,21 @@ object ChatSessionManager {
NotificationManagerCompat.from(app).notify(nextMessageNotificationId(), notification)
}
+ /**
+ * 生成下一个通知 ID(线程安全递增)。
+ * @return 新的通知 ID
+ */
@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"
-}
+}
\ No newline at end of file
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt
index 52b4785..88ecb88 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt
@@ -2,26 +2,45 @@ package com.onlinemsg.client.ui
import java.util.UUID
+/**
+ * 连接状态枚举。
+ */
enum class ConnectionStatus {
- IDLE,
- CONNECTING,
- HANDSHAKING,
- AUTHENTICATING,
- READY,
- ERROR
+ IDLE, // 未连接
+ CONNECTING, // 连接中
+ HANDSHAKING, // 握手阶段
+ AUTHENTICATING, // 认证阶段
+ READY, // 已就绪
+ ERROR // 错误
}
+/**
+ * 消息角色。
+ */
enum class MessageRole {
- SYSTEM,
- INCOMING,
- OUTGOING
+ SYSTEM, // 系统消息
+ INCOMING, // 接收到的消息
+ OUTGOING // 发出的消息
}
+/**
+ * 消息通道(广播/私聊)。
+ */
enum class MessageChannel {
BROADCAST,
PRIVATE
}
+/**
+ * 单条消息的数据类。
+ * @property id 唯一标识(默认随机 UUID)
+ * @property role 消息角色
+ * @property sender 发送者显示名称
+ * @property subtitle 附加说明(如私聊来源/目标缩写)
+ * @property content 消息内容
+ * @property channel 消息通道
+ * @property timestampMillis 消息时间戳(毫秒)
+ */
data class UiMessage(
val id: String = UUID.randomUUID().toString(),
val role: MessageRole,
@@ -32,6 +51,23 @@ data class UiMessage(
val timestampMillis: Long = System.currentTimeMillis()
)
+/**
+ * 整个聊天界面的状态数据类。
+ * @property status 连接状态
+ * @property statusHint 详细状态提示文本
+ * @property displayName 用户显示名称
+ * @property serverUrls 已保存的服务器地址列表
+ * @property serverUrl 当前输入的服务器地址
+ * @property directMode 是否为私聊模式
+ * @property targetKey 私聊目标公钥
+ * @property draft 输入框草稿
+ * @property messages 所有消息列表
+ * @property showSystemMessages 是否显示系统消息
+ * @property certFingerprint 服务器证书指纹
+ * @property myPublicKey 本地公钥
+ * @property sending 是否正在发送消息(用于禁用按钮)
+ * @property loadingPublicKey 是否正在加载公钥
+ */
data class ChatUiState(
val status: ConnectionStatus = ConnectionStatus.IDLE,
val statusHint: String = "点击连接开始聊天",
@@ -46,20 +82,34 @@ data class ChatUiState(
val certFingerprint: String = "",
val myPublicKey: String = "",
val sending: Boolean = false,
- val loadingPublicKey: Boolean = false
+ val loadingPublicKey: Boolean = false,
+ val themeId: String = "default", /* 当前选中的主题名 (@emilia-t)*/
+ val useDynamicColor: Boolean = true /* 是否使用 Android 12+ 动态颜色 (@emilia-t)*/
) {
+ /**
+ * 是否允许连接。
+ */
val canConnect: Boolean
get() = status == ConnectionStatus.IDLE || status == ConnectionStatus.ERROR
+ /**
+ * 是否允许断开连接。
+ */
val canDisconnect: Boolean
get() = status == ConnectionStatus.CONNECTING ||
- status == ConnectionStatus.HANDSHAKING ||
- status == ConnectionStatus.AUTHENTICATING ||
- status == ConnectionStatus.READY
+ status == ConnectionStatus.HANDSHAKING ||
+ status == ConnectionStatus.AUTHENTICATING ||
+ status == ConnectionStatus.READY
+ /**
+ * 是否允许发送消息(就绪且草稿非空且不在发送中)。
+ */
val canSend: Boolean
get() = status == ConnectionStatus.READY && draft.trim().isNotEmpty() && !sending
+ /**
+ * 连接状态的简短文本描述。
+ */
val statusText: String
get() = when (status) {
ConnectionStatus.IDLE -> "未连接"
@@ -70,6 +120,9 @@ data class ChatUiState(
ConnectionStatus.ERROR -> "异常断开"
}
+ /**
+ * 根据当前模式(广播/私聊)和是否显示系统消息,过滤出要显示的消息列表。
+ */
val visibleMessages: List
get() = messages.filter { item ->
if (item.role == MessageRole.SYSTEM) {
@@ -81,6 +134,9 @@ data class ChatUiState(
}
}
+/**
+ * UI 事件接口,用于向界面发送一次性通知。
+ */
sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent
-}
+}
\ No newline at end of file
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 ee5b93c..d6b55ba 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
@@ -3,6 +3,11 @@ package com.onlinemsg.client.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
+/**
+ * ViewModel 层,作为 UI 与 [ChatSessionManager] 的桥梁。
+ * 初始化会话管理器并暴露其状态和事件流,同时提供所有用户操作的代理方法。
+ * @param application Application 实例
+ */
class ChatViewModel(application: Application) : AndroidViewModel(application) {
init {
@@ -26,4 +31,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun disconnect() = ChatSessionManager.disconnect()
fun sendMessage() = ChatSessionManager.sendMessage()
fun onMessageCopied() = ChatSessionManager.onMessageCopied()
-}
+
+ fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId)
+ fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled)
+}
\ No newline at end of file
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt
index 0a1e4b6..f73b859 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt
@@ -2,9 +2,10 @@ package com.onlinemsg.client.ui.theme
import androidx.compose.ui.graphics.Color
+// 应用的主色调、表面色、错误色等定义
val Primary = Color(0xFF0C6D62)
val OnPrimary = Color(0xFFFFFFFF)
val Secondary = Color(0xFF4A635F)
val Surface = Color(0xFFF7FAF8)
val SurfaceVariant = Color(0xFFDCE8E4)
-val Error = Color(0xFFB3261E)
+val Error = Color(0xFFB3261E)
\ No newline at end of file
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt
index df0d31d..2843bee 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt
@@ -9,6 +9,7 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.graphics.Color
private val LightColors = lightColorScheme(
primary = Primary,
@@ -25,20 +26,47 @@ private val DarkColors = darkColorScheme(
error = Error
)
+/**
+ * 应用主题可组合函数。
+ * 支持浅色/深色模式以及 Android 12+ 的动态颜色。
+ * @param darkTheme 是否强制深色模式(默认跟随系统)
+ * @param dynamicColor 是否启用动态颜色(默认 true)
+ * @param content 内部内容
+ */
@Composable
fun OnlineMsgTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
- dynamicColor: Boolean = true,
+ themeId: String = "default", // 从外部传入
+ useDynamicColor: Boolean = true, // 从外部传入
content: @Composable () -> Unit
) {
+ val context = LocalContext.current
val colorScheme = when {
- dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- val context = LocalContext.current
+ // 优先使用动态颜色(如果启用且系统支持)
+ useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
-
- darkTheme -> DarkColors
- else -> LightColors
+ // 否则根据主题 ID 选择预设
+ else -> {
+ val option = themeOptions.find { it.id == themeId } ?: themeOptions.first()
+ if (darkTheme) {
+ darkColorScheme(
+ primary = option.primary,
+ secondary = option.secondary,
+ error = option.error ?: Error,
+ surface = option.surface ?: Surface,
+ // 其他颜色可以沿用默认深色调整
+ )
+ } else {
+ lightColorScheme(
+ primary = option.primary,
+ secondary = option.secondary,
+ error = option.error ?: Error,
+ surface = option.surface ?: Surface,
+ // 其他颜色保持默认浅色
+ )
+ }
+ }
}
MaterialTheme(
@@ -47,3 +75,21 @@ fun OnlineMsgTheme(
content = content
)
}
+
+// 主题选项数据类
+data class ThemeOption(
+ val id: String,
+ val name: String,
+ val primary: Color,
+ val secondary: Color,
+ val surface: Color? = null,
+ val error: Color? = null
+)
+
+// 预设主题列表
+val themeOptions = listOf(
+ ThemeOption("default", "默认", Primary, Secondary),
+ ThemeOption("blue", "蔚蓝", Color(0xFF1E88E5), Color(0xFF6A8DAA)),
+ ThemeOption("green", "翠绿", Color(0xFF2E7D32), Color(0xFF4A635F)),
+ ThemeOption("red", "绯红", Color(0xFFC62828), Color(0xFF8D6E63))
+)
\ No newline at end of file
diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt
index cc0f902..6cd1077 100644
--- a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt
+++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt
@@ -2,4 +2,8 @@ package com.onlinemsg.client.ui.theme
import androidx.compose.material3.Typography
-val AppTypography = Typography()
+/**
+ * 应用程序的默认排版样式。
+ * 使用 Material3 默认排版,可根据需要自定义。
+ */
+val AppTypography = Typography()
\ No newline at end of file