feat: настройка звука push-уведомлений (0.5.4)

Единый звук для каналов, выбор системный/свой в Устройстве, исправлен краш ringtone picker.
This commit is contained in:
PTah
2026-06-14 18:29:21 +10:00
parent 16ce2f94bb
commit 902e47880d
9 changed files with 271 additions and 20 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "ru.kalinamall.seaca" applicationId = "ru.kalinamall.seaca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 16 versionCode = 18
versionName = "0.5.2" versionName = "0.5.4"
} }
buildTypes { buildTypes {
@@ -3,9 +3,11 @@ package ru.kalinamall.seaca
import android.app.Application import android.app.Application
import ru.kalinamall.seaca.push.NotificationHelper import ru.kalinamall.seaca.push.NotificationHelper
import ru.kalinamall.seaca.push.NotificationSoundResolver
class SeacaApplication : Application() { class SeacaApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
NotificationHelper.ensureChannels(this) NotificationHelper.ensureChannels(this, NotificationSoundResolver.defaultNotificationUri())
} }
} }
@@ -0,0 +1,20 @@
package ru.kalinamall.seaca.data
enum class NotificationSoundSource(
val storageKey: String,
val label: String,
) {
SYSTEM("system", "Системный звук"),
CUSTOM("custom", "Свой звук"),
;
companion object {
fun fromStorage(value: String?): NotificationSoundSource =
entries.firstOrNull { it.storageKey == value } ?: SYSTEM
}
}
data class NotificationSoundSettings(
val source: NotificationSoundSource,
val customUri: String?,
)
@@ -40,12 +40,27 @@ class SessionStore(private val context: Context) {
DateTimeDisplayMode.fromStorage(prefs[KEY_DATE_TIME_FORMAT]) DateTimeDisplayMode.fromStorage(prefs[KEY_DATE_TIME_FORMAT])
} }
val notificationSoundSettings: Flow<NotificationSoundSettings> = context.dataStore.data.map { prefs ->
NotificationSoundSettings(
source = NotificationSoundSource.fromStorage(prefs[KEY_NOTIFICATION_SOUND_SOURCE]),
customUri = prefs[KEY_NOTIFICATION_SOUND_URI],
)
}
suspend fun getNotificationMode(): NotificationMode = suspend fun getNotificationMode(): NotificationMode =
NotificationMode.fromStorage(context.dataStore.data.first()[KEY_NOTIFICATION_MODE]) NotificationMode.fromStorage(context.dataStore.data.first()[KEY_NOTIFICATION_MODE])
suspend fun getDateTimeDisplayMode(): DateTimeDisplayMode = suspend fun getDateTimeDisplayMode(): DateTimeDisplayMode =
DateTimeDisplayMode.fromStorage(context.dataStore.data.first()[KEY_DATE_TIME_FORMAT]) DateTimeDisplayMode.fromStorage(context.dataStore.data.first()[KEY_DATE_TIME_FORMAT])
suspend fun getNotificationSoundSettings(): NotificationSoundSettings {
val prefs = context.dataStore.data.first()
return NotificationSoundSettings(
source = NotificationSoundSource.fromStorage(prefs[KEY_NOTIFICATION_SOUND_SOURCE]),
customUri = prefs[KEY_NOTIFICATION_SOUND_URI],
)
}
suspend fun setNotificationMode(mode: NotificationMode) { suspend fun setNotificationMode(mode: NotificationMode) {
context.dataStore.edit { it[KEY_NOTIFICATION_MODE] = mode.storageKey } context.dataStore.edit { it[KEY_NOTIFICATION_MODE] = mode.storageKey }
} }
@@ -54,6 +69,17 @@ class SessionStore(private val context: Context) {
context.dataStore.edit { it[KEY_DATE_TIME_FORMAT] = mode.storageKey } context.dataStore.edit { it[KEY_DATE_TIME_FORMAT] = mode.storageKey }
} }
suspend fun setNotificationSound(source: NotificationSoundSource, customUri: String?) {
context.dataStore.edit {
it[KEY_NOTIFICATION_SOUND_SOURCE] = source.storageKey
if (customUri.isNullOrBlank()) {
it.remove(KEY_NOTIFICATION_SOUND_URI)
} else {
it[KEY_NOTIFICATION_SOUND_URI] = customUri
}
}
}
suspend fun getBaseUrl(): String? = context.dataStore.data.first()[KEY_BASE_URL] suspend fun getBaseUrl(): String? = context.dataStore.data.first()[KEY_BASE_URL]
suspend fun trustAllCerts(): Boolean = context.dataStore.data.first()[KEY_TRUST_ALL] ?: false suspend fun trustAllCerts(): Boolean = context.dataStore.data.first()[KEY_TRUST_ALL] ?: false
@@ -114,6 +140,8 @@ class SessionStore(private val context: Context) {
private val KEY_TRUST_ALL = booleanPreferencesKey("trust_all_certs") private val KEY_TRUST_ALL = booleanPreferencesKey("trust_all_certs")
private val KEY_NOTIFICATION_MODE = stringPreferencesKey("notification_mode") private val KEY_NOTIFICATION_MODE = stringPreferencesKey("notification_mode")
private val KEY_DATE_TIME_FORMAT = stringPreferencesKey("date_time_format") private val KEY_DATE_TIME_FORMAT = stringPreferencesKey("date_time_format")
private val KEY_NOTIFICATION_SOUND_SOURCE = stringPreferencesKey("notification_sound_source")
private val KEY_NOTIFICATION_SOUND_URI = stringPreferencesKey("notification_sound_uri")
private const val KEY_ACCESS = "access_token" private const val KEY_ACCESS = "access_token"
private const val KEY_REFRESH = "refresh_token" private const val KEY_REFRESH = "refresh_token"
private const val KEY_DEVICE_ID = "device_id" private const val KEY_DEVICE_ID = "device_id"
@@ -5,6 +5,8 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
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 ru.kalinamall.seaca.MainActivity import ru.kalinamall.seaca.MainActivity
@@ -18,35 +20,47 @@ object NotificationHelper {
const val CHANNEL_HIGH_SILENT = "sac_high_silent" const val CHANNEL_HIGH_SILENT = "sac_high_silent"
const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent" const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
fun ensureChannels(context: Context) { private val soundChannelIds = listOf(CHANNEL_DEFAULT, CHANNEL_HIGH, CHANNEL_CRITICAL)
private var appliedSoundKey: String? = null
fun ensureChannels(context: Context, soundUri: Uri = NotificationSoundResolver.defaultNotificationUri()) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val soundKey = soundUri.toString()
val mgr = context.getSystemService(NotificationManager::class.java) val mgr = context.getSystemService(NotificationManager::class.java)
if (soundKey != appliedSoundKey) {
soundChannelIds.forEach { mgr.deleteNotificationChannel(it) }
appliedSoundKey = soundKey
}
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
mgr.createNotificationChannel( mgr.createNotificationChannel(
NotificationChannel( soundChannel(context, CHANNEL_DEFAULT, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes),
CHANNEL_DEFAULT,
context.getString(R.string.channel_default),
NotificationManager.IMPORTANCE_DEFAULT,
),
) )
mgr.createNotificationChannel( mgr.createNotificationChannel(
NotificationChannel( soundChannel(context, CHANNEL_HIGH, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
CHANNEL_HIGH,
context.getString(R.string.channel_high),
NotificationManager.IMPORTANCE_HIGH,
),
) )
mgr.createNotificationChannel( mgr.createNotificationChannel(
NotificationChannel( soundChannel(context, CHANNEL_CRITICAL, R.string.channel_critical, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
CHANNEL_CRITICAL,
context.getString(R.string.channel_critical),
NotificationManager.IMPORTANCE_HIGH,
),
) )
mgr.createNotificationChannel(silentChannel(context, CHANNEL_DEFAULT_SILENT, R.string.channel_default_silent)) mgr.createNotificationChannel(silentChannel(context, CHANNEL_DEFAULT_SILENT, R.string.channel_default_silent))
mgr.createNotificationChannel(silentChannel(context, CHANNEL_HIGH_SILENT, R.string.channel_high_silent)) mgr.createNotificationChannel(silentChannel(context, CHANNEL_HIGH_SILENT, R.string.channel_high_silent))
mgr.createNotificationChannel(silentChannel(context, CHANNEL_CRITICAL_SILENT, R.string.channel_critical_silent)) mgr.createNotificationChannel(silentChannel(context, CHANNEL_CRITICAL_SILENT, R.string.channel_critical_silent))
} }
private fun soundChannel(
context: Context,
id: String,
nameRes: Int,
importance: Int,
soundUri: Uri,
audioAttributes: AudioAttributes,
): NotificationChannel = NotificationChannel(id, context.getString(nameRes), importance).apply {
setSound(soundUri, audioAttributes)
enableVibration(true)
}
private fun silentChannel(context: Context, id: String, nameRes: Int): NotificationChannel = private fun silentChannel(context: Context, id: String, nameRes: Int): NotificationChannel =
NotificationChannel(id, context.getString(nameRes), NotificationManager.IMPORTANCE_DEFAULT).apply { NotificationChannel(id, context.getString(nameRes), NotificationManager.IMPORTANCE_DEFAULT).apply {
setSound(null, null) setSound(null, null)
@@ -66,8 +80,9 @@ object NotificationHelper {
kind: String?, kind: String?,
id: String?, id: String?,
silent: Boolean = false, silent: Boolean = false,
soundUri: Uri = NotificationSoundResolver.defaultNotificationUri(),
) { ) {
ensureChannels(context) ensureChannels(context, soundUri)
val channel = resolveChannel(severity, silent) val channel = resolveChannel(severity, silent)
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, 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
@@ -0,0 +1,26 @@
package ru.kalinamall.seaca.push
import android.content.Context
import android.media.RingtoneManager
import android.net.Uri
import ru.kalinamall.seaca.data.NotificationSoundSettings
import ru.kalinamall.seaca.data.NotificationSoundSource
object NotificationSoundResolver {
fun resolve(context: Context, settings: NotificationSoundSettings): Uri {
return when (settings.source) {
NotificationSoundSource.SYSTEM -> defaultNotificationUri()
NotificationSoundSource.CUSTOM -> {
settings.customUri?.let { Uri.parse(it) } ?: defaultNotificationUri()
}
}
}
fun defaultNotificationUri(): Uri =
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
fun displayName(context: Context, settings: NotificationSoundSettings): String {
val uri = resolve(context, settings)
return RingtoneManager.getRingtone(context, uri)?.getTitle(context) ?: ""
}
}
@@ -27,6 +27,12 @@ class SeacaMessagingService : FirebaseMessagingService() {
val data = message.data val data = message.data
val title = data["title"] ?: message.notification?.title ?: "SAC" val title = data["title"] ?: message.notification?.title ?: "SAC"
val body = data["body"] ?: message.notification?.body ?: "" val body = data["body"] ?: message.notification?.body ?: ""
val soundUri = if (silent) {
NotificationSoundResolver.defaultNotificationUri()
} else {
val settings = runBlocking { repo.session.getNotificationSoundSettings() }
NotificationSoundResolver.resolve(this, settings)
}
NotificationHelper.show( NotificationHelper.show(
context = this, context = this,
title = title, title = title,
@@ -35,6 +41,7 @@ class SeacaMessagingService : FirebaseMessagingService() {
kind = data["kind"], kind = data["kind"],
id = data["id"], id = data["id"],
silent = silent, silent = silent,
soundUri = soundUri,
) )
} }
@@ -1,5 +1,16 @@
package ru.kalinamall.seaca.ui.screens package ru.kalinamall.seaca.ui.screens
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.widget.Toast
import androidx.compose.runtime.rememberUpdatedState
import androidx.fragment.app.FragmentActivity
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -35,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -46,8 +58,12 @@ import ru.kalinamall.seaca.data.HostDetail
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.kalinamall.seaca.data.DateTimeDisplayMode import ru.kalinamall.seaca.data.DateTimeDisplayMode
import ru.kalinamall.seaca.data.NotificationMode import ru.kalinamall.seaca.data.NotificationMode
import ru.kalinamall.seaca.data.NotificationSoundSettings
import ru.kalinamall.seaca.data.NotificationSoundSource
import ru.kalinamall.seaca.data.ProblemDetail import ru.kalinamall.seaca.data.ProblemDetail
import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.push.NotificationHelper
import ru.kalinamall.seaca.push.NotificationSoundResolver
import ru.kalinamall.seaca.ui.components.BackArrowButton import ru.kalinamall.seaca.ui.components.BackArrowButton
import ru.kalinamall.seaca.ui.components.ForwardArrowButton import ru.kalinamall.seaca.ui.components.ForwardArrowButton
import ru.kalinamall.seaca.ui.util.SacDateTimes import ru.kalinamall.seaca.ui.util.SacDateTimes
@@ -418,11 +434,69 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
var username by remember { mutableStateOf<String?>(null) } var username by remember { mutableStateOf<String?>(null) }
var role by remember { mutableStateOf<String?>(null) } var role by remember { mutableStateOf<String?>(null) }
var deviceId by remember { mutableStateOf(0) } var deviceId by remember { mutableStateOf(0) }
val context = LocalContext.current
val notificationMode by repo.session.notificationMode.collectAsState(initial = NotificationMode.PUSH_SOUND) val notificationMode by repo.session.notificationMode.collectAsState(initial = NotificationMode.PUSH_SOUND)
val soundSettings by repo.session.notificationSoundSettings.collectAsStateWithLifecycle(
initialValue = NotificationSoundSettings(NotificationSoundSource.SYSTEM, null),
)
val dateTimeMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( val dateTimeMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH, initialValue = DateTimeDisplayMode.SLASH,
) )
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val activity = context as? FragmentActivity
val latestSoundSettings by rememberUpdatedState(soundSettings)
suspend fun applySoundChannels(settings: NotificationSoundSettings) {
NotificationHelper.ensureChannels(
context,
NotificationSoundResolver.resolve(context, settings),
)
}
LaunchedEffect(soundSettings) {
if (notificationMode == NotificationMode.PUSH_SOUND) {
applySoundChannels(soundSettings)
}
}
val ringtonePickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = readRingtonePickerResult(result.data)
scope.launch {
if (uri == null) {
repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null)
} else {
repo.session.setNotificationSound(NotificationSoundSource.CUSTOM, uri.toString())
}
applySoundChannels(repo.session.getNotificationSoundSettings())
}
}
fun openRingtonePicker() {
if (activity == null) {
Toast.makeText(context, "Не удалось открыть выбор звука", Toast.LENGTH_SHORT).show()
return
}
val currentUri = NotificationSoundResolver.resolve(context, latestSoundSettings)
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false)
putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, NotificationSoundResolver.defaultNotificationUri())
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, currentUri)
}
try {
ringtonePickerLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, "На устройстве нет выбора звука уведомлений", Toast.LENGTH_LONG).show()
}
}
fun previewSound() {
RingtoneManager.getRingtone(context, NotificationSoundResolver.resolve(context, soundSettings))?.play()
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
baseUrl = repo.session.getBaseUrl() baseUrl = repo.session.getBaseUrl()
@@ -482,6 +556,71 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
} }
} }
if (notificationMode == NotificationMode.PUSH_SOUND) {
Text(
stringResource(R.string.notification_sound_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
NotificationSoundSource.entries.forEach { source ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = soundSettings.source == source,
onClick = {
when (source) {
NotificationSoundSource.SYSTEM -> {
scope.launch {
repo.session.setNotificationSound(source, null)
applySoundChannels(repo.session.getNotificationSoundSettings())
}
}
NotificationSoundSource.CUSTOM -> {
if (soundSettings.customUri.isNullOrBlank()) {
openRingtonePicker()
} else {
scope.launch {
repo.session.setNotificationSound(source, soundSettings.customUri)
applySoundChannels(repo.session.getNotificationSoundSettings())
}
}
}
}
},
)
Text(
source.label,
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Text(
stringResource(
R.string.notification_sound_current,
NotificationSoundResolver.displayName(context, soundSettings),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (soundSettings.source == NotificationSoundSource.CUSTOM) {
Button(onClick = { openRingtonePicker() }) {
Text(stringResource(R.string.notification_sound_pick))
}
}
Button(onClick = { previewSound() }) {
Text(stringResource(R.string.notification_sound_preview))
}
}
}
Text( Text(
stringResource(R.string.date_time_format_title), stringResource(R.string.date_time_format_title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -591,4 +730,14 @@ private fun Field(label: String, value: String) {
} }
} }
private fun readRingtonePickerResult(data: Intent?): Uri? {
if (data == null) return null
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
} else {
@Suppress("DEPRECATION")
data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
}
}
private fun JsonElement.toPrettyString(): String = toString() private fun JsonElement.toPrettyString(): String = toString()
+4
View File
@@ -9,4 +9,8 @@
<string name="channel_critical_silent">SAC: критические (без звука)</string> <string name="channel_critical_silent">SAC: критические (без звука)</string>
<string name="notification_mode_title">Push-уведомления</string> <string name="notification_mode_title">Push-уведомления</string>
<string name="date_time_format_title">Формат даты и времени</string> <string name="date_time_format_title">Формат даты и времени</string>
<string name="notification_sound_title">Звук уведомлений</string>
<string name="notification_sound_pick">Выбрать звук</string>
<string name="notification_sound_preview">Проверить звук</string>
<string name="notification_sound_current">Сейчас: %1$s</string>
</resources> </resources>