feat(android): add notification sounds and zh-Hant support

pull/14/head
alimu 1 week ago
parent 16bcf34e4a
commit 02046bd3f4

@ -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.
感谢上述所有创作者。

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

@ -46,6 +46,7 @@ import androidx.compose.material.icons.rounded.Forum
import androidx.compose.material.icons.rounded.Keyboard import androidx.compose.material.icons.rounded.Keyboard
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.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Stop import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material.icons.rounded.KeyboardVoice import androidx.compose.material.icons.rounded.KeyboardVoice
@ -241,7 +242,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
) )
} }
} }
@ -1038,7 +1040,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 t(key: String) = LanguageManager.getString(key, state.language) 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 { 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
@ -201,14 +204,13 @@ 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()
val historyMessages = withContext(Dispatchers.IO) { val historyMessages = withContext(Dispatchers.IO) {
historyRepository.loadMessages(MAX_MESSAGES) historyRepository.loadMessages(MAX_MESSAGES)
} }
keepAliveRequested = pref.shouldAutoReconnect keepAliveRequested = pref.shouldAutoReconnect
ensureMessageNotificationChannel(pref.notificationSound)
_uiState.update { current -> _uiState.update { current ->
current.copy( current.copy(
displayName = pref.displayName, displayName = pref.displayName,
@ -219,6 +221,7 @@ 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
) )
} }
@ -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 主题名 * @param enabled 主题名
@ -1467,19 +1482,42 @@ object ChatSessionManager {
/** /**
* 创建消息通知渠道Android O+ * 创建消息通知渠道Android O+
*/ */
private fun ensureMessageNotificationChannel() { private fun ensureMessageNotificationChannel(soundCode: String = "default") {
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 channel = NotificationChannel( val channel = NotificationChannel(
MESSAGE_CHANNEL_ID, channelId,
t("session.notification.channel_name"), t("session.notification.channel_name"),
NotificationManager.IMPORTANCE_DEFAULT NotificationManager.IMPORTANCE_DEFAULT
).apply { ).apply {
description = t("session.notification.channel_desc") 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) 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 通知标题 * @param title 通知标题
@ -1502,8 +1540,10 @@ object ChatSessionManager {
launchIntent, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 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) .setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle(title.ifBlank { "OnlineMsg" }) .setContentTitle(title.ifBlank { "OnlineMsg" })
.setContentText(body.take(120)) .setContentText(body.take(120))

@ -97,7 +97,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"
) { ) {
/** /**
* 是否允许连接 * 是否允许连接

@ -37,4 +37,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)
} }

@ -6,6 +6,76 @@ package com.onlinemsg.client.util
*/ */
object LanguageManager { 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( private val translations = mapOf(
"zh" to mapOf( "zh" to mapOf(
"tab.chat" to "聊天", "tab.chat" to "聊天",
@ -34,6 +104,11 @@ object LanguageManager {
"settings.clear_msg" to "清空消息", "settings.clear_msg" to "清空消息",
"settings.chat_data" to "聊天数据", "settings.chat_data" 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 "风铃",
"status.idle" to "未连接", "status.idle" to "未连接",
"status.connecting" to "连接中", "status.connecting" to "连接中",
"status.ready" to "已连接", "status.ready" to "已连接",
@ -183,6 +258,11 @@ object LanguageManager {
"settings.clear_msg" to "ClearMsg", "settings.clear_msg" to "ClearMsg",
"settings.chat_data" to "Chat Data", "settings.chat_data" to "Chat Data",
"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",
"status.idle" to "Offline", "status.idle" to "Offline",
"status.connecting" to "Connecting", "status.connecting" to "Connecting",
"status.ready" to "Connected", "status.ready" to "Connected",
@ -332,6 +412,11 @@ object LanguageManager {
"settings.clear_msg" to "履歴を消去", "settings.clear_msg" to "履歴を消去",
"settings.chat_data" to "チャットデータ", "settings.chat_data" 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 "風鈴",
"status.idle" to "未接続", "status.idle" to "未接続",
"status.connecting" to "接続中", "status.connecting" to "接続中",
"status.ready" to "接続済み", "status.ready" to "接続済み",
@ -481,6 +566,11 @@ object LanguageManager {
"settings.clear_msg" to "정보 삭제", "settings.clear_msg" to "정보 삭제",
"settings.chat_data" to "채팅 데이터", "settings.chat_data" 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 "풍경",
"status.idle" to "연결 안 됨", "status.idle" to "연결 안 됨",
"status.connecting" to "연결 중", "status.connecting" to "연결 중",
"status.ready" to "연결됨", "status.ready" to "연결됨",
@ -602,15 +692,24 @@ object LanguageManager {
"theme.green" to "초록", "theme.green" to "초록",
"theme.red" to "빨강", "theme.red" to "빨강",
"theme.warm" to "따뜻함" "theme.warm" to "따뜻함"
) ),
"zh-Hant" to zhHantOverrides
) )
fun getString(key: String, lang: String): String { 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( val supportedLanguages = listOf(
LanguageOption("zh", "中文"), LanguageOption("zh", "中文简体"),
LanguageOption("zh-Hant", "繁體中文"),
LanguageOption("en", "English"), LanguageOption("en", "English"),
LanguageOption("ja", "日本语"), LanguageOption("ja", "日本语"),
LanguageOption("ko", "한국어") LanguageOption("ko", "한국어")

Loading…
Cancel
Save