From 902e47880d4bf6d609e24570bc59f0b20c543826 Mon Sep 17 00:00:00 2001 From: PTah Date: Sun, 14 Jun 2026 18:29:21 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B0=20=D0=B7=D0=B2=D1=83=D0=BA=D0=B0=20push-?= =?UTF-8?q?=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20(0.5.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единый звук для каналов, выбор системный/свой в Устройстве, исправлен краш ringtone picker. --- app/build.gradle.kts | 4 +- .../ru/kalinamall/seaca/SeacaApplication.kt | 4 +- .../seaca/data/NotificationSoundSource.kt | 20 +++ .../ru/kalinamall/seaca/data/SessionStore.kt | 28 ++++ .../seaca/push/NotificationHelper.kt | 49 ++++-- .../seaca/push/NotificationSoundResolver.kt | 26 +++ .../seaca/push/SeacaMessagingService.kt | 7 + .../seaca/ui/screens/DetailScreens.kt | 149 ++++++++++++++++++ app/src/main/res/values/strings.xml | 4 + 9 files changed, 271 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/ru/kalinamall/seaca/data/NotificationSoundSource.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 48aba64..534d1e8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt b/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt index 1f9c7b4..004456c 100644 --- a/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt +++ b/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt @@ -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()) } } diff --git a/app/src/main/java/ru/kalinamall/seaca/data/NotificationSoundSource.kt b/app/src/main/java/ru/kalinamall/seaca/data/NotificationSoundSource.kt new file mode 100644 index 0000000..f83bb83 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/NotificationSoundSource.kt @@ -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?, +) diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt b/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt index 87051ce..6d65b6b 100644 --- a/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt +++ b/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt @@ -40,12 +40,27 @@ class SessionStore(private val context: Context) { DateTimeDisplayMode.fromStorage(prefs[KEY_DATE_TIME_FORMAT]) } + val notificationSoundSettings: Flow = 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" diff --git a/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt index d062c74..23d7262 100644 --- a/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt +++ b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt @@ -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 diff --git a/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt b/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt new file mode 100644 index 0000000..1c3d4d3 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt @@ -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) ?: "—" + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt index 6a9af76..fc5f636 100644 --- a/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt +++ b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt @@ -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, ) } diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt index a4381dc..666b964 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt @@ -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(null) } var role by remember { mutableStateOf(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() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 050dd82..5772175 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,4 +9,8 @@ SAC: критические (без звука) Push-уведомления Формат даты и времени + Звук уведомлений + Выбрать звук + Проверить звук + Сейчас: %1$s