Notification settings: pick custom sounds for messages and call ringtones (#6897)

* Notification settings: pick custom sounds for messages and call ringtones (#6702)

* Feature: add notification sound settings for messages and calls

* Add sound not available banner, accessibility checks

* Fix build and Konsist convention violations

- libraries:preferences:api now uses io.element.android-compose-library
  so NotificationSound's @Immutable annotation can resolve, matching the
  pattern in libraries:matrix:api.
- Rename SoundUnavailableBannerStateProvider to
  NotificationSoundUnavailableStateProvider so the class name contains
  the parameter type as required by KonsistClassNameTest.

* Suppress LargeClass on RoomListPresenterTest

The three new sound-unavailable tests pushed the class past Detekt's
default LargeClass threshold. Matches the suppression pattern already
used on five presenter test classes under features/messages/impl/test.

* NotificationSettingsView: guard ringtone-picker launchers in inspection mode

rememberLauncherForActivityResult requires a LocalActivityResultRegistryOwner,
which Paparazzi-driven preview snapshot tests don't provide, causing
NotificationSettingsViewPreview to throw IllegalStateException for every
preview parameter. Extract the click handlers into a helper that returns a
no-op when LocalInspectionMode.current is true; the runtime path is unchanged.

* Handle unresolved notification sounds mid session

* Quality checks, refactor mid session error ui

* Suppress detekt LargeClass on NotificationSettingsPresenterTest

* Remove notification sound resilience layer, replace with file copy

* Reselect element sound option, code cleanup

* Add element fade notification sound option

* Fix new file headers

* Revert to flowOf(Unit) for announcement service

* Add ringtone picker dialog to match message selector

* Update strings in conjunction with ios

* Update test to reflect new string

* Sync strings.

* Update screenshots

* Fix test

* Remove not properly located translations.
This will be fixed in the next String sync.

* Fix test on CI

---------

Co-authored-by: Jenna Vassar <jenna.vassar@toptal.com>
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2026-05-28 16:54:12 +02:00
committed by GitHub
parent bdedf41f99
commit c88edc9621
79 changed files with 3135 additions and 139 deletions
@@ -20,8 +20,12 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.tracing.LogLevel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.preferences.api.store.NotificationSound.Companion.toStored
import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val developerModeKey = booleanPreferencesKey("developerMode")
@@ -32,6 +36,12 @@ private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPr
private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate")
private val logLevelKey = stringPreferencesKey("logLevel")
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
private val messageSoundUriKey = stringPreferencesKey("notificationMessageSoundUri")
private val messageSoundChannelVersionKey = intPreferencesKey("notificationMessageSoundChannelVersion")
private val messageSoundDisplayNameKey = stringPreferencesKey("notificationMessageSoundDisplayName")
private val callRingtoneUriKey = stringPreferencesKey("notificationCallRingtoneUri")
private val callRingtoneChannelVersionKey = intPreferencesKey("notificationCallRingtoneChannelVersion")
private val callRingtoneDisplayNameKey = stringPreferencesKey("notificationCallRingtoneDisplayName")
@ContributesBinding(AppScope::class)
class DefaultAppPreferencesStore(
@@ -158,6 +168,75 @@ class DefaultAppPreferencesStore(
}
}
override fun getMessageSoundFlow(): Flow<NotificationSound> {
return store.data.map { prefs -> NotificationSound.fromStored(prefs[messageSoundUriKey]) }
}
override suspend fun setMessageSoundAndIncrementVersion(sound: NotificationSound, title: String?): Int {
var newVersion = 0
store.edit { prefs ->
val stored = sound.toStored()
if (stored != null) {
prefs[messageSoundUriKey] = stored
} else {
prefs.remove(messageSoundUriKey)
}
// Clear title on non-Custom so the picker doesn't show a stale label after a revert.
if (sound is NotificationSound.Custom && !title.isNullOrBlank()) {
prefs[messageSoundDisplayNameKey] = title
} else {
prefs.remove(messageSoundDisplayNameKey)
}
newVersion = (prefs[messageSoundChannelVersionKey] ?: 0) + 1
prefs[messageSoundChannelVersionKey] = newVersion
}
return newVersion
}
override fun getMessageSoundDisplayNameFlow(): Flow<String?> {
return store.data.map { prefs -> prefs[messageSoundDisplayNameKey] }
}
override fun getCallRingtoneFlow(): Flow<NotificationSound> {
return store.data.map { prefs -> NotificationSound.fromStored(prefs[callRingtoneUriKey]) }
}
override suspend fun setCallRingtoneAndIncrementVersion(sound: NotificationSound, title: String?): Int {
var newVersion = 0
store.edit { prefs ->
val stored = sound.toStored()
if (stored != null) {
prefs[callRingtoneUriKey] = stored
} else {
prefs.remove(callRingtoneUriKey)
}
if (sound is NotificationSound.Custom && !title.isNullOrBlank()) {
prefs[callRingtoneDisplayNameKey] = title
} else {
prefs.remove(callRingtoneDisplayNameKey)
}
newVersion = (prefs[callRingtoneChannelVersionKey] ?: 0) + 1
prefs[callRingtoneChannelVersionKey] = newVersion
}
return newVersion
}
override fun getCallRingtoneDisplayNameFlow(): Flow<String?> {
return store.data.map { prefs -> prefs[callRingtoneDisplayNameKey] }
}
override suspend fun getNotificationSoundChannelConfig(): NotificationSoundChannelConfig {
val prefs = store.data.first()
return NotificationSoundChannelConfig(
messageSound = NotificationSound.fromStored(prefs[messageSoundUriKey]),
messageSoundVersion = prefs[messageSoundChannelVersionKey] ?: 0,
messageSoundDisplayName = prefs[messageSoundDisplayNameKey],
callRingtone = NotificationSound.fromStored(prefs[callRingtoneUriKey]),
callRingtoneVersion = prefs[callRingtoneChannelVersionKey] ?: 0,
callRingtoneDisplayName = prefs[callRingtoneDisplayNameKey],
)
}
override suspend fun reset() {
store.edit { it.clear() }
}