fix: persist custom push sound for FCM cold start (0.5.11)
Копирование ringtone во внутреннее хранилище, FileProvider, каналы с hash и миграция URI.
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
|
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
|
||||||
|
|
||||||
**Версия:** `0.5.10` (versionCode 24)
|
**Версия:** `0.5.11` (versionCode 25)
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "ru.kalinamall.seaca"
|
applicationId = "ru.kalinamall.seaca"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 24
|
versionCode = 25
|
||||||
versionName = "0.5.10"
|
versionName = "0.5.11"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -38,5 +38,15 @@
|
|||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
package ru.kalinamall.seaca
|
package ru.kalinamall.seaca
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import ru.kalinamall.seaca.data.SessionStore
|
||||||
import ru.kalinamall.seaca.push.NotificationHelper
|
import ru.kalinamall.seaca.push.NotificationHelper
|
||||||
|
|
||||||
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
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, NotificationSoundResolver.defaultNotificationUri())
|
val store = SessionStore(this)
|
||||||
|
val settings = runBlocking {
|
||||||
|
NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
|
||||||
|
}
|
||||||
|
NotificationHelper.ensureChannels(
|
||||||
|
this,
|
||||||
|
NotificationSoundResolver.resolve(this, settings),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,36 +13,47 @@ import ru.kalinamall.seaca.MainActivity
|
|||||||
import ru.kalinamall.seaca.R
|
import ru.kalinamall.seaca.R
|
||||||
|
|
||||||
object NotificationHelper {
|
object NotificationHelper {
|
||||||
const val CHANNEL_DEFAULT = "sac_default"
|
private const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
|
||||||
const val CHANNEL_HIGH = "sac_high"
|
private const val CHANNEL_HIGH_SILENT = "sac_high_silent"
|
||||||
const val CHANNEL_CRITICAL = "sac_critical"
|
private const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
|
||||||
const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
|
|
||||||
const val CHANNEL_HIGH_SILENT = "sac_high_silent"
|
private val legacySoundChannelIds = listOf("sac_default", "sac_high", "sac_critical")
|
||||||
const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
|
|
||||||
|
|
||||||
private val soundChannelIds = listOf(CHANNEL_DEFAULT, CHANNEL_HIGH, CHANNEL_CRITICAL)
|
|
||||||
private var appliedSoundKey: String? = null
|
private var appliedSoundKey: String? = null
|
||||||
|
private var soundChannelSuffix: String = "sys"
|
||||||
|
private var channelDefault = "sac_default_sys"
|
||||||
|
private var channelHigh = "sac_high_sys"
|
||||||
|
private var channelCritical = "sac_critical_sys"
|
||||||
|
|
||||||
fun ensureChannels(context: Context, soundUri: Uri = NotificationSoundResolver.defaultNotificationUri()) {
|
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 soundKey = soundUri.toString()
|
||||||
val mgr = context.getSystemService(NotificationManager::class.java)
|
val mgr = context.getSystemService(NotificationManager::class.java)
|
||||||
if (soundKey != appliedSoundKey) {
|
if (soundKey != appliedSoundKey) {
|
||||||
soundChannelIds.forEach { mgr.deleteNotificationChannel(it) }
|
legacySoundChannelIds.forEach { mgr.deleteNotificationChannel(it) }
|
||||||
|
if (appliedSoundKey != null) {
|
||||||
|
mgr.deleteNotificationChannel(channelDefault)
|
||||||
|
mgr.deleteNotificationChannel(channelHigh)
|
||||||
|
mgr.deleteNotificationChannel(channelCritical)
|
||||||
|
}
|
||||||
appliedSoundKey = soundKey
|
appliedSoundKey = soundKey
|
||||||
|
soundChannelSuffix = soundKey.hashCode().toUInt().toString(16).take(8)
|
||||||
|
channelDefault = "sac_default_$soundChannelSuffix"
|
||||||
|
channelHigh = "sac_high_$soundChannelSuffix"
|
||||||
|
channelCritical = "sac_critical_$soundChannelSuffix"
|
||||||
}
|
}
|
||||||
val audioAttributes = AudioAttributes.Builder()
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
.build()
|
.build()
|
||||||
mgr.createNotificationChannel(
|
mgr.createNotificationChannel(
|
||||||
soundChannel(context, CHANNEL_DEFAULT, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes),
|
soundChannel(context, channelDefault, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes),
|
||||||
)
|
)
|
||||||
mgr.createNotificationChannel(
|
mgr.createNotificationChannel(
|
||||||
soundChannel(context, CHANNEL_HIGH, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
|
soundChannel(context, channelHigh, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
|
||||||
)
|
)
|
||||||
mgr.createNotificationChannel(
|
mgr.createNotificationChannel(
|
||||||
soundChannel(context, CHANNEL_CRITICAL, R.string.channel_critical, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
|
soundChannel(context, channelCritical, 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_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))
|
||||||
@@ -106,9 +117,9 @@ object NotificationHelper {
|
|||||||
.setPriority(
|
.setPriority(
|
||||||
when {
|
when {
|
||||||
silent -> NotificationCompat.PRIORITY_DEFAULT
|
silent -> NotificationCompat.PRIORITY_DEFAULT
|
||||||
channel == CHANNEL_CRITICAL || channel == CHANNEL_CRITICAL_SILENT ->
|
channel == channelCritical || channel == CHANNEL_CRITICAL_SILENT ->
|
||||||
NotificationCompat.PRIORITY_MAX
|
NotificationCompat.PRIORITY_MAX
|
||||||
channel == CHANNEL_HIGH || channel == CHANNEL_HIGH_SILENT ->
|
channel == channelHigh || channel == CHANNEL_HIGH_SILENT ->
|
||||||
NotificationCompat.PRIORITY_HIGH
|
NotificationCompat.PRIORITY_HIGH
|
||||||
else -> NotificationCompat.PRIORITY_DEFAULT
|
else -> NotificationCompat.PRIORITY_DEFAULT
|
||||||
},
|
},
|
||||||
@@ -132,9 +143,9 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return when (severity?.lowercase()) {
|
return when (severity?.lowercase()) {
|
||||||
"critical" -> CHANNEL_CRITICAL
|
"critical" -> channelCritical
|
||||||
"high" -> CHANNEL_HIGH
|
"high" -> channelHigh
|
||||||
else -> CHANNEL_DEFAULT
|
else -> channelDefault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,37 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.media.RingtoneManager
|
import android.media.RingtoneManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import ru.kalinamall.seaca.data.NotificationSoundSettings
|
import ru.kalinamall.seaca.data.NotificationSoundSettings
|
||||||
import ru.kalinamall.seaca.data.NotificationSoundSource
|
import ru.kalinamall.seaca.data.NotificationSoundSource
|
||||||
|
import ru.kalinamall.seaca.data.SessionStore
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
object NotificationSoundResolver {
|
object NotificationSoundResolver {
|
||||||
|
private const val CUSTOM_SOUND_FILE = "custom_notification_sound"
|
||||||
|
|
||||||
fun resolve(context: Context, settings: NotificationSoundSettings): Uri {
|
fun resolve(context: Context, settings: NotificationSoundSettings): Uri {
|
||||||
return when (settings.source) {
|
return when (settings.source) {
|
||||||
NotificationSoundSource.SYSTEM -> defaultNotificationUri()
|
NotificationSoundSource.SYSTEM -> defaultNotificationUri()
|
||||||
NotificationSoundSource.CUSTOM -> {
|
NotificationSoundSource.CUSTOM -> {
|
||||||
settings.customUri?.let { Uri.parse(it) } ?: defaultNotificationUri()
|
playableCustomUri(context, settings.customUri) ?: defaultNotificationUri()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun normalizeStoredSettings(context: Context, store: SessionStore): NotificationSoundSettings {
|
||||||
|
val settings = store.getNotificationSoundSettings()
|
||||||
|
if (settings.source != NotificationSoundSource.CUSTOM || settings.customUri.isNullOrBlank()) {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
if (isInternalSoundUri(context, settings.customUri)) {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
val imported = importCustomSound(context, Uri.parse(settings.customUri)) ?: return settings
|
||||||
|
store.setNotificationSound(NotificationSoundSource.CUSTOM, imported)
|
||||||
|
return store.getNotificationSoundSettings()
|
||||||
|
}
|
||||||
|
|
||||||
fun defaultNotificationUri(): Uri =
|
fun defaultNotificationUri(): Uri =
|
||||||
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||||
|
|
||||||
@@ -30,15 +48,62 @@ object NotificationSoundResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun persistCustomUri(context: Context, uri: Uri?) {
|
/**
|
||||||
if (uri == null || uri.scheme != ContentResolver.SCHEME_CONTENT) return
|
* Copies picker/system URI into app storage so FCM in a fresh process can read it.
|
||||||
|
* Returns stored URI string (FileProvider or android.resource).
|
||||||
|
*/
|
||||||
|
fun importCustomSound(context: Context, source: Uri): String? {
|
||||||
|
if (ContentResolver.SCHEME_ANDROID_RESOURCE == source.scheme) {
|
||||||
|
return source.toString()
|
||||||
|
}
|
||||||
|
if (defaultNotificationUri().toString() == source.toString()) {
|
||||||
|
return source.toString()
|
||||||
|
}
|
||||||
|
persistCustomUri(context, source)
|
||||||
|
val dest = File(context.filesDir, CUSTOM_SOUND_FILE)
|
||||||
|
return try {
|
||||||
|
context.contentResolver.openInputStream(source)?.use { input ->
|
||||||
|
dest.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
} ?: return null
|
||||||
|
internalFileUri(context, dest).toString()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playableCustomUri(context: Context, stored: String?): Uri? {
|
||||||
|
if (stored.isNullOrBlank()) return null
|
||||||
|
val parsed = Uri.parse(stored)
|
||||||
|
if (isInternalSoundUri(context, stored)) {
|
||||||
|
val file = File(context.filesDir, CUSTOM_SOUND_FILE)
|
||||||
|
return if (file.isFile) internalFileUri(context, file) else null
|
||||||
|
}
|
||||||
|
if (ContentResolver.SCHEME_ANDROID_RESOURCE == parsed.scheme) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return importCustomSound(context, parsed)?.let { Uri.parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isInternalSoundUri(context: Context, stored: String): Boolean {
|
||||||
|
val parsed = Uri.parse(stored)
|
||||||
|
return parsed.authority == fileProviderAuthority(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun internalFileUri(context: Context, file: File): Uri =
|
||||||
|
FileProvider.getUriForFile(context, fileProviderAuthority(context), file)
|
||||||
|
|
||||||
|
private fun fileProviderAuthority(context: Context): String =
|
||||||
|
"${context.packageName}.fileprovider"
|
||||||
|
|
||||||
|
private fun persistCustomUri(context: Context, uri: Uri) {
|
||||||
|
if (uri.scheme != ContentResolver.SCHEME_CONTENT) return
|
||||||
try {
|
try {
|
||||||
context.contentResolver.takePersistableUriPermission(
|
context.contentResolver.takePersistableUriPermission(
|
||||||
uri,
|
uri,
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||||
)
|
)
|
||||||
} catch (_: SecurityException) {
|
} catch (_: SecurityException) {
|
||||||
// Picker may not grant persistable permission for some URIs.
|
// Best-effort; internal copy is the reliable path.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ class SeacaMessagingService : FirebaseMessagingService() {
|
|||||||
val soundUri = if (silent) {
|
val soundUri = if (silent) {
|
||||||
NotificationSoundResolver.defaultNotificationUri()
|
NotificationSoundResolver.defaultNotificationUri()
|
||||||
} else {
|
} else {
|
||||||
val settings = runBlocking { repo.session.getNotificationSoundSettings() }
|
val settings = runBlocking {
|
||||||
|
NotificationSoundResolver.normalizeStoredSettings(applicationContext, repo.session)
|
||||||
|
}
|
||||||
NotificationSoundResolver.resolve(this, settings)
|
NotificationSoundResolver.resolve(this, settings)
|
||||||
}
|
}
|
||||||
NotificationHelper.show(
|
NotificationHelper.show(
|
||||||
|
|||||||
@@ -472,9 +472,12 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(soundSettings) {
|
LaunchedEffect(soundSettings, notificationMode) {
|
||||||
if (notificationMode == NotificationMode.PUSH_SOUND) {
|
if (notificationMode == NotificationMode.PUSH_SOUND) {
|
||||||
applySoundChannels(soundSettings)
|
val normalized = repo.session.let {
|
||||||
|
NotificationSoundResolver.normalizeStoredSettings(context, it)
|
||||||
|
}
|
||||||
|
applySoundChannels(normalized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,11 +489,15 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
|
|||||||
val currentUri = NotificationSoundResolver.resolve(context, latestSoundSettings)
|
val currentUri = NotificationSoundResolver.resolve(context, latestSoundSettings)
|
||||||
try {
|
try {
|
||||||
RingtonePicker.launch(activity, currentUri) { uri ->
|
RingtonePicker.launch(activity, currentUri) { uri ->
|
||||||
NotificationSoundResolver.persistCustomUri(context, uri)
|
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null)
|
repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null)
|
||||||
} else {
|
} else {
|
||||||
repo.session.setNotificationSound(NotificationSoundSource.CUSTOM, uri.toString())
|
val stored = NotificationSoundResolver.importCustomSound(context, uri)
|
||||||
|
if (stored == null) {
|
||||||
|
Toast.makeText(context, "Не удалось сохранить звук", Toast.LENGTH_LONG).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
repo.session.setNotificationSound(NotificationSoundSource.CUSTOM, stored)
|
||||||
}
|
}
|
||||||
applySoundChannels(repo.session.getNotificationSoundSettings())
|
applySoundChannels(repo.session.getNotificationSoundSettings())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<files-path name="notification_sounds" path="." />
|
||||||
|
</paths>
|
||||||
Reference in New Issue
Block a user