增加界面主题选择

pull/2/head
minxiwan 2 weeks ago
parent 69bc905300
commit 865dafc898

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

@ -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 {

@ -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<Boolean> = booleanPreferencesKey("show_system_messages")
val KEY_DIRECT_MODE: Preferences.Key<Boolean> = booleanPreferencesKey("direct_mode")
val KEY_SHOULD_AUTO_RECONNECT: Preferences.Key<Boolean> = booleanPreferencesKey("should_auto_reconnect")
val KEY_THEME_ID: Preferences.Key<String> = stringPreferencesKey("theme_id")
val KEY_USE_DYNAMIC_COLOR: Preferences.Key<Boolean> = booleanPreferencesKey("use_dynamic_color")
}
}

@ -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,6 +801,11 @@ private fun SettingsTab(
}
}
/**
* 将时间戳格式化为本地时间的小时:分钟 "14:30"
* @param tsMillis 毫秒时间戳
* @return 格式化后的时间字符串
*/
private fun formatTime(tsMillis: Long): String {
val formatter = DateTimeFormatter.ofPattern("HH:mm")
return Instant.ofEpochMilli(tsMillis)

@ -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<UiEvent>()
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<String, Job> = 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<String, Job> = 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<JsonElement>(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<EnvelopeDto>(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,12 +1126,17 @@ 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

@ -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<UiMessage>
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
}

@ -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)
}

@ -2,6 +2,7 @@ package com.onlinemsg.client.ui.theme
import androidx.compose.ui.graphics.Color
// 应用的主色调、表面色、错误色等定义
val Primary = Color(0xFF0C6D62)
val OnPrimary = Color(0xFFFFFFFF)
val Secondary = Color(0xFF4A635F)

@ -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))
)

@ -2,4 +2,8 @@ package com.onlinemsg.client.ui.theme
import androidx.compose.material3.Typography
/**
* 应用程序的默认排版样式
* 使用 Material3 默认排版可根据需要自定义
*/
val AppTypography = Typography()
Loading…
Cancel
Save