1.0.0.3更新1.语音发送支持2.繁体中文支持3.通知音效支持4.修复部分多语言翻译错误

pull/14/head
minxiwan 1 week ago
parent 0404e665c1
commit 82c4d9b6e3

@ -0,0 +1,20 @@
作品Notification sound effects
来源ear0
作者Risteard
许可CC-BY 保留署名许可协议
描述:通知铃声。
-------------------------------------------------
作品load
来源ear0
作者weidu27
许可CC0 公众共享许可协议
描述:叮咚
-------------------------------------------------
作品nameit5
来源Freesound
作者bumpelsnake
许可CC-BY 保留署名许可协议
描述some chime, maybe a notification sound
-------------------------------------------------
Thank you to all the creators mentioned above.
感谢上述所有创作者。

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

@ -24,7 +24,8 @@ data class UserPreferences(
val shouldAutoReconnect: Boolean, val shouldAutoReconnect: Boolean,
val themeId: String = "blue", val themeId: String = "blue",
val useDynamicColor: Boolean = true, val useDynamicColor: Boolean = true,
val language: String = "zh" // 默认中文 val language: String = "zh",
val notificationSound: String = "default"
) )
class UserPreferencesRepository( class UserPreferencesRepository(
@ -50,7 +51,8 @@ class UserPreferencesRepository(
shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false, shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false,
themeId = prefs[KEY_THEME_ID] ?: "blue", themeId = prefs[KEY_THEME_ID] ?: "blue",
useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true, useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true,
language = prefs[KEY_LANGUAGE] ?: "zh" language = prefs[KEY_LANGUAGE] ?: "zh",
notificationSound = prefs[KEY_NOTIFICATION_SOUND] ?: "default"
) )
} }
@ -66,6 +68,12 @@ class UserPreferencesRepository(
} }
} }
suspend fun setNotificationSound(sound: String) {
context.dataStore.edit { prefs ->
prefs[KEY_NOTIFICATION_SOUND] = sound
}
}
suspend fun setUseDynamicColor(enabled: Boolean) { suspend fun setUseDynamicColor(enabled: Boolean) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[KEY_USE_DYNAMIC_COLOR] = enabled prefs[KEY_USE_DYNAMIC_COLOR] = enabled
@ -155,5 +163,6 @@ class UserPreferencesRepository(
val KEY_THEME_ID: Preferences.Key<String> = stringPreferencesKey("theme_id") val KEY_THEME_ID: Preferences.Key<String> = stringPreferencesKey("theme_id")
val KEY_USE_DYNAMIC_COLOR: Preferences.Key<Boolean> = booleanPreferencesKey("use_dynamic_color") val KEY_USE_DYNAMIC_COLOR: Preferences.Key<Boolean> = booleanPreferencesKey("use_dynamic_color")
val KEY_LANGUAGE: Preferences.Key<String> = stringPreferencesKey("language") val KEY_LANGUAGE: Preferences.Key<String> = stringPreferencesKey("language")
val KEY_NOTIFICATION_SOUND: Preferences.Key<String> = stringPreferencesKey("notification_sound")
} }
} }

