diff --git a/android-client/Audio_usage_license.txt b/android-client/Audio_usage_license.txt new file mode 100644 index 0000000..10795ca --- /dev/null +++ b/android-client/Audio_usage_license.txt @@ -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. +感谢上述所有创作者。 diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts index d40f9af..df2b5e5 100644 --- a/android-client/app/build.gradle.kts +++ b/android-client/app/build.gradle.kts @@ -14,7 +14,7 @@ android { minSdk = 26 targetSdk = 34 versionCode = 1 - versionName = "1.0.0.2" + versionName = "1.0.0.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt index 9e7974b..2394210 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt @@ -24,7 +24,8 @@ data class UserPreferences( val shouldAutoReconnect: Boolean, val themeId: String = "blue", val useDynamicColor: Boolean = true, - val language: String = "zh" // 默认中文 + val language: String = "zh", + val notificationSound: String = "default" ) class UserPreferencesRepository( @@ -50,7 +51,8 @@ class UserPreferencesRepository( 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" + 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) { context.dataStore.edit { prefs -> prefs[KEY_USE_DYNAMIC_COLOR] = enabled @@ -155,5 +163,6 @@ class UserPreferencesRepository( val KEY_THEME_ID: Preferences.Key = stringPreferencesKey("theme_id") val KEY_USE_DYNAMIC_COLOR: Preferences.Key = booleanPreferencesKey("use_dynamic_color") val KEY_LANGUAGE: Preferences.Key = stringPreferencesKey("language") + val KEY_NOTIFICATION_SOUND: Preferences.Key = stringPreferencesKey("notification_sound") } } diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt index f286efd..5e3bd26 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt @@ -33,6 +33,7 @@ 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.material.icons.rounded.MusicNote import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -213,7 +214,8 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { onClearMessages = viewModel::clearMessages, onThemeChange = viewModel::updateTheme, onUseDynamicColorChange = viewModel::updateUseDynamicColor, - onLanguageChange = viewModel::updateLanguage + onLanguageChange = viewModel::updateLanguage, + onNotificationSoundChange = viewModel::updateNotificationSound ) } } @@ -577,6 +579,7 @@ private fun MessageItem( * @param onThemeChange 切换主题 * @param onUseDynamicColorChange 切换动态颜色 * @param onLanguageChange 切换语言 + * @param onNotificationSoundChange 切换通知音效 */ @Composable private fun SettingsTab( @@ -593,7 +596,8 @@ private fun SettingsTab( onClearMessages: () -> Unit, onThemeChange: (String) -> Unit, onUseDynamicColorChange: (Boolean) -> Unit, - onLanguageChange: (String) -> Unit + onLanguageChange: (String) -> Unit, + onNotificationSoundChange: (String) -> Unit ) { 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 { Card(modifier = settingsCardModifier) { Column( diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt index 9bdec04..e65118c 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt @@ -5,9 +5,12 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.net.Uri import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -52,7 +55,6 @@ import com.onlinemsg.client.util.LanguageManager /** * 单例管理类,负责整个聊天会话的生命周期、网络连接、消息收发、状态维护和持久化。 - * 所有公开方法均通过 ViewModel 代理调用,内部使用协程处理异步操作。 */ object ChatSessionManager { @@ -81,18 +83,18 @@ object ChatSessionManager { private var identity: RsaCryptoManager.Identity? = null // 连接相关内部状态 - private var manualClose = false // 是否为手动断开 - private var fallbackTried = false // 是否已尝试切换 ws/wss - private var connectedUrl = "" // 当前连接的服务器地址 - private var serverPublicKey = "" // 服务端公钥(握手后获得) - private var helloTimeoutJob: Job? = null // 握手超时任务 - private var authTimeoutJob: Job? = null // 认证超时任务 - private var reconnectJob: Job? = null // 自动重连任务 - private var reconnectAttempt: Int = 0 // 当前重连尝试次数 - private val systemMessageExpiryJobs: MutableMap = mutableMapOf() // 系统消息自动过期任务 + private var manualClose = false + private var fallbackTried = false + private var connectedUrl = "" + private var serverPublicKey = "" + private var helloTimeoutJob: Job? = null + private var authTimeoutJob: Job? = null + private var reconnectJob: Job? = null + private var reconnectAttempt: Int = 0 + private val systemMessageExpiryJobs: MutableMap = mutableMapOf() private var autoReconnectTriggered = false @Volatile - private var keepAliveRequested = false // 是否应保活(前台服务标志) + private var keepAliveRequested = false private var notificationIdSeed = 2000 // WebSocket 事件监听器 @@ -167,10 +169,6 @@ object ChatSessionManager { } } - /** - * 初始化管理器,必须在应用启动时调用一次。 - * @param application Application 实例 - */ @Synchronized fun initialize(application: Application) { if (initialized) return @@ -179,7 +177,6 @@ object ChatSessionManager { preferencesRepository = UserPreferencesRepository(application, json) cryptoManager = RsaCryptoManager(application) historyRepository = ChatHistoryRepository(ChatDatabase.getInstance(application).chatMessageDao()) - ensureMessageNotificationChannel() scope.launch { val pref = preferencesRepository.preferencesFlow.first() @@ -197,11 +194,14 @@ object ChatSessionManager { themeId = pref.themeId, useDynamicColor = pref.useDynamicColor, language = pref.language, + notificationSound = pref.notificationSound, messages = historyMessages, statusHint = LanguageManager.getString("status_hint.click_to_connect", pref.language) ) } - // 如果上次会话启用了自动重连,则自动恢复连接 + + ensureMessageNotificationChannel(pref.notificationSound) + if (pref.shouldAutoReconnect && !autoReconnectTriggered) { autoReconnectTriggered = true ChatForegroundService.start(application) @@ -210,10 +210,14 @@ object ChatSessionManager { } } - /** - * 更新主题 - * @param themeId 主题名 - */ + fun updateNotificationSound(sound: String) { + _uiState.update { it.copy(notificationSound = sound) } + scope.launch { + preferencesRepository.setNotificationSound(sound) + ensureMessageNotificationChannel(sound) + } + } + fun updateTheme(themeId: String) { _uiState.update { it.copy(themeId = themeId) } scope.launch { @@ -221,10 +225,6 @@ object ChatSessionManager { } } - /** - * 更新语言 - * @param language 语言代码 - */ fun updateLanguage(language: String) { _uiState.update { it.copy(language = language) } scope.launch { @@ -232,10 +232,6 @@ object ChatSessionManager { } } - /** - * 更改使用动态颜色 - * @param enabled 主题名 - */ fun updateUseDynamicColor(enabled: Boolean) { _uiState.update { it.copy(useDynamicColor = enabled) } scope.launch { @@ -243,10 +239,6 @@ object ChatSessionManager { } } - /** - * 更新显示名称并持久化。 - * @param value 新名称(自动截断至 64 字符) - */ fun updateDisplayName(value: String) { val displayName = value.take(64) _uiState.update { it.copy(displayName = displayName) } @@ -255,34 +247,18 @@ object ChatSessionManager { } } - /** - * 更新当前输入的服务器地址(不持久化)。 - * @param value 新地址 - */ fun updateServerUrl(value: String) { _uiState.update { it.copy(serverUrl = value) } } - /** - * 更新私聊目标公钥。 - * @param value 公钥字符串 - */ fun updateTargetKey(value: String) { _uiState.update { it.copy(targetKey = value) } } - /** - * 更新消息草稿。 - * @param value 草稿内容 - */ fun updateDraft(value: String) { _uiState.update { it.copy(draft = value) } } - /** - * 切换广播/私聊模式并持久化。 - * @param enabled true 为私聊模式 - */ fun toggleDirectMode(enabled: Boolean) { _uiState.update { it.copy(directMode = enabled) } scope.launch { @@ -290,10 +266,6 @@ object ChatSessionManager { } } - /** - * 切换是否显示系统消息并持久化。 - * @param show true 显示 - */ fun toggleShowSystemMessages(show: Boolean) { _uiState.update { it.copy(showSystemMessages = show) } scope.launch { @@ -301,9 +273,6 @@ object ChatSessionManager { } } - /** - * 清空所有消息,并取消系统消息的过期任务。 - */ fun clearMessages() { cancelSystemMessageExpiryJobs() _uiState.update { it.copy(messages = emptyList()) } @@ -314,9 +283,6 @@ object ChatSessionManager { } } - /** - * 保存当前服务器地址到历史列表并持久化。 - */ fun saveCurrentServerUrl() { val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) if (normalized.isBlank()) { @@ -342,10 +308,6 @@ object ChatSessionManager { } } - /** - * 从历史列表中移除当前服务器地址。 - * 如果列表清空则恢复默认地址。 - */ fun removeCurrentServerUrl() { val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) if (normalized.isBlank()) return @@ -376,9 +338,6 @@ object ChatSessionManager { } } - /** - * 加载或生成本地身份密钥对,并将公钥显示到 UI。 - */ fun revealMyPublicKey() { scope.launch { _uiState.update { it.copy(loadingPublicKey = true) } @@ -398,17 +357,10 @@ object ChatSessionManager { } } - /** - * 主动连接服务器(由用户点击连接触发)。 - */ fun connect() { connectInternal(isAutoRestore = false) } - /** - * 内部连接逻辑,区分自动恢复和手动连接。 - * @param isAutoRestore 是否为应用启动时的自动恢复连接 - */ private fun connectInternal(isAutoRestore: Boolean) { if (!initialized) return val state = _uiState.value @@ -459,10 +411,6 @@ object ChatSessionManager { } } - /** - * 主动断开连接。 - * @param stopService 是否同时停止前台服务(默认 true) - */ fun disconnect(stopService: Boolean = true) { manualClose = true cancelReconnect() @@ -487,10 +435,6 @@ object ChatSessionManager { addSystemMessage("已断开连接") } - /** - * 发送消息(广播或私聊)。 - * 执行签名、加密并发送。 - */ fun sendMessage() { val current = _uiState.value if (!current.canSend) return @@ -549,19 +493,12 @@ object ChatSessionManager { } } - /** - * 消息复制成功后的回调,显示“已复制”提示。 - */ fun onMessageCopied() { scope.launch { _events.emit(UiEvent.ShowSnackbar("已复制")) } } - /** - * 确保本地身份已加载 or 创建。 - * @return 本地身份对象 - */ private suspend fun ensureIdentity(): RsaCryptoManager.Identity { return identityMutex.withLock { identity ?: withContext(Dispatchers.Default) { @@ -572,10 +509,6 @@ object ChatSessionManager { } } - /** - * 处理收到的原始文本消息(可能是握手包 or 加密消息)。 - * @param rawText 原始文本 - */ private suspend fun handleIncomingMessage(rawText: String) { if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { val lang = _uiState.value.language @@ -587,7 +520,6 @@ object ChatSessionManager { json.decodeFromString(normalizedText) as? JsonObject }.getOrNull() - // 尝试直接解析为 HelloDataDto(某些服务器可能直接发送,不带外层) val directHello = rootObject?.let { obj -> val hasPublicKey = obj["publicKey"] != null val hasChallenge = obj["authChallenge"] != null @@ -603,7 +535,6 @@ object ChatSessionManager { return } - // 尝试解析为带外层的 EnvelopeDto val plain = runCatching { json.decodeFromString(normalizedText) }.getOrNull() if (plain?.type == "publickey") { cancelHelloTimeout() @@ -624,7 +555,6 @@ object ChatSessionManager { return } - // 握手阶段收到非预期消息则报错 if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) { val lang = _uiState.value.language _uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.handshake_failed_unexpected", lang)) } @@ -636,7 +566,6 @@ object ChatSessionManager { addSystemMessage("握手包解析失败:$preview") } - // 尝试解密(若已握手完成,收到的应是加密消息) val id = ensureIdentity() val decrypted = runCatching { withContext(Dispatchers.Default) { @@ -654,10 +583,6 @@ object ChatSessionManager { handleSecureMessage(secure) } - /** - * 处理服务端发来的握手 Hello 数据。 - * @param hello 服务端公钥和挑战 - */ private suspend fun handleServerHello(hello: HelloDataDto) { cancelHelloTimeout() serverPublicKey = hello.publicKey @@ -704,10 +629,6 @@ object ChatSessionManager { } } - /** - * 发送认证消息(包含签名后的身份信息)。 - * @param challenge 服务端提供的挑战值 - */ private suspend fun sendAuth(challenge: String) { val id = ensureIdentity() val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() } @@ -751,10 +672,6 @@ object ChatSessionManager { check(socketClient.send(cipher)) { "连接不可用" } } - /** - * 处理安全通道建立后的业务消息(广播、私聊、认证结果等)。 - * @param message 解密后的 EnvelopeDto - */ private fun handleSecureMessage(message: EnvelopeDto) { when (message.type) { "auth_ok" -> { @@ -795,30 +712,15 @@ object ChatSessionManager { } } - /** - * 处理 WebSocket 连接关闭事件。 - * @param code 关闭状态码 - * @param reason 关闭原因 - */ private fun handleSocketClosed(code: Int, reason: String) { cancelHelloTimeout() cancelAuthTimeout() - if (manualClose) { - return - } - if (reason == "reconnect") { - return - } - if (reconnectJob?.isActive == true) { - return - } + if (manualClose || reason == "reconnect" || reconnectJob?.isActive == true) return val currentStatus = _uiState.value.status - val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY - // 尝试切换 ws/wss 协议重试(仅限非就绪状态) if (allowFallback) { val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl) if (fallbackUrl.isNotBlank()) { @@ -847,13 +749,8 @@ object ChatSessionManager { } addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}") scheduleReconnect("连接已中断") - } - /** - * 添加一条系统消息(自动按 TTL 过期)。 - * @param content 消息内容 - */ private fun addSystemMessage(content: String) { val message = UiMessage( role = MessageRole.SYSTEM, @@ -866,13 +763,6 @@ object ChatSessionManager { scheduleSystemMessageExpiry(message.id) } - /** - * 添加一条接收到的用户消息。 - * @param sender 发送者名称 - * @param subtitle 附加说明(如私聊来源) - * @param content 消息内容 - * @param channel 消息通道(广播/私聊) - */ private fun addIncomingMessage( sender: String, subtitle: String, @@ -894,12 +784,6 @@ object ChatSessionManager { ) } - /** - * 添加一条发出的消息。 - * @param content 消息内容 - * @param subtitle 附加说明(如私聊目标) - * @param channel 消息通道 - */ private fun addOutgoingMessage( content: String, subtitle: String, @@ -916,15 +800,11 @@ object ChatSessionManager { ) } - /** - * 将消息追加到列表尾部,并清理超出数量限制的消息。 - * @param message 要追加的消息 - */ private fun appendMessage(message: UiMessage) { _uiState.update { current -> val next = (current.messages + message).takeLast(MAX_MESSAGES) val aliveIds = next.asSequence().map { it.id }.toSet() - val removedIds = systemMessageExpiryJobs.keys.filterNot { it in aliveIds } + val removedIds = systemMessageExpiryJobs.keys.filterNot { id -> id in aliveIds } removedIds.forEach { id -> systemMessageExpiryJobs.remove(id)?.cancel() } @@ -938,21 +818,13 @@ object ChatSessionManager { } } - /** - * 取消认证超时任务。 - */ private fun cancelAuthTimeout() { authTimeoutJob?.cancel() authTimeoutJob = null } - /** - * 安排自动重连(指数退避)。 - * @param reason 触发重连的原因 - */ private fun scheduleReconnect(reason: String) { - if (manualClose) return - if (reconnectJob?.isActive == true) return + if (manualClose || reconnectJob?.isActive == true) return reconnectAttempt += 1 val exponential = 1 shl minOf(reconnectAttempt - 1, 5) @@ -1004,18 +876,11 @@ object ChatSessionManager { } } - /** - * 取消自动重连任务。 - */ private fun cancelReconnect() { reconnectJob?.cancel() reconnectJob = null } - /** - * 为系统消息安排过期自动删除。 - * @param messageId 消息唯一 ID - */ private fun scheduleSystemMessageExpiry(messageId: String) { systemMessageExpiryJobs.remove(messageId)?.cancel() systemMessageExpiryJobs[messageId] = scope.launch { @@ -1028,17 +893,11 @@ object ChatSessionManager { } } - /** - * 取消所有系统消息的过期任务。 - */ private fun cancelSystemMessageExpiryJobs() { systemMessageExpiryJobs.values.forEach { it.cancel() } systemMessageExpiryJobs.clear() } - /** - * 启动握手超时计时器。 - */ private fun startHelloTimeout() { cancelHelloTimeout() helloTimeoutJob = scope.launch { @@ -1058,56 +917,29 @@ object ChatSessionManager { } } - /** - * 取消握手超时任务。 - */ private fun cancelHelloTimeout() { helloTimeoutJob?.cancel() helloTimeoutJob = null } - /** - * 缩写显示公钥(取前后各8字符)。 - * @param key 完整公钥 - * @return 缩写字符串 - */ private fun summarizeKey(key: String): String { if (key.length <= 16) return key return "${key.take(8)}...${key.takeLast(8)}" } - /** - * 生成访客名称(如 guest-123456)。 - * @return 随机名称 - */ private fun createGuestName(): String { val rand = (100000..999999).random() return "guest-$rand" } - /** - * 从可能包含前缀的原始文本中提取 JSON 对象部分。 - * @param rawText 原始文本 - * @return 最外层的 JSON 字符串 - */ private fun extractJsonCandidate(rawText: String): String { val trimmed = rawText.trim() - if (trimmed.startsWith("{") && trimmed.endsWith("}")) { - return trimmed - } - + if (trimmed.startsWith("{") && trimmed.endsWith("}")) return trimmed val start = rawText.indexOf('{') val end = rawText.lastIndexOf('}') - return if (start in 0 until end) { - rawText.substring(start, end + 1) - } else { - rawText - } + return if (start in 0 until end) rawText.substring(start, end + 1) else rawText } - /** - * 关闭所有资源(用于应用退出时)。 - */ fun shutdownAll() { cancelSystemMessageExpiryJobs() cancelReconnect() @@ -1116,9 +948,6 @@ object ChatSessionManager { socketClient.shutdown() } - /** - * 前台服务停止时的回调。 - */ fun onForegroundServiceStopped() { keepAliveRequested = false if (_uiState.value.status != ConnectionStatus.IDLE) { @@ -1130,52 +959,57 @@ object ChatSessionManager { } } - /** - * 判断前台服务是否应该运行。 - * @return true 表示应保持服务运行 - */ fun shouldForegroundServiceRun(): Boolean = keepAliveRequested - /** - * 创建消息通知渠道(Android O+)。 - */ - private fun ensureMessageNotificationChannel() { + private fun ensureMessageNotificationChannel(soundCode: String = "default") { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return 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( - MESSAGE_CHANNEL_ID, + channelId, "OnlineMsg 消息提醒", NotificationManager.IMPORTANCE_DEFAULT ).apply { description = "收到服务器新消息时提醒" + if (soundUri != null) { + setSound(soundUri, AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build()) + } } manager.createNotificationChannel(channel) } - /** - * 显示新消息到达的通知。 - * @param title 通知标题 - * @param body 通知正文 - */ + private fun getSoundUri(code: String): Uri? { + val resId = when(code) { + "ding" -> R.raw.load + "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) { if (!initialized) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(app, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED - ) { - return - } + ) return val launchIntent = Intent(app, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } - val pendingIntent = PendingIntent.getActivity( - app, - 0, - launchIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + val pendingIntent = PendingIntent.getActivity(app, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + val soundCode = _uiState.value.notificationSound + val channelId = "${MESSAGE_CHANNEL_ID}_$soundCode" - val notification = NotificationCompat.Builder(app, MESSAGE_CHANNEL_ID) + val notification = NotificationCompat.Builder(app, channelId) .setSmallIcon(android.R.drawable.stat_notify_chat) .setContentTitle(title.ifBlank { "OnlineMsg" }) .setContentText(body.take(120)) @@ -1188,17 +1022,12 @@ object ChatSessionManager { NotificationManagerCompat.from(app).notify(nextMessageNotificationId(), notification) } - /** - * 生成下一个通知 ID(线程安全递增)。 - * @return 新的通知 ID - */ @Synchronized private fun nextMessageNotificationId(): Int { notificationIdSeed += 1 return notificationIdSeed } - // 常量定义 private const val HELLO_TIMEOUT_MS = 12_000L private const val AUTH_TIMEOUT_MS = 20_000L private const val MAX_MESSAGES = 500 diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt index e0438ac..86f0938 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt @@ -70,7 +70,10 @@ data class UiMessage( * @property myPublicKey 本地公钥 * @property sending 是否正在发送消息(用于禁用按钮) * @property loadingPublicKey 是否正在加载公钥 - * @property language 当前选择的语言代码 (如 "zh", "en", "ja") + * @property themeId 当前选中的主题 ID + * @property useDynamicColor 是否使用 Android 12+ 动态颜色 + * @property language 当前选择的语言代码 + * @property notificationSound 当前选择的通知音效代号 */ data class ChatUiState( val status: ConnectionStatus = ConnectionStatus.IDLE, @@ -89,7 +92,8 @@ data class ChatUiState( val loadingPublicKey: Boolean = false, val themeId: String = "blue", 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 get() = status == ConnectionStatus.READY && draft.trim().isNotEmpty() && !sending - /** * 连接状态的简短文本描述。 */ @@ -145,4 +148,4 @@ data class ChatUiState( */ sealed interface UiEvent { data class ShowSnackbar(val message: String) : UiEvent -} \ No newline at end of file +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt index 8b53474..eb18399 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt @@ -35,4 +35,5 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId) fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled) fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language) + fun updateNotificationSound(sound: String) = ChatSessionManager.updateNotificationSound(sound) } \ No newline at end of file diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt index 40cc2bc..7328ee7 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt @@ -2,7 +2,7 @@ package com.onlinemsg.client.util /** * 语言管理类,统一存储应用内的多语言词条。 - * 类似于 Minecraft 的语言文件映射。 + * 类似于 Minecraft 的 language 文件映射。 */ object LanguageManager { @@ -32,6 +32,11 @@ object LanguageManager { "settings.show_system" to "显示系统消息", "settings.clear_msg" 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.private" to "私聊", "chat.target_key" to "目标公钥", @@ -101,6 +106,11 @@ object LanguageManager { "settings.show_system" to "Show System Messages", "settings.clear_msg" to "ClearMsg", "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.private" to "Private", "chat.target_key" to "Target Public Key", @@ -170,6 +180,11 @@ object LanguageManager { "settings.show_system" to "システムメッセージを表示", "settings.clear_msg" 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.private" to "個人", "chat.target_key" to "相手の公開鍵", @@ -239,6 +254,11 @@ object LanguageManager { "settings.show_system" to "시스템 메시지 표시", "settings.clear_msg" 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.private" to "비공개 채팅", "chat.target_key" to "대상 공개키", @@ -309,6 +329,11 @@ object LanguageManager { "settings.show_system" to "顯示系統訊息", "settings.clear_msg" 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.private" to "私訊", "chat.target_key" to "目標公鑰", diff --git a/android-client/app/src/main/res/raw/default_sound.mp3 b/android-client/app/src/main/res/raw/default_sound.mp3 new file mode 100644 index 0000000..f3a2068 Binary files /dev/null and b/android-client/app/src/main/res/raw/default_sound.mp3 differ diff --git a/android-client/app/src/main/res/raw/load.mp3 b/android-client/app/src/main/res/raw/load.mp3 new file mode 100644 index 0000000..8afb4c6 Binary files /dev/null and b/android-client/app/src/main/res/raw/load.mp3 differ diff --git a/android-client/app/src/main/res/raw/nameit5.wav b/android-client/app/src/main/res/raw/nameit5.wav new file mode 100644 index 0000000..ef49ba4 Binary files /dev/null and b/android-client/app/src/main/res/raw/nameit5.wav differ diff --git a/android-client/app/src/main/res/raw/notification_sound_effects.mp3 b/android-client/app/src/main/res/raw/notification_sound_effects.mp3 new file mode 100644 index 0000000..54402ab Binary files /dev/null and b/android-client/app/src/main/res/raw/notification_sound_effects.mp3 differ