feat: настройка звука push-уведомлений (0.5.4)
Единый звук для каналов, выбор системный/свой в Устройстве, исправлен краш ringtone picker.
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "ru.kalinamall.seaca"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 16
|
||||
versionName = "0.5.2"
|
||||
versionCode = 18
|
||||
versionName = "0.5.4"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -3,9 +3,11 @@ package ru.kalinamall.seaca
|
||||
import android.app.Application
|
||||
import ru.kalinamall.seaca.push.NotificationHelper
|
||||
|
||||
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
||||
|
||||
class SeacaApplication : Application() {
|
||||
override fun 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])
|
||||
}
|
||||
|
||||
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 =
|
||||
NotificationMode.fromStorage(context.dataStore.data.first()[KEY_NOTIFICATION_MODE])
|
||||
|
||||
suspend fun getDateTimeDisplayMode(): DateTimeDisplayMode =
|
||||
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) {
|
||||
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 }
|
||||
}
|
||||
|
||||
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 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_NOTIFICATION_MODE = stringPreferencesKey("notification_mode")
|
||||
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_REFRESH = "refresh_token"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import ru.kalinamall.seaca.MainActivity
|
||||
@@ -18,35 +20,47 @@ object NotificationHelper {
|
||||
const val CHANNEL_HIGH_SILENT = "sac_high_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
|
||||
val soundKey = soundUri.toString()
|
||||
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(
|
||||
NotificationChannel(
|
||||
CHANNEL_DEFAULT,
|
||||
context.getString(R.string.channel_default),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
),
|
||||
soundChannel(context, CHANNEL_DEFAULT, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes),
|
||||
)
|
||||
mgr.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_HIGH,
|
||||
context.getString(R.string.channel_high),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
),
|
||||
soundChannel(context, CHANNEL_HIGH, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
|
||||
)
|
||||
mgr.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_CRITICAL,
|
||||
context.getString(R.string.channel_critical),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
),
|
||||
soundChannel(context, CHANNEL_CRITICAL, R.string.channel_critical, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
|
||||
)
|
||||
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_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 =
|
||||
NotificationChannel(id, context.getString(nameRes), NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
setSound(null, null)
|
||||
@@ -66,8 +80,9 @@ object NotificationHelper {
|
||||
kind: String?,
|
||||
id: String?,
|
||||
silent: Boolean = false,
|
||||
soundUri: Uri = NotificationSoundResolver.defaultNotificationUri(),
|
||||
) {
|
||||
ensureChannels(context)
|
||||
ensureChannels(context, soundUri)
|
||||
val channel = resolveChannel(severity, silent)
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
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 title = data["title"] ?: message.notification?.title ?: "SAC"
|
||||
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(
|
||||
context = this,
|
||||
title = title,
|
||||
@@ -35,6 +41,7 @@ class SeacaMessagingService : FirebaseMessagingService() {
|
||||
kind = data["kind"],
|
||||
id = data["id"],
|
||||
silent = silent,
|
||||
soundUri = soundUri,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -35,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -46,8 +58,12 @@ import ru.kalinamall.seaca.data.HostDetail
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ru.kalinamall.seaca.data.DateTimeDisplayMode
|
||||
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.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.ForwardArrowButton
|
||||
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 role by remember { mutableStateOf<String?>(null) }
|
||||
var deviceId by remember { mutableStateOf(0) }
|
||||
val context = LocalContext.current
|
||||
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(
|
||||
initialValue = DateTimeDisplayMode.SLASH,
|
||||
)
|
||||
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) {
|
||||
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(
|
||||
stringResource(R.string.date_time_format_title),
|
||||
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()
|
||||
|
||||
@@ -9,4 +9,8 @@
|
||||
<string name="channel_critical_silent">SAC: критические (без звука)</string>
|
||||
<string name="notification_mode_title">Push-уведомления</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>
|
||||
|
||||
Reference in New Issue
Block a user