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
+1
View File
@@ -7,4 +7,5 @@
--> -->
<paths> <paths>
<cache-path name="cache" path="." /> <cache-path name="cache" path="." />
<files-path name="notification_sounds" path="notification_sounds/" />
</paths> </paths>
@@ -35,8 +35,9 @@ class DefaultAnnouncementStore(
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> { override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
val key = announcement.toKey() val key = announcement.toKey()
// Announcement.Fullscreen.Space is disabled, consider it's shown // Default-status convention: Shown for one-shot intro/feature announcements that should
// For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08) // NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted
// in by AppMigration08).
val defaultStatus = when (announcement) { val defaultStatus = when (announcement) {
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
Announcement.NewNotificationSound -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown
@@ -245,15 +245,20 @@ private fun RoomsViewList(
) )
} }
} }
SecurityBannerState.None -> if (state.fullScreenIntentPermissionsState.shouldDisplayBanner) { // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization >
// new-notification-sound > sound-unavailable. At most one renders at a time.
SecurityBannerState.None -> when {
state.fullScreenIntentPermissionsState.shouldDisplayBanner -> {
item { item {
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState) FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
} }
} else if (state.batteryOptimizationState.shouldDisplayBanner) { }
state.batteryOptimizationState.shouldDisplayBanner -> {
item { item {
BatteryOptimizationBanner(state = state.batteryOptimizationState) BatteryOptimizationBanner(state = state.batteryOptimizationState)
} }
} else if (state.showNewNotificationSoundBanner) { }
state.showNewNotificationSoundBanner -> {
item { item {
NewNotificationSoundBanner( NewNotificationSoundBanner(
onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
@@ -261,6 +266,7 @@ private fun RoomsViewList(
} }
} }
} }
}
// Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room // Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room
// is moved to the top of the list. // is moved to the top of the list.
@@ -89,6 +89,7 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class RoomListPresenterTest { class RoomListPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -8,6 +8,8 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import io.element.android.libraries.preferences.api.store.NotificationSound
sealed interface NotificationSettingsEvents { sealed interface NotificationSettingsEvents {
data object RefreshSystemNotificationsEnabled : NotificationSettingsEvents data object RefreshSystemNotificationsEnabled : NotificationSettingsEvents
data class SetNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents data class SetNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
@@ -20,4 +22,16 @@ sealed interface NotificationSettingsEvents {
data object ChangePushProvider : NotificationSettingsEvents data object ChangePushProvider : NotificationSettingsEvents
data object CancelChangePushProvider : NotificationSettingsEvents data object CancelChangePushProvider : NotificationSettingsEvents
data class SetPushProvider(val index: Int) : NotificationSettingsEvents data class SetPushProvider(val index: Int) : NotificationSettingsEvents
data class SetMessageSound(val sound: NotificationSound) : NotificationSettingsEvents
data class SetCallRingtone(val sound: NotificationSound) : NotificationSettingsEvents
data object DismissMessageSoundCopyError : NotificationSettingsEvents
data object DismissCallRingtoneCopyError : NotificationSettingsEvents
data object ShowMessageSoundDialog : NotificationSettingsEvents
data object DismissMessageSoundDialog : NotificationSettingsEvents
data class SelectMessageSoundPreset(val sound: NotificationSound) : NotificationSettingsEvents
data object LaunchMessageSoundPicker : NotificationSettingsEvents
data object ShowCallRingtoneDialog : NotificationSettingsEvents
data object DismissCallRingtoneDialog : NotificationSettingsEvents
data class SelectCallRingtonePreset(val sound: NotificationSound) : NotificationSettingsEvents
data object LaunchCallRingtonePicker : NotificationSettingsEvents
} }
@@ -16,10 +16,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@@ -30,18 +32,30 @@ import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermiss
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.api.notifications.NotificationSoundUpdater
import io.element.android.libraries.push.api.notifications.SoundDisplayNameResolver
import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier
import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier.CopyResult
import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier.SoundSlot
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.UserPushStore
import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Inject @Inject
@@ -52,9 +66,22 @@ class NotificationSettingsPresenter(
private val pushService: PushService, private val pushService: PushService,
private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider, private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider,
private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>, private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
private val appPreferencesStore: AppPreferencesStore,
private val notificationSoundUpdater: NotificationSoundUpdater,
private val notificationSoundCopier: NotificationSoundCopier,
private val soundDisplayNameResolver: SoundDisplayNameResolver,
private val stringProvider: StringProvider,
@SessionCoroutineScope @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
) : Presenter<NotificationSettingsState> { ) : Presenter<NotificationSettingsState> {
// Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks
// its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected;
// without this, two rapid picks (e.g. an automation or accessibility service firing back-to-
// back events) could interleave and leave the channel id pointing at the older version while
// DataStore reflects the newer one.
private val messageSoundLock = Mutex()
private val callRingtoneLock = Mutex()
@Composable @Composable
override fun present(): NotificationSettingsState { override fun present(): NotificationSettingsState {
val userPushStore = remember { userPushStoreFactory.getOrCreate(matrixClient.sessionId) } val userPushStore = remember { userPushStoreFactory.getOrCreate(matrixClient.sessionId) }
@@ -109,6 +136,42 @@ class NotificationSettingsPresenter(
var showChangePushProviderDialog by remember { mutableStateOf(false) } var showChangePushProviderDialog by remember { mutableStateOf(false) }
val messageSound by remember { appPreferencesStore.getMessageSoundFlow() }.collectAsState(initial = NotificationSound.SystemDefault)
val callRingtone by remember { appPreferencesStore.getCallRingtoneFlow() }.collectAsState(initial = NotificationSound.SystemDefault)
val persistedMessageSoundTitle by remember { appPreferencesStore.getMessageSoundDisplayNameFlow() }.collectAsState(initial = null)
val persistedCallRingtoneTitle by remember { appPreferencesStore.getCallRingtoneDisplayNameFlow() }.collectAsState(initial = null)
val defaultLabel = stringProvider.getString(R.string.screen_notification_settings_sound_system_default)
// One-shot classification for the call-ringtone slot only: users who customised the
// channel via Android system settings before the in-app picker existed (channel version 0,
// persisted SystemDefault) get reflected here. The message-sound equivalent is handled by
// the eager migration in DefaultNotificationChannels.createNotificationChannels(). Cleared
// on any in-app pick so a stale read can't bleed into the row after the recreate.
var legacyCallRingtone by remember { mutableStateOf<NotificationSound?>(null) }
LaunchedEffect(Unit) {
val config = appPreferencesStore.getNotificationSoundChannelConfig()
if (config.callRingtone == NotificationSound.SystemDefault && config.callRingtoneVersion == 0) {
legacyCallRingtone = notificationSoundUpdater.readRingingCallChannelSound()
}
}
val effectiveMessageSound = messageSound
val effectiveCallRingtone = if (callRingtone == NotificationSound.SystemDefault) {
legacyCallRingtone ?: callRingtone
} else {
callRingtone
}
val messageSoundDisplayName = probeSoundDisplayName(effectiveMessageSound, persistedMessageSoundTitle, defaultLabel)
val callRingtoneDisplayName = probeSoundDisplayName(effectiveCallRingtone, persistedCallRingtoneTitle, defaultLabel)
var messageSoundCopyError by remember { mutableStateOf(false) }
var callRingtoneCopyError by remember { mutableStateOf(false) }
var showMessageSoundDialog by remember { mutableStateOf(false) }
var pendingMessageSoundPickerLaunch by remember { mutableIntStateOf(0) }
var showCallRingtoneDialog by remember { mutableStateOf(false) }
var pendingCallRingtonePickerLaunch by remember { mutableIntStateOf(0) }
fun CoroutineScope.changePushProvider( fun CoroutineScope.changePushProvider(
data: Pair<PushProvider, Distributor>? data: Pair<PushProvider, Distributor>?
) = launch { ) = launch {
@@ -157,6 +220,62 @@ class NotificationSettingsPresenter(
NotificationSettingsEvents.ChangePushProvider -> showChangePushProviderDialog = true NotificationSettingsEvents.ChangePushProvider -> showChangePushProviderDialog = true
NotificationSettingsEvents.CancelChangePushProvider -> showChangePushProviderDialog = false NotificationSettingsEvents.CancelChangePushProvider -> showChangePushProviderDialog = false
is NotificationSettingsEvents.SetPushProvider -> localCoroutineScope.changePushProvider(distributors.getOrNull(event.index)) is NotificationSettingsEvents.SetPushProvider -> localCoroutineScope.changePushProvider(distributors.getOrNull(event.index))
is NotificationSettingsEvents.SetMessageSound -> applyMessageSound(
sound = event.sound,
sessionCoroutineScope = sessionCoroutineScope,
onCopyError = { messageSoundCopyError = true },
onCopySuccess = { messageSoundCopyError = false },
onChannelFailure = { failure -> changeNotificationSettingAction.value = AsyncAction.Failure(failure) },
)
is NotificationSettingsEvents.SelectMessageSoundPreset -> {
showMessageSoundDialog = false
applyMessageSound(
sound = event.sound,
sessionCoroutineScope = sessionCoroutineScope,
onCopyError = { messageSoundCopyError = true },
onCopySuccess = { messageSoundCopyError = false },
onChannelFailure = { failure -> changeNotificationSettingAction.value = AsyncAction.Failure(failure) },
)
}
NotificationSettingsEvents.ShowMessageSoundDialog -> showMessageSoundDialog = true
NotificationSettingsEvents.DismissMessageSoundDialog -> showMessageSoundDialog = false
NotificationSettingsEvents.LaunchMessageSoundPicker -> {
showMessageSoundDialog = false
pendingMessageSoundPickerLaunch++
}
is NotificationSettingsEvents.SetCallRingtone -> {
legacyCallRingtone = null
applyCallRingtone(
sound = event.sound,
sessionCoroutineScope = sessionCoroutineScope,
onCopyError = { callRingtoneCopyError = true },
onCopySuccess = { callRingtoneCopyError = false },
onChannelFailure = { failure -> changeNotificationSettingAction.value = AsyncAction.Failure(failure) },
)
}
is NotificationSettingsEvents.SelectCallRingtonePreset -> {
showCallRingtoneDialog = false
legacyCallRingtone = null
applyCallRingtone(
sound = event.sound,
sessionCoroutineScope = sessionCoroutineScope,
onCopyError = { callRingtoneCopyError = true },
onCopySuccess = { callRingtoneCopyError = false },
onChannelFailure = { failure -> changeNotificationSettingAction.value = AsyncAction.Failure(failure) },
)
}
NotificationSettingsEvents.ShowCallRingtoneDialog -> showCallRingtoneDialog = true
NotificationSettingsEvents.DismissCallRingtoneDialog -> showCallRingtoneDialog = false
NotificationSettingsEvents.LaunchCallRingtonePicker -> {
showCallRingtoneDialog = false
pendingCallRingtonePickerLaunch++
}
NotificationSettingsEvents.DismissMessageSoundCopyError -> {
messageSoundCopyError = false
}
NotificationSettingsEvents.DismissCallRingtoneCopyError -> {
callRingtoneCopyError = false
}
} }
} }
@@ -171,10 +290,140 @@ class NotificationSettingsPresenter(
availablePushDistributors = availableDistributors, availablePushDistributors = availableDistributors,
showChangePushProviderDialog = showChangePushProviderDialog, showChangePushProviderDialog = showChangePushProviderDialog,
fullScreenIntentPermissionsState = key(refreshFullScreenIntentSettings) { fullScreenIntentPermissionsPresenter.present() }, fullScreenIntentPermissionsState = key(refreshFullScreenIntentSettings) { fullScreenIntentPermissionsPresenter.present() },
messageSound = NotificationSettingsState.SoundChannelUiState(
sound = effectiveMessageSound,
displayName = messageSoundDisplayName,
copyError = messageSoundCopyError,
),
callRingtone = NotificationSettingsState.SoundChannelUiState(
sound = effectiveCallRingtone,
displayName = callRingtoneDisplayName,
copyError = callRingtoneCopyError,
),
showMessageSoundDialog = showMessageSoundDialog,
showCallRingtoneDialog = showCallRingtoneDialog,
pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch,
pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
private fun applyMessageSound(
sound: NotificationSound,
sessionCoroutineScope: CoroutineScope,
onCopyError: () -> Unit,
onCopySuccess: () -> Unit,
onChannelFailure: (Throwable) -> Unit,
) {
sessionCoroutineScope.launch {
messageSoundLock.withLock {
// Re-selecting the current non-Custom sound is a no-op: skip the channel churn
// (delete + create round-trips through system_server) and the version bump. Custom
// still falls through so the copier refreshes the file from the (possibly updated)
// source URI.
if (sound !is NotificationSound.Custom && appPreferencesStore.getMessageSoundFlow().first() == sound) {
onCopySuccess()
return@withLock
}
val resolved = resolvePickedSound(sound, SoundSlot.Message, onCopyError) ?: return@withLock
runCatchingExceptions {
onCopySuccess()
val newVersion = appPreferencesStore.setMessageSoundAndIncrementVersion(resolved.first, resolved.second)
notificationSoundUpdater.recreateNoisyChannel(resolved.first, newVersion)
// Non-Custom picks bypass the copier, so they don't sweep the prior Custom file
// inline. Drop it now that the new channel no longer references it.
if (resolved.first !is NotificationSound.Custom) {
notificationSoundCopier.deleteStoredSoundFor(SoundSlot.Message)
}
}.onFailure(onChannelFailure)
}
}
}
private fun applyCallRingtone(
sound: NotificationSound,
sessionCoroutineScope: CoroutineScope,
onCopyError: () -> Unit,
onCopySuccess: () -> Unit,
onChannelFailure: (Throwable) -> Unit,
) {
sessionCoroutineScope.launch {
callRingtoneLock.withLock {
if (sound !is NotificationSound.Custom && appPreferencesStore.getCallRingtoneFlow().first() == sound) {
onCopySuccess()
return@withLock
}
val resolved = resolvePickedSound(sound, SoundSlot.Call, onCopyError) ?: return@withLock
runCatchingExceptions {
onCopySuccess()
val newVersion = appPreferencesStore.setCallRingtoneAndIncrementVersion(resolved.first, resolved.second)
notificationSoundUpdater.recreateRingingCallChannel(resolved.first, newVersion)
if (resolved.first !is NotificationSound.Custom) {
notificationSoundCopier.deleteStoredSoundFor(SoundSlot.Call)
}
}.onFailure(onChannelFailure)
}
}
}
/**
* Custom rows prefer [persistedTitle] (captured at copy time), fall back to a live resolver
* probe for legacy data, then to a localised "Custom" — never to [defaultLabel], so an
* unresolvable Custom isn't mislabelled as Default.
*/
@Composable
private fun probeSoundDisplayName(
sound: NotificationSound,
persistedTitle: String?,
defaultLabel: String,
): String = when (sound) {
NotificationSound.SystemDefault -> defaultLabel
NotificationSound.ElementDefault -> stringProvider.getString(R.string.screen_notification_settings_sound_element_default)
NotificationSound.ElementFade -> stringProvider.getString(R.string.screen_notification_settings_sound_element_fade)
NotificationSound.Silent -> stringProvider.getString(R.string.screen_notification_settings_sound_silent)
is NotificationSound.Custom -> {
val nonBlankPersisted = persistedTitle?.takeUnless { it.isBlank() }
if (nonBlankPersisted != null) {
nonBlankPersisted
} else {
val customFallback = stringProvider.getString(R.string.screen_notification_settings_sound_custom_fallback)
val resolved by produceState(initialValue = "", sound.uri) {
val title = soundDisplayNameResolver.resolveCustomSoundTitle(sound.uri)
value = title?.takeUnless { it.isBlank() } ?: customFallback
}
resolved
}
}
}
/**
* For a Custom pick, copies into app-private storage and returns (FileProvider URI, title).
* Returns null on copy failure after invoking [onCopyError]. Pass-through for SystemDefault /
* Silent.
*/
private suspend fun resolvePickedSound(
requested: NotificationSound,
slot: SoundSlot,
onCopyError: () -> Unit,
): Pair<NotificationSound, String?>? {
if (requested !is NotificationSound.Custom) return requested to null
return when (val result = notificationSoundCopier.copyToAppFiles(requested.uri, slot)) {
is CopyResult.Success -> NotificationSound.Custom(result.fileProviderUriString) to result.displayName
is CopyResult.Failure -> {
Timber.w(result.cause, "Notification sound copy failed for slot=%s", slot)
onCopyError()
null
}
CopyResult.UnplayableSource,
CopyResult.UnplayableCopy,
CopyResult.FileTooLarge -> {
Timber.w("Notification sound rejected: result=%s slot=%s", result::class.simpleName, slot)
onCopyError()
null
}
}
}
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private fun CoroutineScope.observeNotificationSettings( private fun CoroutineScope.observeNotificationSettings(
target: MutableState<NotificationSettingsState.MatrixSettings>, target: MutableState<NotificationSettingsState.MatrixSettings>,
@@ -12,6 +12,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -23,6 +24,24 @@ data class NotificationSettingsState(
val availablePushDistributors: ImmutableList<Distributor>, val availablePushDistributors: ImmutableList<Distributor>,
val showChangePushProviderDialog: Boolean, val showChangePushProviderDialog: Boolean,
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState, val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val messageSound: SoundChannelUiState,
val callRingtone: SoundChannelUiState,
val showMessageSoundDialog: Boolean,
val showCallRingtoneDialog: Boolean,
/**
* One-shot trigger for launching the system ringtone picker. Each
* [NotificationSettingsEvents.LaunchMessageSoundPicker] increments the value;
* the view watches this in `LaunchedEffect` and calls the launcher whenever it goes above 0.
* **Always start at 0** in initial state, providers, and previews — a non-zero seed will
* auto-open the picker on screen entry.
*/
val pendingMessageSoundPickerLaunch: Int,
/**
* One-shot trigger for launching the system ringtone picker from the call ringtone dialog.
* Same contract as [pendingMessageSoundPickerLaunch]: **always start at 0**, or the picker
* auto-opens on screen entry.
*/
val pendingCallRingtonePickerLaunch: Int,
val eventSink: (NotificationSettingsEvents) -> Unit, val eventSink: (NotificationSettingsEvents) -> Unit,
) { ) {
sealed interface MatrixSettings { sealed interface MatrixSettings {
@@ -45,6 +64,16 @@ data class NotificationSettingsState(
val appNotificationsEnabled: Boolean, val appNotificationsEnabled: Boolean,
) )
/**
* UI state for one of the two sound rows (message sound, call ringtone). [copyError] is set
* when the last pick failed to copy into app-private storage; the persisted choice stays put.
*/
data class SoundChannelUiState(
val sound: NotificationSound,
val displayName: String,
val copyError: Boolean,
)
/** /**
* Whether the advanced settings should be shown. * Whether the advanced settings should be shown.
* This is true if the current push distributor is in a failure state or if there are multiple push distributors available. * This is true if the current push distributor is in a failure state or if there are multiple push distributors available.
@@ -14,6 +14,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -45,6 +46,34 @@ open class NotificationSettingsStateProvider : PreviewParameterProvider<Notifica
aInvalidNotificationSettingsState(fixFailed = true), aInvalidNotificationSettingsState(fixFailed = true),
aValidNotificationSettingsState(fullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(permissionGranted = false)), aValidNotificationSettingsState(fullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(permissionGranted = false)),
aValidNotificationSettingsState(appNotificationEnabled = false), aValidNotificationSettingsState(appNotificationEnabled = false),
aValidNotificationSettingsState(
// Sentinel URIs — previews shouldn't depend on the host's content provider.
messageSound = NotificationSound.Custom("preview://message-sound"),
messageSoundDisplayName = "Pixel notification",
callRingtone = NotificationSound.Custom("preview://call-ringtone"),
callRingtoneDisplayName = "Pixel ringtone",
),
aValidNotificationSettingsState(
messageSound = NotificationSound.Silent,
messageSoundDisplayName = "Silent",
callRingtone = NotificationSound.Silent,
callRingtoneDisplayName = "Silent",
),
aValidNotificationSettingsState(
messageSoundCopyError = true,
callRingtoneCopyError = false,
),
aValidNotificationSettingsState(
messageSound = NotificationSound.ElementDefault,
messageSoundDisplayName = "Element default",
),
aValidNotificationSettingsState(showMessageSoundDialog = true),
aValidNotificationSettingsState(showCallRingtoneDialog = true),
aValidNotificationSettingsState(
callRingtone = NotificationSound.Custom("preview://call-ringtone"),
callRingtoneDisplayName = "Pixel ringtone",
showCallRingtoneDialog = true,
),
) )
} }
@@ -62,6 +91,16 @@ fun aValidNotificationSettingsState(
), ),
showChangePushProviderDialog: Boolean = false, showChangePushProviderDialog: Boolean = false,
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
messageSound: NotificationSound = NotificationSound.SystemDefault,
messageSoundDisplayName: String = "Element default",
messageSoundCopyError: Boolean = false,
callRingtone: NotificationSound = NotificationSound.SystemDefault,
callRingtoneDisplayName: String = "System default",
callRingtoneCopyError: Boolean = false,
showMessageSoundDialog: Boolean = false,
showCallRingtoneDialog: Boolean = false,
pendingMessageSoundPickerLaunch: Int = 0,
pendingCallRingtonePickerLaunch: Int = 0,
eventSink: (NotificationSettingsEvents) -> Unit = {}, eventSink: (NotificationSettingsEvents) -> Unit = {},
) = NotificationSettingsState( ) = NotificationSettingsState(
matrixSettings = NotificationSettingsState.MatrixSettings.Valid( matrixSettings = NotificationSettingsState.MatrixSettings.Valid(
@@ -80,6 +119,20 @@ fun aValidNotificationSettingsState(
availablePushDistributors = availablePushDistributors.toImmutableList(), availablePushDistributors = availablePushDistributors.toImmutableList(),
showChangePushProviderDialog = showChangePushProviderDialog, showChangePushProviderDialog = showChangePushProviderDialog,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState, fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
messageSound = NotificationSettingsState.SoundChannelUiState(
sound = messageSound,
displayName = messageSoundDisplayName,
copyError = messageSoundCopyError,
),
callRingtone = NotificationSettingsState.SoundChannelUiState(
sound = callRingtone,
displayName = callRingtoneDisplayName,
copyError = callRingtoneCopyError,
),
showMessageSoundDialog = showMessageSoundDialog,
showCallRingtoneDialog = showCallRingtoneDialog,
pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch,
pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -99,6 +152,20 @@ fun aInvalidNotificationSettingsState(
availablePushDistributors = persistentListOf(), availablePushDistributors = persistentListOf(),
showChangePushProviderDialog = false, showChangePushProviderDialog = false,
fullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), fullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
messageSound = NotificationSettingsState.SoundChannelUiState(
sound = NotificationSound.SystemDefault,
displayName = "System default",
copyError = false,
),
callRingtone = NotificationSettingsState.SoundChannelUiState(
sound = NotificationSound.SystemDefault,
displayName = "System default",
copyError = false,
),
showMessageSoundDialog = false,
showCallRingtoneDialog = false,
pendingMessageSoundPickerLaunch = 0,
pendingCallRingtonePickerLaunch = 0,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -8,16 +8,29 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import android.app.Activity
import android.media.RingtoneManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics import androidx.compose.foundation.progressSemantics
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent
@@ -35,13 +48,17 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
/** /**
@@ -182,6 +199,7 @@ private fun NotificationSettingsContentView(
onCheckedChange = onMentionNotificationsChange onCheckedChange = onMentionNotificationsChange
) )
} }
SoundsPreferenceCategory(state = state)
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_additional_settings_section_title)) { PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_additional_settings_section_title)) {
// TODO We are removing the call notification toggle until support for call notifications has been added // TODO We are removing the call notification toggle until support for call notifications has been added
// PreferenceSwitch( // PreferenceSwitch(
@@ -261,6 +279,189 @@ private fun NotificationSettingsContentView(
} }
} }
@Composable
private fun SoundsPreferenceCategory(state: NotificationSettingsState) {
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_sound_section_title)) {
val launchMessageSoundPicker = rememberSoundPickerOnClick(
type = RingtoneManager.TYPE_NOTIFICATION,
current = state.messageSound.sound,
defaultUri = Settings.System.DEFAULT_NOTIFICATION_URI,
onSoundPicked = { sound -> state.eventSink(NotificationSettingsEvents.SetMessageSound(sound)) },
)
// Skip the initial 0 emission so the picker doesn't auto-open on screen entry; only
// increments fired by LaunchMessageSoundPicker should launch it.
LaunchedEffect(state.pendingMessageSoundPickerLaunch) {
if (state.pendingMessageSoundPickerLaunch > 0) {
launchMessageSoundPicker()
}
}
ListItem(
headlineContent = { Text(stringResource(id = R.string.screen_notification_settings_message_sound_label)) },
supportingContent = { Text(state.messageSound.displayName) },
onClick = { state.eventSink(NotificationSettingsEvents.ShowMessageSoundDialog) },
)
if (state.messageSound.copyError) {
SoundCopyErrorRow(
onDismissClick = { state.eventSink(NotificationSettingsEvents.DismissMessageSoundCopyError) },
)
}
if (state.showMessageSoundDialog) {
MessageSoundDialog(state)
}
val launchCallRingtonePicker = rememberSoundPickerOnClick(
type = RingtoneManager.TYPE_RINGTONE,
current = state.callRingtone.sound,
defaultUri = Settings.System.DEFAULT_RINGTONE_URI,
onSoundPicked = { sound -> state.eventSink(NotificationSettingsEvents.SetCallRingtone(sound)) },
)
// Skip the initial 0 emission so the picker doesn't auto-open on screen entry; only
// increments fired by LaunchCallRingtonePicker should launch it.
LaunchedEffect(state.pendingCallRingtonePickerLaunch) {
if (state.pendingCallRingtonePickerLaunch > 0) {
launchCallRingtonePicker()
}
}
ListItem(
headlineContent = { Text(stringResource(id = R.string.screen_notification_settings_call_ringtone_label)) },
supportingContent = { Text(state.callRingtone.displayName) },
onClick = { state.eventSink(NotificationSettingsEvents.ShowCallRingtoneDialog) },
)
if (state.callRingtone.copyError) {
SoundCopyErrorRow(
onDismissClick = { state.eventSink(NotificationSettingsEvents.DismissCallRingtoneCopyError) },
)
}
if (state.showCallRingtoneDialog) {
CallRingtoneDialog(state)
}
}
}
@Composable
private fun MessageSoundDialog(state: NotificationSettingsState) {
val initialSelection = when (state.messageSound.sound) {
NotificationSound.ElementDefault -> 0
NotificationSound.ElementFade -> 1
NotificationSound.SystemDefault -> 2
else -> null
}
// When the user is on Custom or Silent, no preset row is selected — surface the current
// sound as a subtitle so screen readers (and sighted users) still have state context.
val subtitle = if (initialSelection == null) {
stringResource(
id = R.string.screen_notification_settings_message_sound_dialog_current_subtitle,
state.messageSound.displayName,
)
} else {
null
}
SingleSelectionDialog(
title = stringResource(id = R.string.screen_notification_settings_message_sound_dialog_title),
subtitle = subtitle,
options = persistentListOf(
ListOption(title = stringResource(id = R.string.screen_notification_settings_sound_element_default)),
ListOption(title = stringResource(id = R.string.screen_notification_settings_sound_element_fade)),
ListOption(title = stringResource(id = R.string.screen_notification_settings_sound_system_default)),
ListOption(title = stringResource(id = R.string.screen_notification_settings_message_sound_dialog_choose_other)),
),
initialSelection = initialSelection,
onSelectOption = { index ->
when (index) {
0 -> state.eventSink(NotificationSettingsEvents.SelectMessageSoundPreset(NotificationSound.ElementDefault))
1 -> state.eventSink(NotificationSettingsEvents.SelectMessageSoundPreset(NotificationSound.ElementFade))
2 -> state.eventSink(NotificationSettingsEvents.SelectMessageSoundPreset(NotificationSound.SystemDefault))
else -> state.eventSink(NotificationSettingsEvents.LaunchMessageSoundPicker)
}
},
onDismissRequest = { state.eventSink(NotificationSettingsEvents.DismissMessageSoundDialog) },
)
}
@Composable
private fun CallRingtoneDialog(state: NotificationSettingsState) {
val initialSelection = when (state.callRingtone.sound) {
NotificationSound.SystemDefault -> 0
else -> null
}
val subtitle = if (initialSelection == null) {
stringResource(
id = R.string.screen_notification_settings_message_sound_dialog_current_subtitle,
state.callRingtone.displayName,
)
} else {
null
}
SingleSelectionDialog(
title = stringResource(id = R.string.screen_notification_settings_call_ringtone_label),
subtitle = subtitle,
options = persistentListOf(
ListOption(title = stringResource(id = R.string.screen_notification_settings_sound_system_default)),
ListOption(title = stringResource(id = R.string.screen_notification_settings_message_sound_dialog_choose_other)),
),
initialSelection = initialSelection,
onSelectOption = { index ->
when (index) {
0 -> state.eventSink(NotificationSettingsEvents.SelectCallRingtonePreset(NotificationSound.SystemDefault))
else -> state.eventSink(NotificationSettingsEvents.LaunchCallRingtonePicker)
}
},
onDismissRequest = { state.eventSink(NotificationSettingsEvents.DismissCallRingtoneDialog) },
)
}
@Composable
private fun SoundCopyErrorRow(
onDismissClick: () -> Unit,
) {
ListItem(
modifier = Modifier
.background(ElementTheme.colors.bgSubtleSecondary)
.semantics { liveRegion = LiveRegionMode.Polite },
headlineContent = {
Text(
text = stringResource(R.string.screen_notification_settings_sound_set_sound_error_title),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
},
trailingContent = ListItemContent.Custom { _ ->
IconButton(onClick = onDismissClick) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(R.string.screen_notification_settings_sound_set_sound_error_dismiss_a11y),
)
}
},
)
}
@Composable
private fun rememberSoundPickerOnClick(
type: Int,
current: NotificationSound,
defaultUri: Uri,
onSoundPicked: (NotificationSound) -> Unit,
): () -> Unit {
// Paparazzi previews don't provide a LocalActivityResultRegistryOwner, which
// rememberLauncherForActivityResult requires. Skip the launcher in inspection mode and
// return a no-op click handler — previews don't need to launch the picker.
if (LocalInspectionMode.current) return {}
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val sound = result.data?.toPickedNotificationSound(defaultUri)
if (sound != null) {
onSoundPicked(sound)
}
}
}
return {
launcher.launch(buildRingtonePickerIntent(type = type, current = current, defaultUri = defaultUri))
}
}
@Composable @Composable
private fun getTitleForRoomNotificationMode(mode: RoomNotificationMode?) = private fun getTitleForRoomNotificationMode(mode: RoomNotificationMode?) =
when (mode) { when (mode) {
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.notifications
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import io.element.android.libraries.preferences.api.store.NotificationSound
/**
* Builds an `ACTION_RINGTONE_PICKER` intent for [type] (notification or ringtone), pre-selecting
* the user's [current] choice and showing the system [defaultUri] as the "Default" option.
*/
internal fun buildRingtonePickerIntent(
type: Int,
current: NotificationSound,
defaultUri: Uri,
): Intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, type)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, defaultUri)
val existingUri: Uri? = when (current) {
// ElementDefault and ElementFade refer to bundled raw resources, which the system picker
// can't enumerate. Fall back to the picker's "Default" row so the user has something selected.
NotificationSound.SystemDefault,
NotificationSound.ElementDefault,
NotificationSound.ElementFade -> defaultUri
NotificationSound.Silent -> null
is NotificationSound.Custom -> current.uri.toUri()
}
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existingUri)
}
/**
* Maps the URI returned by the ringtone picker into a [NotificationSound]:
* - null URI → the user picked "Silent",
* - URI matching [defaultUri] → the user picked "Default",
* - any other URI → the user picked a specific ringtone.
*/
internal fun Intent.toPickedNotificationSound(defaultUri: Uri): NotificationSound {
val pickedUri: Uri? = IntentCompat.getParcelableExtra(this, RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
return when {
pickedUri == null -> NotificationSound.Silent
pickedUri == defaultUri -> NotificationSound.SystemDefault
else -> NotificationSound.Custom(pickedUri.toString())
}
}
@@ -58,6 +58,7 @@
<string name="screen_labs_header_title">"Feeling experimental?"</string> <string name="screen_labs_header_title">"Feeling experimental?"</string>
<string name="screen_labs_title">"Labs"</string> <string name="screen_labs_title">"Labs"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Additional settings"</string> <string name="screen_notification_settings_additional_settings_section_title">"Additional settings"</string>
<string name="screen_notification_settings_call_ringtone_label">"Call ringtone"</string>
<string name="screen_notification_settings_calls_label">"Audio and video calls"</string> <string name="screen_notification_settings_calls_label">"Audio and video calls"</string>
<string name="screen_notification_settings_configuration_mismatch">"Configuration mismatch"</string> <string name="screen_notification_settings_configuration_mismatch">"Configuration mismatch"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"We’ve simplified Notifications Settings to make options easier to find. Some custom settings you’ve chosen in the past are not shown here, but they’re still active. <string name="screen_notification_settings_configuration_mismatch_description">"We’ve simplified Notifications Settings to make options easier to find. Some custom settings you’ve chosen in the past are not shown here, but they’re still active.
@@ -76,10 +77,15 @@ If you proceed, some of your settings may change."</string>
<string name="screen_notification_settings_invite_for_me_label">"Invitations"</string> <string name="screen_notification_settings_invite_for_me_label">"Invitations"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Your homeserver does not support this option in encrypted rooms, you may not get notified in some rooms."</string> <string name="screen_notification_settings_mentions_only_disclaimer">"Your homeserver does not support this option in encrypted rooms, you may not get notified in some rooms."</string>
<string name="screen_notification_settings_mentions_section_title">"Mentions"</string> <string name="screen_notification_settings_mentions_section_title">"Mentions"</string>
<string name="screen_notification_settings_message_sound_dialog_choose_other">"Choose another sound…"</string>
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"Currently using %1$s"</string>
<string name="screen_notification_settings_message_sound_dialog_title">"Message sound"</string>
<string name="screen_notification_settings_message_sound_label">"Message sound"</string>
<string name="screen_notification_settings_mode_all">"All"</string> <string name="screen_notification_settings_mode_all">"All"</string>
<string name="screen_notification_settings_mode_mentions">"Mentions"</string> <string name="screen_notification_settings_mode_mentions">"Mentions"</string>
<string name="screen_notification_settings_notification_section_title">"Notify me for"</string> <string name="screen_notification_settings_notification_section_title">"Notify me for"</string>
<string name="screen_notification_settings_room_mention_label">"Notify me on @room"</string> <string name="screen_notification_settings_room_mention_label">"Notify me on @room"</string>
<string name="screen_notification_settings_sound_custom_fallback">"Custom"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"Custom sound…"</string> <string name="screen_notification_settings_sound_custom_sound_button_title">"Custom sound…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"Error deleting file"</string> <string name="screen_notification_settings_sound_delete_sound_error_title">"Error deleting file"</string>
<string name="screen_notification_settings_sound_element_default">"Element Default"</string> <string name="screen_notification_settings_sound_element_default">"Element Default"</string>
@@ -87,7 +93,9 @@ If you proceed, some of your settings may change."</string>
<string name="screen_notification_settings_sound_import_sound_error_title">"Error importing file"</string> <string name="screen_notification_settings_sound_import_sound_error_title">"Error importing file"</string>
<string name="screen_notification_settings_sound_preview_sound_error_title">"Problem previewing alert sound"</string> <string name="screen_notification_settings_sound_preview_sound_error_title">"Problem previewing alert sound"</string>
<string name="screen_notification_settings_sound_section_title">"Sound"</string> <string name="screen_notification_settings_sound_section_title">"Sound"</string>
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"Dismiss alert sound error"</string>
<string name="screen_notification_settings_sound_set_sound_error_title">"Problem setting alert sound"</string> <string name="screen_notification_settings_sound_set_sound_error_title">"Problem setting alert sound"</string>
<string name="screen_notification_settings_sound_silent">"Silent"</string>
<string name="screen_notification_settings_sound_system_alert">"Alert"</string> <string name="screen_notification_settings_sound_system_alert">"Alert"</string>
<string name="screen_notification_settings_sound_system_anticipate">"Anticipate"</string> <string name="screen_notification_settings_sound_system_anticipate">"Anticipate"</string>
<string name="screen_notification_settings_sound_system_bell">"Bell"</string> <string name="screen_notification_settings_sound_system_bell">"Bell"</string>
@@ -95,6 +103,7 @@ If you proceed, some of your settings may change."</string>
<string name="screen_notification_settings_sound_system_calypso">"Calypso"</string> <string name="screen_notification_settings_sound_system_calypso">"Calypso"</string>
<string name="screen_notification_settings_sound_system_chime">"Chime"</string> <string name="screen_notification_settings_sound_system_chime">"Chime"</string>
<string name="screen_notification_settings_sound_system_choo_choo">"Choo Choo"</string> <string name="screen_notification_settings_sound_system_choo_choo">"Choo Choo"</string>
<string name="screen_notification_settings_sound_system_default">"System Default"</string>
<string name="screen_notification_settings_sound_system_descent">"Descent"</string> <string name="screen_notification_settings_sound_system_descent">"Descent"</string>
<string name="screen_notification_settings_sound_system_electronic">"Electronic"</string> <string name="screen_notification_settings_sound_system_electronic">"Electronic"</string>
<string name="screen_notification_settings_sound_system_fanfare">"Fanfare"</string> <string name="screen_notification_settings_sound_system_fanfare">"Fanfare"</string>
@@ -17,21 +17,35 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.api.notifications.NotificationSoundUpdater
import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier
import io.element.android.libraries.push.test.FakePushService import io.element.android.libraries.push.test.FakePushService
import io.element.android.libraries.push.test.notifications.FakeNotificationSoundUpdater
import io.element.android.libraries.push.test.notifications.FakeSoundDisplayNameResolver
import io.element.android.libraries.push.test.notifications.sound.FakeNotificationSoundCopier
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushproviders.test.FakePushProvider import io.element.android.libraries.pushproviders.test.FakePushProvider
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Suppress("LargeClass")
class NotificationSettingsPresenterTest { class NotificationSettingsPresenterTest {
@Test @Test
fun `present - ensures initial state is correct`() = runTest { fun `present - ensures initial state is correct`() = runTest {
@@ -353,10 +367,756 @@ class NotificationSettingsPresenterTest {
) )
} }
@Test
fun `present - SetMessageSound atomically persists, increments version, and recreates channel`() = runTest {
val recreateRecorder = lambdaRecorder<NotificationSound, Int, Unit> { _, _ -> }
val appPreferencesStore = InMemoryAppPreferencesStore()
val customSound = NotificationSound.Custom("content://media/internal/audio/media/42")
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateNoisyChannelLambda = { sound, version -> recreateRecorder(sound, version) },
),
)
presenter.test {
val initialState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
assertThat(initialState.messageSound.sound).isEqualTo(NotificationSound.SystemDefault)
initialState.eventSink(NotificationSettingsEvents.SetMessageSound(customSound))
val updatedState = consumeItemsUntilPredicate { it.messageSound.sound == customSound }.last()
assertThat(updatedState.messageSound.sound).isEqualTo(customSound)
assertThat(appPreferencesStore.getNotificationSoundChannelConfig().messageSoundVersion).isEqualTo(1)
recreateRecorder.assertions().isCalledOnce().with(value(customSound), value(1))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SetCallRingtone with Silent atomically persists and recreates channel`() = runTest {
val recreateRecorder = lambdaRecorder<NotificationSound, Int, Unit> { _, _ -> }
val appPreferencesStore = InMemoryAppPreferencesStore()
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateRingingCallChannelLambda = { sound, version -> recreateRecorder(sound, version) },
),
)
presenter.test {
val initialState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
assertThat(initialState.callRingtone.sound).isEqualTo(NotificationSound.SystemDefault)
initialState.eventSink(NotificationSettingsEvents.SetCallRingtone(NotificationSound.Silent))
val updatedState = consumeItemsUntilPredicate { it.callRingtone.sound == NotificationSound.Silent }.last()
assertThat(updatedState.callRingtone.sound).isEqualTo(NotificationSound.Silent)
assertThat(appPreferencesStore.getNotificationSoundChannelConfig().callRingtoneVersion).isEqualTo(1)
recreateRecorder.assertions().isCalledOnce().with(value(NotificationSound.Silent), value(1))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - successive message sound changes increment version each time`() = runTest {
val versions = mutableListOf<Int>()
val appPreferencesStore = InMemoryAppPreferencesStore()
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateNoisyChannelLambda = { _, version -> versions += version },
),
)
presenter.test {
val initialState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
initialState.eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.Custom("content://a")))
consumeItemsUntilPredicate { it.messageSound.sound == NotificationSound.Custom("content://a") }
initialState.eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.Custom("content://b")))
consumeItemsUntilPredicate { it.messageSound.sound == NotificationSound.Custom("content://b") }
initialState.eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.SystemDefault))
consumeItemsUntilPredicate { it.messageSound.sound == NotificationSound.SystemDefault }
assertThat(versions).containsExactly(1, 2, 3).inOrder()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SetMessageSound with SystemDefault clears existing custom URI`() = runTest {
val recreateRecorder = lambdaRecorder<NotificationSound, Int, Unit> { _, _ -> }
val appPreferencesStore = InMemoryAppPreferencesStore(messageSound = NotificationSound.Custom("content://existing"))
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateNoisyChannelLambda = { sound, version -> recreateRecorder(sound, version) },
),
)
presenter.test {
consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid &&
it.messageSound.sound == NotificationSound.Custom("content://existing")
}.last().eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.SystemDefault))
consumeItemsUntilPredicate { it.messageSound.sound == NotificationSound.SystemDefault }
assertThat(appPreferencesStore.getNotificationSoundChannelConfig().messageSound).isEqualTo(NotificationSound.SystemDefault)
recreateRecorder.assertions().isCalledOnce().with(value(NotificationSound.SystemDefault), value(1))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SetMessageSound surfaces DataStore failure via changeNotificationSettingAction`() = runTest {
val backing = InMemoryAppPreferencesStore()
val store = object : AppPreferencesStore by backing {
override suspend fun setMessageSoundAndIncrementVersion(sound: NotificationSound, title: String?): Int {
error("Simulated DataStore failure")
}
}
val presenter = createNotificationSettingsPresenter(appPreferencesStore = store)
presenter.test {
val initialState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
initialState.eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.Custom("content://x")))
val errorState = consumeItemsUntilPredicate {
it.changeNotificationSettingAction.isFailure()
}.last()
assertThat(errorState.changeNotificationSettingAction.isFailure()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SystemDefault and Silent display names do not invoke the resolver`() = runTest {
val resolverCalls = mutableListOf<String>()
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(
messageSound = NotificationSound.SystemDefault,
callRingtone = NotificationSound.Silent,
),
soundDisplayNameResolver = FakeSoundDisplayNameResolver(
resolveLambda = { uri ->
resolverCalls += uri
"Should not appear"
},
),
)
presenter.test {
// The synchronous branches (SystemDefault / Silent) feed the label directly without
// routing through produceState — so the resolver is never invoked, and the label
// never flashes from the default to itself.
consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
assertThat(resolverCalls).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - ElementDefault and ElementFade display names do not invoke the resolver`() = runTest {
val resolverCalls = mutableListOf<String>()
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(
messageSound = NotificationSound.ElementFade,
callRingtone = NotificationSound.ElementDefault,
),
soundDisplayNameResolver = FakeSoundDisplayNameResolver(
resolveLambda = { uri ->
resolverCalls += uri
"Should not appear"
},
),
)
presenter.test {
consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
assertThat(resolverCalls).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - resolves Custom display name via SoundDisplayNameResolver`() = runTest {
val resolverCalls = mutableListOf<String>()
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(messageSound = NotificationSound.Custom("content://media/42")),
soundDisplayNameResolver = FakeSoundDisplayNameResolver(
resolveLambda = { uri ->
resolverCalls += uri
"Pixel notification"
},
),
)
presenter.test {
val state = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid &&
it.messageSound.displayName == "Pixel notification"
}.last()
assertThat(state.messageSound.displayName).isEqualTo("Pixel notification")
assertThat(resolverCalls).contains("content://media/42")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - Custom display name falls back to localized Custom string when no persisted title and resolver returns null`() = runTest {
val resolverCalls = mutableListOf<String>()
val presenter = createNotificationSettingsPresenter(
// No persisted display name (legacy data path). Resolver returns null. Final fallback
// is the localised string resource (FakeStringProvider returns "A string" for any
// resource id) — never the default label, so the row is not mislabelled as "System default".
appPreferencesStore = InMemoryAppPreferencesStore(messageSound = NotificationSound.Custom("content://gone")),
soundDisplayNameResolver = FakeSoundDisplayNameResolver(
resolveLambda = { uri ->
resolverCalls += uri
null
},
),
)
presenter.test {
val state = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid &&
it.messageSound.sound is NotificationSound.Custom &&
it.messageSound.displayName == "A string"
}.last()
assertThat(state.messageSound.displayName).isEqualTo("A string")
assertThat(resolverCalls).contains("content://gone")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - persisted in-app pick takes precedence over channel read`() = runTest {
// When DataStore holds a real in-app pick, the channel read must not run. Persisted
// value is what the user explicitly chose; channel state is only a fallback.
var channelReadCalls = 0
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(
messageSound = NotificationSound.Custom("content://persisted"),
messageSoundDisplayName = "Persisted Tone",
),
notificationSoundUpdater = FakeNotificationSoundUpdater(
readNoisyChannelSoundLambda = {
channelReadCalls++
NotificationSound.Custom("content://channel/should-not-appear")
},
),
)
presenter.test {
val state = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid &&
it.messageSound.displayName == "Persisted Tone"
}.last()
assertThat(state.messageSound.sound).isEqualTo(NotificationSound.Custom("content://persisted"))
assertThat(channelReadCalls).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - persisted display name takes precedence over live resolver probe`() = runTest {
val resolverCalls = mutableListOf<String>()
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(
messageSound = NotificationSound.Custom("content://media/42"),
messageSoundDisplayName = "Captured at copy time",
),
soundDisplayNameResolver = FakeSoundDisplayNameResolver(
resolveLambda = { uri ->
resolverCalls += uri
"Live probe (should not appear)"
},
),
)
presenter.test {
val state = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid &&
it.messageSound.displayName == "Captured at copy time"
}.last()
assertThat(state.messageSound.displayName).isEqualTo("Captured at copy time")
// Live resolver must not be invoked when a persisted title is available.
assertThat(resolverCalls).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - SetMessageSound with Custom copies via copier and persists the FileProvider URI`() = runTest {
val recreateRecorder = lambdaRecorder<NotificationSound, Int, Unit> { _, _ -> }
val appPreferencesStore = InMemoryAppPreferencesStore()
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateNoisyChannelLambda = { sound, version -> recreateRecorder(sound, version) },
),
notificationSoundCopier = FakeNotificationSoundCopier(copyLambda = { _, _ ->
NotificationSoundCopier.CopyResult.Success(
fileProviderUriString = "content://my.app.fileprovider/notification_sounds/message_sound.ogg",
displayName = "Cool Tone",
)
}),
)
presenter.test {
val initialState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
initialState.eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.Custom("content://media/source")))
advanceUntilIdle()
val expectedSound = NotificationSound.Custom("content://my.app.fileprovider/notification_sounds/message_sound.ogg")
assertThat(appPreferencesStore.getNotificationSoundChannelConfig().messageSound).isEqualTo(expectedSound)
assertThat(appPreferencesStore.getMessageSoundDisplayNameFlow().first()).isEqualTo("Cool Tone")
recreateRecorder.assertions().isCalledOnce().with(value(expectedSound), value(1))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SetMessageSound copy failure surfaces inline error and leaves prior choice intact`() = runTest {
val priorChoice = NotificationSound.Custom("content://prior")
val appPreferencesStore = InMemoryAppPreferencesStore(messageSound = priorChoice)
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundCopier = FakeNotificationSoundCopier(copyLambda = { _, _ ->
NotificationSoundCopier.CopyResult.UnplayableSource
}),
)
presenter.test {
val initialState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
initialState.eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.Custom("content://broken")))
val errorState = consumeItemsUntilPredicate { it.messageSound.copyError }.last()
assertThat(errorState.messageSound.copyError).isTrue()
// Persisted state is unchanged.
assertThat(appPreferencesStore.getNotificationSoundChannelConfig().messageSound).isEqualTo(priorChoice)
// Dismissing the inline error clears the flag without altering persistence.
errorState.eventSink(NotificationSettingsEvents.DismissMessageSoundCopyError)
val cleared = consumeItemsUntilPredicate { !it.messageSound.copyError }.last()
assertThat(cleared.messageSound.copyError).isFalse()
assertThat(appPreferencesStore.getNotificationSoundChannelConfig().messageSound).isEqualTo(priorChoice)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SetCallRingtone copy failure surfaces inline error and leaves prior choice intact`() = runTest {
val priorChoice = NotificationSound.Custom("content://prior")
val appPreferencesStore = InMemoryAppPreferencesStore(callRingtone = priorChoice)
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundCopier = FakeNotificationSoundCopier(copyLambda = { _, _ ->
NotificationSoundCopier.CopyResult.UnplayableSource
}),
)
presenter.test {
val initialState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
initialState.eventSink(NotificationSettingsEvents.SetCallRingtone(NotificationSound.Custom("content://broken")))
val errorState = consumeItemsUntilPredicate { it.callRingtone.copyError }.last()
assertThat(errorState.callRingtone.copyError).isTrue()
// Persisted state is unchanged.
assertThat(appPreferencesStore.getNotificationSoundChannelConfig().callRingtone).isEqualTo(priorChoice)
// Dismissing the inline error clears the flag without altering persistence.
errorState.eventSink(NotificationSettingsEvents.DismissCallRingtoneCopyError)
val cleared = consumeItemsUntilPredicate { !it.callRingtone.copyError }.last()
assertThat(cleared.callRingtone.copyError).isFalse()
assertThat(appPreferencesStore.getNotificationSoundChannelConfig().callRingtone).isEqualTo(priorChoice)
cancelAndIgnoreRemainingEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - SystemDefault and Silent picks bypass the copier`() = runTest {
val copierCalls = mutableListOf<String>()
val presenter = createNotificationSettingsPresenter(
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateNoisyChannelLambda = { _, _ -> },
recreateRingingCallChannelLambda = { _, _ -> },
),
notificationSoundCopier = FakeNotificationSoundCopier(copyLambda = { source, _ ->
copierCalls += source
NotificationSoundCopier.CopyResult.Success(source, "x")
}),
)
presenter.test {
val initialState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
initialState.eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.SystemDefault))
initialState.eventSink(NotificationSettingsEvents.SetCallRingtone(NotificationSound.Silent))
advanceUntilIdle()
assertThat(copierCalls).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - non-Custom picks delete the stored slot file`() = runTest {
val deleteCalls = mutableListOf<NotificationSoundCopier.SoundSlot>()
// Start from persisted Custom for both slots so the non-Custom picks below are real
// transitions (no-op short-circuit only fires when the new sound equals the persisted one).
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(
messageSound = NotificationSound.Custom("content://prior-message"),
callRingtone = NotificationSound.Custom("content://prior-call"),
),
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateNoisyChannelLambda = { _, _ -> },
recreateRingingCallChannelLambda = { _, _ -> },
),
notificationSoundCopier = FakeNotificationSoundCopier(
copyLambda = { _, _ -> NotificationSoundCopier.CopyResult.Failure(IllegalStateException("not expected")) },
deleteStoredSoundForLambda = { slot -> deleteCalls += slot },
),
)
presenter.test {
val initialState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
initialState.eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.SystemDefault))
initialState.eventSink(NotificationSettingsEvents.SetCallRingtone(NotificationSound.Silent))
advanceUntilIdle()
assertThat(deleteCalls).containsExactly(
NotificationSoundCopier.SoundSlot.Message,
NotificationSoundCopier.SoundSlot.Call,
)
cancelAndIgnoreRemainingEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - re-selecting the persisted non-Custom sound is a no-op`() = runTest {
val recreateCalls = mutableListOf<Int>()
val deleteCalls = mutableListOf<NotificationSoundCopier.SoundSlot>()
val copierCalls = mutableListOf<String>()
val appPreferencesStore = InMemoryAppPreferencesStore(
messageSound = NotificationSound.ElementDefault,
messageSoundChannelVersion = 5,
callRingtone = NotificationSound.Silent,
callRingtoneChannelVersion = 3,
)
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundCopier = FakeNotificationSoundCopier(
copyLambda = { source, _ ->
copierCalls += source
NotificationSoundCopier.CopyResult.Success(source, "title")
},
deleteStoredSoundForLambda = { slot -> deleteCalls += slot },
),
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateNoisyChannelLambda = { _, version -> recreateCalls += version },
recreateRingingCallChannelLambda = { _, version -> recreateCalls += version },
),
)
presenter.test {
val initial = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
// Re-pick the persisted sound for both slots — neither should churn the channel,
// bump the version, or hit the copier / delete sweep.
initial.eventSink(NotificationSettingsEvents.SelectMessageSoundPreset(NotificationSound.ElementDefault))
initial.eventSink(NotificationSettingsEvents.SetCallRingtone(NotificationSound.Silent))
advanceUntilIdle()
val config = appPreferencesStore.getNotificationSoundChannelConfig()
assertThat(config.messageSoundVersion).isEqualTo(5)
assertThat(config.callRingtoneVersion).isEqualTo(3)
assertThat(recreateCalls).isEmpty()
assertThat(copierCalls).isEmpty()
assertThat(deleteCalls).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - rapid back-to-back picks settle in deterministic order`() = runTest {
// Defends the persist+recreate window against rapid event firing (e.g. an accessibility
// service driving the dialog). The per-slot mutex in the presenter forces copy → persist
// → recreate to complete for the first pick before the second begins, so the recreated
// version sequence matches the persisted version sequence.
val recreatedVersions = mutableListOf<Int>()
val copyOrder = mutableListOf<String>()
val appPreferencesStore = InMemoryAppPreferencesStore()
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundCopier = FakeNotificationSoundCopier(copyLambda = { source, _ ->
copyOrder += source
NotificationSoundCopier.CopyResult.Success(source, "title")
}),
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateNoisyChannelLambda = { _, version -> recreatedVersions += version },
),
)
presenter.test {
val initial = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
initial.eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.Custom("content://A")))
initial.eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.Custom("content://B")))
advanceUntilIdle()
assertThat(copyOrder).containsExactly("content://A", "content://B").inOrder()
assertThat(recreatedVersions).containsExactly(1, 2).inOrder()
val config = appPreferencesStore.getNotificationSoundChannelConfig()
assertThat(config.messageSoundVersion).isEqualTo(2)
assertThat(config.messageSound).isEqualTo(NotificationSound.Custom("content://B"))
cancelAndIgnoreRemainingEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - Custom pick does not invoke deleteStoredSoundFor (copier sweeps inline)`() = runTest {
val deleteCalls = mutableListOf<NotificationSoundCopier.SoundSlot>()
val presenter = createNotificationSettingsPresenter(
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateNoisyChannelLambda = { _, _ -> },
),
notificationSoundCopier = FakeNotificationSoundCopier(
copyLambda = { _, _ ->
NotificationSoundCopier.CopyResult.Success(
fileProviderUriString = "content://my.app.fileprovider/notification_sounds/message_sound.mp3",
displayName = "New Tone",
)
},
deleteStoredSoundForLambda = { slot -> deleteCalls += slot },
),
)
presenter.test {
val initialState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
initialState.eventSink(NotificationSettingsEvents.SetMessageSound(NotificationSound.Custom("content://media/source")))
advanceUntilIdle()
assertThat(deleteCalls).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - ShowMessageSoundDialog and DismissMessageSoundDialog toggle dialog visibility`() = runTest {
val presenter = createNotificationSettingsPresenter()
presenter.test {
val initial = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
assertThat(initial.showMessageSoundDialog).isFalse()
initial.eventSink(NotificationSettingsEvents.ShowMessageSoundDialog)
val shown = consumeItemsUntilPredicate { it.showMessageSoundDialog }.last()
assertThat(shown.showMessageSoundDialog).isTrue()
shown.eventSink(NotificationSettingsEvents.DismissMessageSoundDialog)
val hidden = consumeItemsUntilPredicate { !it.showMessageSoundDialog }.last()
assertThat(hidden.showMessageSoundDialog).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - SelectMessageSoundPreset persists ElementDefault, recreates the channel and closes the dialog`() = runTest {
val recreateRecorder = lambdaRecorder<NotificationSound, Int, Unit> { _, _ -> }
val appPreferencesStore = InMemoryAppPreferencesStore()
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateNoisyChannelLambda = { sound, version -> recreateRecorder(sound, version) },
),
)
presenter.test {
val initial = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
initial.eventSink(NotificationSettingsEvents.ShowMessageSoundDialog)
val shown = consumeItemsUntilPredicate { it.showMessageSoundDialog }.last()
shown.eventSink(NotificationSettingsEvents.SelectMessageSoundPreset(NotificationSound.ElementDefault))
advanceUntilIdle()
consumeItemsUntilPredicate {
!it.showMessageSoundDialog && it.messageSound.sound == NotificationSound.ElementDefault
}
assertThat(appPreferencesStore.getNotificationSoundChannelConfig().messageSound)
.isEqualTo(NotificationSound.ElementDefault)
recreateRecorder.assertions().isCalledOnce().with(value(NotificationSound.ElementDefault), value(1))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - LaunchMessageSoundPicker increments the picker token and closes the dialog`() = runTest {
val presenter = createNotificationSettingsPresenter()
presenter.test {
val initial = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
assertThat(initial.pendingMessageSoundPickerLaunch).isEqualTo(0)
initial.eventSink(NotificationSettingsEvents.ShowMessageSoundDialog)
val shown = consumeItemsUntilPredicate { it.showMessageSoundDialog }.last()
shown.eventSink(NotificationSettingsEvents.LaunchMessageSoundPicker)
val launched = consumeItemsUntilPredicate { it.pendingMessageSoundPickerLaunch == 1 }.last()
assertThat(launched.showMessageSoundDialog).isFalse()
assertThat(launched.pendingMessageSoundPickerLaunch).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - ShowCallRingtoneDialog and DismissCallRingtoneDialog toggle dialog visibility`() = runTest {
val presenter = createNotificationSettingsPresenter()
presenter.test {
val initial = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
assertThat(initial.showCallRingtoneDialog).isFalse()
initial.eventSink(NotificationSettingsEvents.ShowCallRingtoneDialog)
val shown = consumeItemsUntilPredicate { it.showCallRingtoneDialog }.last()
assertThat(shown.showCallRingtoneDialog).isTrue()
shown.eventSink(NotificationSettingsEvents.DismissCallRingtoneDialog)
val hidden = consumeItemsUntilPredicate { !it.showCallRingtoneDialog }.last()
assertThat(hidden.showCallRingtoneDialog).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - SelectCallRingtonePreset persists SystemDefault, recreates the channel and closes the dialog`() = runTest {
val recreateRecorder = lambdaRecorder<NotificationSound, Int, Unit> { _, _ -> }
val appPreferencesStore = InMemoryAppPreferencesStore(callRingtone = NotificationSound.Silent)
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateRingingCallChannelLambda = { sound, version -> recreateRecorder(sound, version) },
),
)
presenter.test {
val initial = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid &&
it.callRingtone.sound == NotificationSound.Silent
}.last()
initial.eventSink(NotificationSettingsEvents.ShowCallRingtoneDialog)
val shown = consumeItemsUntilPredicate { it.showCallRingtoneDialog }.last()
shown.eventSink(NotificationSettingsEvents.SelectCallRingtonePreset(NotificationSound.SystemDefault))
advanceUntilIdle()
consumeItemsUntilPredicate {
!it.showCallRingtoneDialog && it.callRingtone.sound == NotificationSound.SystemDefault
}
assertThat(appPreferencesStore.getNotificationSoundChannelConfig().callRingtone)
.isEqualTo(NotificationSound.SystemDefault)
recreateRecorder.assertions().isCalledOnce().with(value(NotificationSound.SystemDefault), value(1))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - LaunchCallRingtonePicker increments the picker token and closes the dialog`() = runTest {
val presenter = createNotificationSettingsPresenter()
presenter.test {
val initial = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
assertThat(initial.pendingCallRingtonePickerLaunch).isEqualTo(0)
initial.eventSink(NotificationSettingsEvents.ShowCallRingtoneDialog)
val shown = consumeItemsUntilPredicate { it.showCallRingtoneDialog }.last()
shown.eventSink(NotificationSettingsEvents.LaunchCallRingtonePicker)
val launched = consumeItemsUntilPredicate { it.pendingCallRingtonePickerLaunch == 1 }.last()
assertThat(launched.showCallRingtoneDialog).isFalse()
assertThat(launched.pendingCallRingtonePickerLaunch).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - SelectCallRingtonePreset clears legacy ringtone classification`() = runTest {
// Legacy state: persisted SystemDefault + channel version 0 means the presenter probes the
// channel and surfaces whatever Android system has. We seed that probe with Silent so the
// initial row reads Silent even though DataStore holds SystemDefault. A preset pick must
// null the legacy classifier; otherwise re-reading state would still resolve to Silent
// after the SystemDefault selection persists.
val appPreferencesStore = InMemoryAppPreferencesStore(
callRingtone = NotificationSound.SystemDefault,
callRingtoneChannelVersion = 0,
)
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = appPreferencesStore,
notificationSoundUpdater = FakeNotificationSoundUpdater(
recreateRingingCallChannelLambda = { _, _ -> },
readRingingCallChannelSoundLambda = { NotificationSound.Silent },
),
)
presenter.test {
val initial = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid &&
it.callRingtone.sound == NotificationSound.Silent
}.last()
initial.eventSink(NotificationSettingsEvents.SelectCallRingtonePreset(NotificationSound.SystemDefault))
advanceUntilIdle()
val after = consumeItemsUntilPredicate {
it.callRingtone.sound == NotificationSound.SystemDefault
}.last()
assertThat(after.callRingtone.sound).isEqualTo(NotificationSound.SystemDefault)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - persisted ElementDefault renders Element default label`() = runTest {
val presenter = createNotificationSettingsPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(messageSound = NotificationSound.ElementDefault),
)
presenter.test {
val state = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid &&
it.messageSound.sound == NotificationSound.ElementDefault
}.last()
// FakeStringProvider returns "A string" for any resource, so this just verifies the
// ElementDefault branch is taken (vs falling through to the Custom resolver path).
assertThat(state.messageSound.displayName).isEqualTo("A string")
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createNotificationSettingsPresenter( private fun TestScope.createNotificationSettingsPresenter(
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
pushService: PushService = FakePushService(), pushService: PushService = FakePushService(),
fullScreenIntentPermissionsStateLambda: () -> FullScreenIntentPermissionsState = { aFullScreenIntentPermissionsState() }, fullScreenIntentPermissionsStateLambda: () -> FullScreenIntentPermissionsState = { aFullScreenIntentPermissionsState() },
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
notificationSoundUpdater: NotificationSoundUpdater = FakeNotificationSoundUpdater(),
// Default copier passes the picked URI string straight through as the FileProvider URI
// and uses the URI string as the display name. Tests that exercise copy failures override.
notificationSoundCopier: NotificationSoundCopier = FakeNotificationSoundCopier(copyLambda = { source, _ ->
NotificationSoundCopier.CopyResult.Success(source, source)
}),
soundDisplayNameResolver: FakeSoundDisplayNameResolver = FakeSoundDisplayNameResolver(),
): NotificationSettingsPresenter { ): NotificationSettingsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
return NotificationSettingsPresenter( return NotificationSettingsPresenter(
@@ -366,6 +1126,11 @@ class NotificationSettingsPresenterTest {
pushService = pushService, pushService = pushService,
systemNotificationsEnabledProvider = FakeSystemNotificationsEnabledProvider(), systemNotificationsEnabledProvider = FakeSystemNotificationsEnabledProvider(),
fullScreenIntentPermissionsPresenter = { fullScreenIntentPermissionsStateLambda() }, fullScreenIntentPermissionsPresenter = { fullScreenIntentPermissionsStateLambda() },
appPreferencesStore = appPreferencesStore,
notificationSoundUpdater = notificationSoundUpdater,
notificationSoundCopier = notificationSoundCopier,
soundDisplayNameResolver = soundDisplayNameResolver,
stringProvider = FakeStringProvider(),
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
) )
} }
@@ -13,6 +13,7 @@ package io.element.android.features.preferences.impl.notifications
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
@@ -49,7 +50,7 @@ class NotificationSettingsViewTest {
eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1280dp")
@Test @Test
fun `clicking on troubleshoot notification invokes the expected callback`() = runAndroidComposeUiTest { fun `clicking on troubleshoot notification invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
@@ -241,7 +242,7 @@ class NotificationSettingsViewTest {
) )
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1280dp")
@Test @Test
fun `clicking on Push notification provider emits the expected event`() = runAndroidComposeUiTest { fun `clicking on Push notification provider emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
@@ -277,6 +278,43 @@ class NotificationSettingsViewTest {
) )
) )
} }
@Config(qualifiers = "h1280dp")
@Test
fun `sounds preference category renders rows with current display names`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
setNotificationSettingsView(
state = aValidNotificationSettingsState(
eventSink = eventsRecorder,
messageSoundDisplayName = "Pixel notification",
callRingtoneDisplayName = "Pixel ringtone",
),
)
onNodeWithText("Sound").assertIsDisplayed()
onNodeWithText("Message sound").assertIsDisplayed()
onNodeWithText("Call ringtone").assertIsDisplayed()
onNodeWithText("Pixel notification").assertIsDisplayed()
onNodeWithText("Pixel ringtone").assertIsDisplayed()
}
@Config(qualifiers = "h1280dp")
@Test
fun `clicking the message sound row opens the preset dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
setNotificationSettingsView(
state = aValidNotificationSettingsState(eventSink = eventsRecorder),
)
// The click now opens the in-app preset dialog instead of launching the system picker
// directly; the picker only fires from the dialog's "Choose another sound..." option
// (covered by Presenter tests + NotificationSoundPickerTest).
onNodeWithText("Message sound").performClick()
eventsRecorder.assertList(
listOf(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
NotificationSettingsEvents.ShowMessageSoundDialog,
)
)
}
} }
private fun AndroidComposeUiTest<ComponentActivity>.setNotificationSettingsView( private fun AndroidComposeUiTest<ComponentActivity>.setNotificationSettingsView(
@@ -0,0 +1,90 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.notifications
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.provider.Settings
import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.preferences.api.store.NotificationSound
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class NotificationSoundPickerTest {
@Test
fun `buildRingtonePickerIntent encodes SystemDefault as the system default URI`() {
val intent = buildRingtonePickerIntent(
type = RingtoneManager.TYPE_NOTIFICATION,
current = NotificationSound.SystemDefault,
defaultUri = Settings.System.DEFAULT_NOTIFICATION_URI,
)
assertThat(intent.action).isEqualTo(RingtoneManager.ACTION_RINGTONE_PICKER)
assertThat(intent.getIntExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, -1)).isEqualTo(RingtoneManager.TYPE_NOTIFICATION)
assertThat(intent.getBooleanExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, false)).isTrue()
assertThat(intent.getBooleanExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false)).isTrue()
@Suppress("DEPRECATION")
val existing: Uri? = intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI)
assertThat(existing).isEqualTo(Settings.System.DEFAULT_NOTIFICATION_URI)
}
@Test
fun `buildRingtonePickerIntent encodes Silent as a null existing URI`() {
val intent = buildRingtonePickerIntent(
type = RingtoneManager.TYPE_RINGTONE,
current = NotificationSound.Silent,
defaultUri = Settings.System.DEFAULT_RINGTONE_URI,
)
@Suppress("DEPRECATION")
val existing: Uri? = intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI)
assertThat(existing).isNull()
}
@Test
fun `buildRingtonePickerIntent encodes Custom as the parsed URI`() {
val intent = buildRingtonePickerIntent(
type = RingtoneManager.TYPE_NOTIFICATION,
current = NotificationSound.Custom("content://media/internal/audio/media/42"),
defaultUri = Settings.System.DEFAULT_NOTIFICATION_URI,
)
@Suppress("DEPRECATION")
val existing: Uri? = intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI)
assertThat(existing).isEqualTo("content://media/internal/audio/media/42".toUri())
}
@Test
fun `toPickedNotificationSound maps null URI to Silent`() {
val result = Intent().apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, null as Uri?)
}
assertThat(result.toPickedNotificationSound(Settings.System.DEFAULT_NOTIFICATION_URI))
.isEqualTo(NotificationSound.Silent)
}
@Test
fun `toPickedNotificationSound maps default URI to SystemDefault`() {
val result = Intent().apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Settings.System.DEFAULT_NOTIFICATION_URI)
}
assertThat(result.toPickedNotificationSound(Settings.System.DEFAULT_NOTIFICATION_URI))
.isEqualTo(NotificationSound.SystemDefault)
}
@Test
fun `toPickedNotificationSound maps any other URI to Custom`() {
val customUri = "content://media/internal/audio/media/42".toUri()
val result = Intent().apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, customUri)
}
assertThat(result.toPickedNotificationSound(Settings.System.DEFAULT_NOTIFICATION_URI))
.isEqualTo(NotificationSound.Custom(customUri.toString()))
}
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.preferences.api.store.NotificationSound.Companion.toStored
import org.junit.Test
class NotificationSoundStorageTest {
@Test
fun `fromStored - null maps to SystemDefault`() {
assertThat(NotificationSound.fromStored(null)).isEqualTo(NotificationSound.SystemDefault)
}
@Test
fun `fromStored - silent sentinel maps to Silent`() {
assertThat(NotificationSound.fromStored("silent")).isEqualTo(NotificationSound.Silent)
}
@Test
fun `fromStored - element_default sentinel maps to ElementDefault`() {
assertThat(NotificationSound.fromStored("element_default")).isEqualTo(NotificationSound.ElementDefault)
}
@Test
fun `fromStored - element_fade sentinel maps to ElementFade`() {
assertThat(NotificationSound.fromStored("element_fade")).isEqualTo(NotificationSound.ElementFade)
}
@Test
fun `fromStored - any other string is treated as a Custom URI`() {
assertThat(NotificationSound.fromStored("content://media/42"))
.isEqualTo(NotificationSound.Custom("content://media/42"))
}
@Test
fun `toStored - SystemDefault encodes as null`() {
assertThat(NotificationSound.SystemDefault.toStored()).isNull()
}
@Test
fun `toStored - ElementDefault encodes as element_default`() {
assertThat(NotificationSound.ElementDefault.toStored()).isEqualTo("element_default")
}
@Test
fun `toStored - ElementFade encodes as element_fade`() {
assertThat(NotificationSound.ElementFade.toStored()).isEqualTo("element_fade")
}
@Test
fun `toStored - Silent encodes as silent`() {
assertThat(NotificationSound.Silent.toStored()).isEqualTo("silent")
}
@Test
fun `toStored - Custom encodes as the URI string`() {
assertThat(NotificationSound.Custom("content://x").toStored()).isEqualTo("content://x")
}
@Test
fun `round-trip preserves every variant`() {
val variants = listOf(
NotificationSound.SystemDefault,
NotificationSound.ElementDefault,
NotificationSound.ElementFade,
NotificationSound.Silent,
NotificationSound.Custom("content://media/42"),
)
for (sound in variants) {
assertThat(NotificationSound.fromStored(sound.toStored())).isEqualTo(sound)
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import extension.testCommonDependencies
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
} }
android { android {
@@ -41,5 +41,27 @@ interface AppPreferencesStore {
suspend fun setTracingLogPacks(targets: Set<TraceLogPack>) suspend fun setTracingLogPacks(targets: Set<TraceLogPack>)
fun getTracingLogPacksFlow(): Flow<Set<TraceLogPack>> fun getTracingLogPacksFlow(): Flow<Set<TraceLogPack>>
fun getMessageSoundFlow(): Flow<NotificationSound>
/**
* Atomically persists [sound] (with copy-time [title] for Custom; cleared otherwise) and
* bumps the channel version. Single transaction so process death can't desync URI and version.
*/
suspend fun setMessageSoundAndIncrementVersion(sound: NotificationSound, title: String?): Int
/** Title captured at copy time. Null for SystemDefault / Silent or pre-title persisted data. */
fun getMessageSoundDisplayNameFlow(): Flow<String?>
fun getCallRingtoneFlow(): Flow<NotificationSound>
/** See [setMessageSoundAndIncrementVersion]. */
suspend fun setCallRingtoneAndIncrementVersion(sound: NotificationSound, title: String?): Int
/** See [getMessageSoundDisplayNameFlow]. */
fun getCallRingtoneDisplayNameFlow(): Flow<String?>
/** Single-snapshot read of all sound prefs; used at boot to seed channels without N reads. */
suspend fun getNotificationSoundChannelConfig(): NotificationSoundChannelConfig
suspend fun reset() suspend fun reset()
} }
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.preferences.api.store
import androidx.compose.runtime.Immutable
/**
* The user's choice of notification sound for a notification channel.
*/
@Immutable
sealed interface NotificationSound {
/** Use the system's default tone for this channel (e.g. notification or ringtone). */
data object SystemDefault : NotificationSound
/**
* Use the bundled in-app sound. Semantically meaningful only for the noisy message channel,
* where it resolves to `R.raw.message`; the ringing-call channel has no bundled tone and
* treats this the same as [SystemDefault].
*/
data object ElementDefault : NotificationSound
/**
* Use the bundled in-app fade sound. Like [ElementDefault] this is meaningful only for the
* noisy message channel (`R.raw.element_fade`); the ringing-call channel has no bundled tone
* and treats this the same as [SystemDefault].
*/
data object ElementFade : NotificationSound
/** Produce no sound. */
data object Silent : NotificationSound
/** Use the ringtone at [uri]. */
data class Custom(val uri: String) : NotificationSound
companion object {
// String? round-trip used by [AppPreferencesStore]:
// null -> SystemDefault, "silent" -> Silent, "element_default" -> ElementDefault,
// "element_fade" -> ElementFade, else -> Custom(uri).
private const val STORED_SILENT = "silent"
private const val STORED_ELEMENT_DEFAULT = "element_default"
private const val STORED_ELEMENT_FADE = "element_fade"
fun fromStored(value: String?): NotificationSound = when (value) {
null -> SystemDefault
STORED_SILENT -> Silent
STORED_ELEMENT_DEFAULT -> ElementDefault
STORED_ELEMENT_FADE -> ElementFade
else -> Custom(value)
}
fun NotificationSound.toStored(): String? = when (this) {
SystemDefault -> null
Silent -> STORED_SILENT
ElementDefault -> STORED_ELEMENT_DEFAULT
ElementFade -> STORED_ELEMENT_FADE
is Custom -> uri
}
}
}
/**
* Snapshot of the persisted notification-sound state, returned by
* [AppPreferencesStore.getNotificationSoundChannelConfig] in a single read.
*/
data class NotificationSoundChannelConfig(
val messageSound: NotificationSound,
val messageSoundVersion: Int,
val messageSoundDisplayName: String?,
val callRingtone: NotificationSound,
val callRingtoneVersion: Int,
val callRingtoneDisplayName: String?,
)
@@ -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.LogLevel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack 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.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 io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
private val developerModeKey = booleanPreferencesKey("developerMode") private val developerModeKey = booleanPreferencesKey("developerMode")
@@ -32,6 +36,12 @@ private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPr
private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate") private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate")
private val logLevelKey = stringPreferencesKey("logLevel") private val logLevelKey = stringPreferencesKey("logLevel")
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") 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) @ContributesBinding(AppScope::class)
class DefaultAppPreferencesStore( 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() { override suspend fun reset() {
store.edit { it.clear() } store.edit { it.clear() }
} }
@@ -12,8 +12,11 @@ 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.LogLevel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack 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.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.updateAndGet
class InMemoryAppPreferencesStore( class InMemoryAppPreferencesStore(
isDeveloperModeEnabled: Boolean = false, isDeveloperModeEnabled: Boolean = false,
@@ -23,16 +26,28 @@ class InMemoryAppPreferencesStore(
theme: String? = null, theme: String? = null,
liveLocationMinimumDistanceUpdate: Int = 10, liveLocationMinimumDistanceUpdate: Int = 10,
logLevel: LogLevel = LogLevel.INFO, logLevel: LogLevel = LogLevel.INFO,
traceLockPacks: Set<TraceLogPack> = emptySet(), traceLogPacks: Set<TraceLogPack> = emptySet(),
messageSound: NotificationSound = NotificationSound.SystemDefault,
messageSoundChannelVersion: Int = 0,
messageSoundDisplayName: String? = null,
callRingtone: NotificationSound = NotificationSound.SystemDefault,
callRingtoneChannelVersion: Int = 0,
callRingtoneDisplayName: String? = null,
) : AppPreferencesStore { ) : AppPreferencesStore {
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
private val theme = MutableStateFlow(theme) private val theme = MutableStateFlow(theme)
private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate) private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate)
private val logLevel = MutableStateFlow(logLevel) private val logLevel = MutableStateFlow(logLevel)
private val tracingLogPacks = MutableStateFlow(traceLockPacks) private val tracingLogPacks = MutableStateFlow(traceLogPacks)
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars) private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue) private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue)
private val messageSound = MutableStateFlow(messageSound)
private val messageSoundChannelVersion = MutableStateFlow(messageSoundChannelVersion)
private val messageSoundDisplayName = MutableStateFlow(messageSoundDisplayName)
private val callRingtone = MutableStateFlow(callRingtone)
private val callRingtoneChannelVersion = MutableStateFlow(callRingtoneChannelVersion)
private val callRingtoneDisplayName = MutableStateFlow(callRingtoneDisplayName)
override suspend fun setDeveloperModeEnabled(enabled: Boolean) { override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
isDeveloperModeEnabled.value = enabled isDeveloperModeEnabled.value = enabled
@@ -102,6 +117,45 @@ class InMemoryAppPreferencesStore(
return tracingLogPacks return tracingLogPacks
} }
override fun getMessageSoundFlow(): Flow<NotificationSound> {
return messageSound
}
override suspend fun setMessageSoundAndIncrementVersion(sound: NotificationSound, title: String?): Int {
messageSound.value = sound
messageSoundDisplayName.value = if (sound is NotificationSound.Custom && !title.isNullOrBlank()) title else null
return messageSoundChannelVersion.updateAndGet { it + 1 }
}
override fun getMessageSoundDisplayNameFlow(): Flow<String?> {
return messageSoundDisplayName
}
override fun getCallRingtoneFlow(): Flow<NotificationSound> {
return callRingtone
}
override suspend fun setCallRingtoneAndIncrementVersion(sound: NotificationSound, title: String?): Int {
callRingtone.value = sound
callRingtoneDisplayName.value = if (sound is NotificationSound.Custom && !title.isNullOrBlank()) title else null
return callRingtoneChannelVersion.updateAndGet { it + 1 }
}
override fun getCallRingtoneDisplayNameFlow(): Flow<String?> {
return callRingtoneDisplayName
}
override suspend fun getNotificationSoundChannelConfig(): NotificationSoundChannelConfig {
return NotificationSoundChannelConfig(
messageSound = messageSound.value,
messageSoundVersion = messageSoundChannelVersion.value,
messageSoundDisplayName = messageSoundDisplayName.value,
callRingtone = callRingtone.value,
callRingtoneVersion = callRingtoneChannelVersion.value,
callRingtoneDisplayName = callRingtoneDisplayName.value,
)
}
override suspend fun reset() { override suspend fun reset() {
// No op // No op
} }
+1
View File
@@ -20,5 +20,6 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixmedia.api) implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.pushproviders.api) implementation(projects.libraries.pushproviders.api)
} }
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.api.notifications
import io.element.android.libraries.preferences.api.store.NotificationSound
/**
* Reads and writes the sound on the message / ringing-call channels. Writes create a new
* versioned channel because Android forbids mutating sound after creation.
*/
interface NotificationSoundUpdater {
fun recreateNoisyChannel(sound: NotificationSound, version: Int)
fun recreateRingingCallChannel(sound: NotificationSound, version: Int)
/** Current channel sound classified into [NotificationSound]. Null when the channel doesn't exist. */
suspend fun readNoisyChannelSound(): NotificationSound?
/** See [readNoisyChannelSound]. */
suspend fun readRingingCallChannelSound(): NotificationSound?
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.api.notifications
/**
* Resolves the user-facing title of a custom notification sound URI (the title shown by the
* system ringtone picker). Lifts the [android.media.RingtoneManager] side effect out of UI code
* so views and presenters stay testable.
*/
interface SoundDisplayNameResolver {
/** Returns the title of the ringtone at [uri], or null if it cannot be resolved. */
suspend fun resolveCustomSoundTitle(uri: String): String?
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.api.notifications.sound
/**
* Copies a picked notification sound into app-private storage and re-exposes it via the app's
* FileProvider, so the persisted reference outlives the original source.
*/
interface NotificationSoundCopier {
suspend fun copyToAppFiles(sourceUriString: String, slot: SoundSlot): CopyResult
/**
* Removes any previously copied file for [slot] from app-private storage. No-op when no file
* exists. Call this when persisting a non-[NotificationSound.Custom] pick (SystemDefault /
* Silent) so the previous Custom copy doesn't linger as orphaned bytes — [copyToAppFiles]
* already sweeps stale files inline for Custom-to-Custom transitions.
*/
suspend fun deleteStoredSoundFor(slot: SoundSlot)
enum class SoundSlot { Message, Call }
sealed interface CopyResult {
/**
* @property fileProviderUriString FileProvider URI to persist as the channel sound.
* @property displayName [android.media.Ringtone.getTitle] for the source, or null. The
* picker substitutes a localised fallback label, so localisation stays in the UI layer.
*/
data class Success(val fileProviderUriString: String, val displayName: String?) : CopyResult
/** [android.media.RingtoneManager] could not open the source URI as a Ringtone. */
data object UnplayableSource : CopyResult
/** The bytes copied successfully, but the resulting file is not a playable Ringtone. */
data object UnplayableCopy : CopyResult
/** Source exceeded the 5 MB cap. */
data object FileTooLarge : CopyResult
/** Any other I/O or unexpected failure. */
data class Failure(val cause: Throwable) : CopyResult
}
}
+2
View File
@@ -57,6 +57,7 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.troubleshoot.api) implementation(projects.libraries.troubleshoot.api)
implementation(projects.libraries.workmanager.api) implementation(projects.libraries.workmanager.api)
implementation(projects.features.announcement.api)
implementation(projects.features.call.api) implementation(projects.features.call.api)
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
implementation(projects.features.lockscreen.api) implementation(projects.features.lockscreen.api)
@@ -80,6 +81,7 @@ dependencies {
testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.libraries.troubleshoot.test) testImplementation(projects.libraries.troubleshoot.test)
testImplementation(projects.libraries.workmanager.test) testImplementation(projects.libraries.workmanager.test)
testImplementation(projects.features.announcement.test)
testImplementation(projects.features.call.test) testImplementation(projects.features.call.test)
testImplementation(projects.features.enterprise.test) testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.lockscreen.test) testImplementation(projects.features.lockscreen.test)
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.notifications
import android.content.Context
import android.media.RingtoneManager
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.push.api.notifications.SoundDisplayNameResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ContributesBinding(AppScope::class)
class DefaultSoundDisplayNameResolver(
@ApplicationContext private val context: Context,
) : SoundDisplayNameResolver {
override suspend fun resolveCustomSoundTitle(uri: String): String? = withContext(Dispatchers.IO) {
val parsed = runCatchingExceptions { uri.toUri() }.getOrNull() ?: return@withContext null
// Probing our own FileProvider URI yields the internal filename ("call_sound.ogg"),
// not a user-meaningful tone name — better to fall through to the localised "Custom"
// label in the UI.
if (parsed.authority == context.notificationSoundFileProviderAuthority()) return@withContext null
runCatchingExceptions {
RingtoneManager.getRingtone(context, parsed)?.getTitle(context)
}.getOrNull()
}
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.notifications
import android.content.Context
// Mirrors the FileProvider authority declared in AndroidManifest.xml as
// `${applicationId}.fileprovider`. Kept here so the resolver's skip-rule and the copier's URI
// construction never drift apart.
internal const val NOTIFICATION_SOUND_FILE_PROVIDER_AUTHORITY_SUFFIX = ".fileprovider"
internal fun Context.notificationSoundFileProviderAuthority(): String =
packageName + NOTIFICATION_SOUND_FILE_PROVIDER_AUTHORITY_SUFFIX
@@ -10,6 +10,7 @@ package io.element.android.libraries.push.impl.notifications.channels
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioAttributes.USAGE_NOTIFICATION import android.media.AudioAttributes.USAGE_NOTIFICATION
import android.media.AudioManager import android.media.AudioManager
@@ -19,28 +20,50 @@ import android.provider.Settings
import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.ChecksSdkIntAtLeast
import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.appconfig.NotificationConfig import io.element.android.appconfig.NotificationConfig
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.push.api.notifications.NotificationSoundUpdater
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import timber.log.Timber
/* ========================================================================================== /* ==========================================================================================
* IDs for channels * IDs for channels
* ========================================================================================== */ * ========================================================================================== */
internal const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2" internal const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2"
internal const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_V2" internal const val NOISY_NOTIFICATION_CHANNEL_ID_BASE = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_V2"
internal const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V3" internal const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V3"
internal const val RINGING_CALL_NOTIFICATION_CHANNEL_ID = "RINGING_CALL_NOTIFICATION_CHANNEL_ID" internal const val RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE = "RINGING_CALL_NOTIFICATION_CHANNEL_ID"
private fun versionedChannelId(base: String, version: Int): String =
if (version <= 0) base else "${base}_v$version"
internal fun noisyNotificationChannelId(version: Int): String =
versionedChannelId(NOISY_NOTIFICATION_CHANNEL_ID_BASE, version)
internal fun ringingCallNotificationChannelId(version: Int): String =
versionedChannelId(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE, version)
/** /**
* on devices >= android O, we need to define a channel for each notifications. * on devices >= android O, we need to define a channel for each notifications.
*
* Extends [NotificationSoundUpdater] so consumers can re-create the noisy and ringing-call
* channels when the user picks a new sound URI in settings.
*/ */
interface NotificationChannels { interface NotificationChannels : NotificationSoundUpdater {
/** /**
* Get the channel for incoming call. * Get the channel for incoming call.
* @param ring true if the device should ring when receiving the call. * @param ring true if the device should ring when receiving the call.
@@ -66,14 +89,22 @@ interface NotificationChannels {
private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class, binding = binding<NotificationChannels>())
@ContributesBinding(AppScope::class, binding = binding<NotificationSoundUpdater>())
class DefaultNotificationChannels( class DefaultNotificationChannels(
private val notificationManager: NotificationManagerCompat, private val notificationManager: NotificationManagerCompat,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
@ApplicationContext @ApplicationContext
private val context: Context, private val context: Context,
private val enterpriseService: EnterpriseService, private val enterpriseService: EnterpriseService,
private val appPreferencesStore: AppPreferencesStore,
) : NotificationChannels { ) : NotificationChannels {
@Volatile private var currentNoisyChannelId: String = NOISY_NOTIFICATION_CHANNEL_ID_BASE
@Volatile private var currentRingingCallChannelId: String = RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE
// Serializes concurrent recreate* calls; readers stay lock-free via @Volatile on the id fields.
private val recreateLock = Any()
init { init {
createNotificationChannels() createNotificationChannels()
} }
@@ -88,9 +119,27 @@ class DefaultNotificationChannels(
val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR
// Single-snapshot read; keep this path minimal — extra ContentResolver lookups here would
// risk a cold-start ANR.
val config = runBlocking {
val initial = appPreferencesStore.getNotificationSoundChannelConfig()
// Pre-dialog builds mapped the noisy `SystemDefault` to our bundled message.mp3.
// Now `SystemDefault` means Android's system tone, so untouched legacy state would
// silently flip sound. Promote it to `ElementDefault` once, gated on version==0 so the
// migration is idempotent.
if (initial.messageSoundVersion == 0 && initial.messageSound == NotificationSound.SystemDefault) {
appPreferencesStore.setMessageSoundAndIncrementVersion(NotificationSound.ElementDefault, title = null)
appPreferencesStore.getNotificationSoundChannelConfig()
} else {
initial
}
}
currentNoisyChannelId = noisyNotificationChannelId(config.messageSoundVersion)
currentRingingCallChannelId = ringingCallNotificationChannelId(config.callRingtoneVersion)
// Migration - the noisy channel was deleted and recreated when sound preference was changed (id was DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE // Migration - the noisy channel was deleted and recreated when sound preference was changed (id was DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE
// + currentTimeMillis). // + currentTimeMillis).
// Now the sound can only be change directly in system settings, so for app upgrading we are deleting this former channel
// Starting from this version the channel will not be dynamic // Starting from this version the channel will not be dynamic
for (channel in notificationManager.notificationChannels) { for (channel in notificationManager.notificationChannels) {
val channelId = channel.id val channelId = channel.id
@@ -111,30 +160,19 @@ class DefaultNotificationChannels(
notificationManager.deleteNotificationChannel(channelId) notificationManager.deleteNotificationChannel(channelId)
} }
} }
// Drop older versioned channels; only the current one remains.
deleteStaleVersionedChannels(NOISY_NOTIFICATION_CHANNEL_ID_BASE, currentNoisyChannelId)
deleteStaleVersionedChannels(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE, currentRingingCallChannelId)
// Default notification importance: shows everywhere, makes noise, but does not visually intrude. // Default notification importance: shows everywhere, makes noise, but does not visually intrude.
val noisySoundUri = resolveNoisySoundUri(config.messageSound)
grantSoundUriToSystem(noisySoundUri)
notificationManager.createNotificationChannel( notificationManager.createNotificationChannel(
NotificationChannelCompat.Builder( buildNoisyChannel(
NOISY_NOTIFICATION_CHANNEL_ID, channelId = currentNoisyChannelId,
NotificationManagerCompat.IMPORTANCE_DEFAULT soundUri = noisySoundUri,
accentColor = accentColor,
) )
.setSound(
Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
// Strangely we have to provide a "//" before the package name
.path("//" + context.packageName + "/" + R.raw.message)
.build(),
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(USAGE_NOTIFICATION)
.build(),
)
.setName(stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" })
.setDescription(stringProvider.getString(R.string.notification_channel_noisy))
.setVibrationEnabled(true)
.setLightsEnabled(true)
.setLightColor(accentColor)
.build()
) )
// Low notification importance: shows everywhere, but is not intrusive. // Low notification importance: shows everywhere, but is not intrusive.
@@ -166,40 +204,205 @@ class DefaultNotificationChannels(
) )
// Register a channel for incoming call notifications which will ring the device when received // Register a channel for incoming call notifications which will ring the device when received
val ringingSoundUri = resolveRingingSoundUri(config.callRingtone)
grantSoundUriToSystem(ringingSoundUri)
notificationManager.createNotificationChannel( notificationManager.createNotificationChannel(
NotificationChannelCompat.Builder( buildRingingCallChannel(
RINGING_CALL_NOTIFICATION_CHANNEL_ID, channelId = currentRingingCallChannelId,
NotificationManagerCompat.IMPORTANCE_MAX, soundUri = ringingSoundUri,
accentColor = accentColor,
) )
.setName(stringProvider.getString(R.string.notification_channel_ringing_calls).ifEmpty { "Ringing calls" }) )
}
private fun buildNoisyChannel(channelId: String, soundUri: Uri?, accentColor: Int): NotificationChannelCompat {
val builder = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" })
.setDescription(stringProvider.getString(R.string.notification_channel_noisy))
.setVibrationEnabled(true) .setVibrationEnabled(true)
.setSound( .setLightsEnabled(true)
Settings.System.DEFAULT_RINGTONE_URI, .setLightColor(accentColor)
if (soundUri != null) {
builder.setSound(
soundUri,
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(USAGE_NOTIFICATION)
.build(),
)
} else {
builder.setSound(null, null)
}
return builder.build()
}
private fun buildRingingCallChannel(channelId: String, soundUri: Uri?, accentColor: Int): NotificationChannelCompat {
val builder = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_MAX)
.setName(stringProvider.getString(R.string.notification_channel_ringing_calls).ifEmpty { "Ringing calls" })
.setDescription(stringProvider.getString(R.string.notification_channel_ringing_calls))
.setVibrationEnabled(true)
.setLightsEnabled(true)
.setLightColor(accentColor)
if (soundUri != null) {
builder.setSound(
soundUri,
AudioAttributes.Builder() AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setLegacyStreamType(AudioManager.STREAM_RING) .setLegacyStreamType(AudioManager.STREAM_RING)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build() .build()
) )
.setDescription(stringProvider.getString(R.string.notification_channel_ringing_calls)) } else {
.setLightsEnabled(true) builder.setSound(null, null)
.setLightColor(accentColor) }
.build() return builder.build()
) }
private fun resolveNoisySoundUri(sound: NotificationSound): Uri? = when (sound) {
NotificationSound.Silent -> null
NotificationSound.SystemDefault -> Settings.System.DEFAULT_NOTIFICATION_URI
NotificationSound.ElementDefault -> bundledMessageSoundUri()
NotificationSound.ElementFade -> bundledFadeSoundUri()
is NotificationSound.Custom -> parseUriOrFallback(sound.uri) { bundledMessageSoundUri() }
}
private fun resolveRingingSoundUri(sound: NotificationSound): Uri? = when (sound) {
NotificationSound.Silent -> null
// The ringing channel has no bundled tone — treat ElementDefault and ElementFade like SystemDefault here.
NotificationSound.SystemDefault,
NotificationSound.ElementDefault,
NotificationSound.ElementFade -> Settings.System.DEFAULT_RINGTONE_URI
is NotificationSound.Custom -> parseUriOrFallback(sound.uri) { Settings.System.DEFAULT_RINGTONE_URI }
}
private fun bundledMessageSoundUri(): Uri =
"${ContentResolver.SCHEME_ANDROID_RESOURCE}://${context.packageName}/${R.raw.message}".toUri()
private fun bundledFadeSoundUri(): Uri =
"${ContentResolver.SCHEME_ANDROID_RESOURCE}://${context.packageName}/${R.raw.element_fade}".toUri()
/**
* Lets system_server ("android") and SystemUI read our FileProvider sound URI; no-op otherwise.
* SystemUI hosts the lock-screen notification surface on most OEMs, so a missing grant there
* silently mutes ringtones when the device is locked.
*/
private fun grantSoundUriToSystem(uri: Uri?) {
if (uri == null || uri.scheme != ContentResolver.SCHEME_CONTENT) return
for (pkg in arrayOf("android", "com.android.systemui")) {
runCatchingExceptions {
context.grantUriPermission(pkg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}.onFailure { Timber.w(it, "grantUriPermission(%s) failed for notification sound", pkg) }
}
}
/** Parses [uriString], or returns [fallback] (the SystemDefault URI) on failure. */
private inline fun parseUriOrFallback(uriString: String, fallback: () -> Uri): Uri =
runCatchingExceptions { uriString.toUri() }
.getOrElse {
// Don't pass the throwable: legacy persisted URIs may carry SAF auth tokens.
Timber.w("Failed to parse persisted sound URI; falling back to default cause=%s", it::class.simpleName)
fallback()
}
private fun deleteStaleVersionedChannels(baseId: String, currentId: String) {
if (!supportNotificationChannels()) return
for (channel in notificationManager.notificationChannels) {
val id = channel.id
// Match either the unversioned base ID or any "${baseId}_v<n>" variant.
val isBaseOrVersioned = id == baseId || id.startsWith("${baseId}_v")
if (isBaseOrVersioned && id != currentId) {
notificationManager.deleteNotificationChannel(id)
}
}
} }
override fun getChannelForIncomingCall(ring: Boolean): String { override fun getChannelForIncomingCall(ring: Boolean): String {
return if (ring) RINGING_CALL_NOTIFICATION_CHANNEL_ID else CALL_NOTIFICATION_CHANNEL_ID return if (ring) currentRingingCallChannelId else CALL_NOTIFICATION_CHANNEL_ID
} }
override fun getChannelIdForMessage(sessionId: SessionId, noisy: Boolean): String { override fun getChannelIdForMessage(sessionId: SessionId, noisy: Boolean): String {
return if (noisy) { return if (noisy) {
enterpriseService.getNoisyNotificationChannelId(sessionId) enterpriseService.getNoisyNotificationChannelId(sessionId)
?: NOISY_NOTIFICATION_CHANNEL_ID ?: currentNoisyChannelId
} else { } else {
SILENT_NOTIFICATION_CHANNEL_ID SILENT_NOTIFICATION_CHANNEL_ID
} }
} }
override fun getChannelIdForTest(): String = NOISY_NOTIFICATION_CHANNEL_ID override fun getChannelIdForTest(): String = currentNoisyChannelId
override fun recreateNoisyChannel(sound: NotificationSound, version: Int) {
if (!supportNotificationChannels()) return
synchronized(recreateLock) {
val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR
val newChannelId = noisyNotificationChannelId(version)
val soundUri = resolveNoisySoundUri(sound)
grantSoundUriToSystem(soundUri)
// Create channel before publishing the id: a reader landing between the assignment and
// the create call would notify() against a missing id, which Android silently drops.
notificationManager.createNotificationChannel(
buildNoisyChannel(newChannelId, soundUri, accentColor)
)
currentNoisyChannelId = newChannelId
deleteStaleVersionedChannels(NOISY_NOTIFICATION_CHANNEL_ID_BASE, newChannelId)
}
}
override fun recreateRingingCallChannel(sound: NotificationSound, version: Int) {
if (!supportNotificationChannels()) return
synchronized(recreateLock) {
val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR
val newChannelId = ringingCallNotificationChannelId(version)
val soundUri = resolveRingingSoundUri(sound)
grantSoundUriToSystem(soundUri)
// See recreateNoisyChannel: the channel must exist before the id is published.
notificationManager.createNotificationChannel(
buildRingingCallChannel(newChannelId, soundUri, accentColor)
)
currentRingingCallChannelId = newChannelId
deleteStaleVersionedChannels(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE, newChannelId)
}
}
override suspend fun readNoisyChannelSound(): NotificationSound? {
return readChannelSound(
channelId = currentNoisyChannelId,
bundledUri = bundledMessageSoundUri(),
bundledFadeUri = bundledFadeSoundUri(),
systemDefaultUri = Settings.System.DEFAULT_NOTIFICATION_URI,
)
}
override suspend fun readRingingCallChannelSound(): NotificationSound? {
return readChannelSound(
channelId = currentRingingCallChannelId,
bundledUri = null,
bundledFadeUri = null,
systemDefaultUri = Settings.System.DEFAULT_RINGTONE_URI,
)
}
/**
* Classifies the channel's sound URI: null → Silent, bundled → ElementDefault, bundled fade →
* ElementFade, system default → SystemDefault, else → Custom. Both bundled URIs are nullable
* because the ringing channel has no bundled tones.
*/
private suspend fun readChannelSound(
channelId: String,
bundledUri: Uri?,
bundledFadeUri: Uri?,
systemDefaultUri: Uri,
): NotificationSound? {
if (!supportNotificationChannels()) return null
val channel = withContext(Dispatchers.IO) {
notificationManager.getNotificationChannel(channelId)
} ?: return null
return when (val sound = channel.sound) {
null -> NotificationSound.Silent
bundledUri -> NotificationSound.ElementDefault
bundledFadeUri -> NotificationSound.ElementFade
systemDefaultUri -> NotificationSound.SystemDefault
else -> NotificationSound.Custom(sound.toString())
}
}
} }
@@ -0,0 +1,208 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.notifications.sound
import android.content.ContentResolver
import android.content.Context
import android.media.RingtoneManager
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier
import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier.CopyResult
import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier.SoundSlot
import io.element.android.libraries.push.impl.notifications.notificationSoundFileProviderAuthority
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.io.InputStream
private const val MAX_BYTES = 5L * 1024 * 1024
private const val DIRECTORY_NAME = "notification_sounds"
private const val FALLBACK_EXTENSION = "bin"
private const val COPY_BUFFER_SIZE = 8 * 1024
private const val MAX_DISPLAY_NAME_LENGTH = 256
// Defence-in-depth: we only ever expect URIs from the system ringtone picker (content://) or
// the bundled resource (android.resource://). Reject anything else before doing I/O so a future
// caller that constructs a NotificationSound.Custom from an untrusted intent can't trick the
// copier into reading file:// or unrelated providers.
private val ALLOWED_SOURCE_SCHEMES = setOf(
ContentResolver.SCHEME_CONTENT,
ContentResolver.SCHEME_ANDROID_RESOURCE,
)
@ContributesBinding(AppScope::class)
class DefaultNotificationSoundCopier(
@ApplicationContext private val context: Context,
) : NotificationSoundCopier {
// Per-slot serialization: concurrent picks for the same slot would race on `<slot>.tmp`.
private val slotMutexes = SoundSlot.entries.associateWith { Mutex() }
override suspend fun copyToAppFiles(sourceUriString: String, slot: SoundSlot): CopyResult = withContext(Dispatchers.IO) {
slotMutexes.getValue(slot).withLock {
runCatchingExceptions { performCopy(sourceUriString, slot) }
.getOrElse { ex ->
// Don't pass the throwable to Timber: SAF/MediaProvider exceptions sometimes
// embed the source URI (which can carry short-lived auth tokens).
Timber.w("Notification sound copy failed for slot=%s cause=%s", slot, ex::class.simpleName)
CopyResult.Failure(ex)
}
}
}
override suspend fun deleteStoredSoundFor(slot: SoundSlot) {
withContext(Dispatchers.IO) {
slotMutexes.getValue(slot).withLock {
val dir = File(context.filesDir, DIRECTORY_NAME)
if (!dir.exists()) return@withLock
val slotBase = slotBaseFor(slot)
dir.listFiles { f -> f.isFile && f.name.startsWith("$slotBase.") }
?.forEach { stale ->
if (!stale.delete()) {
Timber.w("Could not remove stored slot file %s", stale.name)
}
}
}
}
}
private fun slotBaseFor(slot: SoundSlot): String = when (slot) {
SoundSlot.Message -> "message_sound"
SoundSlot.Call -> "call_sound"
}
private fun performCopy(sourceUriString: String, slot: SoundSlot): CopyResult {
val source = sourceUriString.toUri()
if (source.scheme !in ALLOWED_SOURCE_SCHEMES) return CopyResult.UnplayableSource
// Reject unplayable sources up front instead of persisting a known-broken pick.
val sourceRingtone = RingtoneManager.getRingtone(context, source)
?: return CopyResult.UnplayableSource
// Capture the source's display name before copy — once persisted, the row only sees the
// FileProvider URI, where Ringtone.getTitle() would resolve to our internal filename
// (e.g. "call_sound.ogg"). RingtoneManager → MediaStore TITLE → OpenableColumns covers
// system tones, MediaStore-indexed audio, and SAF-picked files respectively. The value
// crosses our security boundary (foreign provider → persisted state → UI), so cap and
// sanitize it.
val displayName = (sourceRingtone.getTitle(context)?.takeUnless { it.isBlank() }
?: queryOpenableDisplayName(source))
?.let(::sanitizeDisplayName)
// MIME comes from an arbitrary content provider; refuse extensions outside the platform
// table to keep filenames bounded to notification_sounds/.
val mimeMap = MimeTypeMap.getSingleton()
val candidateExtension = mimeMap.getExtensionFromMimeType(context.contentResolver.getType(source))
val extension = candidateExtension
?.takeIf { it.isNotBlank() && mimeMap.hasExtension(it) }
?: FALLBACK_EXTENSION
val slotBase = slotBaseFor(slot)
val dir = File(context.filesDir, DIRECTORY_NAME).apply { mkdirs() }
val tmpFile = File(dir, "$slotBase.tmp").also { if (it.exists()) it.delete() }
val finalFile = File(dir, "$slotBase.$extension")
val inputStream = context.contentResolver.openInputStream(source)
?: return CopyResult.UnplayableSource
val bytesCopied = try {
inputStream.use { input -> tmpFile.outputStream().buffered().use { out -> input.copyToCapped(out, MAX_BYTES) } }
} catch (_: SizeLimitExceeded) {
tmpFile.delete()
Timber.w("Notification sound copy aborted: source exceeds %d bytes (slot=%s)", MAX_BYTES, slot)
return CopyResult.FileTooLarge
}
// The finally below guarantees the temp file doesn't outlive a thrown probe / rename.
var committed = false
try {
// Probe through the FileProvider URI we'll actually persist (catches torn writes
// and OEM codec quirks).
val fileProviderUri = FileProvider.getUriForFile(
context,
context.notificationSoundFileProviderAuthority(),
tmpFile,
)
if (RingtoneManager.getRingtone(context, fileProviderUri) == null) {
return CopyResult.UnplayableCopy
}
// Sweep all `<slot>.*` so an extension change between picks doesn't leak orphans.
dir.listFiles { f -> f.isFile && f.name.startsWith("$slotBase.") && f.name != tmpFile.name }
?.forEach { stale ->
if (!stale.delete()) {
Timber.w("Could not remove stale slot file %s", stale.name)
}
}
if (!tmpFile.renameTo(finalFile)) {
return CopyResult.Failure(IOException("Could not rename temp file to ${finalFile.name}"))
}
committed = true
val finalUri = FileProvider.getUriForFile(
context,
context.notificationSoundFileProviderAuthority(),
finalFile,
)
Timber.d("Notification sound copied: slot=%s bytes=%d", slot, bytesCopied)
return CopyResult.Success(finalUri.toString(), displayName)
} finally {
if (!committed) tmpFile.delete()
}
}
private fun queryOpenableDisplayName(uri: Uri): String? {
return runCatchingExceptions {
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst() && cursor.columnCount > 0) {
cursor.getString(0)?.takeUnless { it.isBlank() }
} else {
null
}
}
}.getOrNull()
}
}
internal fun sanitizeDisplayName(raw: String): String? {
// Display name comes from a foreign content provider; strip C0 / DEL control characters and
// cap length so a hostile or malformed value can't bloat persisted state or break UI rendering.
val cleaned = buildString(raw.length.coerceAtMost(MAX_DISPLAY_NAME_LENGTH)) {
for (ch in raw) {
if (length >= MAX_DISPLAY_NAME_LENGTH) break
if (ch.code >= 0x20 && ch.code != 0x7F) append(ch)
}
}.trim()
return cleaned.takeUnless { it.isEmpty() }
}
private class SizeLimitExceeded : IOException("Sound source exceeds the configured size limit")
private fun InputStream.copyToCapped(out: java.io.OutputStream, maxBytes: Long): Long {
val buffer = ByteArray(COPY_BUFFER_SIZE)
var totalBytes = 0L
while (true) {
val read = read(buffer)
if (read == -1) break
totalBytes += read
if (totalBytes > maxBytes) throw SizeLimitExceeded()
out.write(buffer, 0, read)
}
out.flush()
return totalBytes
}
@@ -9,11 +9,19 @@
package io.element.android.libraries.push.impl.notifications.channels package io.element.android.libraries.push.impl.notifications.channels
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.tests.testutils.lambda.lambdaError
class FakeNotificationChannels( class FakeNotificationChannels(
var channelForIncomingCall: (ring: Boolean) -> String = { _ -> "" }, var channelForIncomingCall: (ring: Boolean) -> String = { _ -> "" },
var channelIdForMessage: (sessionId: SessionId, noisy: Boolean) -> String = { _, _ -> "" }, var channelIdForMessage: (sessionId: SessionId, noisy: Boolean) -> String = { _, _ -> "" },
var channelIdForTest: () -> String = { "" } var channelIdForTest: () -> String = { "" },
// Side-effecting recreates default to lambdaError (matching FakeNotificationSoundUpdater) so a
// test that doesn't expect a channel rebuild loudly fails instead of silently swallowing the call.
var recreateNoisyChannelLambda: (sound: NotificationSound, version: Int) -> Unit = { _, _ -> lambdaError() },
var recreateRingingCallChannelLambda: (sound: NotificationSound, version: Int) -> Unit = { _, _ -> lambdaError() },
var readNoisyChannelSoundLambda: () -> NotificationSound? = { null },
var readRingingCallChannelSoundLambda: () -> NotificationSound? = { null },
) : NotificationChannels { ) : NotificationChannels {
override fun getChannelForIncomingCall(ring: Boolean): String { override fun getChannelForIncomingCall(ring: Boolean): String {
return channelForIncomingCall(ring) return channelForIncomingCall(ring)
@@ -26,4 +34,16 @@ class FakeNotificationChannels(
override fun getChannelIdForTest(): String { override fun getChannelIdForTest(): String {
return channelIdForTest() return channelIdForTest()
} }
override fun recreateNoisyChannel(sound: NotificationSound, version: Int) {
recreateNoisyChannelLambda(sound, version)
}
override fun recreateRingingCallChannel(sound: NotificationSound, version: Int) {
recreateRingingCallChannelLambda(sound, version)
}
override suspend fun readNoisyChannelSound(): NotificationSound? = readNoisyChannelSoundLambda()
override suspend fun readRingingCallChannelSound(): NotificationSound? = readRingingCallChannelSoundLambda()
} }
@@ -9,16 +9,23 @@
package io.element.android.libraries.push.impl.notifications.channels package io.element.android.libraries.push.impl.notifications.channels
import android.os.Build import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
@@ -45,7 +52,7 @@ class NotificationChannelsTest {
val notificationChannels = createNotificationChannels() val notificationChannels = createNotificationChannels()
val ringingChannel = notificationChannels.getChannelForIncomingCall(ring = true) val ringingChannel = notificationChannels.getChannelForIncomingCall(ring = true)
assertThat(ringingChannel).isEqualTo(RINGING_CALL_NOTIFICATION_CHANNEL_ID) assertThat(ringingChannel).isEqualTo(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE)
val normalChannel = notificationChannels.getChannelForIncomingCall(ring = false) val normalChannel = notificationChannels.getChannelForIncomingCall(ring = false)
assertThat(normalChannel).isEqualTo(CALL_NOTIFICATION_CHANNEL_ID) assertThat(normalChannel).isEqualTo(CALL_NOTIFICATION_CHANNEL_ID)
@@ -59,7 +66,7 @@ class NotificationChannelsTest {
), ),
) )
assertThat(notificationChannels.getChannelIdForMessage(sessionId = A_SESSION_ID, noisy = true)) assertThat(notificationChannels.getChannelIdForMessage(sessionId = A_SESSION_ID, noisy = true))
.isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID) .isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID_BASE)
assertThat(notificationChannels.getChannelIdForMessage(sessionId = A_SESSION_ID, noisy = false)) assertThat(notificationChannels.getChannelIdForMessage(sessionId = A_SESSION_ID, noisy = false))
.isEqualTo(SILENT_NOTIFICATION_CHANNEL_ID) .isEqualTo(SILENT_NOTIFICATION_CHANNEL_ID)
} }
@@ -81,16 +88,418 @@ class NotificationChannelsTest {
fun `getChannelIdForTest - returns the right channel`() { fun `getChannelIdForTest - returns the right channel`() {
val notificationChannels = createNotificationChannels() val notificationChannels = createNotificationChannels()
assertThat(notificationChannels.getChannelIdForTest()).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID) assertThat(notificationChannels.getChannelIdForTest()).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID_BASE)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `init - reads persisted message version and seeds versioned channel id`() {
val notificationChannels = createNotificationChannels(
appPreferencesStore = InMemoryAppPreferencesStore(messageSoundChannelVersion = 3),
enterpriseService = FakeEnterpriseService(getNoisyNotificationChannelIdResult = { null }),
)
assertThat(notificationChannels.getChannelIdForTest()).isEqualTo("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v3")
assertThat(notificationChannels.getChannelIdForMessage(sessionId = A_SESSION_ID, noisy = true))
.isEqualTo("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v3")
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `init - reads persisted call ringtone version and seeds versioned channel id`() {
val notificationChannels = createNotificationChannels(
appPreferencesStore = InMemoryAppPreferencesStore(callRingtoneChannelVersion = 2),
)
assertThat(notificationChannels.getChannelForIncomingCall(ring = true))
.isEqualTo("${RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE}_v2")
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `recreateNoisyChannel - creates new versioned channel and updates current id`() {
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
}
val channels = createNotificationChannels(notificationManager = notificationManager)
channels.recreateNoisyChannel(sound = NotificationSound.Custom("content://media/internal/audio/media/42"), version = 7)
val expectedId = "${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v7"
assertThat(channels.getChannelIdForTest()).isEqualTo(expectedId)
verify { notificationManager.createNotificationChannel(match<NotificationChannelCompat> { it.id == expectedId }) }
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `recreateNoisyChannel - Silent produces channel with no sound`() {
val captured = slot<NotificationChannelCompat>()
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { createNotificationChannel(capture(captured)) } returns Unit
}
val channels = createNotificationChannels(notificationManager = notificationManager)
channels.recreateNoisyChannel(sound = NotificationSound.Silent, version = 1)
// The last captured channel (the recreated one) should have a null sound and the new id.
assertThat(captured.captured.id).isEqualTo("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v1")
assertThat(captured.captured.sound).isNull()
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `recreateNoisyChannel - SystemDefault uses Android system notification URI`() {
val captured = slot<NotificationChannelCompat>()
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { createNotificationChannel(capture(captured)) } returns Unit
}
val channels = createNotificationChannels(notificationManager = notificationManager)
channels.recreateNoisyChannel(sound = NotificationSound.SystemDefault, version = 2)
assertThat(captured.captured.sound).isEqualTo(Settings.System.DEFAULT_NOTIFICATION_URI)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `recreateNoisyChannel - ElementDefault uses bundled message URI`() {
val captured = slot<NotificationChannelCompat>()
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { createNotificationChannel(capture(captured)) } returns Unit
}
val channels = createNotificationChannels(notificationManager = notificationManager)
channels.recreateNoisyChannel(sound = NotificationSound.ElementDefault, version = 1)
assertThat(captured.captured.sound?.scheme).isEqualTo("android.resource")
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `recreateNoisyChannel - ElementFade uses bundled fade URI`() {
val captured = slot<NotificationChannelCompat>()
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { createNotificationChannel(capture(captured)) } returns Unit
}
val channels = createNotificationChannels(notificationManager = notificationManager)
channels.recreateNoisyChannel(sound = NotificationSound.ElementFade, version = 1)
val context = RuntimeEnvironment.getApplication()
val expected = "android.resource://${context.packageName}/${io.element.android.libraries.push.impl.R.raw.element_fade}".toUri()
assertThat(captured.captured.sound).isEqualTo(expected)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `recreateNoisyChannel - deletes prior versioned channels`() {
val priorChannel = mockk<android.app.NotificationChannel>(relaxed = true) {
every { id } returns "${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v3"
}
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns listOf(priorChannel)
}
val channels = createNotificationChannels(notificationManager = notificationManager)
channels.recreateNoisyChannel(sound = NotificationSound.SystemDefault, version = 4)
verify { notificationManager.deleteNotificationChannel("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v3") }
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `recreateRingingCallChannel - creates new versioned channel and updates current id`() {
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
}
val channels = createNotificationChannels(notificationManager = notificationManager)
channels.recreateRingingCallChannel(sound = NotificationSound.Custom("content://settings/system/ringtone"), version = 5)
val expectedId = "${RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE}_v5"
assertThat(channels.getChannelForIncomingCall(ring = true)).isEqualTo(expectedId)
verify { notificationManager.createNotificationChannel(match<NotificationChannelCompat> { it.id == expectedId }) }
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `recreateRingingCallChannel - Silent produces channel with no sound`() {
val captured = slot<NotificationChannelCompat>()
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { createNotificationChannel(capture(captured)) } returns Unit
}
val channels = createNotificationChannels(notificationManager = notificationManager)
channels.recreateRingingCallChannel(sound = NotificationSound.Silent, version = 1)
assertThat(captured.captured.id).isEqualTo("${RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE}_v1")
assertThat(captured.captured.sound).isNull()
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `recreate noisy then ringing produces both versioned channels with current ids`() {
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
}
val channels = createNotificationChannels(notificationManager = notificationManager)
channels.recreateNoisyChannel(sound = NotificationSound.Custom("content://a"), version = 5)
channels.recreateRingingCallChannel(sound = NotificationSound.Custom("content://b"), version = 7)
// Each recreate updates the corresponding currentId independently — the lock guarding
// recreate* against itself does not interfere across the two channel families.
assertThat(channels.getChannelIdForTest()).isEqualTo("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v5")
assertThat(channels.getChannelForIncomingCall(ring = true)).isEqualTo("${RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE}_v7")
verify { notificationManager.createNotificationChannel(match<NotificationChannelCompat> { it.id == "${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v5" }) }
verify {
notificationManager.createNotificationChannel(
match<NotificationChannelCompat> { it.id == "${RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE}_v7" }
)
}
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `readNoisyChannelSound - returns Custom for non-default URI`() = runTest {
val customUri = "content://media/internal/audio/media/42".toUri()
val channel = mockk<android.app.NotificationChannel>(relaxed = true) {
every { sound } returns customUri
}
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns channel
}
val channels = createNotificationChannels(notificationManager = notificationManager)
val result = channels.readNoisyChannelSound()
assertThat(result).isEqualTo(NotificationSound.Custom(customUri.toString()))
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `readNoisyChannelSound - classifies the bundled message URI as ElementDefault`() = runTest {
val context = RuntimeEnvironment.getApplication()
val ourBundled = "android.resource://${context.packageName}/${io.element.android.libraries.push.impl.R.raw.message}".toUri()
val channel = mockk<android.app.NotificationChannel>(relaxed = true) {
every { sound } returns ourBundled
}
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns channel
}
val channels = createNotificationChannels(notificationManager = notificationManager)
val result = channels.readNoisyChannelSound()
assertThat(result).isEqualTo(NotificationSound.ElementDefault)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `readNoisyChannelSound - classifies the bundled fade URI as ElementFade`() = runTest {
val context = RuntimeEnvironment.getApplication()
val ourFade = "android.resource://${context.packageName}/${io.element.android.libraries.push.impl.R.raw.element_fade}".toUri()
val channel = mockk<android.app.NotificationChannel>(relaxed = true) {
every { sound } returns ourFade
}
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns channel
}
val channels = createNotificationChannels(notificationManager = notificationManager)
val result = channels.readNoisyChannelSound()
assertThat(result).isEqualTo(NotificationSound.ElementFade)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `readNoisyChannelSound - classifies DEFAULT_NOTIFICATION_URI as SystemDefault`() = runTest {
val channel = mockk<android.app.NotificationChannel>(relaxed = true) {
every { sound } returns Settings.System.DEFAULT_NOTIFICATION_URI
}
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns channel
}
val channels = createNotificationChannels(notificationManager = notificationManager)
val result = channels.readNoisyChannelSound()
assertThat(result).isEqualTo(NotificationSound.SystemDefault)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `init - migrates legacy SystemDefault message sound to ElementDefault when version is 0`() = runTest {
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
}
val appPreferencesStore = InMemoryAppPreferencesStore(
messageSound = NotificationSound.SystemDefault,
messageSoundChannelVersion = 0,
)
createNotificationChannels(
notificationManager = notificationManager,
appPreferencesStore = appPreferencesStore,
)
val config = appPreferencesStore.getNotificationSoundChannelConfig()
assertThat(config.messageSound).isEqualTo(NotificationSound.ElementDefault)
assertThat(config.messageSoundVersion).isEqualTo(1)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `init - migration is idempotent across multiple instantiations`() = runTest {
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
}
val appPreferencesStore = InMemoryAppPreferencesStore(
messageSound = NotificationSound.SystemDefault,
messageSoundChannelVersion = 0,
)
// First boot: migrate.
createNotificationChannels(notificationManager = notificationManager, appPreferencesStore = appPreferencesStore)
val afterFirst = appPreferencesStore.getNotificationSoundChannelConfig()
assertThat(afterFirst.messageSound).isEqualTo(NotificationSound.ElementDefault)
assertThat(afterFirst.messageSoundVersion).isEqualTo(1)
// Second boot: gate (version == 0) no longer matches, so the version must not bump again.
createNotificationChannels(notificationManager = notificationManager, appPreferencesStore = appPreferencesStore)
val afterSecond = appPreferencesStore.getNotificationSoundChannelConfig()
assertThat(afterSecond.messageSound).isEqualTo(NotificationSound.ElementDefault)
assertThat(afterSecond.messageSoundVersion).isEqualTo(1)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `init - does not re-migrate when message version is non-zero`() = runTest {
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
}
val appPreferencesStore = InMemoryAppPreferencesStore(
messageSound = NotificationSound.SystemDefault,
messageSoundChannelVersion = 5,
)
createNotificationChannels(
notificationManager = notificationManager,
appPreferencesStore = appPreferencesStore,
)
val config = appPreferencesStore.getNotificationSoundChannelConfig()
assertThat(config.messageSound).isEqualTo(NotificationSound.SystemDefault)
assertThat(config.messageSoundVersion).isEqualTo(5)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `init - does not migrate when persisted message sound is not SystemDefault`() = runTest {
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
}
val appPreferencesStore = InMemoryAppPreferencesStore(
messageSound = NotificationSound.Silent,
messageSoundChannelVersion = 0,
)
createNotificationChannels(
notificationManager = notificationManager,
appPreferencesStore = appPreferencesStore,
)
val config = appPreferencesStore.getNotificationSoundChannelConfig()
assertThat(config.messageSound).isEqualTo(NotificationSound.Silent)
assertThat(config.messageSoundVersion).isEqualTo(0)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `readNoisyChannelSound - returns Silent when channel sound is null`() = runTest {
val channel = mockk<android.app.NotificationChannel>(relaxed = true) {
every { sound } returns null
}
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns channel
}
val channels = createNotificationChannels(notificationManager = notificationManager)
val result = channels.readNoisyChannelSound()
assertThat(result).isEqualTo(NotificationSound.Silent)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `readNoisyChannelSound - returns null when channel does not exist`() = runTest {
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns null
}
val channels = createNotificationChannels(notificationManager = notificationManager)
val result = channels.readNoisyChannelSound()
assertThat(result).isNull()
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `readRingingCallChannelSound - returns SystemDefault when channel sound matches DEFAULT_RINGTONE_URI`() = runTest {
val channel = mockk<android.app.NotificationChannel>(relaxed = true) {
every { sound } returns Settings.System.DEFAULT_RINGTONE_URI
}
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { getNotificationChannel(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE) } returns channel
}
val channels = createNotificationChannels(notificationManager = notificationManager)
val result = channels.readRingingCallChannelSound()
assertThat(result).isEqualTo(NotificationSound.SystemDefault)
}
@Test
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun `readRingingCallChannelSound - returns Custom for non-default URI`() = runTest {
val customUri = "content://media/external/audio/media/9001".toUri()
val channel = mockk<android.app.NotificationChannel>(relaxed = true) {
every { sound } returns customUri
}
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
every { getNotificationChannel(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE) } returns channel
}
val channels = createNotificationChannels(notificationManager = notificationManager)
val result = channels.readRingingCallChannelSound()
assertThat(result).isEqualTo(NotificationSound.Custom(customUri.toString()))
} }
private fun createNotificationChannels( private fun createNotificationChannels(
notificationManager: NotificationManagerCompat = mockk(relaxed = true), notificationManager: NotificationManagerCompat = mockk(relaxed = true),
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
// Default to ElementDefault so the legacy `(SystemDefault, version=0)` migration gate
// doesn't fire and silently rewrite the prefs store. Tests that exercise the migration
// construct their own store explicitly.
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(messageSound = NotificationSound.ElementDefault),
) = DefaultNotificationChannels( ) = DefaultNotificationChannels(
notificationManager = notificationManager, notificationManager = notificationManager,
stringProvider = FakeStringProvider(), stringProvider = FakeStringProvider(),
context = RuntimeEnvironment.getApplication(), context = RuntimeEnvironment.getApplication(),
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
appPreferencesStore = appPreferencesStore,
) )
} }
@@ -373,10 +373,12 @@ fun createNotificationChannels(
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
): NotificationChannels { ): NotificationChannels {
val context = RuntimeEnvironment.getApplication() val context = RuntimeEnvironment.getApplication()
val notificationManagerCompat = NotificationManagerCompat.from(context)
return DefaultNotificationChannels( return DefaultNotificationChannels(
notificationManager = NotificationManagerCompat.from(context), notificationManager = notificationManagerCompat,
stringProvider = FakeStringProvider(""), stringProvider = FakeStringProvider(""),
context = context, context = context,
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
appPreferencesStore = io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore(),
) )
} }
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.notifications.sound
import android.content.Context
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class DefaultNotificationSoundCopierTest {
private val context: Context get() = RuntimeEnvironment.getApplication()
@Test
fun `sanitizeDisplayName - trims surrounding whitespace`() {
assertThat(sanitizeDisplayName(" Hello World ")).isEqualTo("Hello World")
}
@Test
fun `sanitizeDisplayName - DEL and control characters are dropped`() {
// Build the raw string programmatically — embedding control chars as source-file bytes
// doesn't survive editor / tool round-tripping reliably.
val bel = 0x07.toChar()
val tab = 0x09.toChar()
val newline = 0x0A.toChar()
val del = 0x7F.toChar()
val raw = "${bel}Hello$tab${newline}World$del"
assertThat(sanitizeDisplayName(raw)).isEqualTo("HelloWorld")
}
@Test
fun `sanitizeDisplayName - truncates oversized input to 256 chars`() {
val long = "x".repeat(500)
val sanitized = sanitizeDisplayName(long)
assertThat(sanitized).isNotNull()
assertThat(sanitized!!.length).isEqualTo(256)
}
@Test
fun `sanitizeDisplayName - returns null when nothing survives sanitization`() {
assertThat(sanitizeDisplayName("")).isNull()
assertThat(sanitizeDisplayName(" ")).isNull()
val controls = (0..0x1F).joinToString(separator = "") { it.toChar().toString() } + 0x7F.toChar()
assertThat(sanitizeDisplayName(controls)).isNull()
}
@Test
fun `sanitizeDisplayName - preserves unicode beyond ASCII`() {
val input = "Element Tone " + 0x266A.toChar()
assertThat(sanitizeDisplayName(input)).isEqualTo(input)
}
@Test
fun `copyToAppFiles - rejects file scheme`() = runTest {
val copier = DefaultNotificationSoundCopier(context)
val result = copier.copyToAppFiles("file:///tmp/evil.mp3", NotificationSoundCopier.SoundSlot.Message)
assertThat(result).isEqualTo(NotificationSoundCopier.CopyResult.UnplayableSource)
}
@Test
fun `copyToAppFiles - rejects http scheme`() = runTest {
val copier = DefaultNotificationSoundCopier(context)
val result = copier.copyToAppFiles("https://attacker.example.com/sound.mp3", NotificationSoundCopier.SoundSlot.Call)
assertThat(result).isEqualTo(NotificationSoundCopier.CopyResult.UnplayableSource)
}
@Test
fun `copyToAppFiles - rejects URI without a scheme`() = runTest {
val copier = DefaultNotificationSoundCopier(context)
val result = copier.copyToAppFiles("not-a-real-uri", NotificationSoundCopier.SoundSlot.Message)
assertThat(result).isEqualTo(NotificationSoundCopier.CopyResult.UnplayableSource)
}
}
+1
View File
@@ -19,6 +19,7 @@ dependencies {
api(projects.libraries.pushproviders.api) api(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.workmanager.api) implementation(projects.libraries.workmanager.api)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.test.notifications
import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.push.api.notifications.NotificationSoundUpdater
import io.element.android.tests.testutils.lambda.lambdaError
class FakeNotificationSoundUpdater(
private val recreateNoisyChannelLambda: (sound: NotificationSound, version: Int) -> Unit = { _, _ -> lambdaError() },
private val recreateRingingCallChannelLambda: (sound: NotificationSound, version: Int) -> Unit = { _, _ -> lambdaError() },
private val readNoisyChannelSoundLambda: () -> NotificationSound? = { null },
private val readRingingCallChannelSoundLambda: () -> NotificationSound? = { null },
) : NotificationSoundUpdater {
override fun recreateNoisyChannel(sound: NotificationSound, version: Int) {
recreateNoisyChannelLambda(sound, version)
}
override fun recreateRingingCallChannel(sound: NotificationSound, version: Int) {
recreateRingingCallChannelLambda(sound, version)
}
override suspend fun readNoisyChannelSound(): NotificationSound? = readNoisyChannelSoundLambda()
override suspend fun readRingingCallChannelSound(): NotificationSound? = readRingingCallChannelSoundLambda()
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.test.notifications
import io.element.android.libraries.push.api.notifications.SoundDisplayNameResolver
class FakeSoundDisplayNameResolver(
// Default resolves to a non-null sentinel so a default-constructed fake doesn't masquerade
// as "every Custom URI is unresolvable" — that would trip mid-session detection paths in
// every test that uses default fakes. Tests that need the unavailable case pass `{ null }`.
private val resolveLambda: suspend (uri: String) -> String? = { "FakeRingtoneTitle" },
) : SoundDisplayNameResolver {
override suspend fun resolveCustomSoundTitle(uri: String): String? = resolveLambda(uri)
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.test.notifications.sound
import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier
import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier.CopyResult
import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier.SoundSlot
import io.element.android.tests.testutils.lambda.lambdaError
class FakeNotificationSoundCopier(
private val copyLambda: (String, SoundSlot) -> CopyResult = { _, _ -> lambdaError() },
private val deleteStoredSoundForLambda: (SoundSlot) -> Unit = {},
) : NotificationSoundCopier {
override suspend fun copyToAppFiles(sourceUriString: String, slot: SoundSlot): CopyResult = copyLambda(sourceUriString, slot)
override suspend fun deleteStoredSoundFor(slot: SoundSlot) {
deleteStoredSoundForLambda(slot)
}
}
@@ -480,10 +480,6 @@ Haluatko varmasti jatkaa?"</string>
<string name="screen_create_poll_options_section_title">"Vaihtoehdot"</string> <string name="screen_create_poll_options_section_title">"Vaihtoehdot"</string>
<string name="screen_create_poll_remove_accessibility_label">"Poista %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Poista %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Asetukset"</string> <string name="screen_create_poll_settings_section_title">"Asetukset"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d aste"</item>
<item quantity="other">"%1$d astetta"</item>
</plurals>
<string name="screen_live_location_sheet_nobody_sharing">"Kukaan ei jaa sijaintiaan"</string> <string name="screen_live_location_sheet_nobody_sharing">"Kukaan ei jaa sijaintiaan"</string>
<string name="screen_live_location_sheet_sharing_live_location">"Jaetaan reaaliaikaista sijaintia"</string> <string name="screen_live_location_sheet_sharing_live_location">"Jaetaan reaaliaikaista sijaintia"</string>
<plurals name="screen_live_location_sheet_subtitle"> <plurals name="screen_live_location_sheet_subtitle">
@@ -480,10 +480,6 @@ Raison : %1$s."</string>
<string name="screen_create_poll_options_section_title">"Options"</string> <string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Supprimer %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Supprimer %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Paramètres"</string> <string name="screen_create_poll_settings_section_title">"Paramètres"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d degré"</item>
<item quantity="other">"%1$d degrés"</item>
</plurals>
<string name="screen_live_location_sheet_nobody_sharing">"Personne ne partage sa position"</string> <string name="screen_live_location_sheet_nobody_sharing">"Personne ne partage sa position"</string>
<string name="screen_live_location_sheet_sharing_live_location">"Partage de la position en direct"</string> <string name="screen_live_location_sheet_sharing_live_location">"Partage de la position en direct"</string>
<plurals name="screen_live_location_sheet_subtitle"> <plurals name="screen_live_location_sheet_subtitle">
@@ -471,11 +471,6 @@
<string name="screen_create_poll_options_section_title">"選択肢"</string> <string name="screen_create_poll_options_section_title">"選択肢"</string>
<string name="screen_create_poll_remove_accessibility_label">"%1$s を削除"</string> <string name="screen_create_poll_remove_accessibility_label">"%1$s を削除"</string>
<string name="screen_create_poll_settings_section_title">"設定"</string> <string name="screen_create_poll_settings_section_title">"設定"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"画像を左に回転"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d°"</item>
</plurals>
<string name="screen_image_edition_title">"写真を編集"</string>
<string name="screen_live_location_sheet_nobody_sharing">"誰も位置情報を共有していません"</string> <string name="screen_live_location_sheet_nobody_sharing">"誰も位置情報を共有していません"</string>
<string name="screen_live_location_sheet_sharing_live_location">"ライブ位置情報を共有しています"</string> <string name="screen_live_location_sheet_sharing_live_location">"ライブ位置情報を共有しています"</string>
<plurals name="screen_live_location_sheet_subtitle"> <plurals name="screen_live_location_sheet_subtitle">
@@ -489,13 +489,6 @@ Czy na pewno chcesz kontynuować?"</string>
<string name="screen_create_poll_options_section_title">"Opcje"</string> <string name="screen_create_poll_options_section_title">"Opcje"</string>
<string name="screen_create_poll_remove_accessibility_label">"Usuń %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Usuń %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Ustawienia"</string> <string name="screen_create_poll_settings_section_title">"Ustawienia"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Obróć obraz w lewo"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d stopień"</item>
<item quantity="few">"%1$d stopnie"</item>
<item quantity="many">"%1$d stopni"</item>
</plurals>
<string name="screen_image_edition_title">"Edytuj zdjęcie"</string>
<string name="screen_live_location_sheet_nobody_sharing">"Nikt nie udostępnia swojej lokalizacji"</string> <string name="screen_live_location_sheet_nobody_sharing">"Nikt nie udostępnia swojej lokalizacji"</string>
<string name="screen_live_location_sheet_sharing_live_location">"Udostępnianie lokalizacji na żywo"</string> <string name="screen_live_location_sheet_sharing_live_location">"Udostępnianie lokalizacji na żywo"</string>
<plurals name="screen_live_location_sheet_subtitle"> <plurals name="screen_live_location_sheet_subtitle">
@@ -469,11 +469,6 @@
<string name="screen_create_poll_options_section_title">"选项"</string> <string name="screen_create_poll_options_section_title">"选项"</string>
<string name="screen_create_poll_remove_accessibility_label">"移除 %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"移除 %1$s"</string>
<string name="screen_create_poll_settings_section_title">"设置"</string> <string name="screen_create_poll_settings_section_title">"设置"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"向左旋转图像"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d 度"</item>
</plurals>
<string name="screen_image_edition_title">"编辑照片"</string>
<string name="screen_live_location_sheet_nobody_sharing">"目前无人分享其位置"</string> <string name="screen_live_location_sheet_nobody_sharing">"目前无人分享其位置"</string>
<string name="screen_live_location_sheet_sharing_live_location">"共享实时位置"</string> <string name="screen_live_location_sheet_sharing_live_location">"共享实时位置"</string>
<plurals name="screen_live_location_sheet_subtitle"> <plurals name="screen_live_location_sheet_subtitle">
@@ -481,12 +481,6 @@ Are you sure you want to continue?"</string>
<string name="screen_create_poll_options_section_title">"Options"</string> <string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Settings"</string> <string name="screen_create_poll_settings_section_title">"Settings"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rotate the image to the left"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d degree"</item>
<item quantity="other">"%1$d degrees"</item>
</plurals>
<string name="screen_image_edition_title">"Edit photo"</string>
<string name="screen_live_location_sheet_nobody_sharing">"Nobody is sharing their location"</string> <string name="screen_live_location_sheet_nobody_sharing">"Nobody is sharing their location"</string>
<string name="screen_live_location_sheet_sharing_live_location">"Sharing live location"</string> <string name="screen_live_location_sheet_sharing_live_location">"Sharing live location"</string>
<plurals name="screen_live_location_sheet_subtitle"> <plurals name="screen_live_location_sheet_subtitle">
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:01999b61120bb58f8cfa56a2f5b1b5573d2755fc5be85d3929237d6a61244ab0 oid sha256:2dd82d156d36a7c7141fd5a4eafdbcbfe9aacc7a8dcede267fdfde272519bee6
size 54308 size 52088
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:72acb2485a98126e53c27459d63fe7d3718ebc94215a597207ff820d4c9e45a7
size 43439
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a8b4f5fe2d2aa5459aac05180882b3aaa9fc9777f84f4c817c9e995049485946
size 42052
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ba8f5f9d0ce23de5e6d57660887478b04851680d13a9f8f70d1488c557a25afc
size 43261
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3dc8f7d0497632c723311c530d37807261686ed9fe4d7ff926c0a0fbf825cf23
size 43179
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2cc1821e0f114837943f9b9ff76f43a1bf871cc49a4eff704e8c87b50cc384ba
size 44736
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cbf8bd63967dc3e0739ea2cc62c42fbcbe7d0a9cdf50b41f24c1ccab1f300eab
size 39442
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:52f7675544ba71ed73d2f4283287f46eaa0798d48993d7eb54fc28f87c89cf0e oid sha256:3dc8f7d0497632c723311c530d37807261686ed9fe4d7ff926c0a0fbf825cf23
size 43466 size 43179
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d0c31e3d7a0788e91287da4ed4f0b6e5e1ade8ce542392f4ba6029d524372c32
size 41551
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:50cca191bf1c21a4d0f2765643712d0db6813dc23f2c4b70589ad65f2847b259 oid sha256:176519a45ab97560dc2f8bf1416915759039616458f5fde22def7022595b4cc9
size 40215 size 39890
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9c13b82c858490228a4f5568b7489115d916af067a8cf2e01e22e44f56cd6c3e oid sha256:7efafe0931f350b3369a73fc4cb4156f641d894db5e939202ab9fec5cec8423b
size 39566 size 39229
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9c13b82c858490228a4f5568b7489115d916af067a8cf2e01e22e44f56cd6c3e oid sha256:7efafe0931f350b3369a73fc4cb4156f641d894db5e939202ab9fec5cec8423b
size 39566 size 39229
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:52f7675544ba71ed73d2f4283287f46eaa0798d48993d7eb54fc28f87c89cf0e oid sha256:3dc8f7d0497632c723311c530d37807261686ed9fe4d7ff926c0a0fbf825cf23
size 43466 size 43179
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:38839f3e1cc1ed7fd02695e1ad68e44692fc836ee3fad075ada9d500a94b2554 oid sha256:73cf2629ea291b423460d8756814e97b2e8720a064aaf7fc9f3f3a7cf9a34862
size 39554 size 39186
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4fc4c290503b6ae3157cf4169b50a8e00bae953d87b2d319389e8ab3decb9ca3 oid sha256:efb91f3ae38c55d585d86d1c83f8c52e7244b62b64c82f51ad145ac3ec9841a7
size 43856 size 43493
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:52f7675544ba71ed73d2f4283287f46eaa0798d48993d7eb54fc28f87c89cf0e oid sha256:3dc8f7d0497632c723311c530d37807261686ed9fe4d7ff926c0a0fbf825cf23
size 43466 size 43179
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:52f7675544ba71ed73d2f4283287f46eaa0798d48993d7eb54fc28f87c89cf0e oid sha256:3dc8f7d0497632c723311c530d37807261686ed9fe4d7ff926c0a0fbf825cf23
size 43466 size 43179
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d8a677a3dbb1cf6cbb306d3b1e4ba612469cba408815320361365d74e188cf74 oid sha256:5d921332fe1eb11a80750ed8934f3d386fa267e180bbd29960a750181bddcb49
size 52703 size 50570
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a189eca358d0bf0c2ed4f107252b667d996e560822ea8e39cf776511ebb0fde4
size 41989
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:019e7a646c19f2056873f186e8c7ee73127e79468e5149034e85455460b689be
size 40637
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5a9585f68e045397a46c8f368e4affa1e9fde985bf415014403b91e73de6eb9e
size 41713
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ca8ace95c2cb039826be9e39da427eebada1a8f6f234d6ae82c9e2a69f9a2c8e
size 41760
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43d661bd16a8343e1750faeed957fe5490cee65d0bcc033ecef6344c7ee85fba
size 42104
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cacac247776f92bf1aeaed8b0b11a2283d27e51c6999f4fcd08b62b8aae4c3d7
size 36997
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d92a5ffd43eac457c3384cfc22b9b29fbf8c21532fc16892258140c557788bd2 oid sha256:ca8ace95c2cb039826be9e39da427eebada1a8f6f234d6ae82c9e2a69f9a2c8e
size 41872 size 41760
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c6685d37388f89b1a6f18a66deb1a7a35f23a88c2bd64e3d4b980f71cd9cc5aa
size 39185
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fd1599665f32844b4757a631542351303b7d4f4a34f32640ee9f60ad38089cab oid sha256:50d4e41bfed974afdcdb1864f95e9b5ea26675f67c16cd17d82c431547be4b5b
size 38707 size 38565
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ea8532bc891ce639c492ed7d6edaaadefc2b99dd3b68873a575858b03b6fcbfe oid sha256:6c31c52efbf995bcc237a309772be0c70221db7d30dec38c734bf01f75bb1a97
size 36998 size 36856
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ea8532bc891ce639c492ed7d6edaaadefc2b99dd3b68873a575858b03b6fcbfe oid sha256:6c31c52efbf995bcc237a309772be0c70221db7d30dec38c734bf01f75bb1a97
size 36998 size 36856
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d92a5ffd43eac457c3384cfc22b9b29fbf8c21532fc16892258140c557788bd2 oid sha256:ca8ace95c2cb039826be9e39da427eebada1a8f6f234d6ae82c9e2a69f9a2c8e
size 41872 size 41760
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:68d4a4cd27afda0d23438451124ef997dadd116e1ea5db4b5a6af0045d95ba2b oid sha256:64b969354abd79e131dc6bf644f48e2218216fd4a6b8e93527f52defc8b2cb38
size 36998 size 36836
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:784b744139daa4216d4137aaaca3a4f1a86ef7297ce2996bb9e9eb78c8aa93fb oid sha256:b07d98cdb41d4daf9bec18285588ad5bf99992a52ef27f2da66545734b8db843
size 41201 size 41036
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d92a5ffd43eac457c3384cfc22b9b29fbf8c21532fc16892258140c557788bd2 oid sha256:ca8ace95c2cb039826be9e39da427eebada1a8f6f234d6ae82c9e2a69f9a2c8e
size 41872 size 41760
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d92a5ffd43eac457c3384cfc22b9b29fbf8c21532fc16892258140c557788bd2 oid sha256:ca8ace95c2cb039826be9e39da427eebada1a8f6f234d6ae82c9e2a69f9a2c8e
size 41872 size 41760
+1
View File
@@ -330,6 +330,7 @@
"screen\\.advanced_settings\\..*", "screen\\.advanced_settings\\..*",
"screen_edit_profile_.*", "screen_edit_profile_.*",
"screen_notification_settings_.*", "screen_notification_settings_.*",
"screen\\.notification_settings\\..*",
"screen_blocked_users_.*", "screen_blocked_users_.*",
"full_screen_intent_banner_.*", "full_screen_intent_banner_.*",
"troubleshoot_notifications_entry_point_.*", "troubleshoot_notifications_entry_point_.*",