mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
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:
+14
@@ -8,6 +8,8 @@
|
||||
|
||||
package io.element.android.features.preferences.impl.notifications
|
||||
|
||||
import io.element.android.libraries.preferences.api.store.NotificationSound
|
||||
|
||||
sealed interface NotificationSettingsEvents {
|
||||
data object RefreshSystemNotificationsEnabled : NotificationSettingsEvents
|
||||
data class SetNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
|
||||
@@ -20,4 +22,16 @@ sealed interface NotificationSettingsEvents {
|
||||
data object ChangePushProvider : NotificationSettingsEvents
|
||||
data object CancelChangePushProvider : 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
|
||||
}
|
||||
|
||||
+249
@@ -16,10 +16,12 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.AsyncData
|
||||
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.notificationsettings.NotificationSettingsService
|
||||
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.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.PushProvider
|
||||
import io.element.android.libraries.pushstore.api.UserPushStore
|
||||
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Inject
|
||||
@@ -52,9 +66,22 @@ class NotificationSettingsPresenter(
|
||||
private val pushService: PushService,
|
||||
private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider,
|
||||
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
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
) : 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
|
||||
override fun present(): NotificationSettingsState {
|
||||
val userPushStore = remember { userPushStoreFactory.getOrCreate(matrixClient.sessionId) }
|
||||
@@ -109,6 +136,42 @@ class NotificationSettingsPresenter(
|
||||
|
||||
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(
|
||||
data: Pair<PushProvider, Distributor>?
|
||||
) = launch {
|
||||
@@ -157,6 +220,62 @@ class NotificationSettingsPresenter(
|
||||
NotificationSettingsEvents.ChangePushProvider -> showChangePushProviderDialog = true
|
||||
NotificationSettingsEvents.CancelChangePushProvider -> showChangePushProviderDialog = false
|
||||
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,
|
||||
showChangePushProviderDialog = showChangePushProviderDialog,
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
private fun CoroutineScope.observeNotificationSettings(
|
||||
target: MutableState<NotificationSettingsState.MatrixSettings>,
|
||||
|
||||
+29
@@ -12,6 +12,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
|
||||
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 kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@@ -23,6 +24,24 @@ data class NotificationSettingsState(
|
||||
val availablePushDistributors: ImmutableList<Distributor>,
|
||||
val showChangePushProviderDialog: Boolean,
|
||||
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,
|
||||
) {
|
||||
sealed interface MatrixSettings {
|
||||
@@ -45,6 +64,16 @@ data class NotificationSettingsState(
|
||||
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.
|
||||
* This is true if the current push distributor is in a failure state or if there are multiple push distributors available.
|
||||
|
||||
+67
@@ -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.aFullScreenIntentPermissionsState
|
||||
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 kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -45,6 +46,34 @@ open class NotificationSettingsStateProvider : PreviewParameterProvider<Notifica
|
||||
aInvalidNotificationSettingsState(fixFailed = true),
|
||||
aValidNotificationSettingsState(fullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(permissionGranted = 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,
|
||||
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 = {},
|
||||
) = NotificationSettingsState(
|
||||
matrixSettings = NotificationSettingsState.MatrixSettings.Valid(
|
||||
@@ -80,6 +119,20 @@ fun aValidNotificationSettingsState(
|
||||
availablePushDistributors = availablePushDistributors.toImmutableList(),
|
||||
showChangePushProviderDialog = showChangePushProviderDialog,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -99,6 +152,20 @@ fun aInvalidNotificationSettingsState(
|
||||
availablePushDistributors = persistentListOf(),
|
||||
showChangePushProviderDialog = false,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
+201
@@ -8,16 +8,29 @@
|
||||
|
||||
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.size
|
||||
import androidx.compose.foundation.progressSemantics
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.preferences.impl.R
|
||||
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.PreviewsDayNight
|
||||
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.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
|
||||
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 kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
@@ -182,6 +199,7 @@ private fun NotificationSettingsContentView(
|
||||
onCheckedChange = onMentionNotificationsChange
|
||||
)
|
||||
}
|
||||
SoundsPreferenceCategory(state = state)
|
||||
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
|
||||
// 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
|
||||
private fun getTitleForRoomNotificationMode(mode: RoomNotificationMode?) =
|
||||
when (mode) {
|
||||
|
||||
+55
@@ -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_title">"Labs"</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_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.
|
||||
@@ -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_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_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_mentions">"Mentions"</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_sound_custom_fallback">"Custom"</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_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_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_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_silent">"Silent"</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_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_chime">"Chime"</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_electronic">"Electronic"</string>
|
||||
<string name="screen_notification_settings_sound_system_fanfare">"Fanfare"</string>
|
||||
|
||||
+765
@@ -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.FakeMatrixClient
|
||||
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.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.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.PushProvider
|
||||
import io.element.android.libraries.pushproviders.test.FakePushProvider
|
||||
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.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
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.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class NotificationSettingsPresenterTest {
|
||||
@Test
|
||||
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(
|
||||
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
|
||||
pushService: PushService = FakePushService(),
|
||||
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 {
|
||||
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
|
||||
return NotificationSettingsPresenter(
|
||||
@@ -366,6 +1126,11 @@ class NotificationSettingsPresenterTest {
|
||||
pushService = pushService,
|
||||
systemNotificationsEnabledProvider = FakeSystemNotificationsEnabledProvider(),
|
||||
fullScreenIntentPermissionsPresenter = { fullScreenIntentPermissionsStateLambda() },
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
notificationSoundUpdater = notificationSoundUpdater,
|
||||
notificationSoundCopier = notificationSoundCopier,
|
||||
soundDisplayNameResolver = soundDisplayNameResolver,
|
||||
stringProvider = FakeStringProvider(),
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
)
|
||||
}
|
||||
|
||||
+40
-2
@@ -13,6 +13,7 @@ package io.element.android.features.preferences.impl.notifications
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
@@ -49,7 +50,7 @@ class NotificationSettingsViewTest {
|
||||
eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Config(qualifiers = "h1280dp")
|
||||
@Test
|
||||
fun `clicking on troubleshoot notification invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
|
||||
@@ -241,7 +242,7 @@ class NotificationSettingsViewTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Config(qualifiers = "h1280dp")
|
||||
@Test
|
||||
fun `clicking on Push notification provider emits the expected event`() = runAndroidComposeUiTest {
|
||||
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(
|
||||
|
||||
+90
@@ -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()))
|
||||
}
|
||||
}
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user