fix: persist custom push sound for FCM cold start (0.5.11)

Копирование ringtone во внутреннее хранилище, FileProvider, каналы с hash и миграция URI.
This commit is contained in:
PTah
2026-06-21 10:46:18 +10:00
parent 363471c563
commit 6a0758ffad
9 changed files with 137 additions and 30 deletions
+10
View File
@@ -38,5 +38,15 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</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>
</manifest>
@@ -1,13 +1,21 @@
package ru.kalinamall.seaca
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.NotificationSoundResolver
class SeacaApplication : Application() {
override fun 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
object NotificationHelper {
const val CHANNEL_DEFAULT = "sac_default"
const val CHANNEL_HIGH = "sac_high"
const val CHANNEL_CRITICAL = "sac_critical"
const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
const val CHANNEL_HIGH_SILENT = "sac_high_silent"
const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
private const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
private const val CHANNEL_HIGH_SILENT = "sac_high_silent"
private const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
private val legacySoundChannelIds = listOf("sac_default", "sac_high", "sac_critical")
private val soundChannelIds = listOf(CHANNEL_DEFAULT, CHANNEL_HIGH, CHANNEL_CRITICAL)
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()) {
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) }
legacySoundChannelIds.forEach { mgr.deleteNotificationChannel(it) }
if (appliedSoundKey != null) {
mgr.deleteNotificationChannel(channelDefault)
mgr.deleteNotificationChannel(channelHigh)
mgr.deleteNotificationChannel(channelCritical)
}
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()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
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(
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(
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_HIGH_SILENT, R.string.channel_high_silent))
@@ -106,9 +117,9 @@ object NotificationHelper {
.setPriority(
when {
silent -> NotificationCompat.PRIORITY_DEFAULT
channel == CHANNEL_CRITICAL || channel == CHANNEL_CRITICAL_SILENT ->
channel == channelCritical || channel == CHANNEL_CRITICAL_SILENT ->
NotificationCompat.PRIORITY_MAX
channel == CHANNEL_HIGH || channel == CHANNEL_HIGH_SILENT ->
channel == channelHigh || channel == CHANNEL_HIGH_SILENT ->
NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT
},
@@ -132,9 +143,9 @@ object NotificationHelper {
}
}
return when (severity?.lowercase()) {
"critical" -> CHANNEL_CRITICAL
"high" -> CHANNEL_HIGH
else -> CHANNEL_DEFAULT
"critical" -> channelCritical
"high" -> channelHigh
else -> channelDefault
}
}
}
@@ -5,19 +5,37 @@ import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import androidx.core.content.FileProvider
import ru.kalinamall.seaca.data.NotificationSoundSettings
import ru.kalinamall.seaca.data.NotificationSoundSource
import ru.kalinamall.seaca.data.SessionStore
import java.io.File
object NotificationSoundResolver {
private const val CUSTOM_SOUND_FILE = "custom_notification_sound"
fun resolve(context: Context, settings: NotificationSoundSettings): Uri {
return when (settings.source) {
NotificationSoundSource.SYSTEM -> defaultNotificationUri()
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 =
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 {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} 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) {
NotificationSoundResolver.defaultNotificationUri()
} else {
val settings = runBlocking { repo.session.getNotificationSoundSettings() }
val settings = runBlocking {
NotificationSoundResolver.normalizeStoredSettings(applicationContext, repo.session)
}
NotificationSoundResolver.resolve(this, settings)
}
NotificationHelper.show(
@@ -472,9 +472,12 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
)
}
LaunchedEffect(soundSettings) {
LaunchedEffect(soundSettings, notificationMode) {
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)
try {
RingtonePicker.launch(activity, currentUri) { uri ->
NotificationSoundResolver.persistCustomUri(context, uri)
if (uri == null) {
repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null)
} 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())
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="notification_sounds" path="." />
</paths>