Merge remote-tracking branch 'origin/emilia-t'

# Conflicts:
#	android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt
pull/2/head
alimu 2 weeks ago
commit ef58ef4b80

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

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$/android-client" />
<option name="gradleJvm" value="21" />
</GradleProjectSettings>
</option>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

@ -13,7 +13,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "1.0.0" versionName = "1.0.0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

@ -21,7 +21,10 @@ data class UserPreferences(
val currentServerUrl: String, val currentServerUrl: String,
val showSystemMessages: Boolean, val showSystemMessages: Boolean,
val directMode: Boolean, val directMode: Boolean,
val shouldAutoReconnect: Boolean val shouldAutoReconnect: Boolean,
val themeId: String = "blue",
val useDynamicColor: Boolean = true,
val language: String = "zh" // 默认中文
) )
class UserPreferencesRepository( class UserPreferencesRepository(
@ -44,10 +47,31 @@ class UserPreferencesRepository(
currentServerUrl = currentServer, currentServerUrl = currentServer,
showSystemMessages = prefs[KEY_SHOW_SYSTEM_MESSAGES] ?: false, 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 shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false,
themeId = prefs[KEY_THEME_ID] ?: "blue",
useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true,
language = prefs[KEY_LANGUAGE] ?: "zh"
) )
} }
suspend fun setThemeId(themeId: String) {
context.dataStore.edit { prefs ->
prefs[KEY_THEME_ID] = themeId
}
}
suspend fun setLanguage(language: String) {
context.dataStore.edit { prefs ->
prefs[KEY_LANGUAGE] = language
}
}
suspend fun setUseDynamicColor(enabled: Boolean) {
context.dataStore.edit { prefs ->
prefs[KEY_USE_DYNAMIC_COLOR] = enabled
}
}
suspend fun setDisplayName(name: String) { suspend fun setDisplayName(name: String) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[KEY_DISPLAY_NAME] = name.trim().take(64) prefs[KEY_DISPLAY_NAME] = name.trim().take(64)
@ -128,5 +152,8 @@ class UserPreferencesRepository(
val KEY_SHOW_SYSTEM_MESSAGES: Preferences.Key<Boolean> = booleanPreferencesKey("show_system_messages") val KEY_SHOW_SYSTEM_MESSAGES: Preferences.Key<Boolean> = booleanPreferencesKey("show_system_messages")
val KEY_DIRECT_MODE: Preferences.Key<Boolean> = booleanPreferencesKey("direct_mode") val KEY_DIRECT_MODE: Preferences.Key<Boolean> = booleanPreferencesKey("direct_mode")
val KEY_SHOULD_AUTO_RECONNECT: Preferences.Key<Boolean> = booleanPreferencesKey("should_auto_reconnect") 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")
val KEY_LANGUAGE: Preferences.Key<String> = stringPreferencesKey("language")
} }
} }

