Merge branch 'develop' into feature/fga/role_and_permissions_rework

This commit is contained in:
ganfra 2025-11-05 20:29:04 +01:00
commit 83a4457d6e
185 changed files with 1900 additions and 1125 deletions

View file

@ -18,12 +18,12 @@ android {
testOptions {
unitTests.isIncludeAndroidResources = true
}
dependencies {
implementation(libs.showkase)
testCommonDependencies(libs)
testImplementation(libs.test.roborazzi)
testImplementation(libs.test.roborazzi.compose)
testImplementation(libs.test.roborazzi.junit)
}
}
dependencies {
implementation(libs.showkase)
testCommonDependencies(libs)
testImplementation(libs.test.roborazzi)
testImplementation(libs.test.roborazzi.compose)
testImplementation(libs.test.roborazzi.junit)
}

View file

@ -11,8 +11,8 @@ plugins {
android {
namespace = "io.element.android.libraries.cryptography.test"
dependencies {
api(projects.libraries.cryptography.api)
}
}
dependencies {
api(projects.libraries.cryptography.api)
}

View file

@ -13,8 +13,8 @@ plugins {
android {
namespace = "io.element.android.libraries.dateformatter.api"
dependencies {
testCommonDependencies(libs)
}
}
dependencies {
testCommonDependencies(libs)
}

View file

@ -30,19 +30,19 @@ android {
)
}
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.di)
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
api(projects.libraries.dateformatter.api)
api(libs.datetime)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.services.toolbox.test)
}
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.di)
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
api(projects.libraries.dateformatter.api)
api(libs.datetime)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.services.toolbox.test)
}

View file

@ -11,9 +11,9 @@ plugins {
android {
namespace = "io.element.android.libraries.dateformatter.test"
dependencies {
api(projects.libraries.dateformatter.api)
api(libs.datetime)
}
}
dependencies {
api(projects.libraries.dateformatter.api)
api(libs.datetime)
}

View file

@ -25,24 +25,24 @@ android {
consumerProguardFiles("consumer-rules.pro")
}
}
dependencies {
api(projects.libraries.compound)
implementation(libs.androidx.compose.material3.windowsizeclass)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.coil.compose)
implementation(libs.vanniktech.blurhash)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
implementation(libs.showkase)
testCommonDependencies(libs)
}
}
dependencies {
api(projects.libraries.compound)
implementation(libs.androidx.compose.material3.windowsizeclass)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.coil.compose)
implementation(libs.vanniktech.blurhash)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
implementation(libs.showkase)
testCommonDependencies(libs)
}

View file

@ -11,11 +11,11 @@ plugins {
android {
namespace = "io.element.android.libraries.featureflag.test"
dependencies {
api(projects.libraries.featureflag.api)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.test)
implementation(libs.coroutines.core)
}
}
dependencies {
api(projects.libraries.featureflag.api)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.test)
implementation(libs.coroutines.core)
}

View file

