mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +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:
@@ -20,5 +20,6 @@ dependencies {
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixmedia.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.pushproviders.api)
|
||||
}
|
||||
|
||||
+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.troubleshoot.api)
|
||||
implementation(projects.libraries.workmanager.api)
|
||||
implementation(projects.features.announcement.api)
|
||||
implementation(projects.features.call.api)
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.features.lockscreen.api)
|
||||
@@ -80,6 +81,7 @@ dependencies {
|
||||
testImplementation(projects.libraries.pushstore.test)
|
||||
testImplementation(projects.libraries.troubleshoot.test)
|
||||
testImplementation(projects.libraries.workmanager.test)
|
||||
testImplementation(projects.features.announcement.test)
|
||||
testImplementation(projects.features.call.test)
|
||||
testImplementation(projects.features.enterprise.test)
|
||||
testImplementation(projects.features.lockscreen.test)
|
||||
|
||||
+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.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioAttributes.USAGE_NOTIFICATION
|
||||
import android.media.AudioManager
|
||||
@@ -19,28 +20,50 @@ import android.provider.Settings
|
||||
import androidx.annotation.ChecksSdkIntAtLeast
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import dev.zacsweers.metro.binding
|
||||
import io.element.android.appconfig.NotificationConfig
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.preferences.api.store.NotificationSound
|
||||
import io.element.android.libraries.push.api.notifications.NotificationSoundUpdater
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
/* ==========================================================================================
|
||||
* IDs for channels
|
||||
* ========================================================================================== */
|
||||
internal const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2"
|
||||
internal const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_V2"
|
||||
internal const val NOISY_NOTIFICATION_CHANNEL_ID_BASE = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_V2"
|
||||
internal const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V3"
|
||||
internal const val RINGING_CALL_NOTIFICATION_CHANNEL_ID = "RINGING_CALL_NOTIFICATION_CHANNEL_ID"
|
||||
internal const val RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE = "RINGING_CALL_NOTIFICATION_CHANNEL_ID"
|
||||
|
||||
private fun versionedChannelId(base: String, version: Int): String =
|
||||
if (version <= 0) base else "${base}_v$version"
|
||||
|
||||
internal fun noisyNotificationChannelId(version: Int): String =
|
||||
versionedChannelId(NOISY_NOTIFICATION_CHANNEL_ID_BASE, version)
|
||||
|
||||
internal fun ringingCallNotificationChannelId(version: Int): String =
|
||||
versionedChannelId(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE, version)
|
||||
|
||||
/**
|
||||
* on devices >= android O, we need to define a channel for each notifications.
|
||||
*
|
||||
* Extends [NotificationSoundUpdater] so consumers can re-create the noisy and ringing-call
|
||||
* channels when the user picks a new sound URI in settings.
|
||||
*/
|
||||
interface NotificationChannels {
|
||||
interface NotificationChannels : NotificationSoundUpdater {
|
||||
/**
|
||||
* Get the channel for incoming call.
|
||||
* @param ring true if the device should ring when receiving the call.
|
||||
@@ -66,14 +89,22 @@ interface NotificationChannels {
|
||||
private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
@ContributesBinding(AppScope::class, binding = binding<NotificationChannels>())
|
||||
@ContributesBinding(AppScope::class, binding = binding<NotificationSoundUpdater>())
|
||||
class DefaultNotificationChannels(
|
||||
private val notificationManager: NotificationManagerCompat,
|
||||
private val stringProvider: StringProvider,
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
) : NotificationChannels {
|
||||
@Volatile private var currentNoisyChannelId: String = NOISY_NOTIFICATION_CHANNEL_ID_BASE
|
||||
@Volatile private var currentRingingCallChannelId: String = RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE
|
||||
|
||||
// Serializes concurrent recreate* calls; readers stay lock-free via @Volatile on the id fields.
|
||||
private val recreateLock = Any()
|
||||
|
||||
init {
|
||||
createNotificationChannels()
|
||||
}
|
||||
@@ -88,9 +119,27 @@ class DefaultNotificationChannels(
|
||||
|
||||
val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR
|
||||
|
||||
// Single-snapshot read; keep this path minimal — extra ContentResolver lookups here would
|
||||
// risk a cold-start ANR.
|
||||
val config = runBlocking {
|
||||
val initial = appPreferencesStore.getNotificationSoundChannelConfig()
|
||||
// Pre-dialog builds mapped the noisy `SystemDefault` to our bundled message.mp3.
|
||||
// Now `SystemDefault` means Android's system tone, so untouched legacy state would
|
||||
// silently flip sound. Promote it to `ElementDefault` once, gated on version==0 so the
|
||||
// migration is idempotent.
|
||||
if (initial.messageSoundVersion == 0 && initial.messageSound == NotificationSound.SystemDefault) {
|
||||
appPreferencesStore.setMessageSoundAndIncrementVersion(NotificationSound.ElementDefault, title = null)
|
||||
appPreferencesStore.getNotificationSoundChannelConfig()
|
||||
} else {
|
||||
initial
|
||||
}
|
||||
}
|
||||
|
||||
currentNoisyChannelId = noisyNotificationChannelId(config.messageSoundVersion)
|
||||
currentRingingCallChannelId = ringingCallNotificationChannelId(config.callRingtoneVersion)
|
||||
|
||||
// Migration - the noisy channel was deleted and recreated when sound preference was changed (id was DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE
|
||||
// + currentTimeMillis).
|
||||
// Now the sound can only be change directly in system settings, so for app upgrading we are deleting this former channel
|
||||
// Starting from this version the channel will not be dynamic
|
||||
for (channel in notificationManager.notificationChannels) {
|
||||
val channelId = channel.id
|
||||
@@ -111,30 +160,19 @@ class DefaultNotificationChannels(
|
||||
notificationManager.deleteNotificationChannel(channelId)
|
||||
}
|
||||
}
|
||||
// Drop older versioned channels; only the current one remains.
|
||||
deleteStaleVersionedChannels(NOISY_NOTIFICATION_CHANNEL_ID_BASE, currentNoisyChannelId)
|
||||
deleteStaleVersionedChannels(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE, currentRingingCallChannelId)
|
||||
|
||||
// Default notification importance: shows everywhere, makes noise, but does not visually intrude.
|
||||
val noisySoundUri = resolveNoisySoundUri(config.messageSound)
|
||||
grantSoundUriToSystem(noisySoundUri)
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
NOISY_NOTIFICATION_CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
buildNoisyChannel(
|
||||
channelId = currentNoisyChannelId,
|
||||
soundUri = noisySoundUri,
|
||||
accentColor = accentColor,
|
||||
)
|
||||
.setSound(
|
||||
Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||
// Strangely we have to provide a "//" before the package name
|
||||
.path("//" + context.packageName + "/" + R.raw.message)
|
||||
.build(),
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setUsage(USAGE_NOTIFICATION)
|
||||
.build(),
|
||||
)
|
||||
.setName(stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" })
|
||||
.setDescription(stringProvider.getString(R.string.notification_channel_noisy))
|
||||
.setVibrationEnabled(true)
|
||||
.setLightsEnabled(true)
|
||||
.setLightColor(accentColor)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Low notification importance: shows everywhere, but is not intrusive.
|
||||
@@ -166,40 +204,205 @@ class DefaultNotificationChannels(
|
||||
)
|
||||
|
||||
// Register a channel for incoming call notifications which will ring the device when received
|
||||
val ringingSoundUri = resolveRingingSoundUri(config.callRingtone)
|
||||
grantSoundUriToSystem(ringingSoundUri)
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
RINGING_CALL_NOTIFICATION_CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_MAX,
|
||||
buildRingingCallChannel(
|
||||
channelId = currentRingingCallChannelId,
|
||||
soundUri = ringingSoundUri,
|
||||
accentColor = accentColor,
|
||||
)
|
||||
.setName(stringProvider.getString(R.string.notification_channel_ringing_calls).ifEmpty { "Ringing calls" })
|
||||
.setVibrationEnabled(true)
|
||||
.setSound(
|
||||
Settings.System.DEFAULT_RINGTONE_URI,
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setLegacyStreamType(AudioManager.STREAM_RING)
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||
.build()
|
||||
)
|
||||
.setDescription(stringProvider.getString(R.string.notification_channel_ringing_calls))
|
||||
.setLightsEnabled(true)
|
||||
.setLightColor(accentColor)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNoisyChannel(channelId: String, soundUri: Uri?, accentColor: Int): NotificationChannelCompat {
|
||||
val builder = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
.setName(stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" })
|
||||
.setDescription(stringProvider.getString(R.string.notification_channel_noisy))
|
||||
.setVibrationEnabled(true)
|
||||
.setLightsEnabled(true)
|
||||
.setLightColor(accentColor)
|
||||
if (soundUri != null) {
|
||||
builder.setSound(
|
||||
soundUri,
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setUsage(USAGE_NOTIFICATION)
|
||||
.build(),
|
||||
)
|
||||
} else {
|
||||
builder.setSound(null, null)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun buildRingingCallChannel(channelId: String, soundUri: Uri?, accentColor: Int): NotificationChannelCompat {
|
||||
val builder = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_MAX)
|
||||
.setName(stringProvider.getString(R.string.notification_channel_ringing_calls).ifEmpty { "Ringing calls" })
|
||||
.setDescription(stringProvider.getString(R.string.notification_channel_ringing_calls))
|
||||
.setVibrationEnabled(true)
|
||||
.setLightsEnabled(true)
|
||||
.setLightColor(accentColor)
|
||||
if (soundUri != null) {
|
||||
builder.setSound(
|
||||
soundUri,
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setLegacyStreamType(AudioManager.STREAM_RING)
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
builder.setSound(null, null)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun resolveNoisySoundUri(sound: NotificationSound): Uri? = when (sound) {
|
||||
NotificationSound.Silent -> null
|
||||
NotificationSound.SystemDefault -> Settings.System.DEFAULT_NOTIFICATION_URI
|
||||
NotificationSound.ElementDefault -> bundledMessageSoundUri()
|
||||
NotificationSound.ElementFade -> bundledFadeSoundUri()
|
||||
is NotificationSound.Custom -> parseUriOrFallback(sound.uri) { bundledMessageSoundUri() }
|
||||
}
|
||||
|
||||
private fun resolveRingingSoundUri(sound: NotificationSound): Uri? = when (sound) {
|
||||
NotificationSound.Silent -> null
|
||||
// The ringing channel has no bundled tone — treat ElementDefault and ElementFade like SystemDefault here.
|
||||
NotificationSound.SystemDefault,
|
||||
NotificationSound.ElementDefault,
|
||||
NotificationSound.ElementFade -> Settings.System.DEFAULT_RINGTONE_URI
|
||||
is NotificationSound.Custom -> parseUriOrFallback(sound.uri) { Settings.System.DEFAULT_RINGTONE_URI }
|
||||
}
|
||||
|
||||
private fun bundledMessageSoundUri(): Uri =
|
||||
"${ContentResolver.SCHEME_ANDROID_RESOURCE}://${context.packageName}/${R.raw.message}".toUri()
|
||||
|
||||
private fun bundledFadeSoundUri(): Uri =
|
||||
"${ContentResolver.SCHEME_ANDROID_RESOURCE}://${context.packageName}/${R.raw.element_fade}".toUri()
|
||||
|
||||
/**
|
||||
* Lets system_server ("android") and SystemUI read our FileProvider sound URI; no-op otherwise.
|
||||
* SystemUI hosts the lock-screen notification surface on most OEMs, so a missing grant there
|
||||
* silently mutes ringtones when the device is locked.
|
||||
*/
|
||||
private fun grantSoundUriToSystem(uri: Uri?) {
|
||||
if (uri == null || uri.scheme != ContentResolver.SCHEME_CONTENT) return
|
||||
for (pkg in arrayOf("android", "com.android.systemui")) {
|
||||
runCatchingExceptions {
|
||||
context.grantUriPermission(pkg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}.onFailure { Timber.w(it, "grantUriPermission(%s) failed for notification sound", pkg) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses [uriString], or returns [fallback] (the SystemDefault URI) on failure. */
|
||||
private inline fun parseUriOrFallback(uriString: String, fallback: () -> Uri): Uri =
|
||||
runCatchingExceptions { uriString.toUri() }
|
||||
.getOrElse {
|
||||
// Don't pass the throwable: legacy persisted URIs may carry SAF auth tokens.
|
||||
Timber.w("Failed to parse persisted sound URI; falling back to default cause=%s", it::class.simpleName)
|
||||
fallback()
|
||||
}
|
||||
|
||||
private fun deleteStaleVersionedChannels(baseId: String, currentId: String) {
|
||||
if (!supportNotificationChannels()) return
|
||||
for (channel in notificationManager.notificationChannels) {
|
||||
val id = channel.id
|
||||
// Match either the unversioned base ID or any "${baseId}_v<n>" variant.
|
||||
val isBaseOrVersioned = id == baseId || id.startsWith("${baseId}_v")
|
||||
if (isBaseOrVersioned && id != currentId) {
|
||||
notificationManager.deleteNotificationChannel(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getChannelForIncomingCall(ring: Boolean): String {
|
||||
return if (ring) RINGING_CALL_NOTIFICATION_CHANNEL_ID else CALL_NOTIFICATION_CHANNEL_ID
|
||||
return if (ring) currentRingingCallChannelId else CALL_NOTIFICATION_CHANNEL_ID
|
||||
}
|
||||
|
||||
override fun getChannelIdForMessage(sessionId: SessionId, noisy: Boolean): String {
|
||||
return if (noisy) {
|
||||
enterpriseService.getNoisyNotificationChannelId(sessionId)
|
||||
?: NOISY_NOTIFICATION_CHANNEL_ID
|
||||
?: currentNoisyChannelId
|
||||
} else {
|
||||
SILENT_NOTIFICATION_CHANNEL_ID
|
||||
}
|
||||
}
|
||||
|
||||
override fun getChannelIdForTest(): String = NOISY_NOTIFICATION_CHANNEL_ID
|
||||
override fun getChannelIdForTest(): String = currentNoisyChannelId
|
||||
|
||||
override fun recreateNoisyChannel(sound: NotificationSound, version: Int) {
|
||||
if (!supportNotificationChannels()) return
|
||||
synchronized(recreateLock) {
|
||||
val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR
|
||||
val newChannelId = noisyNotificationChannelId(version)
|
||||
val soundUri = resolveNoisySoundUri(sound)
|
||||
grantSoundUriToSystem(soundUri)
|
||||
// Create channel before publishing the id: a reader landing between the assignment and
|
||||
// the create call would notify() against a missing id, which Android silently drops.
|
||||
notificationManager.createNotificationChannel(
|
||||
buildNoisyChannel(newChannelId, soundUri, accentColor)
|
||||
)
|
||||
currentNoisyChannelId = newChannelId
|
||||
deleteStaleVersionedChannels(NOISY_NOTIFICATION_CHANNEL_ID_BASE, newChannelId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun recreateRingingCallChannel(sound: NotificationSound, version: Int) {
|
||||
if (!supportNotificationChannels()) return
|
||||
synchronized(recreateLock) {
|
||||
val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR
|
||||
val newChannelId = ringingCallNotificationChannelId(version)
|
||||
val soundUri = resolveRingingSoundUri(sound)
|
||||
grantSoundUriToSystem(soundUri)
|
||||
// See recreateNoisyChannel: the channel must exist before the id is published.
|
||||
notificationManager.createNotificationChannel(
|
||||
buildRingingCallChannel(newChannelId, soundUri, accentColor)
|
||||
)
|
||||
currentRingingCallChannelId = newChannelId
|
||||
deleteStaleVersionedChannels(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE, newChannelId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun readNoisyChannelSound(): NotificationSound? {
|
||||
return readChannelSound(
|
||||
channelId = currentNoisyChannelId,
|
||||
bundledUri = bundledMessageSoundUri(),
|
||||
bundledFadeUri = bundledFadeSoundUri(),
|
||||
systemDefaultUri = Settings.System.DEFAULT_NOTIFICATION_URI,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun readRingingCallChannelSound(): NotificationSound? {
|
||||
return readChannelSound(
|
||||
channelId = currentRingingCallChannelId,
|
||||
bundledUri = null,
|
||||
bundledFadeUri = null,
|
||||
systemDefaultUri = Settings.System.DEFAULT_RINGTONE_URI,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies the channel's sound URI: null → Silent, bundled → ElementDefault, bundled fade →
|
||||
* ElementFade, system default → SystemDefault, else → Custom. Both bundled URIs are nullable
|
||||
* because the ringing channel has no bundled tones.
|
||||
*/
|
||||
private suspend fun readChannelSound(
|
||||
channelId: String,
|
||||
bundledUri: Uri?,
|
||||
bundledFadeUri: Uri?,
|
||||
systemDefaultUri: Uri,
|
||||
): NotificationSound? {
|
||||
if (!supportNotificationChannels()) return null
|
||||
val channel = withContext(Dispatchers.IO) {
|
||||
notificationManager.getNotificationChannel(channelId)
|
||||
} ?: return null
|
||||
return when (val sound = channel.sound) {
|
||||
null -> NotificationSound.Silent
|
||||
bundledUri -> NotificationSound.ElementDefault
|
||||
bundledFadeUri -> NotificationSound.ElementFade
|
||||
systemDefaultUri -> NotificationSound.SystemDefault
|
||||
else -> NotificationSound.Custom(sound.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.preferences.api.store.NotificationSound
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeNotificationChannels(
|
||||
var channelForIncomingCall: (ring: Boolean) -> String = { _ -> "" },
|
||||
var channelIdForMessage: (sessionId: SessionId, noisy: Boolean) -> String = { _, _ -> "" },
|
||||
var channelIdForTest: () -> String = { "" }
|
||||
var channelIdForTest: () -> String = { "" },
|
||||
// Side-effecting recreates default to lambdaError (matching FakeNotificationSoundUpdater) so a
|
||||
// test that doesn't expect a channel rebuild loudly fails instead of silently swallowing the call.
|
||||
var recreateNoisyChannelLambda: (sound: NotificationSound, version: Int) -> Unit = { _, _ -> lambdaError() },
|
||||
var recreateRingingCallChannelLambda: (sound: NotificationSound, version: Int) -> Unit = { _, _ -> lambdaError() },
|
||||
var readNoisyChannelSoundLambda: () -> NotificationSound? = { null },
|
||||
var readRingingCallChannelSoundLambda: () -> NotificationSound? = { null },
|
||||
) : NotificationChannels {
|
||||
override fun getChannelForIncomingCall(ring: Boolean): String {
|
||||
return channelForIncomingCall(ring)
|
||||
@@ -26,4 +34,16 @@ class FakeNotificationChannels(
|
||||
override fun getChannelIdForTest(): String {
|
||||
return channelIdForTest()
|
||||
}
|
||||
|
||||
override fun recreateNoisyChannel(sound: NotificationSound, version: Int) {
|
||||
recreateNoisyChannelLambda(sound, version)
|
||||
}
|
||||
|
||||
override fun recreateRingingCallChannel(sound: NotificationSound, version: Int) {
|
||||
recreateRingingCallChannelLambda(sound, version)
|
||||
}
|
||||
|
||||
override suspend fun readNoisyChannelSound(): NotificationSound? = readNoisyChannelSoundLambda()
|
||||
|
||||
override suspend fun readRingingCallChannelSound(): NotificationSound? = readRingingCallChannelSoundLambda()
|
||||
}
|
||||
|
||||
+412
-3
@@ -9,16 +9,23 @@
|
||||
package io.element.android.libraries.push.impl.notifications.channels
|
||||
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.preferences.api.store.NotificationSound
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@@ -45,7 +52,7 @@ class NotificationChannelsTest {
|
||||
val notificationChannels = createNotificationChannels()
|
||||
|
||||
val ringingChannel = notificationChannels.getChannelForIncomingCall(ring = true)
|
||||
assertThat(ringingChannel).isEqualTo(RINGING_CALL_NOTIFICATION_CHANNEL_ID)
|
||||
assertThat(ringingChannel).isEqualTo(RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE)
|
||||
|
||||
val normalChannel = notificationChannels.getChannelForIncomingCall(ring = false)
|
||||
assertThat(normalChannel).isEqualTo(CALL_NOTIFICATION_CHANNEL_ID)
|
||||
@@ -59,7 +66,7 @@ class NotificationChannelsTest {
|
||||
),
|
||||
)
|
||||
assertThat(notificationChannels.getChannelIdForMessage(sessionId = A_SESSION_ID, noisy = true))
|
||||
.isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID)
|
||||
.isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID_BASE)
|
||||
assertThat(notificationChannels.getChannelIdForMessage(sessionId = A_SESSION_ID, noisy = false))
|
||||
.isEqualTo(SILENT_NOTIFICATION_CHANNEL_ID)
|
||||
}
|
||||
@@ -81,16 +88,418 @@ class NotificationChannelsTest {
|
||||
fun `getChannelIdForTest - returns the right channel`() {
|
||||
val notificationChannels = createNotificationChannels()
|
||||
|
||||
assertThat(notificationChannels.getChannelIdForTest()).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID)
|
||||
assertThat(notificationChannels.getChannelIdForTest()).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID_BASE)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||
fun `init - reads persisted message version and seeds versioned channel id`() {
|
||||
val notificationChannels = createNotificationChannels(
|
||||
appPreferencesStore = InMemoryAppPreferencesStore(messageSoundChannelVersion = 3),
|
||||
enterpriseService = FakeEnterpriseService(getNoisyNotificationChannelIdResult = { null }),
|
||||
)
|
||||
assertThat(notificationChannels.getChannelIdForTest()).isEqualTo("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v3")
|
||||
assertThat(notificationChannels.getChannelIdForMessage(sessionId = A_SESSION_ID, noisy = true))
|
||||
.isEqualTo("${NOISY_NOTIFICATION_CHANNEL_ID_BASE}_v3")
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||
fun `init - reads persisted call ringtone version and seeds versioned channel id`() {
|
||||
val notificationChannels = createNotificationChannels(
|
||||
appPreferencesStore = InMemoryAppPreferencesStore(callRingtoneChannelVersion = 2),
|
||||
)
|
||||
assertThat(notificationChannels.getChannelForIncomingCall(ring = true))
|
||||
.isEqualTo("${RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE}_v2")
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||
fun `recreateNoisyChannel - creates new versioned channel and updates current id`() {
|
||||
val notificationManager = mockk<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(
|
||||
notificationManager: NotificationManagerCompat = mockk(relaxed = true),
|
||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||
// Default to ElementDefault so the legacy `(SystemDefault, version=0)` migration gate
|
||||
// doesn't fire and silently rewrite the prefs store. Tests that exercise the migration
|
||||
// construct their own store explicitly.
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(messageSound = NotificationSound.ElementDefault),
|
||||
) = DefaultNotificationChannels(
|
||||
notificationManager = notificationManager,
|
||||
stringProvider = FakeStringProvider(),
|
||||
context = RuntimeEnvironment.getApplication(),
|
||||
enterpriseService = enterpriseService,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -373,10 +373,12 @@ fun createNotificationChannels(
|
||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||
): NotificationChannels {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val notificationManagerCompat = NotificationManagerCompat.from(context)
|
||||
return DefaultNotificationChannels(
|
||||
notificationManager = NotificationManagerCompat.from(context),
|
||||
notificationManager = notificationManagerCompat,
|
||||
stringProvider = FakeStringProvider(""),
|
||||
context = context,
|
||||
enterpriseService = enterpriseService,
|
||||
appPreferencesStore = io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore(),
|
||||
)
|
||||
}
|
||||
|
||||
+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)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.workmanager.api)
|
||||
implementation(projects.tests.testutils)
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user