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