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

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

* Feature: add notification sound settings for messages and calls

* Add sound not available banner, accessibility checks

* Fix build and Konsist convention violations

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

* Suppress LargeClass on RoomListPresenterTest

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

* NotificationSettingsView: guard ringtone-picker launchers in inspection mode

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

* Handle unresolved notification sounds mid session

* Quality checks, refactor mid session error ui

* Suppress detekt LargeClass on NotificationSettingsPresenterTest

* Remove notification sound resilience layer, replace with file copy

* Reselect element sound option, code cleanup

* Add element fade notification sound option

* Fix new file headers

* Revert to flowOf(Unit) for announcement service

* Add ringtone picker dialog to match message selector

* Update strings in conjunction with ios

* Update test to reflect new string

* Sync strings.

* Update screenshots

* Fix test

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

* Fix test on CI

---------

Co-authored-by: Jenna Vassar <jenna.vassar@toptal.com>
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2026-05-28 16:54:12 +02:00
committed by GitHub
parent bdedf41f99
commit c88edc9621
79 changed files with 3135 additions and 139 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ import extension.testCommonDependencies
*/
plugins {
id("io.element.android-library")
id("io.element.android-compose-library")
}
android {
@@ -41,5 +41,27 @@ interface AppPreferencesStore {
suspend fun setTracingLogPacks(targets: 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()
}
@@ -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?,
)