diff --git a/app/src/main/res/xml/file_providers.xml b/app/src/main/res/xml/file_providers.xml index ac74590cba..330a2e0c3f 100644 --- a/app/src/main/res/xml/file_providers.xml +++ b/app/src/main/res/xml/file_providers.xml @@ -7,4 +7,5 @@ --> + diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt index 5a6c135247..2093baa8b3 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -35,8 +35,9 @@ class DefaultAnnouncementStore( override fun announcementStatusFlow(announcement: Announcement): Flow { val key = announcement.toKey() - // Announcement.Fullscreen.Space is disabled, consider it's shown - // For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08) + // Default-status convention: Shown for one-shot intro/feature announcements that should + // NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted + // in by AppMigration08). val defaultStatus = when (announcement) { Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index a03399baf7..dee175a7b1 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -245,19 +245,25 @@ private fun RoomsViewList( ) } } - SecurityBannerState.None -> if (state.fullScreenIntentPermissionsState.shouldDisplayBanner) { - item { - FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState) + // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > + // new-notification-sound > sound-unavailable. At most one renders at a time. + SecurityBannerState.None -> when { + state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { + item { + FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState) + } } - } else if (state.batteryOptimizationState.shouldDisplayBanner) { - item { - BatteryOptimizationBanner(state = state.batteryOptimizationState) + state.batteryOptimizationState.shouldDisplayBanner -> { + item { + BatteryOptimizationBanner(state = state.batteryOptimizationState) + } } - } else if (state.showNewNotificationSoundBanner) { - item { - NewNotificationSoundBanner( - onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, - ) + state.showNewNotificationSoundBanner -> { + item { + NewNotificationSoundBanner( + onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, + ) + } } } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 99a5550a08..5c2c3e294b 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -89,6 +89,7 @@ import org.junit.Rule import org.junit.Test import kotlin.time.Duration.Companion.seconds +@Suppress("LargeClass") class RoomListPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt index 8f1b332c8c..5ca7fc9fdf 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt @@ -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 } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index 12f13e734b..03fe2cf31b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -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, + 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 { + // Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks + // its `.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(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? ) = 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? { + 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, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt index 0a55909e2c..3c59f6c355 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt @@ -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, 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. diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt index fb396443c3..905f869dbe 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt @@ -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 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, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt index 09f54b7027..5652276c23 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt @@ -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) { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSoundPicker.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSoundPicker.kt new file mode 100644 index 0000000000..df9d8bb0c4 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSoundPicker.kt @@ -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()) + } +} diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index 7389a0433c..f2a3d87770 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -58,6 +58,7 @@ "Feeling experimental?" "Labs" "Additional settings" + "Call ringtone" "Audio and video calls" "Configuration mismatch" "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." "Invitations" "Your homeserver does not support this option in encrypted rooms, you may not get notified in some rooms." "Mentions" + "Choose another sound…" + "Currently using %1$s" + "Message sound" + "Message sound" "All" "Mentions" "Notify me for" "Notify me on @room" + "Custom" "Custom sound…" "Error deleting file" "Element Default" @@ -87,7 +93,9 @@ If you proceed, some of your settings may change." "Error importing file" "Problem previewing alert sound" "Sound" + "Dismiss alert sound error" "Problem setting alert sound" + "Silent" "Alert" "Anticipate" "Bell" @@ -95,6 +103,7 @@ If you proceed, some of your settings may change." "Calypso" "Chime" "Choo Choo" + "System Default" "Descent" "Electronic" "Fanfare" diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index 26bb0ba1ee..29e4b1c40a 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -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 { _, _ -> } + 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 { _, _ -> } + 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() + 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 { _, _ -> } + 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() + 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() + 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() + 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() + 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() + 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 { _, _ -> } + 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() + 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() + // 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() + val deleteCalls = mutableListOf() + val copierCalls = mutableListOf() + 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() + val copyOrder = mutableListOf() + 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() + 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 { _, _ -> } + 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 { _, _ -> } + 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, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt index 66ed0339a3..dee0df511c 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt @@ -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() @@ -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() @@ -277,6 +278,43 @@ class NotificationSettingsViewTest { ) ) } + + @Config(qualifiers = "h1280dp") + @Test + fun `sounds preference category renders rows with current display names`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + 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() + 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.setNotificationSettingsView( diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSoundPickerTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSoundPickerTest.kt new file mode 100644 index 0000000000..3031187f25 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSoundPickerTest.kt @@ -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())) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSoundStorageTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSoundStorageTest.kt new file mode 100644 index 0000000000..7f8dba8226 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSoundStorageTest.kt @@ -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) + } + } +} diff --git a/libraries/preferences/api/build.gradle.kts b/libraries/preferences/api/build.gradle.kts index a441616657..7084c960c6 100644 --- a/libraries/preferences/api/build.gradle.kts +++ b/libraries/preferences/api/build.gradle.kts @@ -9,7 +9,7 @@ import extension.testCommonDependencies */ plugins { - id("io.element.android-library") + id("io.element.android-compose-library") } android { diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt index df8dc8acc9..b1877b3ae5 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -41,5 +41,27 @@ interface AppPreferencesStore { suspend fun setTracingLogPacks(targets: Set) fun getTracingLogPacksFlow(): Flow> + fun getMessageSoundFlow(): Flow + + /** + * Atomically persists [sound] (with copy-time [title] for Custom; cleared otherwise) and + * bumps the channel version. Single transaction so process death can't desync URI and version. + */ + suspend fun setMessageSoundAndIncrementVersion(sound: NotificationSound, title: String?): Int + + /** Title captured at copy time. Null for SystemDefault / Silent or pre-title persisted data. */ + fun getMessageSoundDisplayNameFlow(): Flow + + fun getCallRingtoneFlow(): Flow + + /** See [setMessageSoundAndIncrementVersion]. */ + suspend fun setCallRingtoneAndIncrementVersion(sound: NotificationSound, title: String?): Int + + /** See [getMessageSoundDisplayNameFlow]. */ + fun getCallRingtoneDisplayNameFlow(): Flow + + /** Single-snapshot read of all sound prefs; used at boot to seed channels without N reads. */ + suspend fun getNotificationSoundChannelConfig(): NotificationSoundChannelConfig + suspend fun reset() } diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/NotificationSound.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/NotificationSound.kt new file mode 100644 index 0000000000..f138dd592b --- /dev/null +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/NotificationSound.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.api.store + +import androidx.compose.runtime.Immutable + +/** + * The user's choice of notification sound for a notification channel. + */ +@Immutable +sealed interface NotificationSound { + /** Use the system's default tone for this channel (e.g. notification or ringtone). */ + data object SystemDefault : NotificationSound + + /** + * Use the bundled in-app sound. Semantically meaningful only for the noisy message channel, + * where it resolves to `R.raw.message`; the ringing-call channel has no bundled tone and + * treats this the same as [SystemDefault]. + */ + data object ElementDefault : NotificationSound + + /** + * Use the bundled in-app fade sound. Like [ElementDefault] this is meaningful only for the + * noisy message channel (`R.raw.element_fade`); the ringing-call channel has no bundled tone + * and treats this the same as [SystemDefault]. + */ + data object ElementFade : NotificationSound + + /** Produce no sound. */ + data object Silent : NotificationSound + + /** Use the ringtone at [uri]. */ + data class Custom(val uri: String) : NotificationSound + + companion object { + // String? round-trip used by [AppPreferencesStore]: + // null -> SystemDefault, "silent" -> Silent, "element_default" -> ElementDefault, + // "element_fade" -> ElementFade, else -> Custom(uri). + private const val STORED_SILENT = "silent" + private const val STORED_ELEMENT_DEFAULT = "element_default" + private const val STORED_ELEMENT_FADE = "element_fade" + + fun fromStored(value: String?): NotificationSound = when (value) { + null -> SystemDefault + STORED_SILENT -> Silent + STORED_ELEMENT_DEFAULT -> ElementDefault + STORED_ELEMENT_FADE -> ElementFade + else -> Custom(value) + } + + fun NotificationSound.toStored(): String? = when (this) { + SystemDefault -> null + Silent -> STORED_SILENT + ElementDefault -> STORED_ELEMENT_DEFAULT + ElementFade -> STORED_ELEMENT_FADE + is Custom -> uri + } + } +} + +/** + * Snapshot of the persisted notification-sound state, returned by + * [AppPreferencesStore.getNotificationSoundChannelConfig] in a single read. + */ +data class NotificationSoundChannelConfig( + val messageSound: NotificationSound, + val messageSoundVersion: Int, + val messageSoundDisplayName: String?, + val callRingtone: NotificationSound, + val callRingtoneVersion: Int, + val callRingtoneDisplayName: String?, +) diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index 44260461da..af35ac3c44 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -20,8 +20,12 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.TraceLogPack import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.api.store.NotificationSound +import io.element.android.libraries.preferences.api.store.NotificationSound.Companion.toStored +import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map private val developerModeKey = booleanPreferencesKey("developerMode") @@ -32,6 +36,12 @@ private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPr private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate") private val logLevelKey = stringPreferencesKey("logLevel") private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") +private val messageSoundUriKey = stringPreferencesKey("notificationMessageSoundUri") +private val messageSoundChannelVersionKey = intPreferencesKey("notificationMessageSoundChannelVersion") +private val messageSoundDisplayNameKey = stringPreferencesKey("notificationMessageSoundDisplayName") +private val callRingtoneUriKey = stringPreferencesKey("notificationCallRingtoneUri") +private val callRingtoneChannelVersionKey = intPreferencesKey("notificationCallRingtoneChannelVersion") +private val callRingtoneDisplayNameKey = stringPreferencesKey("notificationCallRingtoneDisplayName") @ContributesBinding(AppScope::class) class DefaultAppPreferencesStore( @@ -158,6 +168,75 @@ class DefaultAppPreferencesStore( } } + override fun getMessageSoundFlow(): Flow { + return store.data.map { prefs -> NotificationSound.fromStored(prefs[messageSoundUriKey]) } + } + + override suspend fun setMessageSoundAndIncrementVersion(sound: NotificationSound, title: String?): Int { + var newVersion = 0 + store.edit { prefs -> + val stored = sound.toStored() + if (stored != null) { + prefs[messageSoundUriKey] = stored + } else { + prefs.remove(messageSoundUriKey) + } + // Clear title on non-Custom so the picker doesn't show a stale label after a revert. + if (sound is NotificationSound.Custom && !title.isNullOrBlank()) { + prefs[messageSoundDisplayNameKey] = title + } else { + prefs.remove(messageSoundDisplayNameKey) + } + newVersion = (prefs[messageSoundChannelVersionKey] ?: 0) + 1 + prefs[messageSoundChannelVersionKey] = newVersion + } + return newVersion + } + + override fun getMessageSoundDisplayNameFlow(): Flow { + return store.data.map { prefs -> prefs[messageSoundDisplayNameKey] } + } + + override fun getCallRingtoneFlow(): Flow { + return store.data.map { prefs -> NotificationSound.fromStored(prefs[callRingtoneUriKey]) } + } + + override suspend fun setCallRingtoneAndIncrementVersion(sound: NotificationSound, title: String?): Int { + var newVersion = 0 + store.edit { prefs -> + val stored = sound.toStored() + if (stored != null) { + prefs[callRingtoneUriKey] = stored + } else { + prefs.remove(callRingtoneUriKey) + } + if (sound is NotificationSound.Custom && !title.isNullOrBlank()) { + prefs[callRingtoneDisplayNameKey] = title + } else { + prefs.remove(callRingtoneDisplayNameKey) + } + newVersion = (prefs[callRingtoneChannelVersionKey] ?: 0) + 1 + prefs[callRingtoneChannelVersionKey] = newVersion + } + return newVersion + } + + override fun getCallRingtoneDisplayNameFlow(): Flow { + return store.data.map { prefs -> prefs[callRingtoneDisplayNameKey] } + } + + override suspend fun getNotificationSoundChannelConfig(): NotificationSoundChannelConfig { + val prefs = store.data.first() + return NotificationSoundChannelConfig( + messageSound = NotificationSound.fromStored(prefs[messageSoundUriKey]), + messageSoundVersion = prefs[messageSoundChannelVersionKey] ?: 0, + messageSoundDisplayName = prefs[messageSoundDisplayNameKey], + callRingtone = NotificationSound.fromStored(prefs[callRingtoneUriKey]), + callRingtoneVersion = prefs[callRingtoneChannelVersionKey] ?: 0, + callRingtoneDisplayName = prefs[callRingtoneDisplayNameKey], + ) + } + override suspend fun reset() { store.edit { it.clear() } } diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index 152de12e99..6499e70223 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -12,8 +12,11 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.TraceLogPack import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.api.store.NotificationSound +import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.updateAndGet class InMemoryAppPreferencesStore( isDeveloperModeEnabled: Boolean = false, @@ -23,16 +26,28 @@ class InMemoryAppPreferencesStore( theme: String? = null, liveLocationMinimumDistanceUpdate: Int = 10, logLevel: LogLevel = LogLevel.INFO, - traceLockPacks: Set = emptySet(), + traceLogPacks: Set = emptySet(), + messageSound: NotificationSound = NotificationSound.SystemDefault, + messageSoundChannelVersion: Int = 0, + messageSoundDisplayName: String? = null, + callRingtone: NotificationSound = NotificationSound.SystemDefault, + callRingtoneChannelVersion: Int = 0, + callRingtoneDisplayName: String? = null, ) : AppPreferencesStore { private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val theme = MutableStateFlow(theme) private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate) private val logLevel = MutableStateFlow(logLevel) - private val tracingLogPacks = MutableStateFlow(traceLockPacks) + private val tracingLogPacks = MutableStateFlow(traceLogPacks) private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars) private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue) + private val messageSound = MutableStateFlow(messageSound) + private val messageSoundChannelVersion = MutableStateFlow(messageSoundChannelVersion) + private val messageSoundDisplayName = MutableStateFlow(messageSoundDisplayName) + private val callRingtone = MutableStateFlow(callRingtone) + private val callRingtoneChannelVersion = MutableStateFlow(callRingtoneChannelVersion) + private val callRingtoneDisplayName = MutableStateFlow(callRingtoneDisplayName) override suspend fun setDeveloperModeEnabled(enabled: Boolean) { isDeveloperModeEnabled.value = enabled @@ -102,6 +117,45 @@ class InMemoryAppPreferencesStore( return tracingLogPacks } + override fun getMessageSoundFlow(): Flow { + return messageSound + } + + override suspend fun setMessageSoundAndIncrementVersion(sound: NotificationSound, title: String?): Int { + messageSound.value = sound + messageSoundDisplayName.value = if (sound is NotificationSound.Custom && !title.isNullOrBlank()) title else null + return messageSoundChannelVersion.updateAndGet { it + 1 } + } + + override fun getMessageSoundDisplayNameFlow(): Flow { + return messageSoundDisplayName + } + + override fun getCallRingtoneFlow(): Flow { + return callRingtone + } + + override suspend fun setCallRingtoneAndIncrementVersion(sound: NotificationSound, title: String?): Int { + callRingtone.value = sound + callRingtoneDisplayName.value = if (sound is NotificationSound.Custom && !title.isNullOrBlank()) title else null + return callRingtoneChannelVersion.updateAndGet { it + 1 } + } + + override fun getCallRingtoneDisplayNameFlow(): Flow { + return callRingtoneDisplayName + } + + override suspend fun getNotificationSoundChannelConfig(): NotificationSoundChannelConfig { + return NotificationSoundChannelConfig( + messageSound = messageSound.value, + messageSoundVersion = messageSoundChannelVersion.value, + messageSoundDisplayName = messageSoundDisplayName.value, + callRingtone = callRingtone.value, + callRingtoneVersion = callRingtoneChannelVersion.value, + callRingtoneDisplayName = callRingtoneDisplayName.value, + ) + } + override suspend fun reset() { // No op } diff --git a/libraries/push/api/build.gradle.kts b/libraries/push/api/build.gradle.kts index df6ac6120a..91e545daed 100644 --- a/libraries/push/api/build.gradle.kts +++ b/libraries/push/api/build.gradle.kts @@ -20,5 +20,6 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixmedia.api) + implementation(projects.libraries.preferences.api) implementation(projects.libraries.pushproviders.api) } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationSoundUpdater.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationSoundUpdater.kt new file mode 100644 index 0000000000..b23c4122ad --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationSoundUpdater.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.notifications + +import io.element.android.libraries.preferences.api.store.NotificationSound + +/** + * Reads and writes the sound on the message / ringing-call channels. Writes create a new + * versioned channel because Android forbids mutating sound after creation. + */ +interface NotificationSoundUpdater { + fun recreateNoisyChannel(sound: NotificationSound, version: Int) + + fun recreateRingingCallChannel(sound: NotificationSound, version: Int) + + /** Current channel sound classified into [NotificationSound]. Null when the channel doesn't exist. */ + suspend fun readNoisyChannelSound(): NotificationSound? + + /** See [readNoisyChannelSound]. */ + suspend fun readRingingCallChannelSound(): NotificationSound? +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/SoundDisplayNameResolver.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/SoundDisplayNameResolver.kt new file mode 100644 index 0000000000..a850b3e435 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/SoundDisplayNameResolver.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.notifications + +/** + * Resolves the user-facing title of a custom notification sound URI (the title shown by the + * system ringtone picker). Lifts the [android.media.RingtoneManager] side effect out of UI code + * so views and presenters stay testable. + */ +interface SoundDisplayNameResolver { + /** Returns the title of the ringtone at [uri], or null if it cannot be resolved. */ + suspend fun resolveCustomSoundTitle(uri: String): String? +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/sound/NotificationSoundCopier.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/sound/NotificationSoundCopier.kt new file mode 100644 index 0000000000..2268198353 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/sound/NotificationSoundCopier.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.notifications.sound + +/** + * Copies a picked notification sound into app-private storage and re-exposes it via the app's + * FileProvider, so the persisted reference outlives the original source. + */ +interface NotificationSoundCopier { + suspend fun copyToAppFiles(sourceUriString: String, slot: SoundSlot): CopyResult + + /** + * Removes any previously copied file for [slot] from app-private storage. No-op when no file + * exists. Call this when persisting a non-[NotificationSound.Custom] pick (SystemDefault / + * Silent) so the previous Custom copy doesn't linger as orphaned bytes — [copyToAppFiles] + * already sweeps stale files inline for Custom-to-Custom transitions. + */ + suspend fun deleteStoredSoundFor(slot: SoundSlot) + + enum class SoundSlot { Message, Call } + + sealed interface CopyResult { + /** + * @property fileProviderUriString FileProvider URI to persist as the channel sound. + * @property displayName [android.media.Ringtone.getTitle] for the source, or null. The + * picker substitutes a localised fallback label, so localisation stays in the UI layer. + */ + data class Success(val fileProviderUriString: String, val displayName: String?) : CopyResult + + /** [android.media.RingtoneManager] could not open the source URI as a Ringtone. */ + data object UnplayableSource : CopyResult + + /** The bytes copied successfully, but the resulting file is not a playable Ringtone. */ + data object UnplayableCopy : CopyResult + + /** Source exceeded the 5 MB cap. */ + data object FileTooLarge : CopyResult + + /** Any other I/O or unexpected failure. */ + data class Failure(val cause: Throwable) : CopyResult + } +} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index e8acda59da..b9082c50f6 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.troubleshoot.api) implementation(projects.libraries.workmanager.api) + implementation(projects.features.announcement.api) implementation(projects.features.call.api) implementation(projects.features.enterprise.api) implementation(projects.features.lockscreen.api) @@ -80,6 +81,7 @@ dependencies { testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.troubleshoot.test) testImplementation(projects.libraries.workmanager.test) + testImplementation(projects.features.announcement.test) testImplementation(projects.features.call.test) testImplementation(projects.features.enterprise.test) testImplementation(projects.features.lockscreen.test) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSoundDisplayNameResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSoundDisplayNameResolver.kt new file mode 100644 index 0000000000..b6237aba03 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSoundDisplayNameResolver.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Context +import android.media.RingtoneManager +import androidx.core.net.toUri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.push.api.notifications.SoundDisplayNameResolver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@ContributesBinding(AppScope::class) +class DefaultSoundDisplayNameResolver( + @ApplicationContext private val context: Context, +) : SoundDisplayNameResolver { + override suspend fun resolveCustomSoundTitle(uri: String): String? = withContext(Dispatchers.IO) { + val parsed = runCatchingExceptions { uri.toUri() }.getOrNull() ?: return@withContext null + // Probing our own FileProvider URI yields the internal filename ("call_sound.ogg"), + // not a user-meaningful tone name — better to fall through to the localised "Custom" + // label in the UI. + if (parsed.authority == context.notificationSoundFileProviderAuthority()) return@withContext null + runCatchingExceptions { + RingtoneManager.getRingtone(context, parsed)?.getTitle(context) + }.getOrNull() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationSoundFileProviderAuthority.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationSoundFileProviderAuthority.kt new file mode 100644 index 0000000000..db9fa47c02 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationSoundFileProviderAuthority.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Context + +// Mirrors the FileProvider authority declared in AndroidManifest.xml as +// `${applicationId}.fileprovider`. Kept here so the resolver's skip-rule and the copier's URI +// construction never drift apart. +internal const val NOTIFICATION_SOUND_FILE_PROVIDER_AUTHORITY_SUFFIX = ".fileprovider" + +internal fun Context.notificationSoundFileProviderAuthority(): String = + packageName + NOTIFICATION_SOUND_FILE_PROVIDER_AUTHORITY_SUFFIX diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt index dc009c0b7d..8d9b776694 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.push.impl.notifications.channels import android.content.ContentResolver import android.content.Context +import android.content.Intent import android.media.AudioAttributes import android.media.AudioAttributes.USAGE_NOTIFICATION import android.media.AudioManager @@ -19,28 +20,50 @@ import android.provider.Settings import androidx.annotation.ChecksSdkIntAtLeast import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding import io.element.android.appconfig.NotificationConfig import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.api.store.NotificationSound +import io.element.android.libraries.push.api.notifications.NotificationSoundUpdater import io.element.android.libraries.push.impl.R import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import timber.log.Timber /* ========================================================================================== * IDs for channels * ========================================================================================== */ internal const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2" -internal const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_V2" +internal const val NOISY_NOTIFICATION_CHANNEL_ID_BASE = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_V2" internal const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V3" -internal const val RINGING_CALL_NOTIFICATION_CHANNEL_ID = "RINGING_CALL_NOTIFICATION_CHANNEL_ID" +internal const val RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE = "RINGING_CALL_NOTIFICATION_CHANNEL_ID" + +private fun versionedChannelId(base: String, version: Int): String = + if (version <= 0) base else "${base}_v$version" + +internal fun noisyNotificationChannelId(version: Int): String = + versionedChannelId(NOISY_NOTIFICATION_CHANNEL_ID_BASE, version) + +internal fun ringingCallNotificationChannelId(version: Int): String = + versionedChannelId(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE, version) /** * on devices >= android O, we need to define a channel for each notifications. + * + * Extends [NotificationSoundUpdater] so consumers can re-create the noisy and ringing-call + * channels when the user picks a new sound URI in settings. */ -interface NotificationChannels { +interface NotificationChannels : NotificationSoundUpdater { /** * Get the channel for incoming call. * @param ring true if the device should ring when receiving the call. @@ -66,14 +89,22 @@ interface NotificationChannels { private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O @SingleIn(AppScope::class) -@ContributesBinding(AppScope::class) +@ContributesBinding(AppScope::class, binding = binding()) +@ContributesBinding(AppScope::class, binding = binding()) class DefaultNotificationChannels( private val notificationManager: NotificationManagerCompat, private val stringProvider: StringProvider, @ApplicationContext private val context: Context, private val enterpriseService: EnterpriseService, + private val appPreferencesStore: AppPreferencesStore, ) : NotificationChannels { + @Volatile private var currentNoisyChannelId: String = NOISY_NOTIFICATION_CHANNEL_ID_BASE + @Volatile private var currentRingingCallChannelId: String = RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE + + // Serializes concurrent recreate* calls; readers stay lock-free via @Volatile on the id fields. + private val recreateLock = Any() + init { createNotificationChannels() } @@ -88,9 +119,27 @@ class DefaultNotificationChannels( val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR + // Single-snapshot read; keep this path minimal — extra ContentResolver lookups here would + // risk a cold-start ANR. + val config = runBlocking { + val initial = appPreferencesStore.getNotificationSoundChannelConfig() + // Pre-dialog builds mapped the noisy `SystemDefault` to our bundled message.mp3. + // Now `SystemDefault` means Android's system tone, so untouched legacy state would + // silently flip sound. Promote it to `ElementDefault` once, gated on version==0 so the + // migration is idempotent. + if (initial.messageSoundVersion == 0 && initial.messageSound == NotificationSound.SystemDefault) { + appPreferencesStore.setMessageSoundAndIncrementVersion(NotificationSound.ElementDefault, title = null) + appPreferencesStore.getNotificationSoundChannelConfig() + } else { + initial + } + } + + currentNoisyChannelId = noisyNotificationChannelId(config.messageSoundVersion) + currentRingingCallChannelId = ringingCallNotificationChannelId(config.callRingtoneVersion) + // Migration - the noisy channel was deleted and recreated when sound preference was changed (id was DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE // + currentTimeMillis). - // Now the sound can only be change directly in system settings, so for app upgrading we are deleting this former channel // Starting from this version the channel will not be dynamic for (channel in notificationManager.notificationChannels) { val channelId = channel.id @@ -111,30 +160,19 @@ class DefaultNotificationChannels( notificationManager.deleteNotificationChannel(channelId) } } + // Drop older versioned channels; only the current one remains. + deleteStaleVersionedChannels(NOISY_NOTIFICATION_CHANNEL_ID_BASE, currentNoisyChannelId) + deleteStaleVersionedChannels(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE, currentRingingCallChannelId) // Default notification importance: shows everywhere, makes noise, but does not visually intrude. + val noisySoundUri = resolveNoisySoundUri(config.messageSound) + grantSoundUriToSystem(noisySoundUri) notificationManager.createNotificationChannel( - NotificationChannelCompat.Builder( - NOISY_NOTIFICATION_CHANNEL_ID, - NotificationManagerCompat.IMPORTANCE_DEFAULT + buildNoisyChannel( + channelId = currentNoisyChannelId, + soundUri = noisySoundUri, + accentColor = accentColor, ) - .setSound( - Uri.Builder() - .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - // Strangely we have to provide a "//" before the package name - .path("//" + context.packageName + "/" + R.raw.message) - .build(), - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(USAGE_NOTIFICATION) - .build(), - ) - .setName(stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" }) - .setDescription(stringProvider.getString(R.string.notification_channel_noisy)) - .setVibrationEnabled(true) - .setLightsEnabled(true) - .setLightColor(accentColor) - .build() ) // Low notification importance: shows everywhere, but is not intrusive. @@ -166,40 +204,205 @@ class DefaultNotificationChannels( ) // Register a channel for incoming call notifications which will ring the device when received + val ringingSoundUri = resolveRingingSoundUri(config.callRingtone) + grantSoundUriToSystem(ringingSoundUri) notificationManager.createNotificationChannel( - NotificationChannelCompat.Builder( - RINGING_CALL_NOTIFICATION_CHANNEL_ID, - NotificationManagerCompat.IMPORTANCE_MAX, + buildRingingCallChannel( + channelId = currentRingingCallChannelId, + soundUri = ringingSoundUri, + accentColor = accentColor, ) - .setName(stringProvider.getString(R.string.notification_channel_ringing_calls).ifEmpty { "Ringing calls" }) - .setVibrationEnabled(true) - .setSound( - Settings.System.DEFAULT_RINGTONE_URI, - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setLegacyStreamType(AudioManager.STREAM_RING) - .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) - .build() - ) - .setDescription(stringProvider.getString(R.string.notification_channel_ringing_calls)) - .setLightsEnabled(true) - .setLightColor(accentColor) - .build() ) } + private fun buildNoisyChannel(channelId: String, soundUri: Uri?, accentColor: Int): NotificationChannelCompat { + val builder = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName(stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" }) + .setDescription(stringProvider.getString(R.string.notification_channel_noisy)) + .setVibrationEnabled(true) + .setLightsEnabled(true) + .setLightColor(accentColor) + if (soundUri != null) { + builder.setSound( + soundUri, + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(USAGE_NOTIFICATION) + .build(), + ) + } else { + builder.setSound(null, null) + } + return builder.build() + } + + private fun buildRingingCallChannel(channelId: String, soundUri: Uri?, accentColor: Int): NotificationChannelCompat { + val builder = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_MAX) + .setName(stringProvider.getString(R.string.notification_channel_ringing_calls).ifEmpty { "Ringing calls" }) + .setDescription(stringProvider.getString(R.string.notification_channel_ringing_calls)) + .setVibrationEnabled(true) + .setLightsEnabled(true) + .setLightColor(accentColor) + if (soundUri != null) { + builder.setSound( + soundUri, + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setLegacyStreamType(AudioManager.STREAM_RING) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .build() + ) + } else { + builder.setSound(null, null) + } + return builder.build() + } + + private fun resolveNoisySoundUri(sound: NotificationSound): Uri? = when (sound) { + NotificationSound.Silent -> null + NotificationSound.SystemDefault -> Settings.System.DEFAULT_NOTIFICATION_URI + NotificationSound.ElementDefault -> bundledMessageSoundUri() + NotificationSound.ElementFade -> bundledFadeSoundUri() + is NotificationSound.Custom -> parseUriOrFallback(sound.uri) { bundledMessageSoundUri() } + } + + private fun resolveRingingSoundUri(sound: NotificationSound): Uri? = when (sound) { + NotificationSound.Silent -> null + // The ringing channel has no bundled tone — treat ElementDefault and ElementFade like SystemDefault here. + NotificationSound.SystemDefault, + NotificationSound.ElementDefault, + NotificationSound.ElementFade -> Settings.System.DEFAULT_RINGTONE_URI + is NotificationSound.Custom -> parseUriOrFallback(sound.uri) { Settings.System.DEFAULT_RINGTONE_URI } + } + + private fun bundledMessageSoundUri(): Uri = + "${ContentResolver.SCHEME_ANDROID_RESOURCE}://${context.packageName}/${R.raw.message}".toUri() + + private fun bundledFadeSoundUri(): Uri = + "${ContentResolver.SCHEME_ANDROID_RESOURCE}://${context.packageName}/${R.raw.element_fade}".toUri() + + /** + * Lets system_server ("android") and SystemUI read our FileProvider sound URI; no-op otherwise. + * SystemUI hosts the lock-screen notification surface on most OEMs, so a missing grant there + * silently mutes ringtones when the device is locked. + */ + private fun grantSoundUriToSystem(uri: Uri?) { + if (uri == null || uri.scheme != ContentResolver.SCHEME_CONTENT) return + for (pkg in arrayOf("android", "com.android.systemui")) { + runCatchingExceptions { + context.grantUriPermission(pkg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + }.onFailure { Timber.w(it, "grantUriPermission(%s) failed for notification sound", pkg) } + } + } + + /** Parses [uriString], or returns [fallback] (the SystemDefault URI) on failure. */ + private inline fun parseUriOrFallback(uriString: String, fallback: () -> Uri): Uri = + runCatchingExceptions { uriString.toUri() } + .getOrElse { + // Don't pass the throwable: legacy persisted URIs may carry SAF auth tokens. + Timber.w("Failed to parse persisted sound URI; falling back to default cause=%s", it::class.simpleName) + fallback() + } + + private fun deleteStaleVersionedChannels(baseId: String, currentId: String) { + if (!supportNotificationChannels()) return + for (channel in notificationManager.notificationChannels) { + val id = channel.id + // Match either the unversioned base ID or any "${baseId}_v" variant. + val isBaseOrVersioned = id == baseId || id.startsWith("${baseId}_v") + if (isBaseOrVersioned && id != currentId) { + notificationManager.deleteNotificationChannel(id) + } + } + } + override fun getChannelForIncomingCall(ring: Boolean): String { - return if (ring) RINGING_CALL_NOTIFICATION_CHANNEL_ID else CALL_NOTIFICATION_CHANNEL_ID + return if (ring) currentRingingCallChannelId else CALL_NOTIFICATION_CHANNEL_ID } override fun getChannelIdForMessage(sessionId: SessionId, noisy: Boolean): String { return if (noisy) { enterpriseService.getNoisyNotificationChannelId(sessionId) - ?: NOISY_NOTIFICATION_CHANNEL_ID + ?: currentNoisyChannelId } else { SILENT_NOTIFICATION_CHANNEL_ID } } - override fun getChannelIdForTest(): String = NOISY_NOTIFICATION_CHANNEL_ID + override fun getChannelIdForTest(): String = currentNoisyChannelId + + override fun recreateNoisyChannel(sound: NotificationSound, version: Int) { + if (!supportNotificationChannels()) return + synchronized(recreateLock) { + val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR + val newChannelId = noisyNotificationChannelId(version) + val soundUri = resolveNoisySoundUri(sound) + grantSoundUriToSystem(soundUri) + // Create channel before publishing the id: a reader landing between the assignment and + // the create call would notify() against a missing id, which Android silently drops. + notificationManager.createNotificationChannel( + buildNoisyChannel(newChannelId, soundUri, accentColor) + ) + currentNoisyChannelId = newChannelId + deleteStaleVersionedChannels(NOISY_NOTIFICATION_CHANNEL_ID_BASE, newChannelId) + } + } + + override fun recreateRingingCallChannel(sound: NotificationSound, version: Int) { + if (!supportNotificationChannels()) return + synchronized(recreateLock) { + val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR + val newChannelId = ringingCallNotificationChannelId(version) + val soundUri = resolveRingingSoundUri(sound) + grantSoundUriToSystem(soundUri) + // See recreateNoisyChannel: the channel must exist before the id is published. + notificationManager.createNotificationChannel( + buildRingingCallChannel(newChannelId, soundUri, accentColor) + ) + currentRingingCallChannelId = newChannelId + deleteStaleVersionedChannels(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE, newChannelId) + } + } + + override suspend fun readNoisyChannelSound(): NotificationSound? { + return readChannelSound( + channelId = currentNoisyChannelId, + bundledUri = bundledMessageSoundUri(), + bundledFadeUri = bundledFadeSoundUri(), + systemDefaultUri = Settings.System.DEFAULT_NOTIFICATION_URI, + ) + } + + override suspend fun readRingingCallChannelSound(): NotificationSound? { + return readChannelSound( + channelId = currentRingingCallChannelId, + bundledUri = null, + bundledFadeUri = null, + systemDefaultUri = Settings.System.DEFAULT_RINGTONE_URI, + ) + } + + /** + * Classifies the channel's sound URI: null → Silent, bundled → ElementDefault, bundled fade → + * ElementFade, system default → SystemDefault, else → Custom. Both bundled URIs are nullable + * because the ringing channel has no bundled tones. + */ + private suspend fun readChannelSound( + channelId: String, + bundledUri: Uri?, + bundledFadeUri: Uri?, + systemDefaultUri: Uri, + ): NotificationSound? { + if (!supportNotificationChannels()) return null + val channel = withContext(Dispatchers.IO) { + notificationManager.getNotificationChannel(channelId) + } ?: return null + return when (val sound = channel.sound) { + null -> NotificationSound.Silent + bundledUri -> NotificationSound.ElementDefault + bundledFadeUri -> NotificationSound.ElementFade + systemDefaultUri -> NotificationSound.SystemDefault + else -> NotificationSound.Custom(sound.toString()) + } + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/sound/DefaultNotificationSoundCopier.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/sound/DefaultNotificationSoundCopier.kt new file mode 100644 index 0000000000..2eeb07189a --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/sound/DefaultNotificationSoundCopier.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.sound + +import android.content.ContentResolver +import android.content.Context +import android.media.RingtoneManager +import android.net.Uri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier +import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier.CopyResult +import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier.SoundSlot +import io.element.android.libraries.push.impl.notifications.notificationSoundFileProviderAuthority +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream + +private const val MAX_BYTES = 5L * 1024 * 1024 +private const val DIRECTORY_NAME = "notification_sounds" +private const val FALLBACK_EXTENSION = "bin" +private const val COPY_BUFFER_SIZE = 8 * 1024 +private const val MAX_DISPLAY_NAME_LENGTH = 256 + +// Defence-in-depth: we only ever expect URIs from the system ringtone picker (content://) or +// the bundled resource (android.resource://). Reject anything else before doing I/O so a future +// caller that constructs a NotificationSound.Custom from an untrusted intent can't trick the +// copier into reading file:// or unrelated providers. +private val ALLOWED_SOURCE_SCHEMES = setOf( + ContentResolver.SCHEME_CONTENT, + ContentResolver.SCHEME_ANDROID_RESOURCE, +) + +@ContributesBinding(AppScope::class) +class DefaultNotificationSoundCopier( + @ApplicationContext private val context: Context, +) : NotificationSoundCopier { + // Per-slot serialization: concurrent picks for the same slot would race on `.tmp`. + private val slotMutexes = SoundSlot.entries.associateWith { Mutex() } + + override suspend fun copyToAppFiles(sourceUriString: String, slot: SoundSlot): CopyResult = withContext(Dispatchers.IO) { + slotMutexes.getValue(slot).withLock { + runCatchingExceptions { performCopy(sourceUriString, slot) } + .getOrElse { ex -> + // Don't pass the throwable to Timber: SAF/MediaProvider exceptions sometimes + // embed the source URI (which can carry short-lived auth tokens). + Timber.w("Notification sound copy failed for slot=%s cause=%s", slot, ex::class.simpleName) + CopyResult.Failure(ex) + } + } + } + + override suspend fun deleteStoredSoundFor(slot: SoundSlot) { + withContext(Dispatchers.IO) { + slotMutexes.getValue(slot).withLock { + val dir = File(context.filesDir, DIRECTORY_NAME) + if (!dir.exists()) return@withLock + val slotBase = slotBaseFor(slot) + dir.listFiles { f -> f.isFile && f.name.startsWith("$slotBase.") } + ?.forEach { stale -> + if (!stale.delete()) { + Timber.w("Could not remove stored slot file %s", stale.name) + } + } + } + } + } + + private fun slotBaseFor(slot: SoundSlot): String = when (slot) { + SoundSlot.Message -> "message_sound" + SoundSlot.Call -> "call_sound" + } + + private fun performCopy(sourceUriString: String, slot: SoundSlot): CopyResult { + val source = sourceUriString.toUri() + if (source.scheme !in ALLOWED_SOURCE_SCHEMES) return CopyResult.UnplayableSource + // Reject unplayable sources up front instead of persisting a known-broken pick. + val sourceRingtone = RingtoneManager.getRingtone(context, source) + ?: return CopyResult.UnplayableSource + // Capture the source's display name before copy — once persisted, the row only sees the + // FileProvider URI, where Ringtone.getTitle() would resolve to our internal filename + // (e.g. "call_sound.ogg"). RingtoneManager → MediaStore TITLE → OpenableColumns covers + // system tones, MediaStore-indexed audio, and SAF-picked files respectively. The value + // crosses our security boundary (foreign provider → persisted state → UI), so cap and + // sanitize it. + val displayName = (sourceRingtone.getTitle(context)?.takeUnless { it.isBlank() } + ?: queryOpenableDisplayName(source)) + ?.let(::sanitizeDisplayName) + + // MIME comes from an arbitrary content provider; refuse extensions outside the platform + // table to keep filenames bounded to notification_sounds/. + val mimeMap = MimeTypeMap.getSingleton() + val candidateExtension = mimeMap.getExtensionFromMimeType(context.contentResolver.getType(source)) + val extension = candidateExtension + ?.takeIf { it.isNotBlank() && mimeMap.hasExtension(it) } + ?: FALLBACK_EXTENSION + + val slotBase = slotBaseFor(slot) + + val dir = File(context.filesDir, DIRECTORY_NAME).apply { mkdirs() } + val tmpFile = File(dir, "$slotBase.tmp").also { if (it.exists()) it.delete() } + val finalFile = File(dir, "$slotBase.$extension") + + val inputStream = context.contentResolver.openInputStream(source) + ?: return CopyResult.UnplayableSource + val bytesCopied = try { + inputStream.use { input -> tmpFile.outputStream().buffered().use { out -> input.copyToCapped(out, MAX_BYTES) } } + } catch (_: SizeLimitExceeded) { + tmpFile.delete() + Timber.w("Notification sound copy aborted: source exceeds %d bytes (slot=%s)", MAX_BYTES, slot) + return CopyResult.FileTooLarge + } + + // The finally below guarantees the temp file doesn't outlive a thrown probe / rename. + var committed = false + try { + // Probe through the FileProvider URI we'll actually persist (catches torn writes + // and OEM codec quirks). + val fileProviderUri = FileProvider.getUriForFile( + context, + context.notificationSoundFileProviderAuthority(), + tmpFile, + ) + if (RingtoneManager.getRingtone(context, fileProviderUri) == null) { + return CopyResult.UnplayableCopy + } + + // Sweep all `.*` so an extension change between picks doesn't leak orphans. + dir.listFiles { f -> f.isFile && f.name.startsWith("$slotBase.") && f.name != tmpFile.name } + ?.forEach { stale -> + if (!stale.delete()) { + Timber.w("Could not remove stale slot file %s", stale.name) + } + } + if (!tmpFile.renameTo(finalFile)) { + return CopyResult.Failure(IOException("Could not rename temp file to ${finalFile.name}")) + } + committed = true + + val finalUri = FileProvider.getUriForFile( + context, + context.notificationSoundFileProviderAuthority(), + finalFile, + ) + Timber.d("Notification sound copied: slot=%s bytes=%d", slot, bytesCopied) + return CopyResult.Success(finalUri.toString(), displayName) + } finally { + if (!committed) tmpFile.delete() + } + } + + private fun queryOpenableDisplayName(uri: Uri): String? { + return runCatchingExceptions { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst() && cursor.columnCount > 0) { + cursor.getString(0)?.takeUnless { it.isBlank() } + } else { + null + } + } + }.getOrNull() + } +} + +internal fun sanitizeDisplayName(raw: String): String? { + // Display name comes from a foreign content provider; strip C0 / DEL control characters and + // cap length so a hostile or malformed value can't bloat persisted state or break UI rendering. + val cleaned = buildString(raw.length.coerceAtMost(MAX_DISPLAY_NAME_LENGTH)) { + for (ch in raw) { + if (length >= MAX_DISPLAY_NAME_LENGTH) break + if (ch.code >= 0x20 && ch.code != 0x7F) append(ch) + } + }.trim() + return cleaned.takeUnless { it.isEmpty() } +} + +private class SizeLimitExceeded : IOException("Sound source exceeds the configured size limit") + +private fun InputStream.copyToCapped(out: java.io.OutputStream, maxBytes: Long): Long { + val buffer = ByteArray(COPY_BUFFER_SIZE) + var totalBytes = 0L + while (true) { + val read = read(buffer) + if (read == -1) break + totalBytes += read + if (totalBytes > maxBytes) throw SizeLimitExceeded() + out.write(buffer, 0, read) + } + out.flush() + return totalBytes +} diff --git a/libraries/push/impl/src/main/res/raw/element_fade.mp3 b/libraries/push/impl/src/main/res/raw/element_fade.mp3 new file mode 100644 index 0000000000..bf31420e49 Binary files /dev/null and b/libraries/push/impl/src/main/res/raw/element_fade.mp3 differ diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/FakeNotificationChannels.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/FakeNotificationChannels.kt index 194caf7287..94d7905f6a 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/FakeNotificationChannels.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/FakeNotificationChannels.kt @@ -9,11 +9,19 @@ package io.element.android.libraries.push.impl.notifications.channels import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.NotificationSound +import io.element.android.tests.testutils.lambda.lambdaError class FakeNotificationChannels( var channelForIncomingCall: (ring: Boolean) -> String = { _ -> "" }, var channelIdForMessage: (sessionId: SessionId, noisy: Boolean) -> String = { _, _ -> "" }, - var channelIdForTest: () -> String = { "" } + var channelIdForTest: () -> String = { "" }, + // Side-effecting recreates default to lambdaError (matching FakeNotificationSoundUpdater) so a + // test that doesn't expect a channel rebuild loudly fails instead of silently swallowing the call. + var recreateNoisyChannelLambda: (sound: NotificationSound, version: Int) -> Unit = { _, _ -> lambdaError() }, + var recreateRingingCallChannelLambda: (sound: NotificationSound, version: Int) -> Unit = { _, _ -> lambdaError() }, + var readNoisyChannelSoundLambda: () -> NotificationSound? = { null }, + var readRingingCallChannelSoundLambda: () -> NotificationSound? = { null }, ) : NotificationChannels { override fun getChannelForIncomingCall(ring: Boolean): String { return channelForIncomingCall(ring) @@ -26,4 +34,16 @@ class FakeNotificationChannels( override fun getChannelIdForTest(): String { return channelIdForTest() } + + override fun recreateNoisyChannel(sound: NotificationSound, version: Int) { + recreateNoisyChannelLambda(sound, version) + } + + override fun recreateRingingCallChannel(sound: NotificationSound, version: Int) { + recreateRingingCallChannelLambda(sound, version) + } + + override suspend fun readNoisyChannelSound(): NotificationSound? = readNoisyChannelSoundLambda() + + override suspend fun readRingingCallChannelSound(): NotificationSound? = readRingingCallChannelSoundLambda() } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannelsTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannelsTest.kt index b004c35537..c53e98bc93 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannelsTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannelsTest.kt @@ -9,16 +9,23 @@ package io.element.android.libraries.push.impl.notifications.channels import android.os.Build +import android.provider.Settings import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.api.store.NotificationSound +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.mockk.every import io.mockk.mockk +import io.mockk.slot import io.mockk.verify +import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -45,7 +52,7 @@ class NotificationChannelsTest { val notificationChannels = createNotificationChannels() val ringingChannel = notificationChannels.getChannelForIncomingCall(ring = true) - assertThat(ringingChannel).isEqualTo(RINGING_CALL_NOTIFICATION_CHANNEL_ID) + assertThat(ringingChannel).isEqualTo(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE) val normalChannel = notificationChannels.getChannelForIncomingCall(ring = false) assertThat(normalChannel).isEqualTo(CALL_NOTIFICATION_CHANNEL_ID) @@ -59,7 +66,7 @@ class NotificationChannelsTest { ), ) assertThat(notificationChannels.getChannelIdForMessage(sessionId = A_SESSION_ID, noisy = true)) - .isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID) + .isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID_BASE) assertThat(notificationChannels.getChannelIdForMessage(sessionId = A_SESSION_ID, noisy = false)) .isEqualTo(SILENT_NOTIFICATION_CHANNEL_ID) } @@ -81,16 +88,418 @@ class NotificationChannelsTest { fun `getChannelIdForTest - returns the right channel`() { val notificationChannels = createNotificationChannels() - assertThat(notificationChannels.getChannelIdForTest()).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID) + assertThat(notificationChannels.getChannelIdForTest()).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID_BASE) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `init - reads persisted message version and seeds versioned channel id`() { + val notificationChannels = createNotificationChannels( + appPreferencesStore = InMemoryAppPreferencesStore(messageSoundChannelVersion = 3), + enterpriseService = FakeEnterpriseService(getNoisyNotificationChannelIdResult = { null }), + ) + assertThat(notificationChannels.getChannelIdForTest()).isEqualTo("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v3") + assertThat(notificationChannels.getChannelIdForMessage(sessionId = A_SESSION_ID, noisy = true)) + .isEqualTo("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v3") + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `init - reads persisted call ringtone version and seeds versioned channel id`() { + val notificationChannels = createNotificationChannels( + appPreferencesStore = InMemoryAppPreferencesStore(callRingtoneChannelVersion = 2), + ) + assertThat(notificationChannels.getChannelForIncomingCall(ring = true)) + .isEqualTo("${RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE}_v2") + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `recreateNoisyChannel - creates new versioned channel and updates current id`() { + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + channels.recreateNoisyChannel(sound = NotificationSound.Custom("content://media/internal/audio/media/42"), version = 7) + + val expectedId = "${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v7" + assertThat(channels.getChannelIdForTest()).isEqualTo(expectedId) + verify { notificationManager.createNotificationChannel(match { it.id == expectedId }) } + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `recreateNoisyChannel - Silent produces channel with no sound`() { + val captured = slot() + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { createNotificationChannel(capture(captured)) } returns Unit + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + channels.recreateNoisyChannel(sound = NotificationSound.Silent, version = 1) + + // The last captured channel (the recreated one) should have a null sound and the new id. + assertThat(captured.captured.id).isEqualTo("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v1") + assertThat(captured.captured.sound).isNull() + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `recreateNoisyChannel - SystemDefault uses Android system notification URI`() { + val captured = slot() + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { createNotificationChannel(capture(captured)) } returns Unit + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + channels.recreateNoisyChannel(sound = NotificationSound.SystemDefault, version = 2) + + assertThat(captured.captured.sound).isEqualTo(Settings.System.DEFAULT_NOTIFICATION_URI) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `recreateNoisyChannel - ElementDefault uses bundled message URI`() { + val captured = slot() + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { createNotificationChannel(capture(captured)) } returns Unit + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + channels.recreateNoisyChannel(sound = NotificationSound.ElementDefault, version = 1) + + assertThat(captured.captured.sound?.scheme).isEqualTo("android.resource") + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `recreateNoisyChannel - ElementFade uses bundled fade URI`() { + val captured = slot() + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { createNotificationChannel(capture(captured)) } returns Unit + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + channels.recreateNoisyChannel(sound = NotificationSound.ElementFade, version = 1) + + val context = RuntimeEnvironment.getApplication() + val expected = "android.resource://${context.packageName}/${io.element.android.libraries.push.impl.R.raw.element_fade}".toUri() + assertThat(captured.captured.sound).isEqualTo(expected) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `recreateNoisyChannel - deletes prior versioned channels`() { + val priorChannel = mockk(relaxed = true) { + every { id } returns "${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v3" + } + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns listOf(priorChannel) + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + channels.recreateNoisyChannel(sound = NotificationSound.SystemDefault, version = 4) + + verify { notificationManager.deleteNotificationChannel("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v3") } + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `recreateRingingCallChannel - creates new versioned channel and updates current id`() { + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + channels.recreateRingingCallChannel(sound = NotificationSound.Custom("content://settings/system/ringtone"), version = 5) + + val expectedId = "${RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE}_v5" + assertThat(channels.getChannelForIncomingCall(ring = true)).isEqualTo(expectedId) + verify { notificationManager.createNotificationChannel(match { it.id == expectedId }) } + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `recreateRingingCallChannel - Silent produces channel with no sound`() { + val captured = slot() + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { createNotificationChannel(capture(captured)) } returns Unit + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + channels.recreateRingingCallChannel(sound = NotificationSound.Silent, version = 1) + + assertThat(captured.captured.id).isEqualTo("${RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE}_v1") + assertThat(captured.captured.sound).isNull() + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `recreate noisy then ringing produces both versioned channels with current ids`() { + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + channels.recreateNoisyChannel(sound = NotificationSound.Custom("content://a"), version = 5) + channels.recreateRingingCallChannel(sound = NotificationSound.Custom("content://b"), version = 7) + + // Each recreate updates the corresponding currentId independently — the lock guarding + // recreate* against itself does not interfere across the two channel families. + assertThat(channels.getChannelIdForTest()).isEqualTo("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v5") + assertThat(channels.getChannelForIncomingCall(ring = true)).isEqualTo("${RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE}_v7") + verify { notificationManager.createNotificationChannel(match { it.id == "${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v5" }) } + verify { + notificationManager.createNotificationChannel( + match { it.id == "${RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE}_v7" } + ) + } + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `readNoisyChannelSound - returns Custom for non-default URI`() = runTest { + val customUri = "content://media/internal/audio/media/42".toUri() + val channel = mockk(relaxed = true) { + every { sound } returns customUri + } + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns channel + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + val result = channels.readNoisyChannelSound() + + assertThat(result).isEqualTo(NotificationSound.Custom(customUri.toString())) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `readNoisyChannelSound - classifies the bundled message URI as ElementDefault`() = runTest { + val context = RuntimeEnvironment.getApplication() + val ourBundled = "android.resource://${context.packageName}/${io.element.android.libraries.push.impl.R.raw.message}".toUri() + val channel = mockk(relaxed = true) { + every { sound } returns ourBundled + } + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns channel + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + val result = channels.readNoisyChannelSound() + + assertThat(result).isEqualTo(NotificationSound.ElementDefault) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `readNoisyChannelSound - classifies the bundled fade URI as ElementFade`() = runTest { + val context = RuntimeEnvironment.getApplication() + val ourFade = "android.resource://${context.packageName}/${io.element.android.libraries.push.impl.R.raw.element_fade}".toUri() + val channel = mockk(relaxed = true) { + every { sound } returns ourFade + } + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns channel + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + val result = channels.readNoisyChannelSound() + + assertThat(result).isEqualTo(NotificationSound.ElementFade) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `readNoisyChannelSound - classifies DEFAULT_NOTIFICATION_URI as SystemDefault`() = runTest { + val channel = mockk(relaxed = true) { + every { sound } returns Settings.System.DEFAULT_NOTIFICATION_URI + } + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns channel + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + val result = channels.readNoisyChannelSound() + + assertThat(result).isEqualTo(NotificationSound.SystemDefault) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `init - migrates legacy SystemDefault message sound to ElementDefault when version is 0`() = runTest { + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + } + val appPreferencesStore = InMemoryAppPreferencesStore( + messageSound = NotificationSound.SystemDefault, + messageSoundChannelVersion = 0, + ) + + createNotificationChannels( + notificationManager = notificationManager, + appPreferencesStore = appPreferencesStore, + ) + + val config = appPreferencesStore.getNotificationSoundChannelConfig() + assertThat(config.messageSound).isEqualTo(NotificationSound.ElementDefault) + assertThat(config.messageSoundVersion).isEqualTo(1) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `init - migration is idempotent across multiple instantiations`() = runTest { + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + } + val appPreferencesStore = InMemoryAppPreferencesStore( + messageSound = NotificationSound.SystemDefault, + messageSoundChannelVersion = 0, + ) + + // First boot: migrate. + createNotificationChannels(notificationManager = notificationManager, appPreferencesStore = appPreferencesStore) + val afterFirst = appPreferencesStore.getNotificationSoundChannelConfig() + assertThat(afterFirst.messageSound).isEqualTo(NotificationSound.ElementDefault) + assertThat(afterFirst.messageSoundVersion).isEqualTo(1) + + // Second boot: gate (version == 0) no longer matches, so the version must not bump again. + createNotificationChannels(notificationManager = notificationManager, appPreferencesStore = appPreferencesStore) + val afterSecond = appPreferencesStore.getNotificationSoundChannelConfig() + assertThat(afterSecond.messageSound).isEqualTo(NotificationSound.ElementDefault) + assertThat(afterSecond.messageSoundVersion).isEqualTo(1) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `init - does not re-migrate when message version is non-zero`() = runTest { + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + } + val appPreferencesStore = InMemoryAppPreferencesStore( + messageSound = NotificationSound.SystemDefault, + messageSoundChannelVersion = 5, + ) + + createNotificationChannels( + notificationManager = notificationManager, + appPreferencesStore = appPreferencesStore, + ) + + val config = appPreferencesStore.getNotificationSoundChannelConfig() + assertThat(config.messageSound).isEqualTo(NotificationSound.SystemDefault) + assertThat(config.messageSoundVersion).isEqualTo(5) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `init - does not migrate when persisted message sound is not SystemDefault`() = runTest { + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + } + val appPreferencesStore = InMemoryAppPreferencesStore( + messageSound = NotificationSound.Silent, + messageSoundChannelVersion = 0, + ) + + createNotificationChannels( + notificationManager = notificationManager, + appPreferencesStore = appPreferencesStore, + ) + + val config = appPreferencesStore.getNotificationSoundChannelConfig() + assertThat(config.messageSound).isEqualTo(NotificationSound.Silent) + assertThat(config.messageSoundVersion).isEqualTo(0) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `readNoisyChannelSound - returns Silent when channel sound is null`() = runTest { + val channel = mockk(relaxed = true) { + every { sound } returns null + } + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns channel + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + val result = channels.readNoisyChannelSound() + + assertThat(result).isEqualTo(NotificationSound.Silent) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `readNoisyChannelSound - returns null when channel does not exist`() = runTest { + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { getNotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID_BASE) } returns null + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + val result = channels.readNoisyChannelSound() + + assertThat(result).isNull() + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `readRingingCallChannelSound - returns SystemDefault when channel sound matches DEFAULT_RINGTONE_URI`() = runTest { + val channel = mockk(relaxed = true) { + every { sound } returns Settings.System.DEFAULT_RINGTONE_URI + } + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { getNotificationChannel(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE) } returns channel + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + val result = channels.readRingingCallChannelSound() + + assertThat(result).isEqualTo(NotificationSound.SystemDefault) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `readRingingCallChannelSound - returns Custom for non-default URI`() = runTest { + val customUri = "content://media/external/audio/media/9001".toUri() + val channel = mockk(relaxed = true) { + every { sound } returns customUri + } + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { getNotificationChannel(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE) } returns channel + } + val channels = createNotificationChannels(notificationManager = notificationManager) + + val result = channels.readRingingCallChannelSound() + + assertThat(result).isEqualTo(NotificationSound.Custom(customUri.toString())) } private fun createNotificationChannels( notificationManager: NotificationManagerCompat = mockk(relaxed = true), enterpriseService: EnterpriseService = FakeEnterpriseService(), + // Default to ElementDefault so the legacy `(SystemDefault, version=0)` migration gate + // doesn't fire and silently rewrite the prefs store. Tests that exercise the migration + // construct their own store explicitly. + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(messageSound = NotificationSound.ElementDefault), ) = DefaultNotificationChannels( notificationManager = notificationManager, stringProvider = FakeStringProvider(), context = RuntimeEnvironment.getApplication(), enterpriseService = enterpriseService, + appPreferencesStore = appPreferencesStore, ) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt index c58a29d866..53bea9e766 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -373,10 +373,12 @@ fun createNotificationChannels( enterpriseService: EnterpriseService = FakeEnterpriseService(), ): NotificationChannels { val context = RuntimeEnvironment.getApplication() + val notificationManagerCompat = NotificationManagerCompat.from(context) return DefaultNotificationChannels( - notificationManager = NotificationManagerCompat.from(context), + notificationManager = notificationManagerCompat, stringProvider = FakeStringProvider(""), context = context, enterpriseService = enterpriseService, + appPreferencesStore = io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore(), ) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/sound/DefaultNotificationSoundCopierTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/sound/DefaultNotificationSoundCopierTest.kt new file mode 100644 index 0000000000..b8d652bafe --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/sound/DefaultNotificationSoundCopierTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.sound + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DefaultNotificationSoundCopierTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + + @Test + fun `sanitizeDisplayName - trims surrounding whitespace`() { + assertThat(sanitizeDisplayName(" Hello World ")).isEqualTo("Hello World") + } + + @Test + fun `sanitizeDisplayName - DEL and control characters are dropped`() { + // Build the raw string programmatically — embedding control chars as source-file bytes + // doesn't survive editor / tool round-tripping reliably. + val bel = 0x07.toChar() + val tab = 0x09.toChar() + val newline = 0x0A.toChar() + val del = 0x7F.toChar() + val raw = "${bel}Hello$tab${newline}World$del" + assertThat(sanitizeDisplayName(raw)).isEqualTo("HelloWorld") + } + + @Test + fun `sanitizeDisplayName - truncates oversized input to 256 chars`() { + val long = "x".repeat(500) + val sanitized = sanitizeDisplayName(long) + assertThat(sanitized).isNotNull() + assertThat(sanitized!!.length).isEqualTo(256) + } + + @Test + fun `sanitizeDisplayName - returns null when nothing survives sanitization`() { + assertThat(sanitizeDisplayName("")).isNull() + assertThat(sanitizeDisplayName(" ")).isNull() + val controls = (0..0x1F).joinToString(separator = "") { it.toChar().toString() } + 0x7F.toChar() + assertThat(sanitizeDisplayName(controls)).isNull() + } + + @Test + fun `sanitizeDisplayName - preserves unicode beyond ASCII`() { + val input = "Element Tone " + 0x266A.toChar() + assertThat(sanitizeDisplayName(input)).isEqualTo(input) + } + + @Test + fun `copyToAppFiles - rejects file scheme`() = runTest { + val copier = DefaultNotificationSoundCopier(context) + val result = copier.copyToAppFiles("file:///tmp/evil.mp3", NotificationSoundCopier.SoundSlot.Message) + assertThat(result).isEqualTo(NotificationSoundCopier.CopyResult.UnplayableSource) + } + + @Test + fun `copyToAppFiles - rejects http scheme`() = runTest { + val copier = DefaultNotificationSoundCopier(context) + val result = copier.copyToAppFiles("https://attacker.example.com/sound.mp3", NotificationSoundCopier.SoundSlot.Call) + assertThat(result).isEqualTo(NotificationSoundCopier.CopyResult.UnplayableSource) + } + + @Test + fun `copyToAppFiles - rejects URI without a scheme`() = runTest { + val copier = DefaultNotificationSoundCopier(context) + val result = copier.copyToAppFiles("not-a-real-uri", NotificationSoundCopier.SoundSlot.Message) + assertThat(result).isEqualTo(NotificationSoundCopier.CopyResult.UnplayableSource) + } +} diff --git a/libraries/push/test/build.gradle.kts b/libraries/push/test/build.gradle.kts index 9dedeb3996..d125a2387e 100644 --- a/libraries/push/test/build.gradle.kts +++ b/libraries/push/test/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { api(projects.libraries.pushproviders.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.preferences.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.workmanager.api) implementation(projects.tests.testutils) diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationSoundUpdater.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationSoundUpdater.kt new file mode 100644 index 0000000000..1bbd410aec --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationSoundUpdater.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.notifications + +import io.element.android.libraries.preferences.api.store.NotificationSound +import io.element.android.libraries.push.api.notifications.NotificationSoundUpdater +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeNotificationSoundUpdater( + private val recreateNoisyChannelLambda: (sound: NotificationSound, version: Int) -> Unit = { _, _ -> lambdaError() }, + private val recreateRingingCallChannelLambda: (sound: NotificationSound, version: Int) -> Unit = { _, _ -> lambdaError() }, + private val readNoisyChannelSoundLambda: () -> NotificationSound? = { null }, + private val readRingingCallChannelSoundLambda: () -> NotificationSound? = { null }, +) : NotificationSoundUpdater { + override fun recreateNoisyChannel(sound: NotificationSound, version: Int) { + recreateNoisyChannelLambda(sound, version) + } + + override fun recreateRingingCallChannel(sound: NotificationSound, version: Int) { + recreateRingingCallChannelLambda(sound, version) + } + + override suspend fun readNoisyChannelSound(): NotificationSound? = readNoisyChannelSoundLambda() + + override suspend fun readRingingCallChannelSound(): NotificationSound? = readRingingCallChannelSoundLambda() +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeSoundDisplayNameResolver.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeSoundDisplayNameResolver.kt new file mode 100644 index 0000000000..69bb77b65c --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeSoundDisplayNameResolver.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.notifications + +import io.element.android.libraries.push.api.notifications.SoundDisplayNameResolver + +class FakeSoundDisplayNameResolver( + // Default resolves to a non-null sentinel so a default-constructed fake doesn't masquerade + // as "every Custom URI is unresolvable" — that would trip mid-session detection paths in + // every test that uses default fakes. Tests that need the unavailable case pass `{ null }`. + private val resolveLambda: suspend (uri: String) -> String? = { "FakeRingtoneTitle" }, +) : SoundDisplayNameResolver { + override suspend fun resolveCustomSoundTitle(uri: String): String? = resolveLambda(uri) +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/sound/FakeNotificationSoundCopier.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/sound/FakeNotificationSoundCopier.kt new file mode 100644 index 0000000000..9fe88b7718 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/sound/FakeNotificationSoundCopier.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.notifications.sound + +import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier +import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier.CopyResult +import io.element.android.libraries.push.api.notifications.sound.NotificationSoundCopier.SoundSlot +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeNotificationSoundCopier( + private val copyLambda: (String, SoundSlot) -> CopyResult = { _, _ -> lambdaError() }, + private val deleteStoredSoundForLambda: (SoundSlot) -> Unit = {}, +) : NotificationSoundCopier { + override suspend fun copyToAppFiles(sourceUriString: String, slot: SoundSlot): CopyResult = copyLambda(sourceUriString, slot) + + override suspend fun deleteStoredSoundFor(slot: SoundSlot) { + deleteStoredSoundForLambda(slot) + } +} diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml index 449fd0f352..88d8d712b0 100644 --- a/libraries/ui-strings/src/main/res/values-fi/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -480,10 +480,6 @@ Haluatko varmasti jatkaa?" "Vaihtoehdot" "Poista %1$s" "Asetukset" - - "%1$d aste" - "%1$d astetta" - "Kukaan ei jaa sijaintiaan" "Jaetaan reaaliaikaista sijaintia" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 212b5eb79a..7d178cc105 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -480,10 +480,6 @@ Raison : %1$s." "Options" "Supprimer %1$s" "Paramètres" - - "%1$d degré" - "%1$d degrés" - "Personne ne partage sa position" "Partage de la position en direct" diff --git a/libraries/ui-strings/src/main/res/values-ja/translations.xml b/libraries/ui-strings/src/main/res/values-ja/translations.xml index c32cbca475..ab5f64fc9e 100644 --- a/libraries/ui-strings/src/main/res/values-ja/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ja/translations.xml @@ -471,11 +471,6 @@ "選択肢" "%1$s を削除" "設定" - "画像を左に回転" - - "%1$d°" - - "写真を編集" "誰も位置情報を共有していません" "ライブ位置情報を共有しています" diff --git a/libraries/ui-strings/src/main/res/values-pl/translations.xml b/libraries/ui-strings/src/main/res/values-pl/translations.xml index da3a5c2fea..7e0b78641e 100644 --- a/libraries/ui-strings/src/main/res/values-pl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml @@ -489,13 +489,6 @@ Czy na pewno chcesz kontynuować?" "Opcje" "Usuń %1$s" "Ustawienia" - "Obróć obraz w lewo" - - "%1$d stopień" - "%1$d stopnie" - "%1$d stopni" - - "Edytuj zdjęcie" "Nikt nie udostępnia swojej lokalizacji" "Udostępnianie lokalizacji na żywo" diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml index 1b50794182..e40d369f76 100644 --- a/libraries/ui-strings/src/main/res/values-zh/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -469,11 +469,6 @@ "选项" "移除 %1$s" "设置" - "向左旋转图像" - - "%1$d 度" - - "编辑照片" "目前无人分享其位置" "共享实时位置" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 0b9490ae59..d74f532484 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -481,12 +481,6 @@ Are you sure you want to continue?" "Options" "Remove %1$s" "Settings" - "Rotate the image to the left" - - "%1$d degree" - "%1$d degrees" - - "Edit photo" "Nobody is sharing their location" "Sharing live location" diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_0_en.png index d927ea14b0..7b985075fa 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:01999b61120bb58f8cfa56a2f5b1b5573d2755fc5be85d3929237d6a61244ab0 -size 54308 +oid sha256:2dd82d156d36a7c7141fd5a4eafdbcbfe9aacc7a8dcede267fdfde272519bee6 +size 52088 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_14_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_14_en.png new file mode 100644 index 0000000000..29adf28369 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_14_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72acb2485a98126e53c27459d63fe7d3718ebc94215a597207ff820d4c9e45a7 +size 43439 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_15_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_15_en.png new file mode 100644 index 0000000000..b79295b20d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_15_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8b4f5fe2d2aa5459aac05180882b3aaa9fc9777f84f4c817c9e995049485946 +size 42052 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_16_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_16_en.png new file mode 100644 index 0000000000..f85174c9c8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_16_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba8f5f9d0ce23de5e6d57660887478b04851680d13a9f8f70d1488c557a25afc +size 43261 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_17_en.png new file mode 100644 index 0000000000..e4c7c476f5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3dc8f7d0497632c723311c530d37807261686ed9fe4d7ff926c0a0fbf825cf23 +size 43179 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_18_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_18_en.png new file mode 100644 index 0000000000..e34ca68b36 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_18_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cc1821e0f114837943f9b9ff76f43a1bf871cc49a4eff704e8c87b50cc384ba +size 44736 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_19_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_19_en.png new file mode 100644 index 0000000000..e9127c1ccd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_19_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbf8bd63967dc3e0739ea2cc62c42fbcbe7d0a9cdf50b41f24c1ccab1f300eab +size 39442 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_1_en.png index 4302a4c031..e4c7c476f5 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52f7675544ba71ed73d2f4283287f46eaa0798d48993d7eb54fc28f87c89cf0e -size 43466 +oid sha256:3dc8f7d0497632c723311c530d37807261686ed9fe4d7ff926c0a0fbf825cf23 +size 43179 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_20_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_20_en.png new file mode 100644 index 0000000000..4c2b80b959 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_20_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0c31e3d7a0788e91287da4ed4f0b6e5e1ade8ce542392f4ba6029d524372c32 +size 41551 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_2_en.png index edcd62c95f..a35714aff4 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:50cca191bf1c21a4d0f2765643712d0db6813dc23f2c4b70589ad65f2847b259 -size 40215 +oid sha256:176519a45ab97560dc2f8bf1416915759039616458f5fde22def7022595b4cc9 +size 39890 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_3_en.png index caf2a0b795..ce4c8a5f2a 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c13b82c858490228a4f5568b7489115d916af067a8cf2e01e22e44f56cd6c3e -size 39566 +oid sha256:7efafe0931f350b3369a73fc4cb4156f641d894db5e939202ab9fec5cec8423b +size 39229 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_4_en.png index caf2a0b795..ce4c8a5f2a 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c13b82c858490228a4f5568b7489115d916af067a8cf2e01e22e44f56cd6c3e -size 39566 +oid sha256:7efafe0931f350b3369a73fc4cb4156f641d894db5e939202ab9fec5cec8423b +size 39229 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_5_en.png index 4302a4c031..e4c7c476f5 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52f7675544ba71ed73d2f4283287f46eaa0798d48993d7eb54fc28f87c89cf0e -size 43466 +oid sha256:3dc8f7d0497632c723311c530d37807261686ed9fe4d7ff926c0a0fbf825cf23 +size 43179 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_6_en.png index 5b314cc55a..6ce1addb3a 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:38839f3e1cc1ed7fd02695e1ad68e44692fc836ee3fad075ada9d500a94b2554 -size 39554 +oid sha256:73cf2629ea291b423460d8756814e97b2e8720a064aaf7fc9f3f3a7cf9a34862 +size 39186 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_7_en.png index b292bd42f5..e368d72b51 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4fc4c290503b6ae3157cf4169b50a8e00bae953d87b2d319389e8ab3decb9ca3 -size 43856 +oid sha256:efb91f3ae38c55d585d86d1c83f8c52e7244b62b64c82f51ad145ac3ec9841a7 +size 43493 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_8_en.png index 4302a4c031..e4c7c476f5 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52f7675544ba71ed73d2f4283287f46eaa0798d48993d7eb54fc28f87c89cf0e -size 43466 +oid sha256:3dc8f7d0497632c723311c530d37807261686ed9fe4d7ff926c0a0fbf825cf23 +size 43179 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_9_en.png index 4302a4c031..e4c7c476f5 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52f7675544ba71ed73d2f4283287f46eaa0798d48993d7eb54fc28f87c89cf0e -size 43466 +oid sha256:3dc8f7d0497632c723311c530d37807261686ed9fe4d7ff926c0a0fbf825cf23 +size 43179 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_0_en.png index 5c281630e3..1859be12da 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8a677a3dbb1cf6cbb306d3b1e4ba612469cba408815320361365d74e188cf74 -size 52703 +oid sha256:5d921332fe1eb11a80750ed8934f3d386fa267e180bbd29960a750181bddcb49 +size 50570 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_14_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_14_en.png new file mode 100644 index 0000000000..f300562d3c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_14_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a189eca358d0bf0c2ed4f107252b667d996e560822ea8e39cf776511ebb0fde4 +size 41989 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_15_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_15_en.png new file mode 100644 index 0000000000..28dad18993 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_15_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:019e7a646c19f2056873f186e8c7ee73127e79468e5149034e85455460b689be +size 40637 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_16_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_16_en.png new file mode 100644 index 0000000000..16bbe20275 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_16_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a9585f68e045397a46c8f368e4affa1e9fde985bf415014403b91e73de6eb9e +size 41713 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_17_en.png new file mode 100644 index 0000000000..d6ac948791 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca8ace95c2cb039826be9e39da427eebada1a8f6f234d6ae82c9e2a69f9a2c8e +size 41760 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_18_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_18_en.png new file mode 100644 index 0000000000..0e5833c48b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_18_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43d661bd16a8343e1750faeed957fe5490cee65d0bcc033ecef6344c7ee85fba +size 42104 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_19_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_19_en.png new file mode 100644 index 0000000000..f169d5f9d8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_19_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cacac247776f92bf1aeaed8b0b11a2283d27e51c6999f4fcd08b62b8aae4c3d7 +size 36997 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_1_en.png index 05b8276ace..d6ac948791 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d92a5ffd43eac457c3384cfc22b9b29fbf8c21532fc16892258140c557788bd2 -size 41872 +oid sha256:ca8ace95c2cb039826be9e39da427eebada1a8f6f234d6ae82c9e2a69f9a2c8e +size 41760 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_20_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_20_en.png new file mode 100644 index 0000000000..03061e5717 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_20_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6685d37388f89b1a6f18a66deb1a7a35f23a88c2bd64e3d4b980f71cd9cc5aa +size 39185 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_2_en.png index 56334c1e2e..d1678d364f 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd1599665f32844b4757a631542351303b7d4f4a34f32640ee9f60ad38089cab -size 38707 +oid sha256:50d4e41bfed974afdcdb1864f95e9b5ea26675f67c16cd17d82c431547be4b5b +size 38565 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_3_en.png index c7e3274c19..a6c678cb89 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea8532bc891ce639c492ed7d6edaaadefc2b99dd3b68873a575858b03b6fcbfe -size 36998 +oid sha256:6c31c52efbf995bcc237a309772be0c70221db7d30dec38c734bf01f75bb1a97 +size 36856 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_4_en.png index c7e3274c19..a6c678cb89 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea8532bc891ce639c492ed7d6edaaadefc2b99dd3b68873a575858b03b6fcbfe -size 36998 +oid sha256:6c31c52efbf995bcc237a309772be0c70221db7d30dec38c734bf01f75bb1a97 +size 36856 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_5_en.png index 05b8276ace..d6ac948791 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d92a5ffd43eac457c3384cfc22b9b29fbf8c21532fc16892258140c557788bd2 -size 41872 +oid sha256:ca8ace95c2cb039826be9e39da427eebada1a8f6f234d6ae82c9e2a69f9a2c8e +size 41760 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_6_en.png index 1f3b3e2b91..aa887109d2 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68d4a4cd27afda0d23438451124ef997dadd116e1ea5db4b5a6af0045d95ba2b -size 36998 +oid sha256:64b969354abd79e131dc6bf644f48e2218216fd4a6b8e93527f52defc8b2cb38 +size 36836 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_7_en.png index bbc69da0c3..4e38e5b2bf 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:784b744139daa4216d4137aaaca3a4f1a86ef7297ce2996bb9e9eb78c8aa93fb -size 41201 +oid sha256:b07d98cdb41d4daf9bec18285588ad5bf99992a52ef27f2da66545734b8db843 +size 41036 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_8_en.png index 05b8276ace..d6ac948791 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d92a5ffd43eac457c3384cfc22b9b29fbf8c21532fc16892258140c557788bd2 -size 41872 +oid sha256:ca8ace95c2cb039826be9e39da427eebada1a8f6f234d6ae82c9e2a69f9a2c8e +size 41760 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_9_en.png index 05b8276ace..d6ac948791 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.notifications_NotificationSettingsView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d92a5ffd43eac457c3384cfc22b9b29fbf8c21532fc16892258140c557788bd2 -size 41872 +oid sha256:ca8ace95c2cb039826be9e39da427eebada1a8f6f234d6ae82c9e2a69f9a2c8e +size 41760 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 9936341cee..64976003d5 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -330,6 +330,7 @@ "screen\\.advanced_settings\\..*", "screen_edit_profile_.*", "screen_notification_settings_.*", + "screen\\.notification_settings\\..*", "screen_blocked_users_.*", "full_screen_intent_banner_.*", "troubleshoot_notifications_entry_point_.*",