@ -33,6 +33,7 @@ import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Forum import androidx.compose.material.icons.rounded.Forum
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.MusicNote
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
@ -213,7 +214,8 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
onClearMessages = viewModel::clearMessages, onClearMessages = viewModel::clearMessages,
onThemeChange = viewModel::updateTheme, onThemeChange = viewModel::updateTheme,
onUseDynamicColorChange = viewModel::updateUseDynamicColor, onUseDynamicColorChange = viewModel::updateUseDynamicColor,
onLanguageChange = viewModel::updateLanguage onLanguageChange = viewModel::updateLanguage,
onNotificationSoundChange = viewModel::updateNotificationSound
) )
} }
} }
@ -577,6 +579,7 @@ private fun MessageItem(
* @param onThemeChange 切换主题 * @param onThemeChange 切换主题
* @param onUseDynamicColorChange 切换动态颜色 * @param onUseDynamicColorChange 切换动态颜色
* @param onLanguageChange 切换语言 * @param onLanguageChange 切换语言
* @param onNotificationSoundChange 切换通知音效
*/ */
@Composable @Composable
private fun SettingsTab( private fun SettingsTab(
@ -593,7 +596,8 @@ private fun SettingsTab(
onClearMessages: () -> Unit, onClearMessages: () -> Unit,
onThemeChange: (String) -> Unit, onThemeChange: (String) -> Unit,
onUseDynamicColorChange: (Boolean) -> Unit, onUseDynamicColorChange: (Boolean) -> Unit,
onLanguageChange: (String) -> Unit onLanguageChange: (String) -> Unit,
onNotificationSoundChange: (String) -> Unit
) { ) {
fun language(key: String) = LanguageManager.getString(key, state.language) fun language(key: String) = LanguageManager.getString(key, state.language)
@ -736,6 +740,39 @@ private fun SettingsTab(
} }
} }
item {
Card(modifier = settingsCardModifier) {
Column(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
Text(language("settings.notification_sound"), style = MaterialTheme.typography.titleMedium)
val sounds = listOf(
"default" to language("sound.default"),
"ding" to language("sound.ding"),
"nameit5" to language("sound.nameit5"),
"wind_chime" to language("sound.wind_chime")
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(sounds) { (id, label) ->
FilterChip(
selected = state.notificationSound == id,
onClick = { onNotificationSoundChange(id) },
label = { Text(label) },
leadingIcon = {
Icon(
Icons.Rounded.MusicNote,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
}
}
item { item {
Card(modifier = settingsCardModifier) { Card(modifier = settingsCardModifier) {
Column( Column(

@ -5,9 +5,12 @@ import android.app.Application
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@ -52,7 +55,6 @@ import com.onlinemsg.client.util.LanguageManager
/** /**
* 单例管理类负责整个聊天会话的生命周期网络连接消息收发状态维护和持久化 * 单例管理类负责整个聊天会话的生命周期网络连接消息收发状态维护和持久化
* 所有公开方法均通过 ViewModel 代理调用内部使用协程处理异步操作
*/ */
object ChatSessionManager { object ChatSessionManager {
@ -81,18 +83,18 @@ object ChatSessionManager {
private var identity: RsaCryptoManager.Identity? = null private var identity: RsaCryptoManager.Identity? = null
// 连接相关内部状态 // 连接相关内部状态
private var manualClose = false // 是否为手动断开 private var manualClose = false
private var fallbackTried = false // 是否已尝试切换 ws/wss private var fallbackTried = false
private var connectedUrl = "" // 当前连接的服务器地址 private var connectedUrl = ""
private var serverPublicKey = "" // 服务端公钥(握手后获得) private var serverPublicKey = ""
private var helloTimeoutJob: Job? = null // 握手超时任务 private var helloTimeoutJob: Job? = null
private var authTimeoutJob: Job? = null // 认证超时任务 private var authTimeoutJob: Job? = null
private var reconnectJob: Job? = null // 自动重连任务 private var reconnectJob: Job? = null
private var reconnectAttempt: Int = 0 // 当前重连尝试次数 private var reconnectAttempt: Int = 0
private val systemMessageExpiryJobs: MutableMap<String, Job> = mutableMapOf() // 系统消息自动过期任务 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 事件监听器 // WebSocket 事件监听器
@ -167,10 +169,6 @@ object ChatSessionManager {
} }
} }
/**
* 初始化管理器必须在应用启动时调用一次
* @param application Application 实例
*/
@Synchronized @Synchronized
fun initialize(application: Application) { fun initialize(application: Application) {
if (initialized) return if (initialized) return
@ -179,7 +177,6 @@ object ChatSessionManager {
preferencesRepository = UserPreferencesRepository(application, json) preferencesRepository = UserPreferencesRepository(application, json)
cryptoManager = RsaCryptoManager(application) cryptoManager = RsaCryptoManager(application)
historyRepository = ChatHistoryRepository(ChatDatabase.getInstance(application).chatMessageDao()) historyRepository = ChatHistoryRepository(ChatDatabase.getInstance(application).chatMessageDao())
ensureMessageNotificationChannel()
scope.launch { scope.launch {
val pref = preferencesRepository.preferencesFlow.first() val pref = preferencesRepository.preferencesFlow.first()
@ -197,11 +194,14 @@ object ChatSessionManager {
themeId = pref.themeId, themeId = pref.themeId,
useDynamicColor = pref.useDynamicColor, useDynamicColor = pref.useDynamicColor,
language = pref.language, language = pref.language,
notificationSound = pref.notificationSound,
messages = historyMessages, messages = historyMessages,
statusHint = LanguageManager.getString("status_hint.click_to_connect", pref.language) statusHint = LanguageManager.getString("status_hint.click_to_connect", pref.language)
) )
} }
// 如果上次会话启用了自动重连,则自动恢复连接
ensureMessageNotificationChannel(pref.notificationSound)
if (pref.shouldAutoReconnect && !autoReconnectTriggered) { if (pref.shouldAutoReconnect && !autoReconnectTriggered) {
autoReconnectTriggered = true autoReconnectTriggered = true
ChatForegroundService.start(application) ChatForegroundService.start(application)
@ -210,10 +210,14 @@ object ChatSessionManager {
} }
} }
/** fun updateNotificationSound(sound: String) {
* 更新主题 _uiState.update { it.copy(notificationSound = sound) }
* @param themeId 主题名 scope.launch {
*/ preferencesRepository.setNotificationSound(sound)
ensureMessageNotificationChannel(sound)
}
}
fun updateTheme(themeId: String) { fun updateTheme(themeId: String) {
_uiState.update { it.copy(themeId = themeId) } _uiState.update { it.copy(themeId = themeId) }
scope.launch { scope.launch {
@ -221,10 +225,6 @@ object ChatSessionManager {
} }
} }
/**
* 更新语言
* @param language 语言代码
*/
fun updateLanguage(language: String) { fun updateLanguage(language: String) {
_uiState.update { it.copy(language = language) } _uiState.update { it.copy(language = language) }
scope.launch { scope.launch {
@ -232,10 +232,6 @@ object ChatSessionManager {
} }
} }
/**
* 更改使用动态颜色
* @param enabled 主题名
*/
fun updateUseDynamicColor(enabled: Boolean) { fun updateUseDynamicColor(enabled: Boolean) {
_uiState.update { it.copy(useDynamicColor = enabled) } _uiState.update { it.copy(useDynamicColor = enabled) }
scope.launch { scope.launch {
@ -243,10 +239,6 @@ object ChatSessionManager {
} }
} }
/**
* 更新显示名称并持久化
* @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) }
@ -255,34 +247,18 @@ 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 {
@ -290,10 +266,6 @@ 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 {
@ -301,9 +273,6 @@ object ChatSessionManager {
} }
} }
/**
* 清空所有消息并取消系统消息的过期任务
*/
fun clearMessages() { fun clearMessages() {
cancelSystemMessageExpiryJobs() cancelSystemMessageExpiryJobs()
_uiState.update { it.copy(messages = emptyList()) } _uiState.update { it.copy(messages = emptyList()) }
@ -314,9 +283,6 @@ object ChatSessionManager {
} }
} }
/**
* 保存当前服务器地址到历史列表并持久化
*/
fun saveCurrentServerUrl() { fun saveCurrentServerUrl() {
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
if (normalized.isBlank()) { if (normalized.isBlank()) {
@ -342,10 +308,6 @@ 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
@ -376,9 +338,6 @@ object ChatSessionManager {
} }
} }
/**
* 加载或生成本地身份密钥对并将公钥显示到 UI
*/
fun revealMyPublicKey() { fun revealMyPublicKey() {
scope.launch { scope.launch {
_uiState.update { it.copy(loadingPublicKey = true) } _uiState.update { it.copy(loadingPublicKey = true) }
@ -398,17 +357,10 @@ 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
@ -459,10 +411,6 @@ object ChatSessionManager {
} }
} }
/**
* 主动断开连接
* @param stopService 是否同时停止前台服务默认 true
*/
fun disconnect(stopService: Boolean = true) { fun disconnect(stopService: Boolean = true) {
manualClose = true manualClose = true
cancelReconnect() cancelReconnect()
@ -487,10 +435,6 @@ object ChatSessionManager {
addSystemMessage("已断开连接") addSystemMessage("已断开连接")
} }
/**
* 发送消息广播或私聊
* 执行签名加密并发送
*/
fun sendMessage() { fun sendMessage() {
val current = _uiState.value val current = _uiState.value
if (!current.canSend) return if (!current.canSend) return
@ -549,19 +493,12 @@ 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) {
@ -572,10 +509,6 @@ 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) {
val lang = _uiState.value.language val lang = _uiState.value.language
@ -587,7 +520,6 @@ object ChatSessionManager {
json.decodeFromString<JsonElement>(normalizedText) as? JsonObject json.decodeFromString<JsonElement>(normalizedText) as? JsonObject
}.getOrNull() }.getOrNull()
// 尝试直接解析为 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
@ -603,7 +535,6 @@ 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()
@ -624,7 +555,6 @@ object ChatSessionManager {
return return
} }
// 握手阶段收到非预期消息则报错
if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) { if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) {
val lang = _uiState.value.language val lang = _uiState.value.language
_uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.handshake_failed_unexpected", lang)) } _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.handshake_failed_unexpected", lang)) }
@ -636,7 +566,6 @@ object ChatSessionManager {
addSystemMessage("握手包解析失败:$preview") addSystemMessage("握手包解析失败:$preview")
} }
// 尝试解密(若已握手完成,收到的应是加密消息)
val id = ensureIdentity() val id = ensureIdentity()
val decrypted = runCatching { val decrypted = runCatching {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@ -654,10 +583,6 @@ 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
@ -704,10 +629,6 @@ 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() }
@ -751,10 +672,6 @@ 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" -> {
@ -795,30 +712,15 @@ 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()
if (manualClose) { if (manualClose || reason == "reconnect" || reconnectJob?.isActive == true) return
return
}
if (reason == "reconnect") {
return
}
if (reconnectJob?.isActive == true) {
return
}
val currentStatus = _uiState.value.status val currentStatus = _uiState.value.status
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()) {
@ -847,13 +749,8 @@ object ChatSessionManager {
} }
addSystemMessage("连接关闭 ($code)${reason.ifBlank { "连接中断" }}") addSystemMessage("连接关闭 ($code)${reason.ifBlank { "连接中断" }}")
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,
@ -866,13 +763,6 @@ 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,
@ -894,12 +784,6 @@ object ChatSessionManager {
) )
} }
/**
* 添加一条发出的消息
* @param content 消息内容
* @param subtitle 附加说明如私聊目标
* @param channel 消息通道
*/
private fun addOutgoingMessage( private fun addOutgoingMessage(
content: String, content: String,
subtitle: String, subtitle: String,
@ -916,15 +800,11 @@ 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)
val aliveIds = next.asSequence().map { it.id }.toSet() val aliveIds = next.asSequence().map { it.id }.toSet()
val removedIds = systemMessageExpiryJobs.keys.filterNot { it in aliveIds } val removedIds = systemMessageExpiryJobs.keys.filterNot { id -> id in aliveIds }
removedIds.forEach { id -> removedIds.forEach { id ->
systemMessageExpiryJobs.remove(id)?.cancel() systemMessageExpiryJobs.remove(id)?.cancel()
} }
@ -938,21 +818,13 @@ 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 || reconnectJob?.isActive == true) return
if (reconnectJob?.isActive == true) return
reconnectAttempt += 1 reconnectAttempt += 1
val exponential = 1 shl minOf(reconnectAttempt - 1, 5) val exponential = 1 shl minOf(reconnectAttempt - 1, 5)
@ -1004,18 +876,11 @@ 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 {
@ -1028,17 +893,11 @@ 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 {
@ -1058,56 +917,29 @@ 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("}")) return trimmed
return trimmed
}
val start = rawText.indexOf('{') val start = rawText.indexOf('{')
val end = rawText.lastIndexOf('}') val end = rawText.lastIndexOf('}')
return if (start in 0 until end) { return if (start in 0 until end) rawText.substring(start, end + 1) else rawText
rawText.substring(start, end + 1)
} else {
rawText
}
} }
/**
* 关闭所有资源用于应用退出时
*/
fun shutdownAll() { fun shutdownAll() {
cancelSystemMessageExpiryJobs() cancelSystemMessageExpiryJobs()
cancelReconnect() cancelReconnect()
@ -1116,9 +948,6 @@ 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) {
@ -1130,52 +959,57 @@ object ChatSessionManager {
} }
} }
/**
* 判断前台服务是否应该运行
* @return true 表示应保持服务运行
*/
fun shouldForegroundServiceRun(): Boolean = keepAliveRequested fun shouldForegroundServiceRun(): Boolean = keepAliveRequested
/** private fun ensureMessageNotificationChannel(soundCode: String = "default") {
* 创建消息通知渠道Android O+
*/
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
val channelId = "${MESSAGE_CHANNEL_ID}_$soundCode"
if (manager.getNotificationChannel(channelId) != null) return
val soundUri = getSoundUri(soundCode)
val channel = NotificationChannel( val channel = NotificationChannel(
MESSAGE_CHANNEL_ID, channelId,
"OnlineMsg 消息提醒", "OnlineMsg 消息提醒",
NotificationManager.IMPORTANCE_DEFAULT NotificationManager.IMPORTANCE_DEFAULT
).apply { ).apply {
description = "收到服务器新消息时提醒" description = "收到服务器新消息时提醒"
if (soundUri != null) {
setSound(soundUri, AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build())
}
} }
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
/** private fun getSoundUri(code: String): Uri? {
* 显示新消息到达的通知 val resId = when(code) {
* @param title 通知标题 "ding" -> R.raw.load
* @param body 通知正文 "nameit5" -> R.raw.nameit5
*/ "wind_chime" -> R.raw.notification_sound_effects
"default" -> R.raw.default_sound
else -> return null
}
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + app.packageName + "/" + resId)
}
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 &&
ContextCompat.checkSelfPermission(app, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(app, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) { ) return
return
}
val launchIntent = Intent(app, MainActivity::class.java).apply { val launchIntent = Intent(app, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
} }
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(app, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
app,
0, val soundCode = _uiState.value.notificationSound
launchIntent, val channelId = "${MESSAGE_CHANNEL_ID}_$soundCode"
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(app, MESSAGE_CHANNEL_ID) val notification = NotificationCompat.Builder(app, channelId)
.setSmallIcon(android.R.drawable.stat_notify_chat) .setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle(title.ifBlank { "OnlineMsg" }) .setContentTitle(title.ifBlank { "OnlineMsg" })
.setContentText(body.take(120)) .setContentText(body.take(120))
@ -1188,17 +1022,12 @@ 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

@ -70,7 +70,10 @@ data class UiMessage(
* @property myPublicKey 本地公钥 * @property myPublicKey 本地公钥
* @property sending 是否正在发送消息用于禁用按钮 * @property sending 是否正在发送消息用于禁用按钮
* @property loadingPublicKey 是否正在加载公钥 * @property loadingPublicKey 是否正在加载公钥
* @property language 当前选择的语言代码 ( "zh", "en", "ja") * @property themeId 当前选中的主题 ID
* @property useDynamicColor 是否使用 Android 12+ 动态颜色
* @property language 当前选择的语言代码
* @property notificationSound 当前选择的通知音效代号
*/ */
data class ChatUiState( data class ChatUiState(
val status: ConnectionStatus = ConnectionStatus.IDLE, val status: ConnectionStatus = ConnectionStatus.IDLE,
@ -89,7 +92,8 @@ data class ChatUiState(
val loadingPublicKey: Boolean = false, val loadingPublicKey: Boolean = false,
val themeId: String = "blue", val themeId: String = "blue",
val useDynamicColor: Boolean = true, val useDynamicColor: Boolean = true,
val language: String = "zh" val language: String = "zh",
val notificationSound: String = "default"
) { ) {
/** /**
* 是否允许连接 * 是否允许连接
@ -112,7 +116,6 @@ data class ChatUiState(
val canSend: Boolean val canSend: Boolean
get() = status == ConnectionStatus.READY && draft.trim().isNotEmpty() && !sending get() = status == ConnectionStatus.READY && draft.trim().isNotEmpty() && !sending
/** /**
* 连接状态的简短文本描述 * 连接状态的简短文本描述
*/ */

@ -35,4 +35,5 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId) fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId)
fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled) fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled)
fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language) fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language)
fun updateNotificationSound(sound: String) = ChatSessionManager.updateNotificationSound(sound)
} }

@ -2,7 +2,7 @@ package com.onlinemsg.client.util
/** /**
* 语言管理类统一存储应用内的多语言词条 * 语言管理类统一存储应用内的多语言词条
* 类似于 Minecraft 语言文件映射 * 类似于 Minecraft language 文件映射
*/ */
object LanguageManager { object LanguageManager {
@ -32,6 +32,11 @@ object LanguageManager {
"settings.show_system" to "显示系统消息", "settings.show_system" to "显示系统消息",
"settings.clear_msg" to "清空消息", "settings.clear_msg" to "清空消息",
"settings.dynamic_color" to "使用动态颜色", "settings.dynamic_color" to "使用动态颜色",
"settings.notification_sound" to "通知音效",
"sound.default" to "默认",
"sound.ding" to "",
"sound.nameit5" to "音效5",
"sound.wind_chime" to "风铃",
"chat.broadcast" to "广播", "chat.broadcast" to "广播",
"chat.private" to "私聊", "chat.private" to "私聊",
"chat.target_key" to "目标公钥", "chat.target_key" to "目标公钥",
@ -101,6 +106,11 @@ object LanguageManager {
"settings.show_system" to "Show System Messages", "settings.show_system" to "Show System Messages",
"settings.clear_msg" to "ClearMsg", "settings.clear_msg" to "ClearMsg",
"settings.dynamic_color" to "Use dynamic color", "settings.dynamic_color" to "Use dynamic color",
"settings.notification_sound" to "Notification Sound",
"sound.default" to "Default",
"sound.ding" to "Ding",
"sound.nameit5" to "Sound 5",
"sound.wind_chime" to "Wind Chime",
"chat.broadcast" to "Broadcast", "chat.broadcast" to "Broadcast",
"chat.private" to "Private", "chat.private" to "Private",
"chat.target_key" to "Target Public Key", "chat.target_key" to "Target Public Key",
@ -170,6 +180,11 @@ object LanguageManager {
"settings.show_system" to "システムメッセージを表示", "settings.show_system" to "システムメッセージを表示",
"settings.clear_msg" to "履歴を消去", "settings.clear_msg" to "履歴を消去",
"settings.dynamic_color" to "動的カラーを使用", "settings.dynamic_color" to "動的カラーを使用",
"settings.notification_sound" to "通知音",
"sound.default" to "デフォルト",
"sound.ding" to "ディン",
"sound.nameit5" to "音効5",
"sound.wind_chime" to "風鈴",
"chat.broadcast" to "全体", "chat.broadcast" to "全体",
"chat.private" to "個人", "chat.private" to "個人",
"chat.target_key" to "相手の公開鍵", "chat.target_key" to "相手の公開鍵",
@ -239,6 +254,11 @@ object LanguageManager {
"settings.show_system" to "시스템 메시지 표시", "settings.show_system" to "시스템 메시지 표시",
"settings.clear_msg" to "정보 삭제", "settings.clear_msg" to "정보 삭제",
"settings.dynamic_color" to "동적 색상 사용", "settings.dynamic_color" to "동적 색상 사용",
"settings.notification_sound" to "알림 소리",
"sound.default" to "기본값",
"sound.ding" to "",
"sound.nameit5" to "효과음 5",
"sound.wind_chime" to "풍경",
"chat.broadcast" to "브로드캐스트", "chat.broadcast" to "브로드캐스트",
"chat.private" to "비공개 채팅", "chat.private" to "비공개 채팅",
"chat.target_key" to "대상 공개키", "chat.target_key" to "대상 공개키",
@ -309,6 +329,11 @@ object LanguageManager {
"settings.show_system" to "顯示系統訊息", "settings.show_system" to "顯示系統訊息",
"settings.clear_msg" to "清空訊息", "settings.clear_msg" to "清空訊息",
"settings.dynamic_color" to "使用動態顏色", "settings.dynamic_color" to "使用動態顏色",
"settings.notification_sound" to "通知音效",
"sound.default" to "預設",
"sound.ding" to "",
"sound.nameit5" to "音效5",
"sound.wind_chime" to "風鈴",
"chat.broadcast" to "廣播", "chat.broadcast" to "廣播",
"chat.private" to "私訊", "chat.private" to "私訊",
"chat.target_key" to "目標公鑰", "chat.target_key" to "目標公鑰",

Loading…
Cancel
Save