@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector 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.matrix.api.auth
/**
* Checks the homeserver's compatibility with Element X.
*/
interface HomeServerLoginCompatibilityChecker {
/**
* Performs the compatibility check given the homeserver's [url].
* @return a `true` value if the homeserver is compatible, `false` if not, or a failure result if the check unexpectedly failed.
*/
suspend fun check(url: String): Result<Boolean>
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.api.encryption
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import kotlinx.coroutines.flow.Flow
@ -17,7 +18,7 @@ interface EncryptionService {
val recoveryStateStateFlow: StateFlow<RecoveryState>
val enableRecoveryProgressStateFlow: StateFlow<EnableRecoveryProgress>
val isLastDevice: StateFlow<Boolean>
val hasDevicesToVerifyAgainst: StateFlow<Boolean>
val hasDevicesToVerifyAgainst: StateFlow<AsyncData<Boolean>>
suspend fun enableBackups(): Result<Unit>

View file

@ -0,0 +1,36 @@
/*
* Copyright 2025 New Vector 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.matrix.impl.auth
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.impl.ClientBuilderProvider
import timber.log.Timber
@ContributesBinding(AppScope::class)
@Inject
class RustHomeServerLoginCompatibilityChecker(
private val clientBuilderProvider: ClientBuilderProvider,
) : HomeServerLoginCompatibilityChecker {
override suspend fun check(url: String): Result<Boolean> = runCatchingExceptions {
clientBuilderProvider.provide()
.inMemoryStore()
.serverNameOrHomeserverUrl(url)
.build()
.use {
it.homeserverLoginDetails()
}
.use {
Timber.d("Homeserver $url | OIDC: ${it.supportsOidcLogin()} | Password: ${it.supportsPasswordLogin()} | SSO: ${it.supportsSsoLogin()}")
it.supportsOidcLogin() || it.supportsPasswordLogin()
}
}
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.encryption
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.mapFailure
@ -42,6 +43,7 @@ import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.EnableRecoveryProgressListener
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.UserIdentity
import timber.log.Timber
import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState
import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress
import org.matrix.rustcomponents.sdk.RecoveryException as RustRecoveryException
@ -103,14 +105,20 @@ class RustEncryptionService(
* TODO This is a temporary workaround, when we will have a way to observe
* the sessions, this code will have to be updated.
*/
override val hasDevicesToVerifyAgainst: StateFlow<Boolean> = flow {
override val hasDevicesToVerifyAgainst: StateFlow<AsyncData<Boolean>> = flow {
while (currentCoroutineContext().isActive) {
val result = hasDevicesToVerifyAgainst().getOrDefault(false)
emit(result)
val result = hasDevicesToVerifyAgainst()
result
.onSuccess {
emit(AsyncData.Success(it))
}
.onFailure {
Timber.e(it, "Failed to get hasDevicesToVerifyAgainst, retrying in 5s...")
}
delay(5_000)
}
}
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, AsyncData.Uninitialized)
override suspend fun enableBackups(): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {

View file

@ -39,6 +39,7 @@ class NotificationMapper(
isDirect = item.roomInfo.isDirect,
activeMembersCount = item.roomInfo.joinedMembersCount.toInt(),
)
val timestamp = item.timestamp() ?: clock.epochMillis()
NotificationData(
sessionId = sessionId,
eventId = eventId,
@ -53,8 +54,8 @@ class NotificationMapper(
isDm = isDm,
isEncrypted = item.roomInfo.isEncrypted.orFalse(),
isNoisy = item.isNoisy.orFalse(),
timestamp = item.timestamp() ?: clock.epochMillis(),
content = item.event.use { notificationContentMapper.map(it) }.getOrThrow(),
timestamp = timestamp,
content = notificationContentMapper.map(item.event).getOrThrow(),
hasMention = item.hasMention.orFalse(),
)
}

View file

@ -25,8 +25,9 @@ class TimelineEventToNotificationContentMapper {
fun map(timelineEvent: TimelineEvent): Result<NotificationContent> {
return runCatchingExceptions {
timelineEvent.use {
val senderId = UserId(timelineEvent.senderId())
timelineEvent.eventType().use { eventType ->
eventType.toContent(senderId = UserId(timelineEvent.senderId()))
eventType.toContent(senderId = senderId)
}
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright 2025 New Vector 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.matrix.impl.auth
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails
import kotlinx.coroutines.test.runTest
import org.junit.Ignore
import org.junit.Test
@Ignore("JNA direct mapping has broken unit tests with FFI fakes")
class RustHomeserverLoginCompatibilityCheckerTest {
@Test
fun `check - is valid if it supports OIDC login`() = runTest {
val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsOidcLogin = true) }
assertThat(sut.check("https://matrix.host.org").getOrNull()).isTrue()
}
@Test
fun `check - is valid if it supports password login`() = runTest {
val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsPasswordLogin = true) }
assertThat(sut.check("https://matrix.host.org").getOrNull()).isTrue()
}
@Test
fun `check - is not valid if it only supports SSO login`() = runTest {
val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsSsoLogin = true) }
assertThat(sut.check("https://matrix.host.org").getOrNull()).isFalse()
}
@Test
fun `check - is not valid if fetching the data fails`() = runTest {
val sut = createChecker { error("Unexpected error!") }
assertThat(sut.check("https://matrix.host.org").isFailure).isTrue()
}
private fun createChecker(
result: () -> FakeFfiHomeserverLoginDetails,
) = RustHomeServerLoginCompatibilityChecker(
clientBuilderProvider = FakeClientBuilderProvider {
FakeFfiClientBuilder {
FakeFfiClient(homeserverLoginDetailsResult = result)
}
}
)
}

View file

@ -12,10 +12,12 @@ import org.matrix.rustcomponents.sdk.NoHandle
class FakeFfiHomeserverLoginDetails(
private val url: String = "https://example.org",
private val supportsPasswordLogin: Boolean = true,
private val supportsOidcLogin: Boolean = false
private val supportsPasswordLogin: Boolean = false,
private val supportsOidcLogin: Boolean = false,
private val supportsSsoLogin: Boolean = false,
) : HomeserverLoginDetails(NoHandle) {
override fun url(): String = url
override fun supportsOidcLogin(): Boolean = supportsOidcLogin
override fun supportsPasswordLogin(): Boolean = supportsPasswordLogin
override fun supportsSsoLogin(): Boolean = supportsSsoLogin
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.test
import androidx.annotation.ColorInt
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.EventId
@ -99,4 +100,5 @@ const val A_FORMATTED_DATE = "April 6, 1980 at 6:35 PM"
const val A_LOGIN_HINT = "mxid:@alice:example.org"
const val A_COLOR_INT = 0xFF0000
@ColorInt
const val A_COLOR_INT: Int = 0xFFFF0000.toInt()

View file

@ -0,0 +1,18 @@
/*
* Copyright 2025 New Vector 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.matrix.test.auth
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
class FakeHomeServerLoginCompatibilityChecker(
private val checkResult: (String) -> Result<Boolean>,
) : HomeServerLoginCompatibilityChecker {
override suspend fun check(url: String): Result<Boolean> {
return checkResult(url)
}
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.test.encryption
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
@ -34,7 +35,7 @@ class FakeEncryptionService(
override val recoveryStateStateFlow: MutableStateFlow<RecoveryState> = MutableStateFlow(RecoveryState.UNKNOWN)
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Starting)
override val isLastDevice: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val hasDevicesToVerifyAgainst: MutableStateFlow<Boolean> = MutableStateFlow(true)
override val hasDevicesToVerifyAgainst: MutableStateFlow<AsyncData<Boolean>> = MutableStateFlow(AsyncData.Uninitialized)
private var waitForBackupUploadSteadyStateFlow: Flow<BackupUploadState> = flowOf()
private var recoverFailure: Exception? = null
@ -84,7 +85,7 @@ class FakeEncryptionService(
this.isLastDevice.value = isLastDevice
}
fun emitHasDevicesToVerifyAgainst(hasDevicesToVerifyAgainst: Boolean) {
fun emitHasDevicesToVerifyAgainst(hasDevicesToVerifyAgainst: AsyncData<Boolean>) {
this.hasDevicesToVerifyAgainst.value = hasDevicesToVerifyAgainst
}

View file

@ -0,0 +1,20 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.matrix.ui.test"
}
dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(libs.coil.compose)
}

View file

@ -0,0 +1,50 @@
/*
* Copyright 2025 New Vector 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.matrix.ui.test.media
import coil3.ComponentRegistry
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import coil3.request.Disposable
import coil3.request.ImageRequest
import coil3.request.ImageResult
class FakeImageLoader : ImageLoader {
private val executedRequests = mutableListOf<ImageRequest>()
override val defaults: ImageRequest.Defaults
get() = error("Not implemented")
override val components: ComponentRegistry
get() = error("Not implemented")
override val memoryCache: MemoryCache?
get() = error("Not implemented")
override val diskCache: DiskCache?
get() = error("Not implemented")
override fun enqueue(request: ImageRequest): Disposable {
error("Not implemented")
}
override suspend fun execute(request: ImageRequest): ImageResult {
executedRequests.add(request)
error("Not implemented")
}
override fun shutdown() {
error("Not implemented")
}
override fun newBuilder(): ImageLoader.Builder {
error("Not implemented")
}
fun getExecutedRequestsData(): List<Any> {
return executedRequests.map { it.data }
}
}

View file

@ -1,21 +1,22 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2025 New Vector 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
package io.element.android.libraries.matrix.ui.test.media
import coil3.ImageLoader
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
class FakeImageLoaderHolder : ImageLoaderHolder {
private val fakeImageLoader = FakeImageLoader()
class FakeImageLoaderHolder(
val fakeImageLoader: ImageLoader = FakeImageLoader(),
) : ImageLoaderHolder {
override fun get(client: MatrixClient): ImageLoader {
return fakeImageLoader.getImageLoader()
return fakeImageLoader
}
override fun remove(sessionId: SessionId) {

View file

@ -13,12 +13,12 @@ plugins {
android {
namespace = "io.element.android.libraries.mediapickers.api"
dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
testCommonDependencies(libs)
}
}
dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
testCommonDependencies(libs)
}

View file

@ -15,10 +15,10 @@ setupDependencyInjection()
android {
namespace = "io.element.android.libraries.mediapickers.test"
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.di)
api(projects.libraries.mediapickers.api)
}
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.di)
api(projects.libraries.mediapickers.api)
}

View file

@ -23,6 +23,6 @@ interface MediaGalleryEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onBackClick()
fun viewInTimeline(eventId: EventId)
fun forward(eventId: EventId)
fun forward(eventId: EventId, fromPinnedEvents: Boolean)
}
}

View file

@ -31,7 +31,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onDone()
fun viewInTimeline(eventId: EventId)
fun forwardEvent(eventId: EventId)
fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
}
data class Params(

View file

@ -85,7 +85,7 @@ class MediaGalleryFlowNode(
}
override fun forward(eventId: EventId) {
callback.forward(eventId)
callback.forward(eventId, fromPinnedEvents = false)
}
override fun showItem(item: MediaItem.Event) {
@ -119,9 +119,9 @@ class MediaGalleryFlowNode(
callback.viewInTimeline(eventId)
}
override fun forwardEvent(eventId: EventId) {
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Need to go to the parent because of the overlay
callback.forward(eventId)
callback.forward(eventId, fromPinnedEvents)
}
}
mediaViewerEntryPoint.createNode(

View file

@ -11,6 +11,6 @@ import io.element.android.libraries.matrix.api.core.EventId
interface MediaViewerNavigator {
fun onViewInTimelineClick(eventId: EventId)
fun onForwardClick(eventId: EventId)
fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean)
fun onItemDeleted()
}

View file

@ -64,8 +64,8 @@ class MediaViewerNode(
callback.viewInTimeline(eventId)
}
override fun onForwardClick(eventId: EventId) {
callback.forwardEvent(eventId)
override fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean) {
callback.forwardEvent(eventId, fromPinnedEvents)
}
override fun onItemDeleted() {
@ -81,11 +81,7 @@ class MediaViewerNode(
timelineMediaGalleryDataSource
} else {
// Can we use a specific timeline?
val timelineMode = when (val mode = inputs.mode) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> mode.timelineMode
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> mode.timelineMode
else -> null
}
val timelineMode = inputs.mode.getTimelineMode()
when (timelineMode) {
null -> timelineMediaGalleryDataSource
Timeline.Mode.Live,
@ -149,3 +145,11 @@ class MediaViewerNode(
}
}
}
internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.Mode? {
return when (this) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> timelineMode
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> timelineMode
else -> null
}
}

View file

@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
@ -119,7 +120,10 @@ class MediaViewerPresenter(
}
is MediaViewerEvents.Forward -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onForwardClick(event.eventId)
navigator.onForwardClick(
eventId = event.eventId,
fromPinnedEvents = inputs.mode.getTimelineMode() == Timeline.Mode.PinnedEvents,
)
}
is MediaViewerEvents.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(

View file

@ -27,6 +27,7 @@ class SingleMediaGalleryDataSource(
override fun start() = Unit
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
override fun getLastData(): AsyncData<GroupedMediaItems> = AsyncData.Success(data)
override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit
override suspend fun deleteItem(eventId: EventId) = Unit

View file

@ -40,7 +40,7 @@ class DefaultMediaGalleryEntryPointTest {
val callback = object : MediaGalleryEntryPoint.Callback {
override fun onBackClick() = lambdaError()
override fun viewInTimeline(eventId: EventId) = lambdaError()
override fun forward(eventId: EventId) = lambdaError()
override fun forward(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,

View file

@ -72,7 +72,7 @@ class DefaultMediaViewerEntryPointTest {
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun viewInTimeline(eventId: EventId) = lambdaError()
override fun forwardEvent(eventId: EventId) = lambdaError()
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
}
val params = createMediaViewerEntryPointParams()
val result = entryPoint.createNode(
@ -118,7 +118,7 @@ class DefaultMediaViewerEntryPointTest {
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun viewInTimeline(eventId: EventId) = lambdaError()
override fun forwardEvent(eventId: EventId) = lambdaError()
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
}
val params = entryPoint.createParamsForAvatar(
filename = "fn",

View file

@ -12,15 +12,15 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakeMediaViewerNavigator(
private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() },
private val onForwardClickLambda: (EventId) -> Unit = { lambdaError() },
private val onForwardClickLambda: (EventId, Boolean) -> Unit = { _, _ -> lambdaError() },
private val onItemDeletedLambda: () -> Unit = { lambdaError() },
) : MediaViewerNavigator {
override fun onViewInTimelineClick(eventId: EventId) {
onViewInTimelineClickLambda(eventId)
}
override fun onForwardClick(eventId: EventId) {
onForwardClickLambda(eventId)
override fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean) {
onForwardClickLambda(eventId, fromPinnedEvents)
}
override fun onItemDeleted() {

View file

@ -785,7 +785,7 @@ class MediaViewerPresenterTest {
@Test
fun `present - forward hides the bottom sheet and invokes the navigator`() = runTest {
val onForwardClickLambda = lambdaRecorder<EventId, Unit> { }
val onForwardClickLambda = lambdaRecorder<EventId, Boolean, Unit> { _, _ -> }
val navigator = FakeMediaViewerNavigator(
onForwardClickLambda = onForwardClickLambda,
)
@ -804,7 +804,35 @@ class MediaViewerPresenterTest {
initialState.eventSink(MediaViewerEvents.Forward(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onForwardClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
onForwardClickLambda.assertions().isCalledOnce()
.with(value(AN_EVENT_ID), value(false))
}
}
@Test
fun `present - forward from pinned events hides the bottom sheet and invokes the navigator`() = runTest {
val onForwardClickLambda = lambdaRecorder<EventId, Boolean, Unit> { _, _ -> }
val navigator = FakeMediaViewerNavigator(
onForwardClickLambda = onForwardClickLambda,
)
val presenter = createMediaViewerPresenter(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.PinnedEvents),
localMediaFactory = localMediaFactory,
mediaViewerNavigator = navigator,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }),
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData()))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
initialState.eventSink(MediaViewerEvents.Forward(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onForwardClickLambda.assertions().isCalledOnce()
.with(value(AN_EVENT_ID), value(true))
}
}

View file

@ -125,7 +125,7 @@ class DefaultPermissionsPresenter(
showDialog = showDialog.value,
permissionAlreadyAsked = isAlreadyAsked,
permissionAlreadyDenied = isAlreadyDenied,
eventSink = { handleEvents(it) }
eventSink = ::handleEvents,
)
}

View file

@ -11,12 +11,12 @@ plugins {
android {
namespace = "io.element.android.libraries.preferences.test"
dependencies {
api(projects.libraries.preferences.api)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
implementation(libs.coroutines.core)
implementation(libs.androidx.datastore.preferences)
}
}
dependencies {
api(projects.libraries.preferences.api)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
implementation(libs.coroutines.core)
implementation(libs.androidx.datastore.preferences)
}

View file

@ -11,11 +11,11 @@ plugins {
android {
namespace = "io.element.android.libraries.previewutils"
dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(libs.kotlinx.collections.immutable)
}
}
dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(libs.kotlinx.collections.immutable)
}

View file

@ -21,6 +21,12 @@ android {
isIncludeAndroidResources = true
}
}
buildTypes {
register("nightly") {
matchingFallbacks += listOf("release")
}
}
}
setupDependencyInjection()
@ -70,6 +76,7 @@ dependencies {
testCommonDependencies(libs)
testImplementation(libs.coil.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.matrixuiTest)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.push.test)

Binary file not shown.

View file

@ -8,7 +8,10 @@
package io.element.android.libraries.push.impl.notifications
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.FileProvider
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
@ -138,7 +141,13 @@ class DefaultNotifiableEventResolver(
is NotificationContent.MessageLike.RoomMessage -> {
val showMediaPreview = client.mediaPreviewService.getMediaPreviewValue() == MediaPreviewValue.On
val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId)
val messageBody = descriptionFromMessageContent(content, senderDisambiguatedDisplayName)
val imageMimeType = if (showMediaPreview) content.getImageMimetype() else null
val imageUriString = imageMimeType?.let { content.fetchImageIfPresent(client, imageMimeType)?.toString() }
val messageBody = descriptionFromMessageContent(
content = content,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
hasImageUri = imageUriString != null,
)
val notifiableMessageEvent = buildNotifiableMessageEvent(
sessionId = userId,
senderId = content.senderId,
@ -149,8 +158,8 @@ class DefaultNotifiableEventResolver(
timestamp = this.timestamp,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
body = messageBody,
imageUriString = if (showMediaPreview) content.fetchImageIfPresent(client)?.toString() else null,
imageMimeType = if (showMediaPreview) content.getImageMimetype() else null,
imageUriString = imageUriString,
imageMimeType = imageMimeType.takeIf { imageUriString != null },
roomName = roomDisplayName,
roomIsDm = isDm,
roomAvatarPath = roomAvatarUrl,
@ -299,13 +308,18 @@ class DefaultNotifiableEventResolver(
private fun descriptionFromMessageContent(
content: NotificationContent.MessageLike.RoomMessage,
senderDisambiguatedDisplayName: String,
): String {
hasImageUri: Boolean,
): String? {
return when (val messageType = content.messageType) {
is AudioMessageType -> messageType.bestDescription
is VoiceMessageType -> stringProvider.getString(CommonStrings.common_voice_message)
is EmoteMessageType -> "* $senderDisambiguatedDisplayName ${messageType.body}"
is FileMessageType -> messageType.bestDescription
is ImageMessageType -> messageType.bestDescription
is ImageMessageType -> if (hasImageUri) {
messageType.caption
} else {
messageType.bestDescription
}
is StickerMessageType -> messageType.bestDescription
is NoticeMessageType -> messageType.body
is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser)
@ -326,14 +340,34 @@ class DefaultNotifiableEventResolver(
}
}
private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? {
/**
* Fetch the image for message type, only if the mime type is supported, as recommended
* per [NotificationCompat.MessagingStyle.Message.setData] documentation.
* Then convert to a [Uri] accessible to the Notification Service.
*/
private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(
client: MatrixClient,
mimeType: String,
): Uri? {
val fileResult = when (val messageType = messageType) {
is ImageMessageType -> notificationMediaRepoFactory.create(client)
.getMediaFile(
mediaSource = messageType.source,
mimeType = messageType.info?.mimetype,
filename = messageType.filename,
)
is ImageMessageType -> {
val isMimeTypeSupported = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageDecoder.isMimeTypeSupported(mimeType)
} else {
// Assume it's supported on old systems...
true
}
if (isMimeTypeSupported) {
notificationMediaRepoFactory.create(client).getMediaFile(
mediaSource = messageType.source,
mimeType = messageType.info?.mimetype,
filename = messageType.filename,
)
} else {
Timber.tag(loggerTag.value).d("Mime type $mimeType not supported by the system")
null
}
}
is VideoMessageType -> null // Use the thumbnail here?
else -> null
}

View file

@ -7,15 +7,10 @@
package io.element.android.libraries.push.impl.notifications
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -32,11 +27,7 @@ import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.api.currentSessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber
private val loggerTag = LoggerTag("DefaultNotificationDrawerManager", LoggerTag.NotificationLoggerTag)
/**
* This class receives notification events as they arrive from the PushHandler calling [onNotifiableEventReceived] and
@ -46,7 +37,7 @@ private val loggerTag = LoggerTag("DefaultNotificationDrawerManager", LoggerTag.
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultNotificationDrawerManager(
private val notificationManager: NotificationManagerCompat,
private val notificationDisplayer: NotificationDisplayer,
private val notificationRenderer: NotificationRenderer,
private val appNavigationStateService: AppNavigationStateService,
@AppCoroutineScope
@ -55,25 +46,17 @@ class DefaultNotificationDrawerManager(
private val imageLoaderHolder: ImageLoaderHolder,
private val activeNotificationsProvider: ActiveNotificationsProvider,
) : NotificationCleaner {
private var appNavigationStateObserver: Job? = null
// TODO EAx add a setting per user for this
private var useCompleteNotificationFormat = true
init {
// Observe application state
appNavigationStateObserver = coroutineScope.launch {
coroutineScope.launch {
appNavigationStateService.appNavigationState
.collect { onAppNavigationStateChange(it.navigationState) }
}
}
// For test only
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun destroy() {
appNavigationStateObserver?.cancel()
}
private var currentAppNavigationState: NavigationState? = null
private fun onAppNavigationStateChange(navigationState: NavigationState) {
@ -124,7 +107,7 @@ class DefaultNotificationDrawerManager(
* Clear all known message events for a [sessionId].
*/
override fun clearAllMessagesEvents(sessionId: SessionId) {
notificationManager.cancel(null, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
notificationDisplayer.cancelNotification(null, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
clearSummaryNotificationIfNeeded(sessionId)
}
@ -133,7 +116,7 @@ class DefaultNotificationDrawerManager(
*/
fun clearAllEvents(sessionId: SessionId) {
activeNotificationsProvider.getNotificationsForSession(sessionId)
.forEach { notificationManager.cancel(it.tag, it.id) }
.forEach { notificationDisplayer.cancelNotification(it.tag, it.id) }
}
/**
@ -142,7 +125,7 @@ class DefaultNotificationDrawerManager(
* Can also be called when a notification for this room is dismissed by the user.
*/
override fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
notificationManager.cancel(roomId.value, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
notificationDisplayer.cancelNotification(roomId.value, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
clearSummaryNotificationIfNeeded(sessionId)
}
@ -152,13 +135,13 @@ class DefaultNotificationDrawerManager(
*/
override fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
val tag = NotificationCreator.messageTag(roomId, threadId)
notificationManager.cancel(tag, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
notificationDisplayer.cancelNotification(tag, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
clearSummaryNotificationIfNeeded(sessionId)
}
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
activeNotificationsProvider.getMembershipNotificationForSession(sessionId)
.forEach { notificationManager.cancel(it.tag, it.id) }
.forEach { notificationDisplayer.cancelNotification(it.tag, it.id) }
clearSummaryNotificationIfNeeded(sessionId)
}
@ -167,7 +150,7 @@ class DefaultNotificationDrawerManager(
*/
override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
activeNotificationsProvider.getMembershipNotificationForRoom(sessionId, roomId)
.forEach { notificationManager.cancel(it.tag, it.id) }
.forEach { notificationDisplayer.cancelNotification(it.tag, it.id) }
clearSummaryNotificationIfNeeded(sessionId)
}
@ -176,14 +159,14 @@ class DefaultNotificationDrawerManager(
*/
override fun clearEvent(sessionId: SessionId, eventId: EventId) {
val id = NotificationIdProvider.getRoomEventNotificationId(sessionId)
notificationManager.cancel(eventId.value, id)
notificationDisplayer.cancelNotification(eventId.value, id)
clearSummaryNotificationIfNeeded(sessionId)
}
private fun clearSummaryNotificationIfNeeded(sessionId: SessionId) {
val summaryNotification = activeNotificationsProvider.getSummaryNotification(sessionId)
if (summaryNotification != null && activeNotificationsProvider.count(sessionId) == 1) {
notificationManager.cancel(null, summaryNotification.id)
notificationDisplayer.cancelNotification(null, summaryNotification.id)
}
}
@ -201,29 +184,9 @@ class DefaultNotificationDrawerManager(
// We have an avatar and a display name, use it
userFromCache
} else {
client.getSafeUserProfile()
client.getUserProfile().getOrNull() ?: MatrixUser(sessionId)
}
notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents, imageLoader)
}
}
private suspend fun MatrixClient.getSafeUserProfile(): MatrixUser {
return tryOrNull(
onException = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") },
operation = {
val profile = getUserProfile().getOrNull()
// displayName cannot be empty else NotificationCompat.MessagingStyle() will crash
if (profile?.displayName.isNullOrEmpty()) {
profile?.copy(displayName = sessionId.value)
} else {
profile
}
}
) ?: MatrixUser(
userId = sessionId,
displayName = sessionId.value,
avatarUrl = null
)
}
}

View file

@ -10,7 +10,6 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import android.graphics.Typeface
import android.text.style.StyleSpan
import androidx.annotation.ColorInt
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import coil3.ImageLoader
@ -19,8 +18,8 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
@ -31,39 +30,37 @@ import io.element.android.services.toolbox.api.strings.StringProvider
interface NotificationDataFactory {
suspend fun toNotifications(
messages: List<NotifiableMessageEvent>,
currentUser: MatrixUser,
imageLoader: ImageLoader,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<RoomNotification>
@JvmName("toNotificationInvites")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(
invites: List<InviteNotifiableEvent>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification>
@JvmName("toNotificationSimpleEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(
simpleEvents: List<SimpleNotifiableEvent>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification>
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(
fallback: List<FallbackNotifiableEvent>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification>
fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): SummaryNotification
}
@ -77,9 +74,8 @@ class DefaultNotificationDataFactory(
) : NotificationDataFactory {
override suspend fun toNotifications(
messages: List<NotifiableMessageEvent>,
currentUser: MatrixUser,
imageLoader: ImageLoader,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<RoomNotification> {
val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() }
.groupBy { it.roomId }
@ -90,13 +86,12 @@ class DefaultNotificationDataFactory(
eventsByThreadId.map { (threadId, events) ->
val notification = roomGroupMessageCreator.createRoomMessage(
currentUser = currentUser,
events = events,
roomId = roomId,
threadId = threadId,
imageLoader = imageLoader,
existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId, threadId),
color = color,
existingNotification = getExistingNotificationForMessages(notificationAccountParams.user.userId, roomId, threadId),
notificationAccountParams = notificationAccountParams,
)
RoomNotification(
notification = notification,
@ -121,12 +116,12 @@ class DefaultNotificationDataFactory(
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(
invites: List<InviteNotifiableEvent>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification> {
return invites.map { event ->
OneShotNotification(
key = event.roomId.value,
notification = notificationCreator.createRoomInvitationNotification(event, color),
tag = event.roomId.value,
notification = notificationCreator.createRoomInvitationNotification(notificationAccountParams, event),
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
@ -138,12 +133,12 @@ class DefaultNotificationDataFactory(
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(
simpleEvents: List<SimpleNotifiableEvent>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification> {
return simpleEvents.map { event ->
OneShotNotification(
key = event.eventId.value,
notification = notificationCreator.createSimpleEventNotification(event, color),
tag = event.eventId.value,
notification = notificationCreator.createSimpleEventNotification(notificationAccountParams, event),
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
@ -155,12 +150,12 @@ class DefaultNotificationDataFactory(
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(
fallback: List<FallbackNotifiableEvent>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification> {
return fallback.map { event ->
OneShotNotification(
key = event.eventId.value,
notification = notificationCreator.createFallbackNotification(event, color),
tag = event.eventId.value,
notification = notificationCreator.createFallbackNotification(notificationAccountParams, event),
summaryLine = event.description.orEmpty(),
isNoisy = false,
timestamp = event.timestamp
@ -169,23 +164,21 @@ class DefaultNotificationDataFactory(
}
override fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): SummaryNotification {
return when {
roomNotifications.isEmpty() && invitationNotifications.isEmpty() && simpleNotifications.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update(
summaryGroupMessageCreator.createSummaryNotification(
currentUser = currentUser,
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
color = color,
notificationAccountParams = notificationAccountParams,
)
)
}
@ -254,7 +247,7 @@ data class RoomNotification(
data class OneShotNotification(
val notification: Notification,
val key: String,
val tag: String,
val summaryLine: CharSequence,
val isNoisy: Boolean,
val timestamp: Long,

View file

@ -19,8 +19,8 @@ import io.element.android.libraries.di.annotations.ApplicationContext
import timber.log.Timber
interface NotificationDisplayer {
fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean
fun cancelNotificationMessage(tag: String?, id: Int)
fun showNotification(tag: String?, id: Int, notification: Notification): Boolean
fun cancelNotification(tag: String?, id: Int)
fun displayDiagnosticNotification(notification: Notification): Boolean
fun dismissDiagnosticNotification()
}
@ -30,7 +30,7 @@ class DefaultNotificationDisplayer(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManagerCompat
) : NotificationDisplayer {
override fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean {
override fun showNotification(tag: String?, id: Int, notification: Notification): Boolean {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
Timber.w("Not allowed to notify.")
return false
@ -40,26 +40,28 @@ class DefaultNotificationDisplayer(
return true
}
override fun cancelNotificationMessage(tag: String?, id: Int) {
override fun cancelNotification(tag: String?, id: Int) {
notificationManager.cancel(tag, id)
}
override fun displayDiagnosticNotification(notification: Notification): Boolean {
return showNotificationMessage(
tag = "DIAGNOSTIC",
return showNotification(
tag = TAG_DIAGNOSTIC,
id = NOTIFICATION_ID_DIAGNOSTIC,
notification = notification
)
}
override fun dismissDiagnosticNotification() {
cancelNotificationMessage(
tag = "DIAGNOSTIC",
cancelNotification(
tag = TAG_DIAGNOSTIC,
id = NOTIFICATION_ID_DIAGNOSTIC
)
}
companion object {
private const val TAG_DIAGNOSTIC = "DIAGNOSTIC"
/* ==========================================================================================
* IDs for notifications
* ========================================================================================== */

View file

@ -15,6 +15,7 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
@ -22,6 +23,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.first
import timber.log.Timber
@ -32,6 +34,7 @@ class NotificationRenderer(
private val notificationDisplayer: NotificationDisplayer,
private val notificationDataFactory: NotificationDataFactory,
private val enterpriseService: EnterpriseService,
private val sessionStore: SessionStore,
) {
suspend fun render(
currentUser: MatrixUser,
@ -41,24 +44,29 @@ class NotificationRenderer(
) {
val color = enterpriseService.brandColorsFlow(currentUser.userId).first()?.toArgb()
?: NotificationConfig.NOTIFICATION_ACCENT_COLOR
val numberOfAccounts = sessionStore.numberOfSessions()
val notificationAccountParams = NotificationAccountParams(
user = currentUser,
color = color,
showSessionId = numberOfAccounts > 1,
)
val groupedEvents = eventsToProcess.groupByType()
val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, currentUser, imageLoader, color)
val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents, color)
val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents, color)
val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents, color)
val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, imageLoader, notificationAccountParams)
val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents, notificationAccountParams)
val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents, notificationAccountParams)
val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents, notificationAccountParams)
val summaryNotification = notificationDataFactory.createSummaryNotification(
currentUser = currentUser,
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
color = color,
notificationAccountParams = notificationAccountParams,
)
// Remove summary first to avoid briefly displaying it after dismissing the last notification
if (summaryNotification == SummaryNotification.Removed) {
Timber.tag(loggerTag.value).d("Removing summary notification")
notificationDisplayer.cancelNotificationMessage(
notificationDisplayer.cancelNotification(
tag = null,
id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId)
)
@ -69,7 +77,7 @@ class NotificationRenderer(
roomId = notificationData.roomId,
threadId = notificationData.threadId
)
notificationDisplayer.showNotificationMessage(
notificationDisplayer.showNotification(
tag = tag,
id = NotificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
notification = notificationData.notification
@ -78,9 +86,9 @@ class NotificationRenderer(
invitationNotifications.forEach { notificationData ->
if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.key}")
notificationDisplayer.showNotificationMessage(
tag = notificationData.key,
Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.tag}")
notificationDisplayer.showNotification(
tag = notificationData.tag,
id = NotificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
notification = notificationData.notification
)
@ -89,9 +97,9 @@ class NotificationRenderer(
simpleNotifications.forEach { notificationData ->
if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.key}")
notificationDisplayer.showNotificationMessage(
tag = notificationData.key,
Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.tag}")
notificationDisplayer.showNotification(
tag = notificationData.tag,
id = NotificationIdProvider.getRoomEventNotificationId(currentUser.userId),
notification = notificationData.notification
)
@ -101,7 +109,7 @@ class NotificationRenderer(
// Show only the first fallback notification
if (fallbackNotifications.isNotEmpty()) {
Timber.tag(loggerTag.value).d("Showing fallback notification")
notificationDisplayer.showNotificationMessage(
notificationDisplayer.showNotification(
tag = "FALLBACK",
id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId),
notification = fallbackNotifications.first().notification
@ -111,7 +119,7 @@ class NotificationRenderer(
// Update summary last to avoid briefly displaying it before other notifications
if (summaryNotification is SummaryNotification.Update) {
Timber.tag(loggerTag.value).d("Updating summary notification")
notificationDisplayer.showNotificationMessage(
notificationDisplayer.showNotification(
tag = null,
id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId),
notification = summaryNotification.notification

View file

@ -9,15 +9,14 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import android.graphics.Bitmap
import androidx.annotation.ColorInt
import coil3.ImageLoader
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.factories.isSmartReplyError
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -25,13 +24,12 @@ import io.element.android.services.toolbox.api.strings.StringProvider
interface RoomGroupMessageCreator {
suspend fun createRoomMessage(
currentUser: MatrixUser,
notificationAccountParams: NotificationAccountParams,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
threadId: ThreadId?,
imageLoader: ImageLoader,
existingNotification: Notification?,
@ColorInt color: Int,
): Notification
}
@ -42,13 +40,12 @@ class DefaultRoomGroupMessageCreator(
private val notificationCreator: NotificationCreator,
) : RoomGroupMessageCreator {
override suspend fun createRoomMessage(
currentUser: MatrixUser,
notificationAccountParams: NotificationAccountParams,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
threadId: ThreadId?,
imageLoader: ImageLoader,
existingNotification: Notification?,
@ColorInt color: Int,
): Notification {
val lastKnownRoomEvent = events.last()
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)"
@ -66,8 +63,9 @@ class DefaultRoomGroupMessageCreator(
val smartReplyErrors = events.filter { it.isSmartReplyError() }
val roomIsDm = !roomIsGroup
return notificationCreator.createMessagesListNotification(
notificationAccountParams = notificationAccountParams,
RoomEventGroupInfo(
sessionId = currentUser.userId,
sessionId = notificationAccountParams.user.userId,
roomId = roomId,
roomDisplayName = roomName,
isDm = roomIsDm,
@ -80,11 +78,9 @@ class DefaultRoomGroupMessageCreator(
largeIcon = largeBitmap,
lastMessageTimestamp = lastMessageTimestamp,
tickerText = tickerText,
currentUser = currentUser,
existingNotification = existingNotification,
imageLoader = imageLoader,
events = events,
color = color,
)
}

View file

@ -8,22 +8,20 @@
package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import androidx.annotation.ColorInt
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.services.toolbox.api.strings.StringProvider
interface SummaryGroupMessageCreator {
fun createSummaryNotification(
currentUser: MatrixUser,
notificationAccountParams: NotificationAccountParams,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
@ColorInt color: Int,
): Notification
}
@ -42,30 +40,25 @@ class DefaultSummaryGroupMessageCreator(
private val notificationCreator: NotificationCreator,
) : SummaryGroupMessageCreator {
override fun createSummaryNotification(
currentUser: MatrixUser,
notificationAccountParams: NotificationAccountParams,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
@ColorInt color: Int,
): Notification {
val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
invitationNotifications.any { it.isNoisy } ||
simpleNotifications.any { it.isNoisy }
val lastMessageTimestamp = roomNotifications.lastOrNull()?.latestTimestamp
?: invitationNotifications.lastOrNull()?.timestamp
?: simpleNotifications.last().timestamp
// FIXME roomIdToEventMap.size is not correct, this is the number of rooms
val nbEvents = roomNotifications.size + simpleNotifications.size
val nbEvents = roomNotifications.size + invitationNotifications.size + simpleNotifications.size
val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents)
return notificationCreator.createSummaryListNotification(
currentUser,
notificationAccountParams = notificationAccountParams,
sumTitle,
noisy = summaryIsNoisy,
lastMessageTimestamp = lastMessageTimestamp,
color = color,
)
}
}

View file

@ -0,0 +1,17 @@
/*
* Copyright 2025 New Vector 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.factories
import androidx.annotation.ColorInt
import io.element.android.libraries.matrix.api.user.MatrixUser
data class NotificationAccountParams(
val user: MatrixUser,
@ColorInt val color: Int,
val showSessionId: Boolean,
)

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
@ -47,42 +48,40 @@ interface NotificationCreator {
* Create a notification for a Room.
*/
suspend fun createMessagesListNotification(
notificationAccountParams: NotificationAccountParams,
roomInfo: RoomEventGroupInfo,
threadId: ThreadId?,
largeIcon: Bitmap?,
lastMessageTimestamp: Long,
tickerText: String,
currentUser: MatrixUser,
existingNotification: Notification?,
imageLoader: ImageLoader,
events: List<NotifiableMessageEvent>,
@ColorInt color: Int,
): Notification
fun createRoomInvitationNotification(
notificationAccountParams: NotificationAccountParams,
inviteNotifiableEvent: InviteNotifiableEvent,
@ColorInt color: Int,
): Notification
fun createSimpleEventNotification(
notificationAccountParams: NotificationAccountParams,
simpleNotifiableEvent: SimpleNotifiableEvent,
@ColorInt color: Int,
): Notification
fun createFallbackNotification(
notificationAccountParams: NotificationAccountParams,
fallbackNotifiableEvent: FallbackNotifiableEvent,
@ColorInt color: Int,
): Notification
/**
* Create the summary notification.
*/
fun createSummaryListNotification(
currentUser: MatrixUser,
notificationAccountParams: NotificationAccountParams,
compatSummary: String,
noisy: Boolean,
lastMessageTimestamp: Long,
@ColorInt color: Int,
): Notification
fun createDiagnosticNotification(
@ -118,16 +117,15 @@ class DefaultNotificationCreator(
* Create a notification for a Room.
*/
override suspend fun createMessagesListNotification(
notificationAccountParams: NotificationAccountParams,
roomInfo: RoomEventGroupInfo,
threadId: ThreadId?,
largeIcon: Bitmap?,
lastMessageTimestamp: Long,
tickerText: String,
currentUser: MatrixUser,
existingNotification: Notification?,
imageLoader: ImageLoader,
events: List<NotifiableMessageEvent>,
@ColorInt color: Int,
): Notification {
// Build the pending intent for when the notification is clicked
val eventId = events.firstOrNull()?.eventId
@ -135,7 +133,6 @@ class DefaultNotificationCreator(
threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId, threadId)
else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId)
}
val smallIcon = CommonDrawables.ic_notification
val containsMissedCall = events.any { it.type == EventType.RTC_NOTIFICATION }
val channelId = if (containsMissedCall) {
notificationChannels.getChannelForIncomingCall(false)
@ -159,9 +156,6 @@ class DefaultNotificationCreator(
setShortcutId(createShortcutId(roomInfo.sessionId, roomInfo.roomId))
}
}
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
.setGroup(roomInfo.sessionId.value)
.setGroupSummary(false)
// In order to avoid notification making sound twice (due to the summary notification)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
@ -171,8 +165,8 @@ class DefaultNotificationCreator(
val messagingStyle = existingNotification?.let {
MessagingStyle.extractMessagingStyleFromNotification(it)
} ?: messagingStyleFromCurrentUser(
user = currentUser,
} ?: createMessagingStyleFromCurrentUser(
user = notificationAccountParams.user,
imageLoader = imageLoader,
roomName = roomInfo.roomDisplayName,
isThread = threadId != null,
@ -187,9 +181,7 @@ class DefaultNotificationCreator(
.setWhen(lastMessageTimestamp)
// MESSAGING_STYLE sets title and content for API 16 and above devices.
.setStyle(messagingStyle)
.setSmallIcon(smallIcon)
// Set primary color (important for Wear 2.0 Notifications).
.setColor(color)
.configureWith(notificationAccountParams)
// Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for
// 'importance' which is set in the NotificationChannel. The integers representing
// 'priority' are different from 'importance', so make sure you don't mix them.
@ -202,7 +194,7 @@ class DefaultNotificationCreator(
setSound(it)
}
*/
setLights(color, 500, 500)
setLights(notificationAccountParams.color, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
@ -234,19 +226,16 @@ class DefaultNotificationCreator(
}
override fun createRoomInvitationNotification(
notificationAccountParams: NotificationAccountParams,
inviteNotifiableEvent: InviteNotifiableEvent,
@ColorInt color: Int,
): Notification {
val smallIcon = CommonDrawables.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
.setContentTitle((inviteNotifiableEvent.roomName ?: buildMeta.applicationName).annotateForDebug(5))
.setContentText(inviteNotifiableEvent.description.annotateForDebug(6))
.setGroup(inviteNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(color)
.configureWith(notificationAccountParams)
.apply {
addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
@ -261,7 +250,7 @@ class DefaultNotificationCreator(
setSound(it)
}
*/
setLights(color, 500, 500)
setLights(notificationAccountParams.color, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
@ -277,19 +266,16 @@ class DefaultNotificationCreator(
}
override fun createSimpleEventNotification(
notificationAccountParams: NotificationAccountParams,
simpleNotifiableEvent: SimpleNotifiableEvent,
@ColorInt color: Int,
): Notification {
val smallIcon = CommonDrawables.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName.annotateForDebug(7))
.setContentText(simpleNotifiableEvent.description.annotateForDebug(8))
.setGroup(simpleNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(color)
.configureWith(notificationAccountParams)
.setAutoCancel(true)
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId, null))
.apply {
@ -301,7 +287,7 @@ class DefaultNotificationCreator(
setSound(it)
}
*/
setLights(color, 500, 500)
setLights(notificationAccountParams.color, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
@ -310,19 +296,16 @@ class DefaultNotificationCreator(
}
override fun createFallbackNotification(
notificationAccountParams: NotificationAccountParams,
fallbackNotifiableEvent: FallbackNotifiableEvent,
@ColorInt color: Int,
): Notification {
val smallIcon = CommonDrawables.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(false)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName.annotateForDebug(7))
.setContentText(fallbackNotifiableEvent.description.orEmpty().annotateForDebug(8))
.setGroup(fallbackNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(color)
.configureWith(notificationAccountParams)
.setAutoCancel(true)
.setWhen(fallbackNotifiableEvent.timestamp)
// Ideally we'd use `createOpenRoomPendingIntent` here, but the broken notification might apply to an invite
@ -343,24 +326,21 @@ class DefaultNotificationCreator(
* Create the summary notification.
*/
override fun createSummaryListNotification(
currentUser: MatrixUser,
notificationAccountParams: NotificationAccountParams,
compatSummary: String,
noisy: Boolean,
lastMessageTimestamp: Long,
@ColorInt color: Int,
): Notification {
val smallIcon = CommonDrawables.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(noisy)
val userId = notificationAccountParams.user.userId
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
// used in compat < N, after summary is built based on child notifications
.setWhen(lastMessageTimestamp)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setSmallIcon(smallIcon)
.setGroup(currentUser.userId.value)
// set this notification as the summary for the group
.setGroupSummary(true)
.setColor(color)
.configureWith(notificationAccountParams)
.apply {
if (noisy) {
// Compat
@ -370,14 +350,14 @@ class DefaultNotificationCreator(
setSound(it)
}
*/
setLights(color, 500, 500)
setLights(notificationAccountParams.color, 500, 500)
} else {
// compat
priority = NotificationCompat.PRIORITY_LOW
}
}
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(currentUser.userId))
.setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(currentUser.userId))
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(userId))
.setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(userId))
.build()
}
@ -431,34 +411,44 @@ class DefaultNotificationCreator(
senderPerson
)
else -> {
val message = MessagingStyle.Message(
event.body?.annotateForDebug(71),
event.timestamp,
senderPerson
).also { message ->
event.imageUri?.let {
message.setData(event.imageMimeType ?: "image/", it)
}
message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value)
}
addMessage(message)
// Add additional message for captions
if (event.imageUri != null && event.body != null) {
addMessage(
MessagingStyle.Message(
event.body,
event.timestamp,
senderPerson,
)
if (event.imageMimeType != null && event.imageUri != null) {
// Image case
val message = MessagingStyle.Message(
// This text will not be rendered, but some systems does not render the image
// if the text is null
stringProvider.getString(CommonStrings.common_image),
event.timestamp,
senderPerson,
)
.setData(event.imageMimeType, event.imageUri)
message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value)
addMessage(message)
// Add additional message for captions
if (event.body != null) {
addMessage(
MessagingStyle.Message(
event.body.annotateForDebug(72),
event.timestamp,
senderPerson,
)
)
}
} else {
// Text case
val message = MessagingStyle.Message(
event.body?.annotateForDebug(71),
event.timestamp,
senderPerson
)
message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value)
addMessage(message)
}
}
}
}
}
private suspend fun messagingStyleFromCurrentUser(
private suspend fun createMessagingStyleFromCurrentUser(
user: MatrixUser,
imageLoader: ImageLoader,
roomName: String,
@ -467,7 +457,8 @@ class DefaultNotificationCreator(
): MessagingStyle {
return MessagingStyle(
Person.Builder()
.setName(user.displayName?.annotateForDebug(50))
// Note: name cannot be empty else NotificationCompat.MessagingStyle() will crash
.setName(user.getBestName().annotateForDebug(50))
.setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader))
.setKey(user.userId.value)
.build()
@ -487,4 +478,13 @@ class DefaultNotificationCreator(
}
}
private fun NotificationCompat.Builder.configureWith(notificationAccountParams: NotificationAccountParams) = apply {
setSmallIcon(CommonDrawables.ic_notification)
setColor(notificationAccountParams.color)
setGroup(notificationAccountParams.user.userId.value)
if (notificationAccountParams.showSessionId) {
setSubText(notificationAccountParams.user.userId.value)
}
}
fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed

View file

@ -73,7 +73,7 @@ class DefaultOnRedactedEventReceived(
oldMessage.person
)
messagingStyle.messages[messageToRedactIndex] = newMessage
notificationDisplayer.showNotificationMessage(
notificationDisplayer.showNotification(
statusBarNotification.tag,
statusBarNotification.id,
NotificationCompat.Builder(context, notification)

Binary file not shown.

View file

@ -13,17 +13,17 @@ import androidx.core.app.NotificationCompat
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.NotificationConfig
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.A_COLOR_INT
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.matrix.ui.test.media.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.factories.MARK_AS_READ_ACTION_TITLE
import io.element.android.libraries.push.impl.notifications.factories.QUICK_REPLY_ACTION_TITLE
import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams
import io.element.android.libraries.push.impl.notifications.factories.createNotificationCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
@ -44,23 +44,22 @@ class DefaultBaseRoomGroupMessageCreatorTest {
val sut = createRoomGroupMessageCreator()
val fakeImageLoader = FakeImageLoader()
val result = sut.createRoomMessage(
currentUser = aMatrixUser(),
notificationAccountParams = aNotificationAccountParams(),
events = listOf(
aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy(
imageUriString = "aUri",
)
),
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
imageLoader = fakeImageLoader,
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(1)
@Suppress("DEPRECATION")
assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_LOW)
assertThat(result.`when`).isEqualTo(A_TIMESTAMP)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty()
}
@Test
@ -68,21 +67,20 @@ class DefaultBaseRoomGroupMessageCreatorTest {
val sut = createRoomGroupMessageCreator()
val fakeImageLoader = FakeImageLoader()
val result = sut.createRoomMessage(
currentUser = aMatrixUser(),
notificationAccountParams = aNotificationAccountParams(),
events = listOf(
aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy(
noisy = true,
)
),
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
imageLoader = fakeImageLoader,
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
@Suppress("DEPRECATION")
assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty()
}
@Test
@ -130,9 +128,11 @@ class DefaultBaseRoomGroupMessageCreatorTest {
sdkIntProvider = FakeBuildVersionSdkIntProvider(api)
)
val result = sut.createRoomMessage(
currentUser = aMatrixUser(
// Some user avatar
avatarUrl = A_USER_AVATAR_1,
notificationAccountParams = aNotificationAccountParams(
user = aMatrixUser(
// Some user avatar
avatarUrl = A_USER_AVATAR_1,
)
),
events = listOf(
aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy(
@ -141,13 +141,12 @@ class DefaultBaseRoomGroupMessageCreatorTest {
)
),
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
imageLoader = fakeImageLoader,
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(1)
assertThat(fakeImageLoader.getCoilRequests()).containsExactlyElementsIn(expectedCoilRequests)
assertThat(fakeImageLoader.getExecutedRequestsData()).containsExactlyElementsIn(expectedCoilRequests)
}
@Test
@ -155,16 +154,15 @@ class DefaultBaseRoomGroupMessageCreatorTest {
val sut = createRoomGroupMessageCreator()
val fakeImageLoader = FakeImageLoader()
val result = sut.createRoomMessage(
currentUser = aMatrixUser(),
notificationAccountParams = aNotificationAccountParams(),
events = listOf(
aNotifiableMessageEvent(timestamp = A_TIMESTAMP),
aNotifiableMessageEvent(timestamp = A_TIMESTAMP + 10),
),
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
imageLoader = fakeImageLoader,
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(2)
assertThat(result.`when`).isEqualTo(A_TIMESTAMP + 10)
@ -175,7 +173,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
QUICK_REPLY_ACTION_TITLE.takeIf { NotificationConfig.SHOW_QUICK_REPLY_ACTION },
)
)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty()
}
@Test
@ -183,7 +181,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
val sut = createRoomGroupMessageCreator()
val fakeImageLoader = FakeImageLoader()
val result = sut.createRoomMessage(
currentUser = aMatrixUser(),
notificationAccountParams = aNotificationAccountParams(),
events = listOf(
aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy(
outGoingMessage = true,
@ -191,10 +189,9 @@ class DefaultBaseRoomGroupMessageCreatorTest {
),
),
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
imageLoader = fakeImageLoader,
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
val actionTitles = result.actions?.map { it.title }
assertThat(actionTitles).isEqualTo(
@ -202,7 +199,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
MARK_AS_READ_ACTION_TITLE.takeIf { NotificationConfig.SHOW_MARK_AS_READ_ACTION }
)
)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty()
}
@Test
@ -210,21 +207,20 @@ class DefaultBaseRoomGroupMessageCreatorTest {
val sut = createRoomGroupMessageCreator()
val fakeImageLoader = FakeImageLoader()
val result = sut.createRoomMessage(
currentUser = aMatrixUser(),
notificationAccountParams = aNotificationAccountParams(),
events = listOf(
aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy(
roomIsDm = true,
),
),
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
imageLoader = fakeImageLoader,
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(1)
assertThat(result.`when`).isEqualTo(A_TIMESTAMP)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty()
}
}

View file

@ -8,10 +8,10 @@
package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import androidx.core.app.NotificationManagerCompat
import androidx.compose.ui.graphics.Color
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.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
@ -20,13 +20,17 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.test.media.FakeImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
@ -38,25 +42,19 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class DefaultNotificationDrawerManagerTest {
@Test
fun `clearAllEvents should have no effect when queue is empty`() = runTest {
val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager()
defaultNotificationDrawerManager.clearAllEvents(A_SESSION_ID)
defaultNotificationDrawerManager.destroy()
}
@Test
@ -64,8 +62,8 @@ class DefaultNotificationDrawerManagerTest {
// For now just call all the API. Later, add more valuable tests.
val matrixUser = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice", avatarUrl = "mxc://data")
val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator(
createRoomMessageResult = lambdaRecorder { user, _, roomId, _, _, existingNotification ->
assertThat(user).isEqualTo(matrixUser)
createRoomMessageResult = lambdaRecorder { notificationAccountParams, _, roomId, _, _, existingNotification ->
assertThat(notificationAccountParams.user).isEqualTo(matrixUser)
assertThat(roomId).isEqualTo(A_ROOM_ID)
assertThat(existingNotification).isNull()
Notification()
@ -88,7 +86,6 @@ class DefaultNotificationDrawerManagerTest {
defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent())
// Add the same Event again (will be ignored)
defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent())
defaultNotificationDrawerManager.destroy()
}
@Test
@ -101,7 +98,7 @@ class DefaultNotificationDrawerManagerTest {
)
)
val appNavigationStateService = FakeAppNavigationStateService(appNavigationState = appNavigationStateFlow)
val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager(
createDefaultNotificationDrawerManager(
appNavigationStateService = appNavigationStateService
)
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true))
@ -117,17 +114,22 @@ class DefaultNotificationDrawerManagerTest {
// Like a user sign out
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true))
runCurrent()
defaultNotificationDrawerManager.destroy()
}
@Test
fun `when MatrixClient has no cached user name a fallback one is used to render the notification`() = runTest {
val matrixClient = FakeMatrixClient(userDisplayName = null)
fun `when MatrixClient has no cached user name and avatar, the profile is loaded to render the notification`() = runTest {
val matrixClient = FakeMatrixClient(
userDisplayName = null,
userAvatarUrl = null,
)
val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
val messageCreator = FakeRoomGroupMessageCreator()
val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager(
matrixClientProvider = matrixClientProvider,
roomGroupMessageCreator = messageCreator,
enterpriseService = FakeEnterpriseService(
initialBrandColor = Color.Red,
)
)
// Gets a display name from MatrixClient.getUserProfile
matrixClient.givenGetProfileResult(A_SESSION_ID, Result.success(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")))
@ -144,27 +146,41 @@ class DefaultNotificationDrawerManagerTest {
messageCreator.createRoomMessageResult.assertions()
.isCalledExactly(3)
.withSequence(
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), any(), any(), any(), any(), any()),
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value)), any(), any(), any(), any(), any()),
listOf(
value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)),
value(aNotificationAccountParams(user = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice"))),
any(),
any(),
any(),
any(),
any(),
),
listOf(
value(aNotificationAccountParams(user = aMatrixUser(id = A_SESSION_ID.value, displayName = ""))),
any(),
any(),
any(),
any(),
any(),
),
listOf(
value(aNotificationAccountParams(user = aMatrixUser(id = A_SESSION_ID.value, displayName = null, avatarUrl = null))),
any(),
any(),
any(),
any(),
any(),
any()
),
)
defaultNotificationDrawerManager.destroy()
}
@Test
fun `clearSummaryNotificationIfNeeded will run after clearing all other notifications`() = runTest {
val notificationManager = mockk<NotificationManagerCompat> {
every { cancel(any(), any()) } returns Unit
}
val cancelNotificationResult = lambdaRecorder<String?, Int, Unit> { _, _ -> }
val notificationDisplayer = FakeNotificationDisplayer(
cancelNotificationResult = cancelNotificationResult,
)
val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID)
val roomMessageId = NotificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)
val activeNotificationsProvider = FakeActiveNotificationsProvider(
getSummaryNotificationResult = {
mockk {
@ -174,7 +190,7 @@ class DefaultNotificationDrawerManagerTest {
countResult = { 1 },
)
val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager(
notificationManager = notificationManager,
notificationDisplayer = notificationDisplayer,
activeNotificationsProvider = activeNotificationsProvider,
)
@ -182,24 +198,26 @@ class DefaultNotificationDrawerManagerTest {
defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID)
// Verify we asked to cancel the notification with summaryId
verify { notificationManager.cancel(null, summaryId) }
defaultNotificationDrawerManager.destroy()
cancelNotificationResult.assertions().isCalledExactly(2).withSequence(
listOf(value(null), value(roomMessageId)),
listOf(value(null), value(summaryId)),
)
}
private fun TestScope.createDefaultNotificationDrawerManager(
notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(RuntimeEnvironment.getApplication()),
notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(),
appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(),
roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(),
summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(),
activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(),
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
sessionStore: SessionStore = InMemorySessionStore(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
): DefaultNotificationDrawerManager {
val context = RuntimeEnvironment.getApplication()
return DefaultNotificationDrawerManager(
notificationManager = notificationManager,
notificationDisplayer = notificationDisplayer,
notificationRenderer = NotificationRenderer(
notificationDisplayer = DefaultNotificationDisplayer(context, NotificationManagerCompat.from(context)),
notificationDisplayer = FakeNotificationDisplayer(),
notificationDataFactory = DefaultNotificationDataFactory(
notificationCreator = FakeNotificationCreator(),
roomGroupMessageCreator = roomGroupMessageCreator,
@ -207,10 +225,11 @@ class DefaultNotificationDrawerManagerTest {
activeNotificationsProvider = activeNotificationsProvider,
stringProvider = FakeStringProvider(),
),
enterpriseService = FakeEnterpriseService(),
enterpriseService = enterpriseService,
sessionStore = sessionStore,
),
appNavigationStateService = appNavigationStateService,
coroutineScope = this,
coroutineScope = backgroundScope,
matrixClientProvider = matrixClientProvider,
imageLoaderHolder = FakeImageLoaderHolder(),
activeNotificationsProvider = activeNotificationsProvider,

View file

@ -7,7 +7,6 @@
package io.element.android.libraries.push.impl.notifications
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
@ -16,23 +15,19 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
import io.element.android.libraries.matrix.test.notification.aNotificationData
import io.element.android.libraries.matrix.ui.test.media.FakeImageLoaderHolder
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultOnMissedCallNotificationHandlerTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
@ -52,11 +47,9 @@ class DefaultOnMissedCallNotificationHandlerTest {
val defaultOnMissedCallNotificationHandler = DefaultOnMissedCallNotificationHandler(
matrixClientProvider = matrixClientProvider,
defaultNotificationDrawerManager = DefaultNotificationDrawerManager(
notificationManager = mockk(relaxed = true),
notificationRenderer = NotificationRenderer(
notificationDisplayer = FakeNotificationDisplayer(),
notificationDisplayer = FakeNotificationDisplayer(),
notificationRenderer = createNotificationRenderer(
notificationDataFactory = dataFactory,
enterpriseService = FakeEnterpriseService(),
),
appNavigationStateService = FakeAppNavigationStateService(),
coroutineScope = backgroundScope,

View file

@ -10,9 +10,8 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import androidx.core.app.NotificationCompat
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_COLOR_INT
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
@ -34,7 +33,7 @@ class DefaultSummaryGroupMessageCreatorTest {
)
val result = summaryCreator.createSummaryNotification(
currentUser = aMatrixUser(),
notificationAccountParams = aNotificationAccountParams(),
roomNotifications = listOf(
RoomNotification(
notification = Notification(),
@ -49,12 +48,11 @@ class DefaultSummaryGroupMessageCreatorTest {
invitationNotifications = emptyList(),
simpleNotifications = emptyList(),
fallbackNotifications = emptyList(),
color = A_COLOR_INT,
)
notificationCreator.createSummaryListNotificationResult.assertions()
.isCalledOnce()
.with(any(), nonNull(), any(), any())
.with(any(), any(), nonNull(), any(), any())
// Set from the events included
@Suppress("DEPRECATION")

View file

@ -11,9 +11,10 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_COLOR_INT
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.ui.test.media.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
@ -21,7 +22,6 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGrou
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -51,16 +51,18 @@ class NotificationDataFactoryTest {
@Test
fun `given a room invitation when mapping to notification then it's added`() = testWith(notificationDataFactory) {
val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(AN_INVITATION_EVENT)
val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(
aNotificationAccountParams(),
AN_INVITATION_EVENT,
)
val roomInvitation = listOf(AN_INVITATION_EVENT)
val result = toNotifications(roomInvitation, A_COLOR_INT)
val result = toNotifications(roomInvitation, aNotificationAccountParams())
assertThat(result).isEqualTo(
listOf(
OneShotNotification(
notification = expectedNotification,
key = A_ROOM_ID.value,
tag = A_ROOM_ID.value,
summaryLine = AN_INVITATION_EVENT.description,
isNoisy = AN_INVITATION_EVENT.noisy,
timestamp = AN_INVITATION_EVENT.timestamp
@ -71,20 +73,18 @@ class NotificationDataFactoryTest {
@Test
fun `given a simple event when mapping to notification then it's added`() = testWith(notificationDataFactory) {
val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(AN_INVITATION_EVENT)
val roomInvitation = listOf(A_SIMPLE_EVENT)
val result = toNotifications(roomInvitation, A_COLOR_INT)
assertThat(result).isEqualTo(
listOf(
OneShotNotification(
notification = expectedNotification,
key = AN_EVENT_ID.value,
summaryLine = A_SIMPLE_EVENT.description,
isNoisy = A_SIMPLE_EVENT.noisy,
timestamp = AN_INVITATION_EVENT.timestamp
)
val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(
aNotificationAccountParams(),
AN_INVITATION_EVENT,
)
val result = toNotifications(listOf(A_SIMPLE_EVENT), aNotificationAccountParams())
assertThat(result).containsExactly(
OneShotNotification(
notification = expectedNotification,
tag = AN_EVENT_ID.value,
summaryLine = A_SIMPLE_EVENT.description,
isNoisy = A_SIMPLE_EVENT.noisy,
timestamp = AN_INVITATION_EVENT.timestamp
)
)
}
@ -94,13 +94,14 @@ class NotificationDataFactoryTest {
val events = listOf(A_MESSAGE_EVENT)
val expectedNotification = RoomNotification(
notification = fakeRoomGroupMessageCreator.createRoomMessage(
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
notificationAccountParams = aNotificationAccountParams(
user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
),
events = events,
roomId = A_ROOM_ID,
threadId = null,
imageLoader = FakeImageLoader().getImageLoader(),
imageLoader = FakeImageLoader(),
existingNotification = null,
color = A_COLOR_INT,
),
roomId = A_ROOM_ID,
summaryLine = "A room name: Bob Hello world!",
@ -109,35 +110,33 @@ class NotificationDataFactoryTest {
shouldBing = events.any { it.noisy },
threadId = null,
)
val roomWithMessage = listOf(A_MESSAGE_EVENT)
val fakeImageLoader = FakeImageLoader()
val result = toNotifications(
messages = roomWithMessage,
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
imageLoader = fakeImageLoader.getImageLoader(),
color = A_COLOR_INT,
messages = listOf(A_MESSAGE_EVENT),
notificationAccountParams = aNotificationAccountParams(
user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
),
imageLoader = fakeImageLoader,
)
assertThat(result.size).isEqualTo(1)
assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue()
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty()
}
@Test
fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationDataFactory) {
val redactedRoom = listOf(A_MESSAGE_EVENT.copy(isRedacted = true))
val redactedRoom = A_MESSAGE_EVENT.copy(isRedacted = true)
val fakeImageLoader = FakeImageLoader()
val result = toNotifications(
messages = redactedRoom,
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
imageLoader = fakeImageLoader.getImageLoader(),
color = A_COLOR_INT,
messages = listOf(redactedRoom),
notificationAccountParams = aNotificationAccountParams(
user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
),
imageLoader = fakeImageLoader,
)
assertThat(result).isEmpty()
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty()
}
@Test
@ -151,13 +150,14 @@ class NotificationDataFactoryTest {
val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted")))
val expectedNotification = RoomNotification(
notification = fakeRoomGroupMessageCreator.createRoomMessage(
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
notificationAccountParams = aNotificationAccountParams(
user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
),
events = withRedactedRemoved,
roomId = A_ROOM_ID,
threadId = null,
imageLoader = FakeImageLoader().getImageLoader(),
imageLoader = FakeImageLoader(),
existingNotification = null,
color = A_COLOR_INT,
),
roomId = A_ROOM_ID,
summaryLine = "A room name: Bob Hello world!",
@ -170,14 +170,15 @@ class NotificationDataFactoryTest {
val fakeImageLoader = FakeImageLoader()
val result = toNotifications(
messages = roomWithRedactedMessage,
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
imageLoader = fakeImageLoader.getImageLoader(),
color = A_COLOR_INT,
notificationAccountParams = aNotificationAccountParams(
user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
),
imageLoader = fakeImageLoader,
)
assertThat(result.size).isEqualTo(1)
assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue()
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty()
}
}

View file

@ -7,14 +7,17 @@
package io.element.android.libraries.push.impl.notifications
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.ui.test.media.FakeImageLoader
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
@ -23,7 +26,8 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@ -38,7 +42,7 @@ private const val USE_COMPLETE_NOTIFICATION_FORMAT = true
private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(A_NOTIFICATION)
private val ONE_SHOT_NOTIFICATION =
OneShotNotification(notification = A_NOTIFICATION, key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1)
OneShotNotification(notification = A_NOTIFICATION, tag = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1)
@RunWith(RobolectricTestRunner::class)
class NotificationRendererTest {
@ -56,10 +60,9 @@ class NotificationRendererTest {
)
private val notificationIdProvider = NotificationIdProvider
private val notificationRenderer = NotificationRenderer(
private val notificationRenderer = createNotificationRenderer(
notificationDisplayer = notificationDisplayer,
notificationDataFactory = notificationDataFactory,
enterpriseService = FakeEnterpriseService(),
)
@Test
@ -75,7 +78,7 @@ class NotificationRendererTest {
renderEventsAsNotifications(listOf(aNotifiableMessageEvent()))
notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence(
notificationDisplayer.showNotificationResult.assertions().isCalledExactly(2).withSequence(
listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)),
listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification))
)
@ -83,11 +86,11 @@ class NotificationRendererTest {
@Test
fun `given a simple notification is added when rendering then show the simple notification and update summary`() = runTest {
notificationCreator.createSimpleNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification }
notificationCreator.createSimpleNotificationResult = lambdaRecorder { _, _ -> ONE_SHOT_NOTIFICATION.copy(tag = AN_EVENT_ID.value).notification }
renderEventsAsNotifications(listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID)))
notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence(
notificationDisplayer.showNotificationResult.assertions().isCalledExactly(2).withSequence(
listOf(value(AN_EVENT_ID.value), value(notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)),
listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification))
)
@ -95,11 +98,11 @@ class NotificationRendererTest {
@Test
fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() = runTest {
notificationCreator.createRoomInvitationNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification }
notificationCreator.createRoomInvitationNotificationResult = lambdaRecorder { _, _ -> ONE_SHOT_NOTIFICATION.copy(tag = AN_EVENT_ID.value).notification }
renderEventsAsNotifications(listOf(anInviteNotifiableEvent()))
notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence(
notificationDisplayer.showNotificationResult.assertions().isCalledExactly(2).withSequence(
listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)),
listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification))
)
@ -110,7 +113,19 @@ class NotificationRendererTest {
MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL),
useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT,
eventsToProcess = events,
imageLoader = FakeImageLoader().getImageLoader(),
imageLoader = FakeImageLoader(),
)
}
}
fun createNotificationRenderer(
notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(),
notificationDataFactory: NotificationDataFactory = FakeNotificationDataFactory(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
sessionStore: SessionStore = InMemorySessionStore(),
) = NotificationRenderer(
notificationDisplayer = notificationDisplayer,
notificationDataFactory = notificationDataFactory,
enterpriseService = enterpriseService,
sessionStore = sessionStore,
)

View file

@ -20,9 +20,9 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.test.media.FakeImageLoaderHolder
import io.element.android.libraries.push.impl.notifications.factories.FakeIntentProvider
import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.test.media.FakeImageLoader
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.DefaultNotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
@ -36,7 +37,6 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
@ -65,6 +65,7 @@ class DefaultNotificationCreatorTest {
fun `test createFallbackNotification`() {
val sut = createNotificationCreator()
val result = sut.createFallbackNotification(
notificationAccountParams = aNotificationAccountParams(),
FallbackNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
@ -77,7 +78,6 @@ class DefaultNotificationCreatorTest {
timestamp = A_FAKE_TIMESTAMP,
cause = null,
),
color = A_COLOR_INT,
)
result.commonAssertions(
expectedCategory = null,
@ -88,6 +88,7 @@ class DefaultNotificationCreatorTest {
fun `test createSimpleEventNotification`() {
val sut = createNotificationCreator()
val result = sut.createSimpleEventNotification(
notificationAccountParams = aNotificationAccountParams(),
SimpleNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
@ -103,7 +104,6 @@ class DefaultNotificationCreatorTest {
isRedacted = false,
isUpdated = false,
),
color = A_COLOR_INT,
)
result.commonAssertions(
expectedCategory = null,
@ -114,6 +114,7 @@ class DefaultNotificationCreatorTest {
fun `test createSimpleEventNotification noisy`() {
val sut = createNotificationCreator()
val result = sut.createSimpleEventNotification(
notificationAccountParams = aNotificationAccountParams(),
SimpleNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
@ -129,7 +130,6 @@ class DefaultNotificationCreatorTest {
isRedacted = false,
isUpdated = false,
),
color = A_COLOR_INT,
)
result.commonAssertions(
expectedCategory = null,
@ -140,6 +140,7 @@ class DefaultNotificationCreatorTest {
fun `test createRoomInvitationNotification`() {
val sut = createNotificationCreator()
val result = sut.createRoomInvitationNotification(
notificationAccountParams = aNotificationAccountParams(),
InviteNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
@ -156,7 +157,6 @@ class DefaultNotificationCreatorTest {
isUpdated = false,
roomName = "roomName",
),
color = A_COLOR_INT,
)
result.commonAssertions(
expectedCategory = null,
@ -174,6 +174,7 @@ class DefaultNotificationCreatorTest {
fun `test createRoomInvitationNotification noisy`() {
val sut = createNotificationCreator()
val result = sut.createRoomInvitationNotification(
notificationAccountParams = aNotificationAccountParams(),
InviteNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
@ -190,7 +191,6 @@ class DefaultNotificationCreatorTest {
isUpdated = false,
roomName = "roomName",
),
color = A_COLOR_INT,
)
result.commonAssertions(
expectedCategory = null,
@ -202,11 +202,10 @@ class DefaultNotificationCreatorTest {
val sut = createNotificationCreator()
val matrixUser = aMatrixUser()
val result = sut.createSummaryListNotification(
currentUser = matrixUser,
notificationAccountParams = aNotificationAccountParams(user = matrixUser),
compatSummary = "compatSummary",
noisy = false,
lastMessageTimestamp = 123_456L,
color = A_COLOR_INT,
)
result.commonAssertions(
expectedGroup = matrixUser.userId.value,
@ -218,11 +217,10 @@ class DefaultNotificationCreatorTest {
val sut = createNotificationCreator()
val matrixUser = aMatrixUser()
val result = sut.createSummaryListNotification(
currentUser = matrixUser,
notificationAccountParams = aNotificationAccountParams(user = matrixUser),
compatSummary = "compatSummary",
noisy = true,
lastMessageTimestamp = 123_456L,
color = A_COLOR_INT,
)
result.commonAssertions(
expectedGroup = matrixUser.userId.value,
@ -232,8 +230,8 @@ class DefaultNotificationCreatorTest {
@Test
fun `test createMessagesListNotification`() = runTest {
val sut = createNotificationCreator()
aMatrixUser()
val result = sut.createMessagesListNotification(
notificationAccountParams = aNotificationAccountParams(),
roomInfo = RoomEventGroupInfo(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
@ -247,11 +245,9 @@ class DefaultNotificationCreatorTest {
largeIcon = null,
lastMessageTimestamp = 123_456L,
tickerText = "tickerText",
currentUser = aMatrixUser(),
existingNotification = null,
imageLoader = FakeImageLoader().getImageLoader(),
imageLoader = FakeImageLoader(),
events = listOf(aNotifiableMessageEvent()),
color = A_COLOR_INT,
)
result.commonAssertions()
}
@ -259,8 +255,8 @@ class DefaultNotificationCreatorTest {
@Test
fun `test createMessagesListNotification should bing and thread`() = runTest {
val sut = createNotificationCreator()
aMatrixUser()
val result = sut.createMessagesListNotification(
notificationAccountParams = aNotificationAccountParams(),
roomInfo = RoomEventGroupInfo(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
@ -274,17 +270,15 @@ class DefaultNotificationCreatorTest {
largeIcon = null,
lastMessageTimestamp = 123_456L,
tickerText = "tickerText",
currentUser = aMatrixUser(),
existingNotification = null,
imageLoader = FakeImageLoader().getImageLoader(),
imageLoader = FakeImageLoader(),
events = listOf(aNotifiableMessageEvent()),
color = A_COLOR_INT,
)
result.commonAssertions()
}
private fun Notification.commonAssertions(
expectedGroup: String? = A_SESSION_ID.value,
expectedGroup: String? = aMatrixUser().userId.value,
expectedCategory: String? = NotificationCompat.CATEGORY_MESSAGE,
) {
assertThat(contentIntent).isNotNull()

View file

@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector 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.factories
import androidx.annotation.ColorInt
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_COLOR_INT
import io.element.android.libraries.matrix.ui.components.aMatrixUser
fun aNotificationAccountParams(
user: MatrixUser = aMatrixUser(),
@ColorInt color: Int = A_COLOR_INT,
showSessionId: Boolean = false,
) = NotificationAccountParams(
user = user,
color = color,
showSessionId = showSessionId,
)

View file

@ -12,81 +12,84 @@ import android.graphics.Bitmap
import androidx.annotation.ColorInt
import coil3.ImageLoader
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.tests.testutils.lambda.LambdaFourParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaListAnyParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaAnyRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
class FakeNotificationCreator(
var createMessagesListNotificationResult: LambdaListAnyParamsRecorder<Notification> = lambdaAnyRecorder { A_NOTIFICATION },
var createRoomInvitationNotificationResult: LambdaOneParamRecorder<InviteNotifiableEvent, Notification> = lambdaRecorder { _ -> A_NOTIFICATION },
var createSimpleNotificationResult: LambdaOneParamRecorder<SimpleNotifiableEvent, Notification> = lambdaRecorder { _ -> A_NOTIFICATION },
var createFallbackNotificationResult: LambdaOneParamRecorder<FallbackNotifiableEvent, Notification> = lambdaRecorder { _ -> A_NOTIFICATION },
var createSummaryListNotificationResult: LambdaFourParamsRecorder<MatrixUser, String, Boolean, Long, Notification> =
lambdaRecorder { _, _, _, _ -> A_NOTIFICATION },
var createDiagnosticNotificationResult: LambdaNoParamRecorder<Notification> = lambdaRecorder<Notification> { A_NOTIFICATION },
var createRoomInvitationNotificationResult: LambdaTwoParamsRecorder<NotificationAccountParams, InviteNotifiableEvent, Notification> =
lambdaRecorder { _, _ -> A_NOTIFICATION },
var createSimpleNotificationResult: LambdaTwoParamsRecorder<NotificationAccountParams, SimpleNotifiableEvent, Notification> =
lambdaRecorder { _, _ -> A_NOTIFICATION },
var createFallbackNotificationResult: LambdaTwoParamsRecorder<NotificationAccountParams, FallbackNotifiableEvent, Notification> =
lambdaRecorder { _, _ -> A_NOTIFICATION },
var createSummaryListNotificationResult: LambdaFiveParamsRecorder<
NotificationAccountParams, String, Boolean, Long, NotificationAccountParams, Notification
> = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION },
var createDiagnosticNotificationResult: LambdaOneParamRecorder<Int, Notification> =
lambdaRecorder<Int, Notification> { _ -> A_NOTIFICATION },
) : NotificationCreator {
override suspend fun createMessagesListNotification(
notificationAccountParams: NotificationAccountParams,
roomInfo: RoomEventGroupInfo,
threadId: ThreadId?,
largeIcon: Bitmap?,
lastMessageTimestamp: Long,
tickerText: String,
currentUser: MatrixUser,
existingNotification: Notification?,
imageLoader: ImageLoader,
events: List<NotifiableMessageEvent>,
@ColorInt color: Int,
): Notification {
return createMessagesListNotificationResult(
listOf(roomInfo, threadId, largeIcon, lastMessageTimestamp, tickerText, currentUser, existingNotification, imageLoader, events)
listOf(notificationAccountParams, roomInfo, threadId, largeIcon, lastMessageTimestamp, tickerText, existingNotification, imageLoader, events)
)
}
override fun createRoomInvitationNotification(
notificationAccountParams: NotificationAccountParams,
inviteNotifiableEvent: InviteNotifiableEvent,
@ColorInt color: Int,
): Notification {
return createRoomInvitationNotificationResult(inviteNotifiableEvent)
return createRoomInvitationNotificationResult(notificationAccountParams, inviteNotifiableEvent)
}
override fun createSimpleEventNotification(
notificationAccountParams: NotificationAccountParams,
simpleNotifiableEvent: SimpleNotifiableEvent,
@ColorInt color: Int,
): Notification {
return createSimpleNotificationResult(simpleNotifiableEvent)
return createSimpleNotificationResult(notificationAccountParams, simpleNotifiableEvent)
}
override fun createFallbackNotification(
notificationAccountParams: NotificationAccountParams,
fallbackNotifiableEvent: FallbackNotifiableEvent,
@ColorInt color: Int,
): Notification {
return createFallbackNotificationResult(fallbackNotifiableEvent)
return createFallbackNotificationResult(notificationAccountParams, fallbackNotifiableEvent)
}
override fun createSummaryListNotification(
currentUser: MatrixUser,
notificationAccountParams: NotificationAccountParams,
compatSummary: String,
noisy: Boolean,
lastMessageTimestamp: Long,
@ColorInt color: Int,
): Notification {
return createSummaryListNotificationResult(currentUser, compatSummary, noisy, lastMessageTimestamp)
return createSummaryListNotificationResult(notificationAccountParams, compatSummary, noisy, lastMessageTimestamp, notificationAccountParams)
}
override fun createDiagnosticNotification(
@ColorInt color: Int,
): Notification {
return createDiagnosticNotificationResult()
return createDiagnosticNotificationResult(color)
}
}

View file

@ -7,13 +7,12 @@
package io.element.android.libraries.push.impl.notifications.fake
import androidx.annotation.ColorInt
import coil3.ImageLoader
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.NotificationDataFactory
import io.element.android.libraries.push.impl.notifications.OneShotNotification
import io.element.android.libraries.push.impl.notifications.RoomNotification
import io.element.android.libraries.push.impl.notifications.SummaryNotification
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
@ -25,14 +24,15 @@ import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
class FakeNotificationDataFactory(
var messageEventToNotificationsResult: LambdaThreeParamsRecorder<List<NotifiableMessageEvent>, MatrixUser, ImageLoader, List<RoomNotification>> =
lambdaRecorder { _, _, _ -> emptyList() },
var messageEventToNotificationsResult: LambdaThreeParamsRecorder<
List<NotifiableMessageEvent>, ImageLoader, NotificationAccountParams, List<RoomNotification>
> = lambdaRecorder { _, _, _ -> emptyList() },
var summaryToNotificationsResult: LambdaFiveParamsRecorder<
MatrixUser,
List<RoomNotification>,
List<OneShotNotification>,
List<OneShotNotification>,
List<OneShotNotification>,
NotificationAccountParams,
SummaryNotification
> = lambdaRecorder { _, _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) },
var inviteToNotificationsResult: LambdaOneParamRecorder<List<InviteNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() },
@ -42,18 +42,17 @@ class FakeNotificationDataFactory(
) : NotificationDataFactory {
override suspend fun toNotifications(
messages: List<NotifiableMessageEvent>,
currentUser: MatrixUser,
imageLoader: ImageLoader,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<RoomNotification> {
return messageEventToNotificationsResult(messages, currentUser, imageLoader)
return messageEventToNotificationsResult(messages, imageLoader, notificationAccountParams)
}
@JvmName("toNotificationInvites")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(
invites: List<InviteNotifiableEvent>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification> {
return inviteToNotificationsResult(invites)
}
@ -62,7 +61,7 @@ class FakeNotificationDataFactory(
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(
simpleEvents: List<SimpleNotifiableEvent>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification> {
return simpleEventToNotificationsResult(simpleEvents)
}
@ -71,25 +70,24 @@ class FakeNotificationDataFactory(
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(
fallback: List<FallbackNotifiableEvent>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification> {
return fallbackEventToNotificationsResult(fallback)
}
override fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
@ColorInt color: Int,
notificationAccountParams: NotificationAccountParams,
): SummaryNotification {
return summaryToNotificationsResult(
currentUser,
roomNotifications,
invitationNotifications,
simpleNotifications,
fallbackNotifications,
notificationAccountParams,
)
}
}

View file

@ -19,17 +19,17 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
class FakeNotificationDisplayer(
var showNotificationMessageResult: LambdaThreeParamsRecorder<String?, Int, Notification, Boolean> = lambdaRecorder { _, _, _ -> true },
var cancelNotificationMessageResult: LambdaTwoParamsRecorder<String?, Int, Unit> = lambdaRecorder { _, _ -> },
var showNotificationResult: LambdaThreeParamsRecorder<String?, Int, Notification, Boolean> = lambdaRecorder { _, _, _ -> true },
var cancelNotificationResult: LambdaTwoParamsRecorder<String?, Int, Unit> = lambdaRecorder { _, _ -> },
var displayDiagnosticNotificationResult: LambdaOneParamRecorder<Notification, Boolean> = lambdaRecorder { _ -> true },
var dismissDiagnosticNotificationResult: LambdaNoParamRecorder<Unit> = lambdaRecorder { -> },
) : NotificationDisplayer {
override fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean {
return showNotificationMessageResult(tag, id, notification)
override fun showNotification(tag: String?, id: Int, notification: Notification): Boolean {
return showNotificationResult(tag, id, notification)
}
override fun cancelNotificationMessage(tag: String?, id: Int) {
return cancelNotificationMessageResult(tag, id)
override fun cancelNotification(tag: String?, id: Int) {
return cancelNotificationResult(tag, id)
}
override fun displayDiagnosticNotification(notification: Notification): Boolean {
@ -41,7 +41,7 @@ class FakeNotificationDisplayer(
}
fun verifySummaryCancelled(times: Int = 1) {
cancelNotificationMessageResult.assertions().isCalledExactly(times).withSequence(
cancelNotificationResult.assertions().isCalledExactly(times).withSequence(
listOf(value(null), value(NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID)))
)
}

View file

@ -8,12 +8,11 @@
package io.element.android.libraries.push.impl.notifications.fake
import android.app.Notification
import androidx.annotation.ColorInt
import coil3.ImageLoader
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.tests.testutils.lambda.LambdaSixParamsRecorder
@ -22,18 +21,18 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
// We just can't make the param types fit
@Suppress("MaxLineLength", "ktlint:standard:max-line-length", "ktlint:standard:parameter-wrapping")
class FakeRoomGroupMessageCreator(
var createRoomMessageResult: LambdaSixParamsRecorder<MatrixUser, List<NotifiableMessageEvent>, RoomId, ThreadId?, ImageLoader, Notification?, Notification> =
lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION }
var createRoomMessageResult: LambdaSixParamsRecorder<
NotificationAccountParams, List<NotifiableMessageEvent>, RoomId, ThreadId?, ImageLoader, Notification?, Notification
> = lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION }
) : RoomGroupMessageCreator {
override suspend fun createRoomMessage(
currentUser: MatrixUser,
notificationAccountParams: NotificationAccountParams,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
threadId: ThreadId?,
imageLoader: ImageLoader,
existingNotification: Notification?,
@ColorInt color: Int,
): Notification {
return createRoomMessageResult(currentUser, events, roomId, threadId, imageLoader, existingNotification)
return createRoomMessageResult(notificationAccountParams, events, roomId, threadId, imageLoader, existingNotification)
}
}

View file

@ -8,30 +8,28 @@
package io.element.android.libraries.push.impl.notifications.fake
import android.app.Notification
import androidx.annotation.ColorInt
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.OneShotNotification
import io.element.android.libraries.push.impl.notifications.RoomNotification
import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
class FakeSummaryGroupMessageCreator(
var createSummaryNotificationResult: LambdaFiveParamsRecorder<
MatrixUser, List<RoomNotification>, List<OneShotNotification>, List<OneShotNotification>, List<OneShotNotification>, Notification> =
NotificationAccountParams, List<RoomNotification>, List<OneShotNotification>, List<OneShotNotification>, List<OneShotNotification>, Notification> =
lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }
) : SummaryGroupMessageCreator {
override fun createSummaryNotification(
currentUser: MatrixUser,
notificationAccountParams: NotificationAccountParams,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
@ColorInt color: Int,
): Notification {
return createSummaryNotificationResult(
currentUser,
notificationAccountParams,
roomNotifications,
invitationNotifications,
simpleNotifications,

View file

@ -122,7 +122,7 @@ class DefaultOnRedactedEventReceivedTest {
}
)
},
displayer = FakeNotificationDisplayer(showNotificationMessageResult = showNotificationLambda),
displayer = FakeNotificationDisplayer(showNotificationResult = showNotificationLambda),
)
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))

View file

@ -1,45 +0,0 @@
/*
* Copyright 2023, 2024 New Vector 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 android.graphics.Color
import android.graphics.drawable.ColorDrawable
import coil3.ImageLoader
import coil3.test.FakeImageLoaderEngine
import coil3.test.intercept
import org.robolectric.RuntimeEnvironment
class FakeImageLoader {
private val coilRequests = mutableListOf<Any>()
private var cache: ImageLoader? = null
fun getImageLoader(): ImageLoader {
return cache ?: ImageLoader.Builder(RuntimeEnvironment.getApplication())
.components {
val engine = FakeImageLoaderEngine.Builder()
.intercept(
predicate = {
coilRequests.add(it)
true
},
drawable = ColorDrawable(Color.BLUE)
)
.build()
add(engine)
}
.build()
.also {
cache = it
}
}
fun getCoilRequests(): List<Any> {
return coilRequests.toList()
}
}

View file

@ -87,7 +87,7 @@ class RoomSelectPresenter(
query = searchQuery,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
eventSink = { handleEvents(it) }
eventSink = ::handleEvents,
)
}
}

View file

@ -50,6 +50,11 @@ interface SessionStore {
*/
suspend fun getAllSessions(): List<SessionData>
/**
* Get the number of sessions.
*/
suspend fun numberOfSessions(): Int
/**
* Get the latest session, or null if no session exists.
*/

View file

@ -161,6 +161,15 @@ class DatabaseSessionStore(
}
}
override suspend fun numberOfSessions(): Int {
return sessionDataMutex.withLock {
database.sessionDataQueries.count()
.executeAsOneOrNull()
?.toInt()
?: 0
}
}
override fun sessionsFlow(): Flow<List<SessionData>> {
return database.sessionDataQueries.selectAll()
.asFlow()

View file

@ -47,6 +47,9 @@ SELECT * FROM SessionData ORDER BY lastUsageIndex DESC LIMIT 1;
selectAll:
SELECT * FROM SessionData ORDER BY lastUsageIndex DESC;
count:
SELECT count(*) FROM SessionData;
selectByUserId:
SELECT * FROM SessionData WHERE userId = ?;

View file

@ -52,6 +52,7 @@ class DatabaseSessionStoreTest {
assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isEqualTo(aSessionData)
assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1)
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1)
}
@Test
@ -109,6 +110,7 @@ class DatabaseSessionStoreTest {
assertThat(foundSession).isEqualTo(aSessionData)
assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(2)
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(2)
}
@Test
@ -196,12 +198,16 @@ class DatabaseSessionStoreTest {
position = 1,
lastUsageIndex = 1,
)
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1)
databaseSessionStore.addSession(secondSessionData.toApiModel())
assertThat(awaitItem().size).isEqualTo(2)
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(2)
databaseSessionStore.removeSession(aSessionData.userId)
assertThat(awaitItem().size).isEqualTo(1)
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1)
databaseSessionStore.removeSession(secondSessionData.userId)
assertThat(awaitItem()).isEmpty()
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(0)
}
}

View file

@ -67,6 +67,10 @@ class InMemorySessionStore(
return sessionDataListFlow.value
}
override suspend fun numberOfSessions(): Int {
return sessionDataListFlow.value.size
}
override suspend fun getLatestSession(): SessionData? {
return sessionDataListFlow.value.firstOrNull()
}

View file

@ -13,11 +13,11 @@ plugins {
android {
namespace = "io.element.android.libraries.ui.utils"
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.services.toolbox.impl)
testCommonDependencies(libs)
}
}
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.services.toolbox.impl)
testCommonDependencies(libs)
}

View file

@ -84,7 +84,7 @@ class VoiceMessagePresenter(
}
}
fun eventSink(event: VoiceMessageEvents) {
fun handleEvent(event: VoiceMessageEvents) {
when (event) {
is VoiceMessageEvents.PlayPause -> {
if (playerState.isPlaying) {
@ -119,7 +119,7 @@ class VoiceMessagePresenter(
progress = progress,
time = time,
showCursor = showCursor,
eventSink = { eventSink(it) },
eventSink = ::handleEvent,
)
}
}

View file

@ -8,6 +8,5 @@
package io.element.android.libraries.wellknown.api
interface SessionWellknownRetriever {
suspend fun getWellKnown(): WellknownRetrieverResult<WellKnown>
suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown>
}

View file

@ -1,17 +0,0 @@
/*
* Copyright 2023, 2024 New Vector 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.wellknown.api
data class WellKnown(
val homeServer: WellKnownBaseConfig?,
val identityServer: WellKnownBaseConfig?,
)
data class WellKnownBaseConfig(
val baseURL: String?
)

View file

@ -8,6 +8,5 @@
package io.element.android.libraries.wellknown.api
interface WellknownRetriever {
suspend fun getWellKnown(baseUrl: String): WellknownRetrieverResult<WellKnown>
suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown>
}

View file

@ -15,7 +15,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import timber.log.Timber
@ -26,17 +25,6 @@ class DefaultSessionWellknownRetriever(
) : SessionWellknownRetriever {
private val domain by lazy { matrixClient.userIdServerName() }
override suspend fun getWellKnown(): WellknownRetrieverResult<WellKnown> {
val url = "https://$domain/.well-known/matrix/client"
return matrixClient
.getUrl(url)
.mapCatchingExceptions {
val data = String(it)
json().decodeFromString<InternalWellKnown>(data).map()
}
.toWellknownRetrieverResult()
}
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> {
val url = "https://$domain/.well-known/element/element.json"
return matrixClient

View file

@ -13,7 +13,6 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.network.RetrofitFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import retrofit2.HttpException
@ -24,27 +23,6 @@ import java.net.HttpURLConnection
class DefaultWellknownRetriever(
private val retrofitFactory: RetrofitFactory,
) : WellknownRetriever {
override suspend fun getWellKnown(baseUrl: String): WellknownRetrieverResult<WellKnown> {
return buildWellknownApi(baseUrl)
.map { wellknownApi ->
try {
val result = wellknownApi.getWellKnown().map()
WellknownRetrieverResult.Success(result)
} catch (e: Exception) {
Timber.e(e, "Failed to retrieve well-known data for $baseUrl")
if ((e as? HttpException)?.code() == HttpURLConnection.HTTP_NOT_FOUND) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Error(e)
}
}
}
.fold(
onSuccess = { it },
onFailure = { WellknownRetrieverResult.Error(it as Exception) }
)
}
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> {
return buildWellknownApi(baseUrl)
.map { wellknownApi ->

View file

@ -8,8 +8,6 @@
package io.element.android.libraries.wellknown.impl
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.libraries.wellknown.api.WellKnownBaseConfig
internal fun InternalElementWellKnown.map() = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
@ -17,12 +15,3 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown(
rageshakeUrl = rageshakeUrl,
brandColor = brandColor,
)
internal fun InternalWellKnown.map() = WellKnown(
homeServer = homeServer?.map(),
identityServer = identityServer?.map(),
)
internal fun InternalWellKnownBaseConfig.map() = WellKnownBaseConfig(
baseURL = baseURL,
)

View file

@ -12,8 +12,6 @@ import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.libraries.wellknown.api.WellKnownBaseConfig
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@ -21,142 +19,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultSessionWellknownRetrieverTest {
@Test
fun `get empty wellknown`() = runTest {
val getUrlLambda = lambdaRecorder<String, Result<ByteArray>> {
Result.success("{}".toByteArray())
}
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = getUrlLambda,
)
assertThat(sut.getWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
WellKnown(
homeServer = null,
identityServer = null,
)
)
)
getUrlLambda.assertions().isCalledOnce()
.with(value("https://user.domain.org/.well-known/matrix/client"))
}
@Test
fun `get wellknown with full content`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"m.homeserver": {
"base_url": "https://example.org"
},
"m.identity_server": {
"base_url": "https://identity.example.org"
}
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
WellKnown(
homeServer = WellKnownBaseConfig(
baseURL = "https://example.org",
),
identityServer = WellKnownBaseConfig(
baseURL = "https://identity.example.org",
),
)
)
)
}
@Test
fun `get wellknown with full content empty base_url`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"m.homeserver": {
"base_url": "https://example.org"
},
"m.identity_server": {}
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
WellKnown(
homeServer = WellKnownBaseConfig(
baseURL = "https://example.org",
),
identityServer = WellKnownBaseConfig(
baseURL = null,
),
)
)
)
}
@Test
fun `get wellknown with unknown key`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"m.homeserver": {
"base_url": "https://example.org"
},
"m.identity_server": {
"base_url": "https://identity.example.org"
},
"other": true
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
WellKnown(
homeServer = WellKnownBaseConfig(
baseURL = "https://example.org",
),
identityServer = WellKnownBaseConfig(
baseURL = "https://identity.example.org",
),
)
)
)
}
@Test
fun `get wellknown json error`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"m.homeserver": {
"base_url": "https://example.org"
},
error
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get wellknown network error`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.failure(AN_EXCEPTION)
}
)
assertThat(sut.getWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get empty element wellknown`() = runTest {
val getUrlLambda = lambdaRecorder<String, Result<ByteArray>> {

View file

@ -9,18 +9,12 @@ package io.element.android.features.wellknown.test
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.simulateLongTask
class FakeSessionWellknownRetriever(
private val getWellKnownResult: () -> WellknownRetrieverResult<WellKnown> = { WellknownRetrieverResult.NotFound },
private val getElementWellKnownResult: () -> WellknownRetrieverResult<ElementWellKnown> = { WellknownRetrieverResult.NotFound },
) : SessionWellknownRetriever {
override suspend fun getWellKnown(): WellknownRetrieverResult<WellKnown> = simulateLongTask {
getWellKnownResult()
}
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> = simulateLongTask {
getElementWellKnownResult()
}

View file

@ -8,19 +8,13 @@
package io.element.android.features.wellknown.test
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.simulateLongTask
class FakeWellknownRetriever(
private val getWellKnownResult: (String) -> WellknownRetrieverResult<WellKnown> = { WellknownRetrieverResult.NotFound },
private val getElementWellKnownResult: (String) -> WellknownRetrieverResult<ElementWellKnown> = { WellknownRetrieverResult.NotFound },
) : WellknownRetriever {
override suspend fun getWellKnown(baseUrl: String): WellknownRetrieverResult<WellKnown> = simulateLongTask {
getWellKnownResult(baseUrl)
}
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> = simulateLongTask {
getElementWellKnownResult(baseUrl)
}