@ -1,10 +1,11 @@
package com.onlinemsg.client.ui package com.onlinemsg.client.ui
import android.annotation.SuppressLint
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
@ -29,6 +30,9 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.material.icons.automirrored.rounded.Send
import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Forum
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -49,6 +53,7 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -66,26 +71,47 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.isSystemInDarkTheme
import android.os.Build
import com.onlinemsg.client.ui.theme.OnlineMsgTheme import com.onlinemsg.client.ui.theme.OnlineMsgTheme
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import com.onlinemsg.client.ui.theme.themeOptions
import com.onlinemsg.client.util.LanguageManager
private enum class MainTab(val label: String) {
CHAT("聊天"), /**
SETTINGS("设置") * 主界面底部导航栏的选项卡枚举
*/
private enum class MainTab(val labelKey: String) {
CHAT("tab.chat"),
SETTINGS("tab.settings")
} }
/**
* 应用程序的根可组合函数
* 集成 ViewModel主题Scaffold 以及选项卡切换逻辑
* @param viewModel [viewModel] 自动提供的 [ChatViewModel] 实例
*/
@Composable @Composable
fun OnlineMsgApp( fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
viewModel: ChatViewModel = viewModel()
) {
OnlineMsgTheme {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
OnlineMsgTheme(
darkTheme = isSystemInDarkTheme(), // 仍可跟随系统
themeId = state.themeId,
useDynamicColor = state.useDynamicColor
)
{
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var tab by rememberSaveable { mutableStateOf(MainTab.CHAT) } var tab by rememberSaveable { mutableStateOf(MainTab.CHAT) }
// 定义翻译函数 t
fun t(key: String) = LanguageManager.getString(key, state.language)
// 监听 ViewModel 发送的 UI 事件(如 Snackbar 消息)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.events.collect { event -> viewModel.events.collect { event ->
when (event) { when (event) {
@ -110,18 +136,36 @@ fun OnlineMsgApp(
) )
}, },
bottomBar = { bottomBar = {
NavigationBar(modifier = Modifier.navigationBarsPadding()) { NavigationBar(
modifier = Modifier
.navigationBarsPadding()
.height(64.dp),
containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp
) {
NavigationBarItem( NavigationBarItem(
selected = tab == MainTab.CHAT, selected = tab == MainTab.CHAT,
onClick = { tab = MainTab.CHAT }, onClick = { tab = MainTab.CHAT },
label = { Text(MainTab.CHAT.label) }, label = { Text(t(MainTab.CHAT.labelKey), style = MaterialTheme.typography.labelSmall) },
icon = {} icon = {
Icon(
imageVector = Icons.Rounded.Forum,
contentDescription = null,
modifier = Modifier.size(22.dp)
)
}
) )
NavigationBarItem( NavigationBarItem(
selected = tab == MainTab.SETTINGS, selected = tab == MainTab.SETTINGS,
onClick = { tab = MainTab.SETTINGS }, onClick = { tab = MainTab.SETTINGS },
label = { Text(MainTab.SETTINGS.label) }, label = { Text(t(MainTab.SETTINGS.labelKey), style = MaterialTheme.typography.labelSmall) },
icon = {} icon = {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = null,
modifier = Modifier.size(22.dp)
)
}
) )
} }
}, },
@ -164,7 +208,10 @@ fun OnlineMsgApp(
viewModel.onMessageCopied() viewModel.onMessageCopied()
} }
}, },
onClearMessages = viewModel::clearMessages onClearMessages = viewModel::clearMessages,
onThemeChange = viewModel::updateTheme,
onUseDynamicColorChange = viewModel::updateUseDynamicColor,
onLanguageChange = viewModel::updateLanguage
) )
} }
} }
@ -172,6 +219,11 @@ fun OnlineMsgApp(
} }
} }
/**
* 应用程序顶部栏显示标题和当前连接状态徽章
* @param statusText 状态文本
* @param statusColor 状态指示点的颜色
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AppTopBar( private fun AppTopBar(
@ -186,8 +238,8 @@ private fun AppTopBar(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = "OnlineMsg Chat", text = "OnlineMsg",
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleMedium
) )
}, },
actions = { actions = {
@ -203,17 +255,26 @@ private fun AppTopBar(
leadingIcon = { leadingIcon = {
Box( Box(
modifier = Modifier modifier = Modifier
.width(10.dp) .size(8.dp)
.height(10.dp)
.background(statusColor, RoundedCornerShape(999.dp)) .background(statusColor, RoundedCornerShape(999.dp))
) )
} },
modifier = Modifier.height(32.dp)
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
} },
windowInsets = WindowInsets(top = 20.dp), // 顶部高度(状态栏)
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface
)
) )
} }
/**
* 聊天选项卡的主界面
* 包含模式切换消息列表和输入区域
*/
@Composable @Composable
private fun ChatTab( private fun ChatTab(
modifier: Modifier, modifier: Modifier,
@ -226,6 +287,10 @@ private fun ChatTab(
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
// 定义翻译函数 t
fun t(key: String) = LanguageManager.getString(key, state.language)
// 当消息列表新增消息时,自动滚动到底部
LaunchedEffect(state.visibleMessages.size) { LaunchedEffect(state.visibleMessages.size) {
if (state.visibleMessages.isNotEmpty()) { if (state.visibleMessages.isNotEmpty()) {
listState.animateScrollToItem(state.visibleMessages.lastIndex) listState.animateScrollToItem(state.visibleMessages.lastIndex)
@ -236,19 +301,26 @@ private fun ChatTab(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.imePadding() .imePadding()
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(start = 16.dp, end = 16.dp, bottom = 8.dp) // 移除了顶部多余 padding
) {
// 广播/私聊模式切换按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FilterChip( FilterChip(
selected = !state.directMode, selected = !state.directMode,
onClick = { onToggleDirectMode(false) }, onClick = { onToggleDirectMode(false) },
label = { Text("广播") } label = { Text(t("chat.broadcast")) }
) )
FilterChip( FilterChip(
selected = state.directMode, selected = state.directMode,
onClick = { onToggleDirectMode(true) }, onClick = { onToggleDirectMode(true) },
label = { Text("私聊") } label = { Text(t("chat.private")) }
) )
// 在这一行腾出的空间可以放置其他快捷操作,或者保持简洁
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -259,28 +331,30 @@ private fun ChatTab(
) )
if (state.directMode) { if (state.directMode) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField( OutlinedTextField(
value = state.targetKey, value = state.targetKey,
onValueChange = onTargetKeyChange, onValueChange = onTargetKeyChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text("目标公钥") }, label = { Text(t("chat.target_key")) },
placeholder = { Text("私聊模式:粘贴目标公钥") }, placeholder = { Text(t("chat.target_key")) },
maxLines = 3 maxLines = 3
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(4.dp))
// 消息列表
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
state = listState, state = listState,
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (state.visibleMessages.isEmpty()) { if (state.visibleMessages.isEmpty()) {
// 无消息时显示提示卡片
item { item {
Card( Card(
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@ -288,7 +362,7 @@ private fun ChatTab(
) )
) { ) {
Text( Text(
text = "连接后即可聊天。默认广播,切换到私聊后可填写目标公钥。", text = t("chat.empty_hint"),
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
@ -298,7 +372,8 @@ private fun ChatTab(
items(state.visibleMessages, key = { it.id }) { message -> items(state.visibleMessages, key = { it.id }) { message ->
MessageItem( MessageItem(
message = message, message = message,
onCopy = { onCopyMessage(message.content) } onCopy = { onCopyMessage(message.content) },
currentLanguage = state.language
) )
} }
} }
@ -306,6 +381,7 @@ private fun ChatTab(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// 消息输入区域
Row( Row(
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
@ -314,7 +390,7 @@ private fun ChatTab(
value = state.draft, value = state.draft,
onValueChange = onDraftChange, onValueChange = onDraftChange,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
label = { Text("输入消息") }, label = { Text(t("chat.input_placeholder")) },
maxLines = 4, maxLines = 4,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
@ -327,31 +403,36 @@ private fun ChatTab(
enabled = state.canSend, enabled = state.canSend,
modifier = Modifier.height(56.dp) modifier = Modifier.height(56.dp)
) { ) {
Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = "发送") Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = null)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text(if (state.sending) "发送中" else "发送") Text(if (state.sending) "..." else t("chat.send"))
} }
} }
} }
} }
/**
* 单个消息气泡组件
*/
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable @Composable
private fun MessageItem( private fun MessageItem(
message: UiMessage, message: UiMessage,
onCopy: () -> Unit onCopy: () -> Unit,
currentLanguage: String
) { ) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
val maxBubbleWidth = if (message.role == MessageRole.SYSTEM) { val isSystem = message.role == MessageRole.SYSTEM
maxWidth * 0.9f
} else {
maxWidth * 0.82f
}
if (message.role == MessageRole.SYSTEM) { if (isSystem) {
Card( // 系统消息居中显示,最大占比 90%
Box(
modifier = Modifier modifier = Modifier
.widthIn(max = maxBubbleWidth) .fillMaxWidth(0.9f)
.align(Alignment.Center), .align(Alignment.Center),
contentAlignment = Alignment.Center
) {
Card(
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer containerColor = MaterialTheme.colorScheme.secondaryContainer
@ -374,9 +455,8 @@ private fun MessageItem(
) )
} }
} }
return@BoxWithConstraints
} }
} else {
val isOutgoing = message.role == MessageRole.OUTGOING val isOutgoing = message.role == MessageRole.OUTGOING
val bubbleColor = if (isOutgoing) { val bubbleColor = if (isOutgoing) {
MaterialTheme.colorScheme.primaryContainer MaterialTheme.colorScheme.primaryContainer
@ -407,9 +487,13 @@ private fun MessageItem(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start
) {
// 使用 Box(fillMaxWidth(0.82f)) 限制气泡最大宽度占比
Box(
modifier = Modifier.fillMaxWidth(0.82f),
contentAlignment = if (isOutgoing) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
Card( Card(
modifier = Modifier.widthIn(max = maxBubbleWidth),
shape = bubbleShape, shape = bubbleShape,
colors = CardDefaults.cardColors(containerColor = bubbleColor) colors = CardDefaults.cardColors(containerColor = bubbleColor)
) { ) {
@ -437,12 +521,14 @@ private fun MessageItem(
} }
} }
// 消息正文
Text( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = bubbleTextColor color = bubbleTextColor
) )
// 时间戳和复制按钮
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@ -459,7 +545,7 @@ private fun MessageItem(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.ContentCopy, imageVector = Icons.Rounded.ContentCopy,
contentDescription = "复制", contentDescription = LanguageManager.getString("common.copied", currentLanguage),
tint = bubbleTextColor.copy(alpha = 0.7f), tint = bubbleTextColor.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
@ -470,7 +556,26 @@ 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 onClearMessages 清空消息
* @param onThemeChange 切换主题
* @param onUseDynamicColorChange 切换动态颜色
* @param onLanguageChange 切换语言
*/
@Composable @Composable
private fun SettingsTab( private fun SettingsTab(
modifier: Modifier, modifier: Modifier,
@ -483,8 +588,13 @@ private fun SettingsTab(
onToggleShowSystem: (Boolean) -> Unit, onToggleShowSystem: (Boolean) -> Unit,
onRevealPublicKey: () -> Unit, onRevealPublicKey: () -> Unit,
onCopyPublicKey: () -> Unit, onCopyPublicKey: () -> Unit,
onClearMessages: () -> Unit onClearMessages: () -> Unit,
onThemeChange: (String) -> Unit,
onUseDynamicColorChange: (Boolean) -> Unit,
onLanguageChange: (String) -> Unit
) { ) {
fun t(key: String) = LanguageManager.getString(key, state.language)
val settingsCardModifier = Modifier.fillMaxWidth() val settingsCardModifier = Modifier.fillMaxWidth()
val settingsCardContentModifier = Modifier val settingsCardContentModifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -504,13 +614,12 @@ private fun SettingsTab(
modifier = settingsCardContentModifier, modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing verticalArrangement = settingsCardContentSpacing
) { ) {
Text("个人设置", style = MaterialTheme.typography.titleMedium) Text(t("settings.personal"), style = MaterialTheme.typography.titleMedium)
OutlinedTextField( OutlinedTextField(
value = state.displayName, value = state.displayName,
onValueChange = onDisplayNameChange, onValueChange = onDisplayNameChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text("显示名称") }, label = { Text(t("settings.display_name")) },
supportingText = { Text("最长 64 字符") },
maxLines = 1 maxLines = 1
) )
} }
@ -525,7 +634,7 @@ private fun SettingsTab(
) { ) {
Text("聊天数据", style = MaterialTheme.typography.titleMedium) Text("聊天数据", style = MaterialTheme.typography.titleMedium)
OutlinedButton(onClick = onClearMessages) { OutlinedButton(onClick = onClearMessages) {
Text("清空聊天记录") Text(t("settings.clear_msg"))
} }
} }
} }
@ -537,37 +646,26 @@ private fun SettingsTab(
modifier = settingsCardContentModifier, modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing verticalArrangement = settingsCardContentSpacing
) { ) {
Text("服务器", style = MaterialTheme.typography.titleMedium) Text(t("settings.server"), style = MaterialTheme.typography.titleMedium)
OutlinedTextField( OutlinedTextField(
value = state.serverUrl, value = state.serverUrl,
onValueChange = onServerUrlChange, onValueChange = onServerUrlChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text("服务器地址") }, label = { Text(t("settings.server_url")) },
placeholder = { Text("ws://10.0.2.2:13173/") },
maxLines = 1 maxLines = 1
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onSaveServer) { Button(onClick = onSaveServer) { Text(t("settings.save_server")) }
Text("保存地址") OutlinedButton(onClick = onRemoveServer) { Text(t("settings.remove_current")) }
}
OutlinedButton(onClick = onRemoveServer) {
Text("删除当前")
}
} }
if (state.serverUrls.isNotEmpty()) { if (state.serverUrls.isNotEmpty()) {
HorizontalDivider() HorizontalDivider()
Text("已保存地址", style = MaterialTheme.typography.labelLarge) Text(t("settings.saved_servers"), style = MaterialTheme.typography.labelLarge)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(state.serverUrls) { url -> items(state.serverUrls) { url ->
AssistChip( AssistChip(
onClick = { onSelectServer(url) }, onClick = { onSelectServer(url) },
label = { label = { Text(url, maxLines = 1, overflow = TextOverflow.Ellipsis) }
Text(
text = url,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
) )
} }
} }
@ -582,19 +680,16 @@ private fun SettingsTab(
modifier = settingsCardContentModifier, modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing verticalArrangement = settingsCardContentSpacing
) { ) {
Text("身份与安全", style = MaterialTheme.typography.titleMedium) Text(t("settings.identity"), style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button( Button(onClick = onRevealPublicKey, enabled = !state.loadingPublicKey) {
onClick = onRevealPublicKey, Text(if (state.loadingPublicKey) "..." else t("settings.reveal_key"))
enabled = !state.loadingPublicKey
) {
Text(if (state.loadingPublicKey) "读取中" else "查看/生成公钥")
} }
OutlinedButton( OutlinedButton(
onClick = onCopyPublicKey, onClick = onCopyPublicKey,
enabled = state.myPublicKey.isNotBlank() enabled = state.myPublicKey.isNotBlank()
) { ) {
Text("复制公钥") Text(t("settings.copy_key"))
} }
} }
OutlinedTextField( OutlinedTextField(
@ -602,8 +697,7 @@ private fun SettingsTab(
onValueChange = {}, onValueChange = {},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
readOnly = true, readOnly = true,
label = { Text("我的公钥") }, label = { Text(t("settings.my_key")) },
placeholder = { Text("点击“查看/生成公钥”") },
maxLines = 4 maxLines = 4
) )
} }
@ -616,30 +710,106 @@ private fun SettingsTab(
modifier = settingsCardContentModifier, modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing verticalArrangement = settingsCardContentSpacing
) { ) {
Text("诊断", style = MaterialTheme.typography.titleMedium) Text(t("settings.language"), style = MaterialTheme.typography.titleMedium)
Text("连接提示:${state.statusHint}") LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("当前状态:${state.statusText}") items(LanguageManager.supportedLanguages) { lang ->
Text("证书指纹:${state.certFingerprint.ifBlank { "未获取" }}") FilterChip(
selected = state.language == lang.code,
onClick = { onLanguageChange(lang.code) },
label = { Text(lang.name) },
leadingIcon = {
Icon(
Icons.Rounded.Language,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
}
}
item {
Card(modifier = settingsCardModifier) {
Column(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
Text(t("settings.theme"), style = MaterialTheme.typography.titleMedium)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Switch( Switch(
checked = state.showSystemMessages, checked = state.useDynamicColor,
onCheckedChange = onToggleShowSystem onCheckedChange = onUseDynamicColorChange
)
Text(t("settings.dynamic_color"))
}
}
if (!state.useDynamicColor || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
Text(t("settings.preset_themes"), style = MaterialTheme.typography.labelLarge)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(themeOptions) { option ->
val themeName = when (option.id) {
"blue" -> t("theme.blue")
"gray" -> t("theme.gray")
"green" -> t("theme.green")
"red" -> t("theme.red")
else -> option.name
}
FilterChip(
selected = state.themeId == option.id,
onClick = { onThemeChange(option.id) },
label = { Text(themeName) },
leadingIcon = {
Box(
modifier = Modifier
.size(16.dp)
.background(option.primary, RoundedCornerShape(4.dp))
) )
Text("显示系统消息") }
)
}
}
} }
} }
} }
} }
item { item {
Spacer(modifier = Modifier.height(12.dp)) Card(modifier = settingsCardModifier) {
Column(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
Text(t("settings.diagnostics"), style = MaterialTheme.typography.titleMedium)
Text("${t("settings.status_hint")}${state.statusHint}")
Text("${t("settings.current_status")}${state.statusText}")
Text("${t("settings.cert_fingerprint")}${state.certFingerprint.ifBlank { "N/A" }}")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Switch(checked = state.showSystemMessages, onCheckedChange = onToggleShowSystem)
Text(t("settings.show_system"))
}
}
} }
} }
item { Spacer(modifier = Modifier.height(12.dp)) }
}
} }
/**
* 将时间戳格式化为本地时间的小时:分钟 "14:30"
* @param tsMillis 毫秒时间戳
* @return 格式化后的时间字符串
*/
private fun formatTime(tsMillis: Long): String { private fun formatTime(tsMillis: Long): String {
val formatter = DateTimeFormatter.ofPattern("HH:mm") val formatter = DateTimeFormatter.ofPattern("HH:mm")
return Instant.ofEpochMilli(tsMillis) return Instant.ofEpochMilli(tsMillis)

@ -47,6 +47,10 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
/**
* 单例管理类负责整个聊天会话的生命周期网络连接消息收发状态维护和持久化
* 所有公开方法均通过 ViewModel 代理调用内部使用协程处理异步操作
*/
object ChatSessionManager { object ChatSessionManager {
private val json = Json { private val json = Json {
@ -60,29 +64,34 @@ object ChatSessionManager {
private val socketClient = OnlineMsgSocketClient() private val socketClient = OnlineMsgSocketClient()
private var initialized = false private var initialized = false
// 状态流,供 UI 层订阅
private val _uiState = MutableStateFlow(ChatUiState()) private val _uiState = MutableStateFlow(ChatUiState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
// 事件流(如 Snackbar 消息)
private val _events = MutableSharedFlow<UiEvent>() private val _events = MutableSharedFlow<UiEvent>()
val events = _events.asSharedFlow() val events = _events.asSharedFlow()
// 用于线程安全地访问本地身份
private val identityMutex = Mutex() private val identityMutex = Mutex()
private var identity: RsaCryptoManager.Identity? = null private var identity: RsaCryptoManager.Identity? = null
private var manualClose = false // 连接相关内部状态
private var fallbackTried = false private var manualClose = false // 是否为手动断开
private var connectedUrl = "" private var fallbackTried = false // 是否已尝试切换 ws/wss
private var serverPublicKey = "" private var connectedUrl = "" // 当前连接的服务器地址
private var helloTimeoutJob: Job? = null private var serverPublicKey = "" // 服务端公钥(握手后获得)
private var authTimeoutJob: Job? = null private var helloTimeoutJob: Job? = null // 握手超时任务
private var reconnectJob: Job? = null private var authTimeoutJob: Job? = null // 认证超时任务
private var reconnectAttempt: Int = 0 private var reconnectJob: Job? = null // 自动重连任务
private val systemMessageExpiryJobs: MutableMap<String, Job> = mutableMapOf() private var reconnectAttempt: Int = 0 // 当前重连尝试次数
private val systemMessageExpiryJobs: MutableMap<String, Job> = mutableMapOf() // 系统消息自动过期任务
private var autoReconnectTriggered = false private var autoReconnectTriggered = false
@Volatile @Volatile
private var keepAliveRequested = false private var keepAliveRequested = false // 是否应保活(前台服务标志)
private var notificationIdSeed = 2000 private var notificationIdSeed = 2000
// WebSocket 事件监听器
private val socketListener = object : OnlineMsgSocketClient.Listener { private val socketListener = object : OnlineMsgSocketClient.Listener {
override fun onOpen() { override fun onOpen() {
scope.launch { scope.launch {
@ -151,6 +160,10 @@ object ChatSessionManager {
} }
} }
/**
* 初始化管理器必须在应用启动时调用一次
* @param application Application 实例
*/
@Synchronized @Synchronized
fun initialize(application: Application) { fun initialize(application: Application) {
if (initialized) return if (initialized) return
@ -169,9 +182,13 @@ object ChatSessionManager {
serverUrls = pref.serverUrls, serverUrls = pref.serverUrls,
serverUrl = pref.currentServerUrl, serverUrl = pref.currentServerUrl,
directMode = pref.directMode, directMode = pref.directMode,
showSystemMessages = pref.showSystemMessages showSystemMessages = pref.showSystemMessages,
themeId = pref.themeId,
useDynamicColor = pref.useDynamicColor,
language = pref.language
) )
} }
// 如果上次会话启用了自动重连,则自动恢复连接
if (pref.shouldAutoReconnect && !autoReconnectTriggered) { if (pref.shouldAutoReconnect && !autoReconnectTriggered) {
autoReconnectTriggered = true autoReconnectTriggered = true
ChatForegroundService.start(application) ChatForegroundService.start(application)
@ -180,6 +197,43 @@ object ChatSessionManager {
} }
} }
/**
* 更新主题
* @param themeId 主题名
*/
fun updateTheme(themeId: String) {
_uiState.update { it.copy(themeId = themeId) }
scope.launch {
preferencesRepository.setThemeId(themeId)
}
}
/**
* 更新语言
* @param language 语言代码
*/
fun updateLanguage(language: String) {
_uiState.update { it.copy(language = language) }
scope.launch {
preferencesRepository.setLanguage(language)
}
}
/**
* 更改使用动态颜色
* @param enabled 主题名
*/
fun updateUseDynamicColor(enabled: Boolean) {
_uiState.update { it.copy(useDynamicColor = enabled) }
scope.launch {
preferencesRepository.setUseDynamicColor(enabled)
}
}
/**
* 更新显示名称并持久化
* @param value 新名称自动截断至 64 字符
*/
fun updateDisplayName(value: String) { fun updateDisplayName(value: String) {
val displayName = value.take(64) val displayName = value.take(64)
_uiState.update { it.copy(displayName = displayName) } _uiState.update { it.copy(displayName = displayName) }
@ -188,18 +242,34 @@ object ChatSessionManager {
} }
} }
/**
* 更新当前输入的服务器地址不持久化
* @param value 新地址
*/
fun updateServerUrl(value: String) { fun updateServerUrl(value: String) {
_uiState.update { it.copy(serverUrl = value) } _uiState.update { it.copy(serverUrl = value) }
} }
/**
* 更新私聊目标公钥
* @param value 公钥字符串
*/
fun updateTargetKey(value: String) { fun updateTargetKey(value: String) {
_uiState.update { it.copy(targetKey = value) } _uiState.update { it.copy(targetKey = value) }
} }
/**
* 更新消息草稿
* @param value 草稿内容
*/
fun updateDraft(value: String) { fun updateDraft(value: String) {
_uiState.update { it.copy(draft = value) } _uiState.update { it.copy(draft = value) }
} }
/**
* 切换广播/私聊模式并持久化
* @param enabled true 为私聊模式
*/
fun toggleDirectMode(enabled: Boolean) { fun toggleDirectMode(enabled: Boolean) {
_uiState.update { it.copy(directMode = enabled) } _uiState.update { it.copy(directMode = enabled) }
scope.launch { scope.launch {
@ -207,6 +277,10 @@ object ChatSessionManager {
} }
} }
/**
* 切换是否显示系统消息并持久化
* @param show true 显示
*/
fun toggleShowSystemMessages(show: Boolean) { fun toggleShowSystemMessages(show: Boolean) {
_uiState.update { it.copy(showSystemMessages = show) } _uiState.update { it.copy(showSystemMessages = show) }
scope.launch { scope.launch {
@ -214,11 +288,17 @@ object ChatSessionManager {
} }
} }
/**
* 清空所有消息并取消系统消息的过期任务
*/
fun clearMessages() { fun clearMessages() {
cancelSystemMessageExpiryJobs() cancelSystemMessageExpiryJobs()
_uiState.update { it.copy(messages = emptyList()) } _uiState.update { it.copy(messages = emptyList()) }
} }
/**
* 保存当前服务器地址到历史列表并持久化
*/
fun saveCurrentServerUrl() { fun saveCurrentServerUrl() {
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
if (normalized.isBlank()) { if (normalized.isBlank()) {
@ -243,6 +323,10 @@ object ChatSessionManager {
} }
} }
/**
* 从历史列表中移除当前服务器地址
* 如果列表清空则恢复默认地址
*/
fun removeCurrentServerUrl() { fun removeCurrentServerUrl() {
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
if (normalized.isBlank()) return if (normalized.isBlank()) return
@ -268,6 +352,9 @@ object ChatSessionManager {
} }
} }
/**
* 加载或生成本地身份密钥对并将公钥显示到 UI
*/
fun revealMyPublicKey() { fun revealMyPublicKey() {
scope.launch { scope.launch {
_uiState.update { it.copy(loadingPublicKey = true) } _uiState.update { it.copy(loadingPublicKey = true) }
@ -287,10 +374,17 @@ object ChatSessionManager {
} }
} }
/**
* 主动连接服务器由用户点击连接触发
*/
fun connect() { fun connect() {
connectInternal(isAutoRestore = false) connectInternal(isAutoRestore = false)
} }
/**
* 内部连接逻辑区分自动恢复和手动连接
* @param isAutoRestore 是否为应用启动时的自动恢复连接
*/
private fun connectInternal(isAutoRestore: Boolean) { private fun connectInternal(isAutoRestore: Boolean) {
if (!initialized) return if (!initialized) return
val state = _uiState.value val state = _uiState.value
@ -339,6 +433,10 @@ object ChatSessionManager {
} }
} }
/**
* 主动断开连接
* @param stopService 是否同时停止前台服务默认 true
*/
fun disconnect(stopService: Boolean = true) { fun disconnect(stopService: Boolean = true) {
manualClose = true manualClose = true
cancelReconnect() cancelReconnect()
@ -362,6 +460,10 @@ object ChatSessionManager {
addSystemMessage("已断开连接") addSystemMessage("已断开连接")
} }
/**
* 发送消息广播或私聊
* 执行签名加密并发送
*/
fun sendMessage() { fun sendMessage() {
val current = _uiState.value val current = _uiState.value
if (!current.canSend) return if (!current.canSend) return
@ -419,12 +521,19 @@ object ChatSessionManager {
} }
} }
/**
* 消息复制成功后的回调显示已复制提示
*/
fun onMessageCopied() { fun onMessageCopied() {
scope.launch { scope.launch {
_events.emit(UiEvent.ShowSnackbar("已复制")) _events.emit(UiEvent.ShowSnackbar("已复制"))
} }
} }
/**
* 确保本地身份已加载 or 创建
* @return 本地身份对象
*/
private suspend fun ensureIdentity(): RsaCryptoManager.Identity { private suspend fun ensureIdentity(): RsaCryptoManager.Identity {
return identityMutex.withLock { return identityMutex.withLock {
identity ?: withContext(Dispatchers.Default) { identity ?: withContext(Dispatchers.Default) {
@ -435,6 +544,10 @@ object ChatSessionManager {
} }
} }
/**
* 处理收到的原始文本消息可能是握手包 or 加密消息
* @param rawText 原始文本
*/
private suspend fun handleIncomingMessage(rawText: String) { private suspend fun handleIncomingMessage(rawText: String) {
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
_uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") } _uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") }
@ -445,7 +558,7 @@ object ChatSessionManager {
json.decodeFromString<JsonElement>(normalizedText) as? JsonObject json.decodeFromString<JsonElement>(normalizedText) as? JsonObject
}.getOrNull() }.getOrNull()
// 兼容某些代理/中间层直接转发 hello data 对象(没有 envelope 外层) // 尝试直接解析为 HelloDataDto某些服务器可能直接发送不带外层)
val directHello = rootObject?.let { obj -> val directHello = rootObject?.let { obj ->
val hasPublicKey = obj["publicKey"] != null val hasPublicKey = obj["publicKey"] != null
val hasChallenge = obj["authChallenge"] != null val hasChallenge = obj["authChallenge"] != null
@ -461,6 +574,7 @@ object ChatSessionManager {
return return
} }
// 尝试解析为带外层的 EnvelopeDto
val plain = runCatching { json.decodeFromString<EnvelopeDto>(normalizedText) }.getOrNull() val plain = runCatching { json.decodeFromString<EnvelopeDto>(normalizedText) }.getOrNull()
if (plain?.type == "publickey") { if (plain?.type == "publickey") {
cancelHelloTimeout() cancelHelloTimeout()
@ -480,6 +594,7 @@ object ChatSessionManager {
return return
} }
// 握手阶段收到非预期消息则报错
if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) { if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) {
_uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") } _uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") }
addSystemMessage("握手阶段收到非预期消息类型:${plain.type}") addSystemMessage("握手阶段收到非预期消息类型:${plain.type}")
@ -492,6 +607,7 @@ object ChatSessionManager {
addSystemMessage("握手包解析失败:$preview") addSystemMessage("握手包解析失败:$preview")
} }
// 尝试解密(若已握手完成,收到的应是加密消息)
val id = ensureIdentity() val id = ensureIdentity()
val decrypted = runCatching { val decrypted = runCatching {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@ -509,6 +625,10 @@ object ChatSessionManager {
handleSecureMessage(secure) handleSecureMessage(secure)
} }
/**
* 处理服务端发来的握手 Hello 数据
* @param hello 服务端公钥和挑战
*/
private suspend fun handleServerHello(hello: HelloDataDto) { private suspend fun handleServerHello(hello: HelloDataDto) {
cancelHelloTimeout() cancelHelloTimeout()
serverPublicKey = hello.publicKey serverPublicKey = hello.publicKey
@ -552,6 +672,10 @@ object ChatSessionManager {
} }
} }
/**
* 发送认证消息包含签名后的身份信息
* @param challenge 服务端提供的挑战值
*/
private suspend fun sendAuth(challenge: String) { private suspend fun sendAuth(challenge: String) {
val id = ensureIdentity() val id = ensureIdentity()
val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() } val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() }
@ -595,6 +719,10 @@ object ChatSessionManager {
check(socketClient.send(cipher)) { "连接不可用" } check(socketClient.send(cipher)) { "连接不可用" }
} }
/**
* 处理安全通道建立后的业务消息广播私聊认证结果等
* @param message 解密后的 EnvelopeDto
*/
private fun handleSecureMessage(message: EnvelopeDto) { private fun handleSecureMessage(message: EnvelopeDto) {
when (message.type) { when (message.type) {
"auth_ok" -> { "auth_ok" -> {
@ -634,6 +762,11 @@ object ChatSessionManager {
} }
} }
/**
* 处理 WebSocket 连接关闭事件
* @param code 关闭状态码
* @param reason 关闭原因
*/
private fun handleSocketClosed(code: Int, reason: String) { private fun handleSocketClosed(code: Int, reason: String) {
cancelHelloTimeout() cancelHelloTimeout()
cancelAuthTimeout() cancelAuthTimeout()
@ -652,6 +785,7 @@ object ChatSessionManager {
val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY
// 尝试切换 ws/wss 协议重试(仅限非就绪状态)
if (allowFallback) { if (allowFallback) {
val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl) val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl)
if (fallbackUrl.isNotBlank()) { if (fallbackUrl.isNotBlank()) {
@ -680,6 +814,10 @@ object ChatSessionManager {
scheduleReconnect("连接已中断") scheduleReconnect("连接已中断")
} }
/**
* 添加一条系统消息自动按 TTL 过期
* @param content 消息内容
*/
private fun addSystemMessage(content: String) { private fun addSystemMessage(content: String) {
val message = UiMessage( val message = UiMessage(
role = MessageRole.SYSTEM, role = MessageRole.SYSTEM,
@ -692,6 +830,13 @@ object ChatSessionManager {
scheduleSystemMessageExpiry(message.id) scheduleSystemMessageExpiry(message.id)
} }
/**
* 添加一条接收到的用户消息
* @param sender 发送者名称
* @param subtitle 附加说明如私聊来源
* @param content 消息内容
* @param channel 消息通道广播/私聊
*/
private fun addIncomingMessage( private fun addIncomingMessage(
sender: String, sender: String,
subtitle: String, subtitle: String,
@ -713,6 +858,12 @@ object ChatSessionManager {
) )
} }
/**
* 添加一条发出的消息
* @param content 消息内容
* @param subtitle 附加说明如私聊目标
* @param channel 消息通道
*/
private fun addOutgoingMessage( private fun addOutgoingMessage(
content: String, content: String,
subtitle: String, subtitle: String,
@ -729,6 +880,10 @@ object ChatSessionManager {
) )
} }
/**
* 将消息追加到列表尾部并清理超出数量限制的消息
* @param message 要追加的消息
*/
private fun appendMessage(message: UiMessage) { private fun appendMessage(message: UiMessage) {
_uiState.update { current -> _uiState.update { current ->
val next = (current.messages + message).takeLast(MAX_MESSAGES) val next = (current.messages + message).takeLast(MAX_MESSAGES)
@ -741,11 +896,18 @@ object ChatSessionManager {
} }
} }
/**
* 取消认证超时任务
*/
private fun cancelAuthTimeout() { private fun cancelAuthTimeout() {
authTimeoutJob?.cancel() authTimeoutJob?.cancel()
authTimeoutJob = null authTimeoutJob = null
} }
/**
* 安排自动重连指数退避
* @param reason 触发重连的原因
*/
private fun scheduleReconnect(reason: String) { private fun scheduleReconnect(reason: String) {
if (manualClose) return if (manualClose) return
if (reconnectJob?.isActive == true) return if (reconnectJob?.isActive == true) return
@ -793,11 +955,18 @@ object ChatSessionManager {
} }
} }
/**
* 取消自动重连任务
*/
private fun cancelReconnect() { private fun cancelReconnect() {
reconnectJob?.cancel() reconnectJob?.cancel()
reconnectJob = null reconnectJob = null
} }
/**
* 为系统消息安排过期自动删除
* @param messageId 消息唯一 ID
*/
private fun scheduleSystemMessageExpiry(messageId: String) { private fun scheduleSystemMessageExpiry(messageId: String) {
systemMessageExpiryJobs.remove(messageId)?.cancel() systemMessageExpiryJobs.remove(messageId)?.cancel()
systemMessageExpiryJobs[messageId] = scope.launch { systemMessageExpiryJobs[messageId] = scope.launch {
@ -810,11 +979,17 @@ object ChatSessionManager {
} }
} }
/**
* 取消所有系统消息的过期任务
*/
private fun cancelSystemMessageExpiryJobs() { private fun cancelSystemMessageExpiryJobs() {
systemMessageExpiryJobs.values.forEach { it.cancel() } systemMessageExpiryJobs.values.forEach { it.cancel() }
systemMessageExpiryJobs.clear() systemMessageExpiryJobs.clear()
} }
/**
* 启动握手超时计时器
*/
private fun startHelloTimeout() { private fun startHelloTimeout() {
cancelHelloTimeout() cancelHelloTimeout()
helloTimeoutJob = scope.launch { helloTimeoutJob = scope.launch {
@ -833,21 +1008,38 @@ object ChatSessionManager {
} }
} }
/**
* 取消握手超时任务
*/
private fun cancelHelloTimeout() { private fun cancelHelloTimeout() {
helloTimeoutJob?.cancel() helloTimeoutJob?.cancel()
helloTimeoutJob = null helloTimeoutJob = null
} }
/**
* 缩写显示公钥取前后各8字符
* @param key 完整公钥
* @return 缩写字符串
*/
private fun summarizeKey(key: String): String { private fun summarizeKey(key: String): String {
if (key.length <= 16) return key if (key.length <= 16) return key
return "${key.take(8)}...${key.takeLast(8)}" return "${key.take(8)}...${key.takeLast(8)}"
} }
/**
* 生成访客名称 guest-123456
* @return 随机名称
*/
private fun createGuestName(): String { private fun createGuestName(): String {
val rand = (100000..999999).random() val rand = (100000..999999).random()
return "guest-$rand" return "guest-$rand"
} }
/**
* 从可能包含前缀的原始文本中提取 JSON 对象部分
* @param rawText 原始文本
* @return 最外层的 JSON 字符串
*/
private fun extractJsonCandidate(rawText: String): String { private fun extractJsonCandidate(rawText: String): String {
val trimmed = rawText.trim() val trimmed = rawText.trim()
if (trimmed.startsWith("{") && trimmed.endsWith("}")) { if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
@ -863,6 +1055,9 @@ object ChatSessionManager {
} }
} }
/**
* 关闭所有资源用于应用退出时
*/
fun shutdownAll() { fun shutdownAll() {
cancelSystemMessageExpiryJobs() cancelSystemMessageExpiryJobs()
cancelReconnect() cancelReconnect()
@ -871,6 +1066,9 @@ object ChatSessionManager {
socketClient.shutdown() socketClient.shutdown()
} }
/**
* 前台服务停止时的回调
*/
fun onForegroundServiceStopped() { fun onForegroundServiceStopped() {
keepAliveRequested = false keepAliveRequested = false
if (_uiState.value.status != ConnectionStatus.IDLE) { if (_uiState.value.status != ConnectionStatus.IDLE) {
@ -882,8 +1080,15 @@ object ChatSessionManager {
} }
} }
/**
* 判断前台服务是否应该运行
* @return true 表示应保持服务运行
*/
fun shouldForegroundServiceRun(): Boolean = keepAliveRequested fun shouldForegroundServiceRun(): Boolean = keepAliveRequested
/**
* 创建消息通知渠道Android O+
*/
private fun ensureMessageNotificationChannel() { private fun ensureMessageNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@ -897,6 +1102,11 @@ object ChatSessionManager {
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
/**
* 显示新消息到达的通知
* @param title 通知标题
* @param body 通知正文
*/
private fun showIncomingNotification(title: String, body: String) { private fun showIncomingNotification(title: String, body: String) {
if (!initialized) return if (!initialized) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
@ -928,12 +1138,17 @@ object ChatSessionManager {
NotificationManagerCompat.from(app).notify(nextMessageNotificationId(), notification) NotificationManagerCompat.from(app).notify(nextMessageNotificationId(), notification)
} }
/**
* 生成下一个通知 ID线程安全递增
* @return 新的通知 ID
*/
@Synchronized @Synchronized
private fun nextMessageNotificationId(): Int { private fun nextMessageNotificationId(): Int {
notificationIdSeed += 1 notificationIdSeed += 1
return notificationIdSeed return notificationIdSeed
} }
// 常量定义
private const val HELLO_TIMEOUT_MS = 12_000L private const val HELLO_TIMEOUT_MS = 12_000L
private const val AUTH_TIMEOUT_MS = 20_000L private const val AUTH_TIMEOUT_MS = 20_000L
private const val MAX_MESSAGES = 500 private const val MAX_MESSAGES = 500

@ -2,26 +2,45 @@ package com.onlinemsg.client.ui
import java.util.UUID import java.util.UUID
/**
* 连接状态枚举
*/
enum class ConnectionStatus { enum class ConnectionStatus {
IDLE, IDLE, // 未连接
CONNECTING, CONNECTING, // 连接中
HANDSHAKING, HANDSHAKING, // 握手阶段
AUTHENTICATING, AUTHENTICATING, // 认证阶段
READY, READY, // 已就绪
ERROR ERROR // 错误
} }
/**
* 消息角色
*/
enum class MessageRole { enum class MessageRole {
SYSTEM, SYSTEM, // 系统消息
INCOMING, INCOMING, // 接收到的消息
OUTGOING OUTGOING // 发出的消息
} }
/**
* 消息通道广播/私聊
*/
enum class MessageChannel { enum class MessageChannel {
BROADCAST, BROADCAST,
PRIVATE PRIVATE
} }
/**
* 单条消息的数据类
* @property id 唯一标识默认随机 UUID
* @property role 消息角色
* @property sender 发送者显示名称
* @property subtitle 附加说明如私聊来源/目标缩写
* @property content 消息内容
* @property channel 消息通道
* @property timestampMillis 消息时间戳毫秒
*/
data class UiMessage( data class UiMessage(
val id: String = UUID.randomUUID().toString(), val id: String = UUID.randomUUID().toString(),
val role: MessageRole, val role: MessageRole,
@ -32,6 +51,24 @@ data class UiMessage(
val timestampMillis: Long = System.currentTimeMillis() 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 是否正在加载公钥
* @property language 当前选择的语言代码 ( "zh", "en", "ja")
*/
data class ChatUiState( data class ChatUiState(
val status: ConnectionStatus = ConnectionStatus.IDLE, val status: ConnectionStatus = ConnectionStatus.IDLE,
val statusHint: String = "点击连接开始聊天", val statusHint: String = "点击连接开始聊天",
@ -46,20 +83,35 @@ data class ChatUiState(
val certFingerprint: String = "", val certFingerprint: String = "",
val myPublicKey: String = "", val myPublicKey: String = "",
val sending: Boolean = false, val sending: Boolean = false,
val loadingPublicKey: Boolean = false val loadingPublicKey: Boolean = false,
val themeId: String = "blue",
val useDynamicColor: Boolean = true,
val language: String = "zh"
) { ) {
/**
* 是否允许连接
*/
val canConnect: Boolean val canConnect: Boolean
get() = status == ConnectionStatus.IDLE || status == ConnectionStatus.ERROR get() = status == ConnectionStatus.IDLE || status == ConnectionStatus.ERROR
/**
* 是否允许断开连接
*/
val canDisconnect: Boolean val canDisconnect: Boolean
get() = status == ConnectionStatus.CONNECTING || get() = status == ConnectionStatus.CONNECTING ||
status == ConnectionStatus.HANDSHAKING || status == ConnectionStatus.HANDSHAKING ||
status == ConnectionStatus.AUTHENTICATING || status == ConnectionStatus.AUTHENTICATING ||
status == ConnectionStatus.READY status == ConnectionStatus.READY
/**
* 是否允许发送消息就绪且草稿非空且不在发送中
*/
val canSend: Boolean val canSend: Boolean
get() = status == ConnectionStatus.READY && draft.trim().isNotEmpty() && !sending get() = status == ConnectionStatus.READY && draft.trim().isNotEmpty() && !sending
/**
* 连接状态的简短文本描述
*/
val statusText: String val statusText: String
get() = when (status) { get() = when (status) {
ConnectionStatus.IDLE -> "未连接" ConnectionStatus.IDLE -> "未连接"
@ -70,6 +122,9 @@ data class ChatUiState(
ConnectionStatus.ERROR -> "异常断开" ConnectionStatus.ERROR -> "异常断开"
} }
/**
* 根据当前模式广播/私聊和是否显示系统消息过滤出要显示的消息列表
*/
val visibleMessages: List<UiMessage> val visibleMessages: List<UiMessage>
get() = messages.filter { item -> get() = messages.filter { item ->
if (item.role == MessageRole.SYSTEM) { if (item.role == MessageRole.SYSTEM) {
@ -81,6 +136,9 @@ data class ChatUiState(
} }
} }
/**
* UI 事件接口用于向界面发送一次性通知
*/
sealed interface UiEvent { sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent data class ShowSnackbar(val message: String) : UiEvent
} }

@ -3,6 +3,11 @@ package com.onlinemsg.client.ui
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
/**
* ViewModel 作为 UI [ChatSessionManager] 的桥梁
* 初始化会话管理器并暴露其状态 and 事件流同时提供所有用户操作的代理方法
* @param application Application 实例
*/
class ChatViewModel(application: Application) : AndroidViewModel(application) { class ChatViewModel(application: Application) : AndroidViewModel(application) {
init { init {
@ -26,4 +31,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun disconnect() = ChatSessionManager.disconnect() fun disconnect() = ChatSessionManager.disconnect()
fun sendMessage() = ChatSessionManager.sendMessage() fun sendMessage() = ChatSessionManager.sendMessage()
fun onMessageCopied() = ChatSessionManager.onMessageCopied() fun onMessageCopied() = ChatSessionManager.onMessageCopied()
fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId)
fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled)
fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language)
} }

@ -2,9 +2,16 @@ package com.onlinemsg.client.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Primary = Color(0xFF0C6D62) // 浅色模式 - 温暖色调定义
val Primary = Color(0xFFD84315) // 深橙色 800
val OnPrimary = Color(0xFFFFFFFF) val OnPrimary = Color(0xFFFFFFFF)
val Secondary = Color(0xFF4A635F) val Secondary = Color(0xFF6D4C41) // 棕色 600
val Surface = Color(0xFFF7FAF8) val Surface = Color(0xFFFFFBF0) // 温暖的奶油白
val SurfaceVariant = Color(0xFFDCE8E4) val SurfaceVariant = Color(0xFFFBE9E7) // 极浅橙色
val Error = Color(0xFFB3261E) val Error = Color(0xFFB00020)
// 深色模式 - 温暖色调定义
val DarkPrimary = Color(0xFFFFB5A0) // 浅暖橙
val DarkSecondary = Color(0xFFD7C1B1) // 浅棕
val DarkSurface = Color(0xFF1D1B16) // 暖深褐色(解决深色模式下背景过亮问题)
val OnDarkSurface = Color(0xFFEDE0DD)

@ -9,6 +9,7 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
private val LightColors = lightColorScheme( private val LightColors = lightColorScheme(
primary = Primary, primary = Primary,
@ -20,25 +21,54 @@ private val LightColors = lightColorScheme(
) )
private val DarkColors = darkColorScheme( private val DarkColors = darkColorScheme(
primary = Primary.copy(alpha = 0.9f), primary = DarkPrimary,
secondary = Secondary.copy(alpha = 0.9f), onPrimary = Color.Black,
secondary = DarkSecondary,
surface = DarkSurface,
onSurface = OnDarkSurface,
error = Error error = Error
) )
/**
* 应用主题可组合函数
* 支持浅色/深色模式以及 Android 12+ 的动态颜色
* @param darkTheme 是否强制深色模式默认跟随系统
* @param themeId 当前选中的主题 ID (默认为 "blue")
* @param useDynamicColor 是否启用动态颜色Android 12+ 支持
*/
@Composable @Composable
fun OnlineMsgTheme( fun OnlineMsgTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, themeId: String = "blue", // 默认预设设为 blue
useDynamicColor: Boolean = true,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current val context = LocalContext.current
val colorScheme = when {
// 1. 优先使用动态颜色(如果启用且系统支持)
useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
// 2. 根据 themeId 选择预设
darkTheme -> DarkColors else -> {
else -> LightColors val option = themeOptions.find { it.id == themeId } ?: themeOptions.first()
if (darkTheme) {
darkColorScheme(
primary = option.primaryDark ?: option.primary.copy(alpha = 0.8f),
secondary = option.secondary.copy(alpha = 0.8f),
surface = option.surfaceDark ?: DarkSurface,
onSurface = OnDarkSurface,
error = option.error ?: Error
)
} else {
lightColorScheme(
primary = option.primary,
secondary = option.secondary,
surface = option.surface ?: Surface,
error = option.error ?: Error
)
}
}
} }
MaterialTheme( MaterialTheme(
@ -47,3 +77,46 @@ fun OnlineMsgTheme(
content = content content = content
) )
} }
// 主题选项数据类
data class ThemeOption(
val id: String,
val name: String,
val primary: Color,
val secondary: Color,
val surface: Color? = null,
val primaryDark: Color? = null, // 显式深色主色
val surfaceDark: Color? = null, // 显式深色背景
val error: Color? = null
)
// 预设主题列表
val themeOptions = listOf(
// 默认列表首位即为默认主题
ThemeOption(
id = "blue",
name = "蔚蓝",
primary = Color(0xFF1E88E5),
secondary = Color(0xFF6A8DAA)
),
ThemeOption(
id = "gray",
name = "商务灰",
primary = Color(0xFF607D8B),
secondary = Color(0xFF90A4AE),
primaryDark = Color(0xFFCFD8DC),
surfaceDark = Color(0xFF263238)
),
ThemeOption(
id = "green",
name = "翠绿",
primary = Color(0xFF2E7D32),
secondary = Color(0xFF4A635F)
),
ThemeOption(
id = "red",
name = "绯红",
primary = Color(0xFFC62828),
secondary = Color(0xFF8D6E63)
)
)

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

@ -0,0 +1,188 @@
package com.onlinemsg.client.util
/**
* 语言管理类统一存储应用内的多语言词条
* 类似于 Minecraft 的语言文件映射
*/
object LanguageManager {
private val translations = mapOf(
"zh" to mapOf(
"tab.chat" to "聊天",
"tab.settings" to "设置",
"settings.personal" to "个人设置",
"settings.display_name" to "显示名称",
"settings.server" to "服务器",
"settings.server_url" to "服务器地址",
"settings.save_server" to "保存地址",
"settings.remove_current" to "删除当前",
"settings.saved_servers" to "已保存地址",
"settings.identity" to "身份与安全",
"settings.reveal_key" to "查看/生成公钥",
"settings.copy_key" to "复制公钥",
"settings.my_key" to "我的公钥",
"settings.theme" to "主题",
"settings.preset_themes" to "预设主题",
"settings.language" to "语言",
"settings.diagnostics" to "诊断",
"settings.status_hint" to "连接提示",
"settings.current_status" to "当前状态",
"settings.cert_fingerprint" to "证书指纹",
"settings.show_system" to "显示系统消息",
"settings.connect" to "连接",
"settings.disconnect" to "断开",
"settings.clear_msg" to "清空消息",
"settings.dynamic_color" to "使用动态颜色",
"chat.broadcast" to "广播",
"chat.private" to "私聊",
"chat.target_key" to "目标公钥",
"chat.input_placeholder" to "输入消息",
"chat.send" to "发送",
"chat.sending" to "发送中",
"chat.empty_hint" to "连接后即可聊天。默认广播,切换到私聊后可填写目标公钥。",
"common.copied" to "已复制",
"common.unknown" to "未知",
"theme.blue" to "蔚蓝",
"theme.gray" to "商务灰",
"theme.green" to "翠绿",
"theme.red" to "绯红",
"theme.warm" to "温暖"
),
"en" to mapOf(
"tab.chat" to "Chat",
"tab.settings" to "Settings",
"settings.personal" to "Personal",
"settings.display_name" to "Display Name",
"settings.server" to "Server",
"settings.server_url" to "Server Address",
"settings.save_server" to "Save",
"settings.remove_current" to "Remove",
"settings.saved_servers" to "Saved Addresses",
"settings.identity" to "Identity & Security",
"settings.reveal_key" to "Reveal/Generate Key",
"settings.copy_key" to "Copy Key",
"settings.my_key" to "My Public Key",
"settings.theme" to "Theme",
"settings.preset_themes" to "Preset Themes",
"settings.language" to "Language",
"settings.diagnostics" to "Diagnostics",
"settings.status_hint" to "Hint",
"settings.current_status" to "Status",
"settings.cert_fingerprint" to "Fingerprint",
"settings.show_system" to "Show System Messages",
"settings.connect" to "Link",
"settings.disconnect" to "Dislink",
"settings.clear_msg" to "ClearMsg",
"settings.dynamic_color" to "Use dynamic color",
"chat.broadcast" to "Broadcast",
"chat.private" to "Private",
"chat.target_key" to "Target Public Key",
"chat.input_placeholder" to "Type a message",
"chat.send" to "Send",
"chat.sending" to "Sending",
"chat.empty_hint" to "Connect to start chatting. Default is broadcast.",
"common.copied" to "Copied",
"common.unknown" to "Unknown",
"theme.blue" to "Blue",
"theme.gray" to "Business Gray",
"theme.green" to "Green",
"theme.red" to "Red",
"theme.warm" to "Warm"
),
"ja" to mapOf(
"tab.chat" to "チャット",
"tab.settings" to "設定",
"settings.personal" to "個人設定",
"settings.display_name" to "表示名",
"settings.server" to "サーバー",
"settings.server_url" to "アドレス",
"settings.save_server" to "保存",
"settings.remove_current" to "削除",
"settings.saved_servers" to "保存済みアドレス",
"settings.identity" to "セキュリティ",
"settings.reveal_key" to "公開鍵を表示/生成",
"settings.copy_key" to "鍵をコピー",
"settings.my_key" to "マイ公開鍵",
"settings.theme" to "テーマ",
"settings.preset_themes" to "プリセット",
"settings.language" to "言語",
"settings.diagnostics" to "診断",
"settings.status_hint" to "ヒント",
"settings.current_status" to "ステータス",
"settings.cert_fingerprint" to "証明書指紋",
"settings.show_system" to "システムメッセージを表示",
"settings.connect" to "接続",
"settings.disconnect" to "切断",
"settings.clear_msg" to "履歴を消去",
"settings.dynamic_color" to "動的カラーを使用",
"chat.broadcast" to "全体",
"chat.private" to "個人",
"chat.target_key" to "相手の公開鍵",
"chat.input_placeholder" to "メッセージを入力",
"chat.send" to "送信",
"chat.sending" to "送信中",
"chat.empty_hint" to "接続するとチャットを開始できます。",
"common.copied" to "コピーしました",
"common.unknown" to "不明",
"theme.blue" to "ブルー",
"theme.gray" to "ビジネスグレー",
"theme.green" to "グリーン",
"theme.red" to "レッド",
"theme.warm" to "ウォーム"
),
"ko" to mapOf(
"tab.chat" to "채팅",
"tab.settings" to "설정",
"settings.personal" to "개인 설정",
"settings.display_name" to "표시 이름",
"settings.server" to "서버",
"settings.server_url" to "서버 주소",
"settings.save_server" to "주소 저장",
"settings.remove_current" to "현재 삭제",
"settings.saved_servers" to "저장된 주소",
"settings.identity" to "신원 및 보안",
"settings.reveal_key" to "공개키 보기/생성",
"settings.copy_key" to "공개키 복사",
"settings.my_key" to "나의 공개키",
"settings.theme" to "테마",
"settings.preset_themes" to "프리셋 테마",
"settings.language" to "언어",
"settings.diagnostics" to "진단",
"settings.status_hint" to "연결 힌트",
"settings.current_status" to "현재 상태",
"settings.cert_fingerprint" to "인증서 지문",
"settings.show_system" to "시스템 메시지 표시",
"settings.connect" to "연결",
"settings.disconnect" to "연결 끊기",
"settings.clear_msg" to "정보 삭제",
"settings.dynamic_color" to "동적 색상 사용",
"chat.broadcast" to "브로드캐스트",
"chat.private" to "비공개 채팅",
"chat.target_key" to "대상 공개키",
"chat.input_placeholder" to "메시지 입력",
"chat.send" to "전송",
"chat.sending" to "전송 중",
"chat.empty_hint" to "연결 후 채팅이 가능합니다. 기본은 브로드캐스트이며, 비공개 채팅으로 전환 후 대상 공개키를 입력할 수 있습니다.",
"common.copied" to "복사됨",
"common.unknown" to "알 수 없음",
"theme.blue" to "파랑",
"theme.gray" to "비즈니스 그레이",
"theme.green" to "초록",
"theme.red" to "빨강",
"theme.warm" to "따뜻함"
)
)
fun getString(key: String, lang: String): String {
return translations[lang]?.get(key) ?: translations["en"]?.get(key) ?: key
}
val supportedLanguages = listOf(
LanguageOption("zh", "中文"),
LanguageOption("en", "English"),
LanguageOption("ja", "日本语"),
LanguageOption("ko", "한국어")
)
}
data class LanguageOption(val code: String, val name: String)
Loading…
Cancel
Save