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/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..13d79a7 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 64881b7..d8ba727 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 @@ -46,6 +46,7 @@ import androidx.compose.material.icons.rounded.Forum import androidx.compose.material.icons.rounded.Keyboard import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Stop import androidx.compose.material.icons.rounded.KeyboardVoice @@ -241,7 +242,8 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { onClearMessages = viewModel::clearMessages, onThemeChange = viewModel::updateTheme, onUseDynamicColorChange = viewModel::updateUseDynamicColor, - onLanguageChange = viewModel::updateLanguage + onLanguageChange = viewModel::updateLanguage, + onNotificationSoundChange = viewModel::updateNotificationSound ) } } @@ -1038,7 +1040,8 @@ private fun SettingsTab( onClearMessages: () -> Unit, onThemeChange: (String) -> Unit, onUseDynamicColorChange: (Boolean) -> Unit, - onLanguageChange: (String) -> Unit + onLanguageChange: (String) -> Unit, + onNotificationSoundChange: (String) -> Unit ) { fun t(key: String) = LanguageManager.getString(key, state.language) @@ -1073,6 +1076,33 @@ private fun SettingsTab( } } + item { + Card(modifier = settingsCardModifier) { + Column( + modifier = settingsCardContentModifier, + verticalArrangement = settingsCardContentSpacing + ) { + Text(t("settings.notification_sound"), style = MaterialTheme.typography.titleMedium) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(listOf("default", "ding", "nameit5", "wind_chime")) { sound -> + FilterChip( + selected = state.notificationSound == sound, + onClick = { onNotificationSoundChange(sound) }, + label = { Text(t("sound.$sound")) }, + leadingIcon = { + Icon( + imageVector = 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 5c2c526..db285cf 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 @@ -201,14 +204,13 @@ object ChatSessionManager { preferencesRepository = UserPreferencesRepository(application, json) cryptoManager = RsaCryptoManager(application) historyRepository = ChatHistoryRepository(ChatDatabase.getInstance(application).chatMessageDao()) - ensureMessageNotificationChannel() - scope.launch { val pref = preferencesRepository.preferencesFlow.first() val historyMessages = withContext(Dispatchers.IO) { historyRepository.loadMessages(MAX_MESSAGES) } keepAliveRequested = pref.shouldAutoReconnect + ensureMessageNotificationChannel(pref.notificationSound) _uiState.update { current -> current.copy( displayName = pref.displayName, @@ -219,6 +221,7 @@ object ChatSessionManager { themeId = pref.themeId, useDynamicColor = pref.useDynamicColor, language = pref.language, + notificationSound = pref.notificationSound, messages = historyMessages ) } @@ -253,6 +256,18 @@ object ChatSessionManager { } } + /** + * 更新通知音效。 + * @param sound 音效代号 + */ + fun updateNotificationSound(sound: String) { + _uiState.update { it.copy(notificationSound = sound) } + scope.launch { + preferencesRepository.setNotificationSound(sound) + ensureMessageNotificationChannel(sound) + } + } + /** * 更改使用动态颜色 * @param enabled 主题名 @@ -1467,19 +1482,42 @@ object ChatSessionManager { /** * 创建消息通知渠道(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 channel = NotificationChannel( - MESSAGE_CHANNEL_ID, + channelId, t("session.notification.channel_name"), NotificationManager.IMPORTANCE_DEFAULT ).apply { description = t("session.notification.channel_desc") + getSoundUri(soundCode)?.let { uri -> + setSound( + uri, + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + } } manager.createNotificationChannel(channel) } + 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") + } + /** * 显示新消息到达的通知。 * @param title 通知标题 @@ -1502,8 +1540,10 @@ object ChatSessionManager { launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + val channelId = "${MESSAGE_CHANNEL_ID}_${_uiState.value.notificationSound}" + ensureMessageNotificationChannel(_uiState.value.notificationSound) - 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)) 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 1269f13..913b9bc 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 @@ -97,7 +97,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" ) { /** * 是否允许连接。 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 5f8c2f8..f8135f3 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 @@ -37,4 +37,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) } 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 57ebc9b..d9ba62e 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 @@ -6,6 +6,76 @@ package com.onlinemsg.client.util */ object LanguageManager { + private val zhHantOverrides = mapOf( + "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.clear_msg" to "清空訊息", + "settings.chat_data" to "聊天資料", + "settings.dynamic_color" to "使用動態顏色", + "settings.notification_sound" to "通知音效", + "status.idle" to "未連線", + "status.connecting" to "連線中", + "status.ready" to "已連線", + "status.error" to "異常中斷", + "hint.tap_to_connect" to "點擊連線開始聊天", + "hint.connecting_server" to "正在連線伺服器...", + "hint.ready_chat" to "已連線,可以開始聊天", + "hint.closed" to "連線已關閉", + "hint.reconnecting" to "連線已中斷,正在重試", + "hint.reconnect_invalid_server" to "重連失敗:伺服器位址無效", + "hint.fill_target_key" to "請先填寫目標公鑰,再傳送私訊", + "hint.server_rejected_prefix" to "伺服器拒絕連線:", + "hint.audio_send_failed_prefix" to "語音傳送失敗:", + "chat.private" to "私訊", + "chat.target_key" to "目標公鑰", + "chat.input_placeholder" to "輸入訊息", + "chat.send" to "傳送", + "chat.sending" to "傳送中", + "chat.empty_hint" to "連線後即可聊天。預設為廣播,切換到私訊後可填寫目標公鑰。", + "chat.mode_text" to "文字", + "chat.mode_audio" to "語音", + "chat.audio_hold_to_talk" to "按住說話", + "chat.audio_release_send" to "鬆開即可傳送", + "chat.audio_release_cancel" to "鬆開即可取消", + "chat.audio_slide_cancel" to "按住時上滑即可取消", + "chat.audio_canceled" to "已取消語音傳送", + "chat.audio_sent" to "語音訊息已傳送", + "chat.audio_too_short" to "錄音時間太短", + "chat.audio_too_long" to "錄音時間太長", + "chat.audio_record_failed" to "錄音失敗", + "chat.audio_permission_required" to "需要麥克風權限", + "chat.audio_recording" to "錄音中", + "chat.audio_play" to "播放語音", + "chat.audio_stop" to "停止播放", + "sound.default" to "預設", + "sound.ding" to "叮", + "sound.nameit5" to "音效 5", + "sound.wind_chime" to "風鈴", + "theme.blue" to "蔚藍", + "theme.gray" to "商務灰", + "theme.green" to "翠綠", + "theme.red" to "緋紅", + "theme.warm" to "溫暖" + ) + private val translations = mapOf( "zh" to mapOf( "tab.chat" to "聊天", @@ -34,6 +104,11 @@ object LanguageManager { "settings.clear_msg" to "清空消息", "settings.chat_data" to "聊天数据", "settings.dynamic_color" to "使用动态颜色", + "settings.notification_sound" to "通知音效", + "sound.default" to "默认", + "sound.ding" to "叮", + "sound.nameit5" to "音效 5", + "sound.wind_chime" to "风铃", "status.idle" to "未连接", "status.connecting" to "连接中", "status.ready" to "已连接", @@ -183,6 +258,11 @@ object LanguageManager { "settings.clear_msg" to "ClearMsg", "settings.chat_data" to "Chat Data", "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", "status.idle" to "Offline", "status.connecting" to "Connecting", "status.ready" to "Connected", @@ -332,6 +412,11 @@ object LanguageManager { "settings.clear_msg" to "履歴を消去", "settings.chat_data" to "チャットデータ", "settings.dynamic_color" to "動的カラーを使用", + "settings.notification_sound" to "通知音", + "sound.default" to "デフォルト", + "sound.ding" to "ディン", + "sound.nameit5" to "効果音 5", + "sound.wind_chime" to "風鈴", "status.idle" to "未接続", "status.connecting" to "接続中", "status.ready" to "接続済み", @@ -481,6 +566,11 @@ object LanguageManager { "settings.clear_msg" to "정보 삭제", "settings.chat_data" to "채팅 데이터", "settings.dynamic_color" to "동적 색상 사용", + "settings.notification_sound" to "알림 효과음", + "sound.default" to "기본값", + "sound.ding" to "딩", + "sound.nameit5" to "효과음 5", + "sound.wind_chime" to "풍경", "status.idle" to "연결 안 됨", "status.connecting" to "연결 중", "status.ready" to "연결됨", @@ -602,15 +692,24 @@ object LanguageManager { "theme.green" to "초록", "theme.red" to "빨강", "theme.warm" to "따뜻함" - ) + ), + "zh-Hant" to zhHantOverrides ) fun getString(key: String, lang: String): String { - return translations[lang]?.get(key) ?: translations["en"]?.get(key) ?: key + return when (lang) { + "zh-Hant" -> translations["zh-Hant"]?.get(key) + ?: translations["zh"]?.get(key) + ?: translations["en"]?.get(key) + ?: key + + else -> translations[lang]?.get(key) ?: translations["en"]?.get(key) ?: key + } } val supportedLanguages = listOf( - LanguageOption("zh", "中文"), + LanguageOption("zh", "中文简体"), + LanguageOption("zh-Hant", "繁體中文"), LanguageOption("en", "English"), LanguageOption("ja", "日本语"), LanguageOption("ko", "한국어") 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