diff --git a/CHANGES.md b/CHANGES.md index b9fefeaad8..71a53e7e82 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,71 @@ +Changes in Element X v25.04.0 +============================= + + + +## What's Changed +### ✨ Features +* Enable Rust trace log packs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4514 +* Allow using a hardware keyboard to unlock the app using a pin code by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4530 +### 🙌 Improvements +* Change (mention span) : rework and add more cases by @ganfra in https://github.com/element-hq/element-x-android/pull/4476 +* Add kick (remove) confirmation and reason by @bmarty in https://github.com/element-hq/element-x-android/pull/4507 +* Remove the green badge on a pending invite after a first preview by @bmarty in https://github.com/element-hq/element-x-android/pull/4532 +### 🐛 Bugfixes +* Improve touch indicators for the user info UI in the timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4482 +* Limit the text length in the 'in reply to' preview by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4491 +* Timeline header: ensure that the decoration is clickable by @bmarty in https://github.com/element-hq/element-x-android/pull/4495 +* Add video autoplay to media gallery by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4499 +* Add `WakeLock` to dismiss ringing call screen when call is cancelled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4478 +* Make sure the live timeline is destroyed before clearing a room's cache by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4515 +* Fix bullet points not having leading margin on timeline items by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4536 +* Fix the share location URI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4544 +* Add a inderminate progress bar when loging out and in Waiting state. by @bmarty in https://github.com/element-hq/element-x-android/pull/4538 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4506 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4543 +### 🧱 Build +* Element config by @bmarty in https://github.com/element-hq/element-x-android/pull/4471 +* Check if Manifest.permission.REQUEST_INSTALL_PACKAGES is in the manifest by @bmarty in https://github.com/element-hq/element-x-android/pull/4490 +* Remove nightly_enterprise.yml. by @bmarty in https://github.com/element-hq/element-x-android/pull/4492 +* Log the packageId which is currently built. by @bmarty in https://github.com/element-hq/element-x-android/pull/4494 +* Use handy buildConfigFieldStr. by @bmarty in https://github.com/element-hq/element-x-android/pull/4501 +* Fix warnings in InMemoryAppPreferencesStore by @bmarty in https://github.com/element-hq/element-x-android/pull/4523 +### Dependency upgrades +* fix(deps): update camera to v1.4.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4483 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.5 by @renovate in https://github.com/element-hq/element-x-android/pull/4487 +* fix(deps): update dependency com.posthog:posthog-android to v3.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4469 +* fix(deps): update dependency androidx.compose:compose-bom to v2025.03.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4484 +* fix(deps): update dependencyanalysis to v2.13.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4493 +* fix(deps): update media3 to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4488 +* fix(deps): update dependency io.element.android:element-call-embedded to v0.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4498 +* fix(deps): update dependency com.google.firebase:firebase-bom to v33.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4508 +* fix(deps): update dependency com.posthog:posthog-android to v3.13.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4516 +* fix(deps): update dependency io.sentry:sentry-android to v8.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4509 +* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4444 +* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4522 +* fix(deps): update dependencyanalysis to v2.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4527 +* fix(deps): update dependency io.element.android:compound-android to v25.4.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4537 +* chore(deps): update plugin dependencycheck to v12.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4540 +* fix(deps): update appyx to v1.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4547 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.4.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4548 +### Others +* Update screenshots by @bmarty in https://github.com/element-hq/element-x-android/pull/4497 +* Update store description. by @bmarty in https://github.com/element-hq/element-x-android/pull/4496 +* Improve TextFieldDialog by @bmarty in https://github.com/element-hq/element-x-android/pull/4512 +* Make `RustMatrixClient.close` asynchronous by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4513 +* Replace OutlinedTextField by our TextField by @bmarty in https://github.com/element-hq/element-x-android/pull/4521 +* Remove alias from room invite item by @bmarty in https://github.com/element-hq/element-x-android/pull/4531 +* Remember flows by @bmarty in https://github.com/element-hq/element-x-android/pull/4533 +* Use colors from compound for badges by @bmarty in https://github.com/element-hq/element-x-android/pull/4545 +* Update app icon by @bmarty in https://github.com/element-hq/element-x-android/pull/4534 +* Click on userId / room alias to copy value to clipboard. by @bmarty in https://github.com/element-hq/element-x-android/pull/4549 +* Run the 'prevent blocked' workflow even if PR has conflicts by @robintown in https://github.com/element-hq/element-x-android/pull/4432 +* Update wording for push provider support test. (#4079) by @bmarty in https://github.com/element-hq/element-x-android/pull/4553 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.4...v25.04.0 + Changes in Element X v25.03.4 ============================= diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index dbf7ab6c6a..abcdca12cc 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -70,6 +70,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.MAIN_SPACE import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -378,6 +379,14 @@ class LoggedInFlowNode @AssistedInject constructor( override fun onOpenRoomNotificationSettings(roomId: RoomId) { backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings)) } + + override fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId) { + // We do not check the sessionId, but it will have to be done at some point (multi account) + if (sessionId != matrixClient.sessionId) { + Timber.e("SessionId mismatch, expected ${matrixClient.sessionId} but got $sessionId") + } + backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Messages(eventId))) + } } val inputs = PreferencesEntryPoint.Params(navTarget.initialElement) preferencesEntryPoint.nodeBuilder(this, buildContext) diff --git a/fastlane/metadata/android/en-US/changelogs/202504010.txt b/fastlane/metadata/android/en-US/changelogs/202504010.txt new file mode 100644 index 0000000000..8955ade680 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202504010.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt index 3accc163b0..38295daa27 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt @@ -12,36 +12,25 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile -import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.hash.hash -import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.di.SingleIn -import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.user.CurrentSessionIdHolder import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import javax.inject.Inject private val seenInvitesKey = stringSetPreferencesKey("seenInvites") -@SingleIn(SessionScope::class) -@ContributesBinding(SessionScope::class) -class DefaultSeenInvitesStore @Inject constructor( - @ApplicationContext context: Context, - currentSessionIdHolder: CurrentSessionIdHolder, - @SessionCoroutineScope sessionCoroutineScope: CoroutineScope, +class DefaultSeenInvitesStore( + context: Context, + sessionId: SessionId, + sessionCoroutineScope: CoroutineScope, sessionObserver: SessionObserver, ) : SeenInvitesStore { - private val sessionId: SessionId = currentSessionIdHolder.current - init { sessionObserver.addListener(object : SessionListener { override suspend fun onSessionCreated(userId: String) = Unit diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStoreFactory.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStoreFactory.kt new file mode 100644 index 0000000000..256214e5d2 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStoreFactory.kt @@ -0,0 +1,44 @@ +/* + * 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.features.invite.impl + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import kotlinx.coroutines.CoroutineScope +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultSeenInvitesStoreFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val sessionObserver: SessionObserver, +) : SeenInvitesStoreFactory { + // We can have only one class accessing a single data store, so keep a cache of them. + private val cache = ConcurrentHashMap() + + override fun getOrCreate( + sessionId: SessionId, + sessionCoroutineScope: CoroutineScope, + ): SeenInvitesStore { + return cache.getOrPut(sessionId) { + DefaultSeenInvitesStore( + context = context, + sessionId = sessionId, + sessionCoroutineScope = sessionCoroutineScope, + sessionObserver = sessionObserver, + ) + } + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/SeenInvitesStoreFactory.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/SeenInvitesStoreFactory.kt new file mode 100644 index 0000000000..4d681e8462 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/SeenInvitesStoreFactory.kt @@ -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.features.invite.impl + +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.CoroutineScope + +interface SeenInvitesStoreFactory { + fun getOrCreate( + sessionId: SessionId, + sessionCoroutineScope: CoroutineScope, + ): SeenInvitesStore +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt index 6a117b621b..a9308ca0fc 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt @@ -10,14 +10,31 @@ package io.element.android.features.invite.impl.di import com.squareup.anvil.annotations.ContributesTo import dagger.Binds import dagger.Module +import dagger.Provides +import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.impl.SeenInvitesStoreFactory import io.element.android.features.invite.impl.response.AcceptDeclineInvitePresenter import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient @ContributesTo(SessionScope::class) @Module interface InviteModule { @Binds fun bindAcceptDeclinePresenter(presenter: AcceptDeclineInvitePresenter): Presenter + + companion object { + @Provides + fun providesSeenInvitesStore( + factory: SeenInvitesStoreFactory, + matrixClient: MatrixClient, + ): SeenInvitesStore { + return factory.getOrCreate( + matrixClient.sessionId, + matrixClient.sessionCoroutineScope, + ) + } + } } diff --git a/features/joinroom/impl/build.gradle.kts b/features/joinroom/impl/build.gradle.kts index ecf3843674..983ae33ce3 100644 --- a/features/joinroom/impl/build.gradle.kts +++ b/features/joinroom/impl/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(projects.features.invite.api) implementation(projects.features.roomdirectory.api) implementation(projects.services.analytics.api) + implementation(projects.libraries.preferences.api) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) @@ -46,5 +47,6 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.tests.testutils) testImplementation(libs.androidx.compose.ui.test.junit) + testImplementation(projects.libraries.preferences.test) testReleaseImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt index 9914afbded..53f33be16a 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt @@ -52,6 +52,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo import io.element.android.libraries.matrix.ui.model.toInviteSender +import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.util.Optional @@ -69,6 +70,7 @@ class JoinRoomPresenter @AssistedInject constructor( private val forgetRoom: ForgetRoom, private val acceptDeclineInvitePresenter: Presenter, private val buildMeta: BuildMeta, + private val appPreferencesStore: AppPreferencesStore, private val seenInvitesStore: SeenInvitesStore, ) : Presenter { interface Factory { @@ -94,6 +96,9 @@ class JoinRoomPresenter @AssistedInject constructor( val forgetRoomAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } var knockMessage by rememberSaveable { mutableStateOf("") } var isDismissingContent by remember { mutableStateOf(false) } + val hideInviteAvatars by remember { + appPreferencesStore.getHideInviteAvatarsFlow() + }.collectAsState(initial = false) val contentState by produceState( initialValue = ContentState.Loading, key1 = roomInfo, @@ -202,6 +207,7 @@ class JoinRoomPresenter @AssistedInject constructor( cancelKnockAction = cancelKnockAction.value, applicationName = buildMeta.applicationName, knockMessage = knockMessage, + hideInviteAvatars = hideInviteAvatars, eventSink = ::handleEvents ) } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt index 7162aea414..df2a5fcf8f 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt @@ -31,6 +31,7 @@ data class JoinRoomState( val cancelKnockAction: AsyncAction, private val applicationName: String, val knockMessage: String, + val hideInviteAvatars: Boolean, val eventSink: (JoinRoomEvents) -> Unit ) { val isJoinActionUnauthorized = joinAction is AsyncAction.Failure && joinAction.error is JoinRoomFailures.UnauthorizedJoin @@ -57,6 +58,8 @@ data class JoinRoomState( } else -> JoinAuthorisationStatus.None } + + val hideAvatarsImages = hideInviteAvatars && joinAuthorisationStatus is JoinAuthorisationStatus.IsInvited } @Immutable diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt index 3ebf021cc1..e21794b46d 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt @@ -171,6 +171,7 @@ fun aJoinRoomState( forgetAction: AsyncAction = AsyncAction.Uninitialized, cancelKnockAction: AsyncAction = AsyncAction.Uninitialized, knockMessage: String = "", + hideInviteAvatars: Boolean = false, eventSink: (JoinRoomEvents) -> Unit = {} ) = JoinRoomState( roomIdOrAlias = roomIdOrAlias, @@ -182,6 +183,7 @@ fun aJoinRoomState( forgetAction = forgetAction, applicationName = "AppName", knockMessage = knockMessage, + hideInviteAvatars = hideInviteAvatars, eventSink = eventSink ) diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt index 0e8c3e3581..8aae4a029b 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt @@ -97,6 +97,7 @@ fun JoinRoomView( roomIdOrAlias = state.roomIdOrAlias, contentState = state.contentState, knockMessage = state.knockMessage, + hideAvatarsImages = state.hideAvatarsImages, onKnockMessageUpdate = { state.eventSink(JoinRoomEvents.UpdateKnockMessage(it)) }, ) }, @@ -371,6 +372,7 @@ private fun JoinRoomContent( roomIdOrAlias: RoomIdOrAlias, contentState: ContentState, knockMessage: String, + hideAvatarsImages: Boolean, onKnockMessageUpdate: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -385,13 +387,14 @@ private fun JoinRoomContent( Column(horizontalAlignment = Alignment.CenterHorizontally) { val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender if (inviteSender != null) { - InviteSenderView(inviteSender = inviteSender) + InviteSenderView(inviteSender = inviteSender, hideAvatarImage = hideAvatarsImages) Spacer(modifier = Modifier.height(32.dp)) } DefaultLoadedContent( modifier = Modifier.verticalScroll(rememberScrollState()), contentState = contentState, knockMessage = knockMessage, + hideAvatarImage = hideAvatarsImages, onKnockMessageUpdate = onKnockMessageUpdate ) } @@ -474,13 +477,14 @@ private fun IsKnockedLoadedContent(modifier: Modifier = Modifier) { private fun DefaultLoadedContent( contentState: ContentState.Loaded, knockMessage: String, + hideAvatarImage: Boolean, onKnockMessageUpdate: (String) -> Unit, modifier: Modifier = Modifier, ) { RoomPreviewOrganism( modifier = modifier, avatar = { - Avatar(contentState.avatarData(AvatarSize.RoomHeader)) + Avatar(contentState.avatarData(AvatarSize.RoomHeader), hideImage = hideAvatarImage) }, title = { if (contentState.name != null) { diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt index 6a9bd559af..90e22b70bc 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.preferences.api.store.AppPreferencesStore import java.util.Optional @Module @@ -36,6 +37,7 @@ object JoinRoomModule { forgetRoom: ForgetRoom, acceptDeclineInvitePresenter: Presenter, buildMeta: BuildMeta, + appPreferencesStore: AppPreferencesStore, seenInvitesStore: SeenInvitesStore, ): JoinRoomPresenter.Factory { return object : JoinRoomPresenter.Factory { @@ -59,6 +61,7 @@ object JoinRoomModule { cancelKnockRoom = cancelKnockRoom, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, buildMeta = buildMeta, + appPreferencesStore = appPreferencesStore, seenInvitesStore = seenInvitesStore, ) } diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt index b226d53706..22f7796a5b 100644 --- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt @@ -47,6 +47,8 @@ import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.ui.model.toInviteSender +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.assert @@ -768,6 +770,7 @@ class JoinRoomPresenterTest { forgetRoom: ForgetRoom = FakeForgetRoom(), buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"), acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() }, + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), ): JoinRoomPresenter { return JoinRoomPresenter( @@ -783,6 +786,7 @@ class JoinRoomPresenterTest { forgetRoom = forgetRoom, buildMeta = buildMeta, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, + appPreferencesStore = appPreferencesStore, seenInvitesStore = seenInvitesStore, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt index e4283b1a02..b04ee1f176 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt @@ -16,25 +16,32 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.api.media.isPreviewEnabled +import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.collections.immutable.toImmutableSet import javax.inject.Inject class TimelineProtectionPresenter @Inject constructor( private val appPreferencesStore: AppPreferencesStore, + private val room: MatrixRoom, ) : Presenter { + private val allowedEvents = mutableStateOf>(setOf()) + @Composable override fun present(): TimelineProtectionState { - val hideMediaContent by remember { - appPreferencesStore.doesHideImagesAndVideosFlow() - }.collectAsState(initial = false) - var allowedEvents by remember { mutableStateOf>(setOf()) } - val protectionState by remember(hideMediaContent) { + val mediaPreviewValue = remember { + appPreferencesStore.getTimelineMediaPreviewValueFlow() + }.collectAsState(initial = MediaPreviewValue.On) + val roomInfo = room.roomInfoFlow.collectAsState() + val protectionState by remember { derivedStateOf { - if (hideMediaContent) { - ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet()) - } else { + val isPreviewEnabled = mediaPreviewValue.value.isPreviewEnabled(roomInfo.value.joinRule) + if (isPreviewEnabled) { ProtectionState.RenderAll + } else { + ProtectionState.RenderOnly(eventIds = allowedEvents.value.toImmutableSet()) } } } @@ -42,7 +49,7 @@ class TimelineProtectionPresenter @Inject constructor( fun handleEvent(event: TimelineProtectionEvent) { when (event) { is TimelineProtectionEvent.ShowContent -> { - allowedEvents = allowedEvents + setOfNotNull(event.eventId) + allowedEvents.value = allowedEvents.value + setOfNotNull(event.eventId) } } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt index b55d9313af..6da5b38185 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt @@ -8,7 +8,12 @@ package io.element.android.features.messages.impl.timeline.protection import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule @@ -32,8 +37,8 @@ class TimelineProtectionPresenterTest { } @Test - fun `present - protected`() = runTest { - val appPreferencesStore = InMemoryAppPreferencesStore(hideImagesAndVideos = true) + fun `present - media preview value off`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Off) val presenter = createPresenter(appPreferencesStore) presenter.test { skipItems(1) @@ -47,9 +52,42 @@ class TimelineProtectionPresenterTest { } } + @Test + fun `present - media preview value private in public room`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Private) + val room = FakeMatrixRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Public)) + val presenter = createPresenter(appPreferencesStore, room) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf())) + // ShowContent with null should have no effect. + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = null)) + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID))) + } + } + + @Test + fun `present - media preview value private in non public room`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Private) + val room = FakeMatrixRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite)) + val presenter = createPresenter(appPreferencesStore, room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderAll) + // ShowContent with null should have no effect. + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = null)) + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID)) + } + } + private fun createPresenter( appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + room: MatrixRoom = FakeMatrixRoom(), ) = TimelineProtectionPresenter( appPreferencesStore = appPreferencesStore, + room = room, ) } diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index 293124d9fe..f41d497b18 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -13,7 +13,9 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.parcelize.Parcelize interface PreferencesEntryPoint : FeatureEntryPoint { @@ -29,6 +31,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint { } data class Params(val initialElement: InitialTarget) : NodeInputs + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder interface NodeBuilder { @@ -41,5 +44,6 @@ interface PreferencesEntryPoint : FeatureEntryPoint { fun onOpenBugReport() fun onSecureBackupClick() fun onOpenRoomNotificationSettings(roomId: RoomId) + fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 27987e7960..234b4c683b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -39,9 +39,12 @@ import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.appyx.canPop import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.EventId 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.user.MatrixUser import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint +import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint import kotlinx.parcelize.Parcelize @ContributesNode(SessionScope::class) @@ -50,6 +53,7 @@ class PreferencesFlowNode @AssistedInject constructor( @Assisted plugins: List, private val lockScreenEntryPoint: LockScreenEntryPoint, private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint, + private val pushHistoryEntryPoint: PushHistoryEntryPoint, private val logoutEntryPoint: LogoutEntryPoint, private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint, private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint, @@ -83,6 +87,9 @@ class PreferencesFlowNode @AssistedInject constructor( @Parcelize data object TroubleshootNotifications : NavTarget + @Parcelize + data object PushHistory : NavTarget + @Parcelize data object LockScreenSettings : NavTarget @@ -182,6 +189,10 @@ class PreferencesFlowNode @AssistedInject constructor( override fun onTroubleshootNotificationsClick() { backstack.push(NavTarget.TroubleshootNotifications) } + + override fun onPushHistoryClick() { + backstack.push(NavTarget.PushHistory) + } } createNode(buildContext, listOf(notificationSettingsCallback)) } @@ -198,6 +209,23 @@ class PreferencesFlowNode @AssistedInject constructor( }) .build() } + NavTarget.PushHistory -> { + pushHistoryEntryPoint.nodeBuilder(this, buildContext) + .callback(object : PushHistoryEntryPoint.Callback { + override fun onDone() { + if (backstack.canPop()) { + backstack.pop() + } else { + navigateUp() + } + } + + override fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) { + plugins().forEach { it.navigateTo(sessionId, roomId, eventId) } + } + }) + .build() + } is NavTarget.EditDefaultNotificationSetting -> { val callback = object : EditDefaultNotificationSettingNode.Callback { override fun openRoomNotificationSettings(roomId: RoomId) { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt index 45d7ae615b..fabaf7afc5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -8,6 +8,7 @@ package io.element.android.features.preferences.impl.advanced import io.element.android.compound.theme.Theme +import io.element.android.libraries.matrix.api.media.MediaPreviewValue sealed interface AdvancedSettingsEvents { data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents @@ -16,4 +17,6 @@ sealed interface AdvancedSettingsEvents { data object ChangeTheme : AdvancedSettingsEvents data object CancelChangeTheme : AdvancedSettingsEvents data class SetTheme(val theme: Theme) : AdvancedSettingsEvents + data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents + data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index 80f9a98b0c..065c6fc553 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue import io.element.android.compound.theme.Theme import io.element.android.compound.theme.mapToTheme import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import kotlinx.coroutines.launch @@ -43,6 +44,14 @@ class AdvancedSettingsPresenter @Inject constructor( }.collectAsState(initial = Theme.System) var showChangeThemeDialog by remember { mutableStateOf(false) } + val hideInviteAvatars by remember { + appPreferencesStore.getHideInviteAvatarsFlow() + }.collectAsState(false) + + val timelineMediaPreviewValue by remember { + appPreferencesStore.getTimelineMediaPreviewValueFlow() + }.collectAsState(initial = MediaPreviewValue.On) + fun handleEvents(event: AdvancedSettingsEvents) { when (event) { is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { @@ -60,6 +69,12 @@ class AdvancedSettingsPresenter @Inject constructor( appPreferencesStore.setTheme(event.theme.name) showChangeThemeDialog = false } + is AdvancedSettingsEvents.SetHideInviteAvatars -> localCoroutineScope.launch { + appPreferencesStore.setHideInviteAvatars(event.value) + } + is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> localCoroutineScope.launch { + appPreferencesStore.setTimelineMediaPreviewValue(event.value) + } } } @@ -69,6 +84,8 @@ class AdvancedSettingsPresenter @Inject constructor( doesCompressMedia = doesCompressMedia, theme = theme, showChangeThemeDialog = showChangeThemeDialog, + hideInviteAvatars = hideInviteAvatars, + timelineMediaPreviewValue = timelineMediaPreviewValue, eventSink = { handleEvents(it) } ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index a202ccc95f..9f55036154 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -8,6 +8,7 @@ package io.element.android.features.preferences.impl.advanced import io.element.android.compound.theme.Theme +import io.element.android.libraries.matrix.api.media.MediaPreviewValue data class AdvancedSettingsState( val isDeveloperModeEnabled: Boolean, @@ -15,5 +16,7 @@ data class AdvancedSettingsState( val doesCompressMedia: Boolean, val theme: Theme, val showChangeThemeDialog: Boolean, + val hideInviteAvatars: Boolean, + val timelineMediaPreviewValue: MediaPreviewValue, val eventSink: (AdvancedSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index d8e0730bf0..19e4b8b26a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.compound.theme.Theme +import io.element.android.libraries.matrix.api.media.MediaPreviewValue open class AdvancedSettingsStateProvider : PreviewParameterProvider { override val values: Sequence @@ -18,6 +19,8 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider Unit = {}, ) = AdvancedSettingsState( isDeveloperModeEnabled = isDeveloperModeEnabled, @@ -33,5 +38,7 @@ fun aAdvancedSettingsState( doesCompressMedia = doesCompressMedia, theme = Theme.System, showChangeThemeDialog = showChangeThemeDialog, + hideInviteAvatars = hideInviteAvatars, + timelineMediaPreviewValue = timelineMediaPreviewValue, eventSink = eventSink ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt index 0332e98a95..dc26487f91 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt @@ -15,14 +15,22 @@ import im.vector.app.features.analytics.plan.Interaction import io.element.android.compound.theme.Theme import io.element.android.compound.theme.themes import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.components.dialogs.ListOption import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferencePage -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader +import io.element.android.libraries.designsystem.theme.components.ListSupportingText +import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.compose.LocalAnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @@ -98,6 +106,7 @@ fun AdvancedSettingsView( state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue)) } ) + ModerationAndSafety(state) } if (state.showChangeThemeDialog) { @@ -116,6 +125,57 @@ fun AdvancedSettingsView( } } +@Composable +private fun ModerationAndSafety( + state: AdvancedSettingsState, + modifier: Modifier = Modifier, +) { + PreferenceCategory( + modifier = modifier, + title = stringResource(R.string.screen_advanced_settings_moderation_and_safety_section_title), + showTopDivider = true + ) { + PreferenceSwitch( + title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title), + isChecked = state.hideInviteAvatars, + onCheckedChange = { + state.eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(it)) + }, + ) + ListSectionHeader( + title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title), + hasDivider = false, + description = { + ListSupportingText( + text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle), + contentPadding = ListSupportingTextDefaults.Padding.None, + ) + } + ) + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) }, + leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Off, compact = true), + onClick = { + state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + }, + ) + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) }, + leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Private, compact = true), + onClick = { + state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + }, + ) + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) }, + leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.On, compact = true), + onClick = { + state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) + }, + ) + } +} + @Composable private fun getOptions(): ImmutableList { return themes.map { @@ -134,9 +194,21 @@ private fun Theme.toHumanReadable(): String { ) } -@PreviewsDayNight +@PreviewWithLargeHeight @Composable -internal fun AdvancedSettingsViewPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) = - ElementPreview { - AdvancedSettingsView(state = state, onBackClick = { }) - } +internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) = + ElementPreviewLight { ContentToPreview(state) } + +@PreviewWithLargeHeight +@Composable +internal fun AdvancedSettingsViewDarkPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) = + ElementPreviewDark { ContentToPreview(state) } + +@ExcludeFromCoverage +@Composable +private fun ContentToPreview(state: AdvancedSettingsState) { + AdvancedSettingsView( + state = state, + onBackClick = { } + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt index 74c673bc8a..ced7b8d2b4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt @@ -14,7 +14,6 @@ import io.element.android.libraries.matrix.api.tracing.TraceLogPack sealed interface DeveloperSettingsEvents { data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents - data class SetHideImagesAndVideos(val value: Boolean) : DeveloperSettingsEvents data class SetTracingLogLevel(val logLevel: LogLevelItem) : DeveloperSettingsEvents data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : DeveloperSettingsEvents data object ClearCache : DeveloperSettingsEvents diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index 5159983a6e..cc4ae25eba 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -75,10 +75,6 @@ class DeveloperSettingsPresenter @Inject constructor( appPreferencesStore .getCustomElementCallBaseUrlFlow() }.collectAsState(initial = null) - val hideImagesAndVideos by remember { - appPreferencesStore - .doesHideImagesAndVideosFlow() - }.collectAsState(initial = false) val tracingLogLevelFlow = remember { appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) } @@ -128,9 +124,6 @@ class DeveloperSettingsPresenter @Inject constructor( appPreferencesStore.setCustomElementCallBaseUrl(urlToSave) } DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction) - is DeveloperSettingsEvents.SetHideImagesAndVideos -> coroutineScope.launch { - appPreferencesStore.setHideImagesAndVideos(event.value) - } is DeveloperSettingsEvents.SetTracingLogLevel -> coroutineScope.launch { appPreferencesStore.setTracingLogLevel(event.logLevel.toLogLevel()) } @@ -155,7 +148,6 @@ class DeveloperSettingsPresenter @Inject constructor( baseUrl = customElementCallBaseUrl, validator = ::customElementCallUrlValidator, ), - hideImagesAndVideos = hideImagesAndVideos, tracingLogLevel = tracingLogLevel, tracingLogPacks = tracingLogPacks, eventSink = ::handleEvents diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index efcfcd01d4..93e7b9ae7b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -21,7 +21,6 @@ data class DeveloperSettingsState( val rageshakeState: RageshakePreferencesState, val clearCacheAction: AsyncAction, val customElementCallBaseUrlState: CustomElementCallBaseUrlState, - val hideImagesAndVideos: Boolean, val tracingLogLevel: AsyncData, val tracingLogPacks: ImmutableList, val eventSink: (DeveloperSettingsEvents) -> Unit diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt index 18151d32c7..1585a3b8dd 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -34,7 +34,6 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized, customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(), - hideImagesAndVideos: Boolean = false, traceLogPacks: List = emptyList(), eventSink: (DeveloperSettingsEvents) -> Unit = {}, ) = DeveloperSettingsState( @@ -43,7 +42,6 @@ fun aDeveloperSettingsState( cacheSize = AsyncData.Success("1.2 MB"), clearCacheAction = clearCacheAction, customElementCallBaseUrlState = customElementCallBaseUrlState, - hideImagesAndVideos = hideImagesAndVideos, tracingLogLevel = AsyncData.Success(LogLevelItem.INFO), tracingLogPacks = traceLogPacks.toPersistentList(), eventSink = eventSink, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index e335c0a6cd..9a9cc61d68 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -51,7 +51,6 @@ fun DeveloperSettingsView( title = stringResource(id = CommonStrings.common_developer_options) ) { // Note: this is OK to hardcode strings in this debug screen. - SettingsCategory(state) PreferenceCategory( title = "Feature flags", showTopDivider = true, @@ -134,22 +133,6 @@ fun DeveloperSettingsView( } } -@Composable -private fun SettingsCategory( - state: DeveloperSettingsState, -) { - PreferenceCategory(title = "Preferences", showTopDivider = false) { - PreferenceSwitch( - title = "Hide image & video previews", - subtitle = "When toggled image & video will not render in the timeline by default.", - isChecked = state.hideImagesAndVideos, - onCheckedChange = { - state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(it)) - } - ) - } -} - @Composable private fun ElementCallCategory( state: DeveloperSettingsState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt index 15a6afcd89..75f38f60ee 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt @@ -27,6 +27,7 @@ class NotificationSettingsNode @AssistedInject constructor( interface Callback : Plugin { fun editDefaultNotificationMode(isOneToOne: Boolean) fun onTroubleshootNotificationsClick() + fun onPushHistoryClick() } private val callbacks = plugins() @@ -39,6 +40,10 @@ class NotificationSettingsNode @AssistedInject constructor( callbacks.forEach { it.onTroubleshootNotificationsClick() } } + private fun onPushHistoryClick() { + callbacks.forEach { it.onPushHistoryClick() } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() @@ -47,6 +52,7 @@ class NotificationSettingsNode @AssistedInject constructor( onOpenEditDefault = { openEditDefault(isOneToOne = it) }, onBackClick = ::navigateUp, onTroubleshootNotificationsClick = ::onTroubleshootNotificationsClick, + onPushHistoryClick = ::onPushHistoryClick, modifier = modifier, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt index 1828e75374..8f2543c161 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt @@ -50,6 +50,7 @@ fun NotificationSettingsView( state: NotificationSettingsState, onOpenEditDefault: (isOneToOne: Boolean) -> Unit, onTroubleshootNotificationsClick: () -> Unit, + onPushHistoryClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -82,6 +83,7 @@ fun NotificationSettingsView( // onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) }, onInviteForMeNotificationsChange = { state.eventSink(NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(it)) }, onTroubleshootNotificationsClick = onTroubleshootNotificationsClick, + onPushHistoryClick = onPushHistoryClick, ) } AsyncActionView( @@ -105,6 +107,7 @@ private fun NotificationSettingsContentView( // onCallsNotificationsChanged: (Boolean) -> Unit, onInviteForMeNotificationsChange: (Boolean) -> Unit, onTroubleshootNotificationsClick: () -> Unit, + onPushHistoryClick: () -> Unit, ) { val context = LocalContext.current val systemSettings: NotificationSettingsState.AppSettings = state.appSettings @@ -203,6 +206,12 @@ private fun NotificationSettingsContentView( }, onClick = onTroubleshootNotificationsClick ) + ListItem( + headlineContent = { + Text(stringResource(R.string.troubleshoot_notifications_entry_point_push_history_title)) + }, + onClick = onPushHistoryClick + ) } if (state.showAdvancedSettings) { PreferenceCategory(title = stringResource(id = CommonStrings.common_advanced_settings)) { @@ -303,5 +312,6 @@ internal fun NotificationSettingsViewPreview(@PreviewParameter(NotificationSetti onBackClick = {}, onOpenEditDefault = {}, onTroubleshootNotificationsClick = {}, + onPushHistoryClick = {}, ) } diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index 989910384c..520a2a9e18 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -19,6 +19,11 @@ "If turned off, your read receipts won\'t be sent to anyone. You will still receive read receipts from other users." "Share presence" "If turned off, you won’t be able to send or receive read receipts or typing notifications." + "Always hide" + "Always show" + "In private rooms" + "A hidden media can always be shown by tapping on it" + "Show media in timeline" "Enable option to view message source in the timeline." "You have no blocked users" "Unblock" @@ -58,6 +63,7 @@ If you proceed, some of your settings may change." "system settings" "System notifications turned off" "Notifications" + "Push history" "Troubleshoot" "Troubleshoot notifications" diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index c2b4672878..cf372fdd9d 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -44,7 +44,6 @@ class DeveloperSettingsPresenterTest { assertThat(state.cacheSize).isEqualTo(AsyncData.Uninitialized) assertThat(state.customElementCallBaseUrlState).isNotNull() assertThat(state.customElementCallBaseUrlState.baseUrl).isNull() - assertThat(state.hideImagesAndVideos).isFalse() assertThat(state.rageshakeState.isEnabled).isFalse() assertThat(state.rageshakeState.isSupported).isTrue() assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f) @@ -147,28 +146,6 @@ class DeveloperSettingsPresenterTest { } } - @Test - fun `present - toggling hide image and video`() = runTest { - val preferences = InMemoryAppPreferencesStore() - val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences) - presenter.test { - skipItems(2) - awaitItem().also { state -> - assertThat(state.hideImagesAndVideos).isFalse() - state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(true)) - } - awaitItem().also { state -> - assertThat(state.hideImagesAndVideos).isTrue() - assertThat(preferences.doesHideImagesAndVideosFlow().first()).isTrue() - state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(false)) - } - awaitItem().also { state -> - assertThat(state.hideImagesAndVideos).isFalse() - assertThat(preferences.doesHideImagesAndVideosFlow().first()).isFalse() - } - } - } - @Test fun `present - changing tracing log level`() = runTest { val preferences = InMemoryAppPreferencesStore() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt index 255c6442d9..5d0030af8e 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt @@ -109,18 +109,6 @@ class DeveloperSettingsViewTest { rule.onNodeWithText("Clear cache").performClick() eventsRecorder.assertSingle(DeveloperSettingsEvents.ClearCache) } - - @Test - fun `clicking on the hide images and videos switch emits the expected event`() { - val eventsRecorder = EventsRecorder() - rule.setDeveloperSettingsView( - state = aDeveloperSettingsState( - eventSink = eventsRecorder - ), - ) - rule.onNodeWithText("Hide image & video previews").performClick() - eventsRecorder.assertSingle(DeveloperSettingsEvents.SetHideImagesAndVideos(true)) - } } private fun AndroidComposeTestRule.setDeveloperSettingsView( diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt index 2aa485a192..3b2a2663a5 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt @@ -66,6 +66,22 @@ class NotificationSettingsViewTest { eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) } + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on push history notification invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + ensureCalledOnce { + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + eventSink = eventsRecorder + ), + onPushHistoryClick = it + ) + rule.clickOn(R.string.troubleshoot_notifications_entry_point_push_history_title) + } + eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) + } + @Config(qualifiers = "h1024dp") @Test fun `clicking on group chats invokes the expected callback`() { @@ -284,6 +300,7 @@ private fun AndroidComposeTestRule.setNotif state: NotificationSettingsState, onOpenEditDefault: (isOneToOne: Boolean) -> Unit = EnsureNeverCalledWithParam(), onTroubleshootNotificationsClick: () -> Unit = EnsureNeverCalled(), + onPushHistoryClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { @@ -291,6 +308,7 @@ private fun AndroidComposeTestRule.setNotif state = state, onOpenEditDefault = onOpenEditDefault, onTroubleshootNotificationsClick = onTroubleshootNotificationsClick, + onPushHistoryClick = onPushHistoryClick, onBackClick = onBackClick, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 461204b2a2..082a662c30 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -119,6 +119,9 @@ class RoomListPresenter @Inject constructor( // Avatar indicator val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() + val hideInvitesAvatar by remember { + appPreferencesStore.getHideInviteAvatarsFlow() + }.collectAsState(initial = false) val contextMenu = remember { mutableStateOf(RoomListState.ContextMenu.Hidden) } @@ -173,6 +176,7 @@ class RoomListPresenter @Inject constructor( contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, directLogoutState = directLogoutState, + hideInvitesAvatars = hideInvitesAvatar, eventSink = ::handleEvents, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index ae62b88deb..307a2d1313 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -35,6 +35,7 @@ data class RoomListState( val contentState: RoomListContentState, val acceptDeclineInviteState: AcceptDeclineInviteState, val directLogoutState: DirectLogoutState, + val hideInvitesAvatars: Boolean, val eventSink: (RoomListEvents) -> Unit, ) { val displayFilters = contentState is RoomListContentState.Rooms diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index 6b075db9df..1f93e81d67 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -61,6 +61,7 @@ internal fun aRoomListState( contentState: RoomListContentState = aRoomsContentState(), acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), directLogoutState: DirectLogoutState = aDirectLogoutState(), + hideInvitesAvatars: Boolean = false, eventSink: (RoomListEvents) -> Unit = {} ) = RoomListState( matrixUser = matrixUser, @@ -75,6 +76,7 @@ internal fun aRoomListState( contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, directLogoutState = directLogoutState, + hideInvitesAvatars = hideInvitesAvatars, eventSink = eventSink, ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 64ec1f8406..090180a7b8 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -81,6 +81,7 @@ fun RoomListView( RoomListSearchView( state = state.searchState, eventSink = state.eventSink, + hideInvitesAvatars = state.hideInvitesAvatars, onRoomClick = onRoomClick, modifier = Modifier .statusBarsPadding() @@ -134,6 +135,7 @@ private fun RoomListScaffold( RoomListContentView( contentState = state.contentState, filtersState = state.filtersState, + hideInvitesAvatars = state.hideInvitesAvatars, eventSink = state.eventSink, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt index acdb762fa7..0118a4fb81 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt @@ -60,6 +60,7 @@ import kotlinx.collections.immutable.ImmutableList fun RoomListContentView( contentState: RoomListContentState, filtersState: RoomListFiltersState, + hideInvitesAvatars: Boolean, eventSink: (RoomListEvents) -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, @@ -86,6 +87,7 @@ fun RoomListContentView( is RoomListContentState.Rooms -> { RoomsView( state = contentState, + hideInvitesAvatars = hideInvitesAvatars, filtersState = filtersState, eventSink = eventSink, onSetUpRecoveryClick = onSetUpRecoveryClick, @@ -156,6 +158,7 @@ private fun EmptyView( @Composable private fun RoomsView( state: RoomListContentState.Rooms, + hideInvitesAvatars: Boolean, filtersState: RoomListFiltersState, eventSink: (RoomListEvents) -> Unit, onSetUpRecoveryClick: () -> Unit, @@ -171,6 +174,7 @@ private fun RoomsView( } else { RoomsViewList( state = state, + hideInvitesAvatars = hideInvitesAvatars, eventSink = eventSink, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, @@ -183,6 +187,7 @@ private fun RoomsView( @Composable private fun RoomsViewList( state: RoomListContentState.Rooms, + hideInvitesAvatars: Boolean, eventSink: (RoomListEvents) -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, @@ -240,6 +245,7 @@ private fun RoomsViewList( ) { index, room -> RoomSummaryRow( room = room, + hideInviteAvatars = hideInvitesAvatars, isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE && state.seenRoomInvites.contains(room.roomId), onClick = onRoomClick, @@ -303,6 +309,7 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr filtersState = aRoomListFiltersState( filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) } ), + hideInvitesAvatars = false, eventSink = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt index bfa255af13..9f7a67bd00 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt @@ -68,6 +68,7 @@ internal val minHeight = 84.dp @Composable internal fun RoomSummaryRow( room: RoomListRoomSummary, + hideInviteAvatars: Boolean, isInviteSeen: Boolean, onClick: (RoomListRoomSummary) -> Unit, eventSink: (RoomListEvents) -> Unit, @@ -81,6 +82,7 @@ internal fun RoomSummaryRow( RoomSummaryDisplayType.INVITE -> { RoomSummaryScaffoldRow( room = room, + hideAvatarImage = hideInviteAvatars, onClick = onClick, onLongClick = { Timber.d("Long click on invite room") @@ -93,6 +95,7 @@ internal fun RoomSummaryRow( InviteSenderView( modifier = Modifier.fillMaxWidth(), inviteSender = room.inviteSender, + hideAvatarImage = hideInviteAvatars ) } Spacer(modifier = Modifier.height(12.dp)) @@ -165,6 +168,7 @@ private fun RoomSummaryScaffoldRow( onClick: (RoomListRoomSummary) -> Unit, onLongClick: (RoomListRoomSummary) -> Unit, modifier: Modifier = Modifier, + hideAvatarImage: Boolean = false, content: @Composable ColumnScope.() -> Unit ) { val clickModifier = Modifier.combinedClickable( @@ -185,6 +189,7 @@ private fun RoomSummaryScaffoldRow( CompositeAvatar( avatarData = room.avatarData, heroes = room.heroes, + hideAvatarImages = hideAvatarImage, ) Spacer(modifier = Modifier.width(16.dp)) Column( @@ -388,6 +393,7 @@ private fun MentionIndicatorAtom() { internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = ElementPreview { RoomSummaryRow( room = data, + hideInviteAvatars = false, // Set isInviteSeen to true for the preview when the room has name "Bob" isInviteSeen = data.name == "Bob", onClick = {}, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt index 4f6e783704..bfb2011dd5 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt @@ -54,6 +54,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable internal fun RoomListSearchView( state: RoomListSearchState, + hideInvitesAvatars: Boolean, eventSink: (RoomListEvents) -> Unit, onRoomClick: (RoomId) -> Unit, modifier: Modifier = Modifier, @@ -80,6 +81,7 @@ internal fun RoomListSearchView( if (state.isSearchActive) { RoomListSearchContent( state = state, + hideInvitesAvatars = hideInvitesAvatars, onRoomClick = onRoomClick, eventSink = eventSink, ) @@ -92,6 +94,7 @@ internal fun RoomListSearchView( @Composable private fun RoomListSearchContent( state: RoomListSearchState, + hideInvitesAvatars: Boolean, eventSink: (RoomListEvents) -> Unit, onRoomClick: (RoomId) -> Unit, ) { @@ -173,6 +176,7 @@ private fun RoomListSearchContent( ) { room -> RoomSummaryRow( room = room, + hideInviteAvatars = hideInvitesAvatars, // TODO isInviteSeen = false, onClick = ::onRoomClick, @@ -189,6 +193,7 @@ private fun RoomListSearchContent( internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview { RoomListSearchContent( state = state, + hideInvitesAvatars = false, onRoomClick = {}, eventSink = {}, ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c5bde3385a..e7af576f45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ android_gradle_plugin = "8.9.1" kotlin = "2.1.20" kotlinpoet = "2.1.0" -ksp = "2.1.20-1.0.32" +ksp = "2.1.20-2.0.0" firebaseAppDistribution = "5.1.1" # AndroidX @@ -29,7 +29,7 @@ compose_bom = "2025.03.01" composecompiler = "1.5.15" # Coroutines -coroutines = "1.10.1" +coroutines = "1.10.2" # Accompanist accompanist = "0.37.2" @@ -50,7 +50,7 @@ wysiwyg = "2.38.3" telephoto = "0.15.1" # Dependency analysis -dependencyAnalysis = "2.14.0" +dependencyAnalysis = "2.16.0" # DI dagger = "2.56.1" @@ -149,7 +149,7 @@ test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" } test_arch_core = "androidx.arch.core:core-testing:2.2.0" test_junit = "junit:junit:4.13.2" test_runner = "androidx.test:runner:1.6.2" -test_mockk = "io.mockk:mockk:1.13.17" +test_mockk = "io.mockk:mockk:1.14.0" test_konsist = "com.lemonappdev:konsist:0.17.3" test_turbine = "app.cash.turbine:turbine:1.2.0" test_truth = "com.google.truth:truth:1.4.4" @@ -188,7 +188,7 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } statemachine = "com.freeletics.flowredux:compose:1.2.2" -maplibre = "org.maplibre.gl:android-sdk:11.8.5" +maplibre = "org.maplibre.gl:android-sdk:11.8.6" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" opusencoder = "io.element.android:opusencoder:1.1.0" diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt index 31dd89b87a..1086fec9d0 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt @@ -48,11 +48,13 @@ fun Avatar( contentDescription: String? = null, // If not null, will be used instead of the size from avatarData forcedAvatarSize: Dp? = null, + // If true, will show initials even if avatarData.url is not null + hideImage: Boolean = false, ) { val commonModifier = modifier .size(forcedAvatarSize ?: avatarData.size.dp) .clip(CircleShape) - if (avatarData.url.isNullOrBlank()) { + if (avatarData.url.isNullOrBlank() || hideImage) { InitialsAvatar( avatarData = avatarData, forcedAvatarSize = forcedAvatarSize, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/CompositeAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/CompositeAvatar.kt index 9e40e08e2a..c696050f23 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/CompositeAvatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/CompositeAvatar.kt @@ -33,10 +33,16 @@ fun CompositeAvatar( avatarData: AvatarData, heroes: ImmutableList, modifier: Modifier = Modifier, + hideAvatarImages: Boolean = false, contentDescription: String? = null, ) { if (avatarData.url != null || heroes.isEmpty()) { - Avatar(avatarData, modifier, contentDescription) + Avatar( + avatarData = avatarData, + modifier = modifier, + contentDescription = contentDescription, + hideImage = hideAvatarImages + ) } else { val limitedHeroes = heroes.take(4) val numberOfHeroes = limitedHeroes.size @@ -49,7 +55,12 @@ fun CompositeAvatar( error("Unsupported number of heroes: 0") } 1 -> { - Avatar(heroes[0], modifier, contentDescription) + Avatar( + avatarData = heroes[0], + modifier = modifier, + contentDescription = contentDescription, + hideImage = hideAvatarImages + ) } else -> { val angle = 2 * Math.PI / numberOfHeroes @@ -91,8 +102,9 @@ fun CompositeAvatar( ) ) { Avatar( - heroAvatar, + avatarData = heroAvatar, forcedAvatarSize = heroAvatarSize, + hideImage = hideAvatarImages, ) } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt new file mode 100644 index 0000000000..83d6d464d5 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt @@ -0,0 +1,39 @@ +/* + * 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.media + +import io.element.android.libraries.matrix.api.media.MediaPreviewValue.Off +import io.element.android.libraries.matrix.api.media.MediaPreviewValue.On +import io.element.android.libraries.matrix.api.media.MediaPreviewValue.Private +import io.element.android.libraries.matrix.api.room.join.JoinRule + +/** + * Represents the values for media preview settings. + * - [On] means that media preview are enabled + * - [Off] means that media preview are disabled + * - [Private] means that media preview are enabled only for private chats. + */ +enum class MediaPreviewValue { + On, + Off, + Private +} + +fun MediaPreviewValue.isPreviewEnabled(joinRule: JoinRule?): Boolean { + return when (this) { + On -> true + Off -> false + Private -> when (joinRule) { + is JoinRule.Knock, + is JoinRule.Invite, + is JoinRule.Restricted, + is JoinRule.KnockRestricted -> true + else -> false + } + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index 7f9e218697..24732f5b65 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -87,3 +87,4 @@ const val A_RECOVERY_KEY = "1234 5678" val A_SERVER_LIST = listOf("server1", "server2") const val A_TIMESTAMP = 567L +const val A_FORMATTED_DATE = "April 6, 1980 at 6:35 PM" diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt index 452f6ed5a2..946c0d51e9 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt @@ -27,14 +27,15 @@ import io.element.android.libraries.matrix.ui.model.InviteSender @Composable fun InviteSenderView( inviteSender: InviteSender, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + hideAvatarImage: Boolean = false, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { Box(modifier = Modifier.padding(vertical = 2.dp)) { - Avatar(avatarData = inviteSender.avatarData) + Avatar(avatarData = inviteSender.avatarData, hideImage = hideAvatarImage) } Text( text = inviteSender.annotatedString(), diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt index c072229626..dfc0e38b89 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.preferences.api.store +import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.TraceLogPack import kotlinx.coroutines.flow.Flow @@ -21,8 +22,11 @@ interface AppPreferencesStore { suspend fun setTheme(theme: String) fun getThemeFlow(): Flow - suspend fun setHideImagesAndVideos(value: Boolean) - fun doesHideImagesAndVideosFlow(): Flow + suspend fun setHideInviteAvatars(value: Boolean) + fun getHideInviteAvatarsFlow(): Flow + + suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) + fun getTimelineMediaPreviewValueFlow(): Flow suspend fun setTracingLogLevel(logLevel: LogLevel) fun getTracingLogLevelFlow(): Flow diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index a05e9c48da..599c73fc9d 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -19,6 +19,7 @@ import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.TraceLogPack import io.element.android.libraries.preferences.api.store.AppPreferencesStore @@ -31,7 +32,8 @@ private val Context.dataStore: DataStore by preferencesDataStore(na private val developerModeKey = booleanPreferencesKey("developerMode") private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl") private val themeKey = stringPreferencesKey("theme") -private val hideImagesAndVideosKey = booleanPreferencesKey("hideImagesAndVideos") +private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars") +private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue") private val logLevelKey = stringPreferencesKey("logLevel") private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") @@ -83,15 +85,27 @@ class DefaultAppPreferencesStore @Inject constructor( } } - override suspend fun setHideImagesAndVideos(value: Boolean) { + override suspend fun setHideInviteAvatars(value: Boolean) { store.edit { prefs -> - prefs[hideImagesAndVideosKey] = value + prefs[hideInviteAvatarsKey] = value } } - override fun doesHideImagesAndVideosFlow(): Flow { + override fun getHideInviteAvatarsFlow(): Flow { return store.data.map { prefs -> - prefs[hideImagesAndVideosKey] ?: false + prefs[hideInviteAvatarsKey] == true + } + } + + override suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) { + store.edit { prefs -> + prefs[timelineMediaPreviewValueKey] = value.name + } + } + + override fun getTimelineMediaPreviewValueFlow(): Flow { + return store.data.map { prefs -> + prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) } ?: MediaPreviewValue.On } } diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index ab3913cd08..c5440a6be9 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.preferences.test +import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.TraceLogPack import io.element.android.libraries.preferences.api.store.AppPreferencesStore @@ -15,18 +16,20 @@ import kotlinx.coroutines.flow.MutableStateFlow class InMemoryAppPreferencesStore( isDeveloperModeEnabled: Boolean = false, - hideImagesAndVideos: Boolean = false, customElementCallBaseUrl: String? = null, + hideInviteAvatars: Boolean = false, + timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, theme: String? = null, logLevel: LogLevel = LogLevel.INFO, traceLockPacks: Set = emptySet(), ) : AppPreferencesStore { private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) - private val hideImagesAndVideos = MutableStateFlow(hideImagesAndVideos) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val theme = MutableStateFlow(theme) private val logLevel = MutableStateFlow(logLevel) private val tracingLogPacks = MutableStateFlow(traceLockPacks) + private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars) + private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue) override suspend fun setDeveloperModeEnabled(enabled: Boolean) { isDeveloperModeEnabled.value = enabled @@ -52,12 +55,20 @@ class InMemoryAppPreferencesStore( return theme } - override suspend fun setHideImagesAndVideos(value: Boolean) { - hideImagesAndVideos.value = value + override suspend fun setHideInviteAvatars(value: Boolean) { + hideInviteAvatars.value = value } - override fun doesHideImagesAndVideosFlow(): Flow { - return hideImagesAndVideos + override fun getHideInviteAvatarsFlow(): Flow { + return hideInviteAvatars + } + + override suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) { + timelineMediaPreviewValue.value = value + } + + override fun getTimelineMediaPreviewValueFlow(): Flow { + return timelineMediaPreviewValue } override suspend fun setTracingLogLevel(logLevel: LogLevel) { diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt index 5a82b31fd9..f9a0496efb 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.push.api import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.history.PushHistoryItem import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider import kotlinx.coroutines.flow.Flow @@ -51,4 +52,19 @@ interface PushService { * Return false in case of early error. */ suspend fun testPush(): Boolean + + /** + * Get a flow of total number of received Push. + */ + val pushCounter: Flow + + /** + * Get a flow of list of [PushHistoryItem]. + */ + fun getPushHistoryItemsFlow(): Flow> + + /** + * Reset the push history, including the push counter. + */ + suspend fun resetPushHistory() } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/history/PushHistoryItem.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/history/PushHistoryItem.kt new file mode 100644 index 0000000000..9606e7e6dc --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/history/PushHistoryItem.kt @@ -0,0 +1,34 @@ +/* + * 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.api.history + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * Data class representing a push history item. + * @property pushDate Date (timestamp). + * @property formattedDate Formatted date. + * @property providerInfo Push provider name / info + * @property eventId EventId from the push, can be null if the received data are not correct. + * @property roomId RoomId from the push, can be null if the received data are not correct. + * @property sessionId The session Id, can be null if the session cannot be retrieved + * @property hasBeenResolved Result of resolving the event + * @property comment Comment. Can contains an error message if the event could not be resolved, or other any information. + */ +data class PushHistoryItem( + val pushDate: Long, + val formattedDate: String, + val providerInfo: String, + val eventId: EventId?, + val roomId: RoomId?, + val sessionId: SessionId?, + val hasBeenResolved: Boolean, + val comment: String?, +) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt deleted file mode 100644 index 2d37548722..0000000000 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt +++ /dev/null @@ -1,14 +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.api.store - -import kotlinx.coroutines.flow.Flow - -interface PushDataStore { - val pushCounterFlow: Flow -} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 1c13a67ae5..dbf3bbcf5a 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -9,6 +9,7 @@ import extension.setupAnvil plugins { id("io.element.android-compose-library") alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.sqldelight) } android { @@ -32,9 +33,16 @@ dependencies { implementation(libs.serialization.json) implementation(libs.coil) + implementation(libs.sqldelight.driver.android) + implementation(libs.sqlcipher) + implementation(libs.sqlite) + implementation(libs.sqldelight.coroutines) + implementation(projects.libraries.encryptedDb) + implementation(projects.appconfig) implementation(projects.libraries.architecture) implementation(projects.libraries.core) + implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.di) implementation(projects.libraries.androidutils) @@ -76,3 +84,11 @@ dependencies { testImplementation(projects.libraries.featureflag.test) testImplementation(libs.kotlinx.collections.immutable) } + +sqldelight { + databases { + create("PushDatabase") { + schemaOutputDirectory = File("src/main/sqldelight/databases") + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt index 87f45e3c1d..498b461110 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt @@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.push.api.GetCurrentPushProvider import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.api.history.PushHistoryItem +import io.element.android.libraries.push.impl.store.PushDataStore import io.element.android.libraries.push.impl.test.TestPush import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider @@ -34,6 +36,7 @@ class DefaultPushService @Inject constructor( private val getCurrentPushProvider: GetCurrentPushProvider, private val sessionObserver: SessionObserver, private val pushClientSecretStore: PushClientSecretStore, + private val pushDataStore: PushDataStore, ) : PushService, SessionListener { init { observeSessions() @@ -125,4 +128,14 @@ class DefaultPushService @Inject constructor( pushClientSecretStore.resetSecret(sessionId) userPushStore.reset() } + + override val pushCounter: Flow = pushDataStore.pushCounterFlow + + override fun getPushHistoryItemsFlow(): Flow> { + return pushDataStore.getPushHistoryItemsFlow() + } + + override suspend fun resetPushHistory() { + pushDataStore.reset() + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt new file mode 100644 index 0000000000..ba0fe826eb --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt @@ -0,0 +1,48 @@ +/* + * 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.history + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.PushDatabase +import io.element.android.libraries.push.impl.db.PushHistory +import io.element.android.services.toolbox.api.systemclock.SystemClock +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultPushHistoryService @Inject constructor( + private val pushDatabase: PushDatabase, + private val systemClock: SystemClock, +) : PushHistoryService { + override fun onPushReceived( + providerInfo: String, + eventId: EventId?, + roomId: RoomId?, + sessionId: SessionId?, + hasBeenResolved: Boolean, + comment: String?, + ) { + pushDatabase.pushHistoryQueries.insertPushHistory( + PushHistory( + pushDate = systemClock.epochMillis(), + providerInfo = providerInfo, + eventId = eventId?.value, + roomId = roomId?.value, + sessionId = sessionId?.value, + hasBeenResolved = if (hasBeenResolved) 1 else 0, + comment = comment, + ) + ) + + // Keep only the last 100 events + pushDatabase.pushHistoryQueries.removeOldest(100) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt new file mode 100644 index 0000000000..b66fd0317b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt @@ -0,0 +1,98 @@ +/* + * 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.history + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +interface PushHistoryService { + /** + * Create a new push history entry. + * Do not use directly, prefer using the extension functions. + */ + fun onPushReceived( + providerInfo: String, + eventId: EventId?, + roomId: RoomId?, + sessionId: SessionId?, + hasBeenResolved: Boolean, + comment: String?, + ) +} + +fun PushHistoryService.onInvalidPushReceived( + providerInfo: String, +) = onPushReceived( + providerInfo = providerInfo, + eventId = null, + roomId = null, + sessionId = null, + hasBeenResolved = false, + comment = "Invalid push data", +) + +fun PushHistoryService.onUnableToRetrieveSession( + providerInfo: String, + eventId: EventId, + roomId: RoomId, + reason: String, +) = onPushReceived( + providerInfo = providerInfo, + eventId = eventId, + roomId = roomId, + sessionId = null, + hasBeenResolved = false, + comment = "Unable to retrieve session: $reason", +) + +fun PushHistoryService.onUnableToResolveEvent( + providerInfo: String, + eventId: EventId, + roomId: RoomId, + sessionId: SessionId, + reason: String, +) = onPushReceived( + providerInfo = providerInfo, + eventId = eventId, + roomId = roomId, + sessionId = sessionId, + hasBeenResolved = false, + comment = "Unable to resolve event: $reason", +) + +fun PushHistoryService.onSuccess( + providerInfo: String, + eventId: EventId, + roomId: RoomId, + sessionId: SessionId, + comment: String?, +) = onPushReceived( + providerInfo = providerInfo, + eventId = eventId, + roomId = roomId, + sessionId = sessionId, + hasBeenResolved = true, + comment = buildString { + append("Success") + if (comment.isNullOrBlank().not()) { + append(" - $comment") + } + }, +) + +fun PushHistoryService.onDiagnosticPush( + providerInfo: String, +) = onPushReceived( + providerInfo = providerInfo, + eventId = null, + roomId = null, + sessionId = null, + hasBeenResolved = true, + comment = "Diagnostic push", +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/di/PushHistoryModule.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/di/PushHistoryModule.kt new file mode 100644 index 0000000000..248ba8182d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/di/PushHistoryModule.kt @@ -0,0 +1,43 @@ +/* + * 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.history.di + +import android.content.Context +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.push.impl.PushDatabase +import io.element.encrypteddb.SqlCipherDriverFactory +import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider + +@Module +@ContributesTo(AppScope::class) +object PushHistoryModule { + @Provides + @SingleIn(AppScope::class) + fun providePushDatabase( + @ApplicationContext context: Context, + ): PushDatabase { + val name = "push_database" + val secretFile = context.getDatabasePath("$name.key") + + // Make sure the parent directory of the key file exists, otherwise it will crash in older Android versions + val parentDir = secretFile.parentFile + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs() + } + + val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile) + val driver = SqlCipherDriverFactory(passphraseProvider) + .create(PushDatabase.Schema, "$name.db", context) + return PushDatabase(driver) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt index ecbee49810..b41dae4500 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt @@ -30,16 +30,26 @@ interface CallNotificationEventResolver { * @param forceNotify `true` to force the notification to be non-ringing, `false` to use the default behaviour. Default is `false`. * @return a [NotifiableEvent] if the notification data is a call notification, null otherwise */ - fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean = false): NotifiableEvent? + fun resolveEvent( + sessionId: SessionId, + notificationData: NotificationData, + forceNotify: Boolean = false, + ): Result } @ContributesBinding(AppScope::class) class DefaultCallNotificationEventResolver @Inject constructor( private val stringProvider: StringProvider, ) : CallNotificationEventResolver { - override fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): NotifiableEvent? { - val content = notificationData.content as? NotificationContent.MessageLike.CallNotify ?: return null - return notificationData.run { + override fun resolveEvent( + sessionId: SessionId, + notificationData: NotificationData, + forceNotify: Boolean + ): Result = runCatching { + val content = notificationData.content as? NotificationContent.MessageLike.CallNotify + ?: throw ResolvingException("content is not a call notify") + + notificationData.run { if (NotifiableRingingCallEvent.shouldRing(content.type, timestamp) && !forceNotify) { NotifiableRingingCallEvent( sessionId = sessionId, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index b84a13b4f2..d7557f41df 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -11,6 +11,7 @@ import android.content.Context import android.net.Uri import androidx.core.content.FileProvider import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext @@ -21,6 +22,7 @@ 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.core.UserId +import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.permalink.PermalinkParser @@ -59,7 +61,7 @@ private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.No * this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk. */ interface NotifiableEventResolver { - suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent? + suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result } @ContributesBinding(AppScope::class) @@ -73,31 +75,39 @@ class DefaultNotifiableEventResolver @Inject constructor( private val callNotificationEventResolver: CallNotificationEventResolver, private val appPreferencesStore: AppPreferencesStore, ) : NotifiableEventResolver { - override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent? { + override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result { // Restore session - val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return Result.failure( + ResolvingException("Unable to restore session for $sessionId") + ) val notificationService = client.notificationService() val notificationData = notificationService.getNotification( roomId = roomId, eventId = eventId, ).onFailure { Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.") - }.getOrNull() + } // TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event - return notificationData?.asNotifiableEvent(client, sessionId) + return notificationData.flatMap { + if (it == null) { + Timber.tag(loggerTag.value).d("No notification data found for event $eventId") + return@flatMap Result.failure(ResolvingException("Unable to resolve event")) + } else { + it.asNotifiableEvent(client, sessionId) + } + } } private suspend fun NotificationData.asNotifiableEvent( client: MatrixClient, userId: SessionId, - ): ResolvedPushEvent? { - val content = this.content - val notifiableEvent = when (content) { + ): Result = runCatching { + when (val content = this.content) { is NotificationContent.MessageLike.RoomMessage -> { val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId) val messageBody = descriptionFromMessageContent(content, senderDisambiguatedDisplayName) - buildNotifiableMessageEvent( + val notifiableMessageEvent = buildNotifiableMessageEvent( sessionId = userId, senderId = content.senderId, roomId = roomId, @@ -115,10 +125,11 @@ class DefaultNotifiableEventResolver @Inject constructor( senderAvatarPath = senderAvatarUrl, hasMentionOrReply = hasMention, ) + ResolvedPushEvent.Event(notifiableMessageEvent) } is NotificationContent.Invite -> { val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId) - InviteNotifiableEvent( + val inviteNotifiableEvent = InviteNotifiableEvent( sessionId = userId, roomId = roomId, eventId = eventId, @@ -136,15 +147,16 @@ class DefaultNotifiableEventResolver @Inject constructor( // TODO check if title is needed anymore title = null, ) + ResolvedPushEvent.Event(inviteNotifiableEvent) } NotificationContent.MessageLike.CallAnswer, NotificationContent.MessageLike.CallCandidates, NotificationContent.MessageLike.CallHangup -> { Timber.tag(loggerTag.value).d("Ignoring notification for call ${content.javaClass.simpleName}") - null + throw ResolvingException("Ignoring notification for call ${content.javaClass.simpleName}") } is NotificationContent.MessageLike.CallInvite -> { - buildNotifiableMessageEvent( + val notifiableMessageEvent = buildNotifiableMessageEvent( sessionId = userId, senderId = content.senderId, roomId = roomId, @@ -158,9 +170,11 @@ class DefaultNotifiableEventResolver @Inject constructor( roomAvatarPath = roomAvatarUrl, senderAvatarPath = senderAvatarUrl, ) + ResolvedPushEvent.Event(notifiableMessageEvent) } is NotificationContent.MessageLike.CallNotify -> { - callNotificationEventResolver.resolveEvent(userId, this) + val notifiableEvent = callNotificationEventResolver.resolveEvent(userId, this).getOrThrow() + ResolvedPushEvent.Event(notifiableEvent) } NotificationContent.MessageLike.KeyVerificationAccept, NotificationContent.MessageLike.KeyVerificationCancel, @@ -168,11 +182,12 @@ class DefaultNotifiableEventResolver @Inject constructor( NotificationContent.MessageLike.KeyVerificationKey, NotificationContent.MessageLike.KeyVerificationMac, NotificationContent.MessageLike.KeyVerificationReady, - NotificationContent.MessageLike.KeyVerificationStart -> null.also { + NotificationContent.MessageLike.KeyVerificationStart -> { Timber.tag(loggerTag.value).d("Ignoring notification for verification ${content.javaClass.simpleName}") + throw ResolvingException("Ignoring notification for verification ${content.javaClass.simpleName}") } is NotificationContent.MessageLike.Poll -> { - buildNotifiableMessageEvent( + val notifiableEventMessage = buildNotifiableMessageEvent( sessionId = userId, senderId = content.senderId, roomId = roomId, @@ -187,19 +202,35 @@ class DefaultNotifiableEventResolver @Inject constructor( roomAvatarPath = roomAvatarUrl, senderAvatarPath = senderAvatarUrl, ) + ResolvedPushEvent.Event(notifiableEventMessage) } - is NotificationContent.MessageLike.ReactionContent -> null.also { + is NotificationContent.MessageLike.ReactionContent -> { Timber.tag(loggerTag.value).d("Ignoring notification for reaction") + throw ResolvingException("Ignoring notification for reaction") } - NotificationContent.MessageLike.RoomEncrypted -> fallbackNotifiableEvent(userId, roomId, eventId).also { + NotificationContent.MessageLike.RoomEncrypted -> { Timber.tag(loggerTag.value).w("Notification with encrypted content -> fallback") + val fallbackNotifiableEvent = fallbackNotifiableEvent(userId, roomId, eventId) + ResolvedPushEvent.Event(fallbackNotifiableEvent) } is NotificationContent.MessageLike.RoomRedaction -> { // Note: this case will be handled below - null + val redactedEventId = content.redactedEventId + if (redactedEventId == null) { + Timber.tag(loggerTag.value).d("redactedEventId is null.") + throw ResolvingException("redactedEventId is null") + } else { + ResolvedPushEvent.Redaction( + sessionId = userId, + roomId = roomId, + redactedEventId = redactedEventId, + reason = content.reason, + ) + } } - NotificationContent.MessageLike.Sticker -> null.also { + NotificationContent.MessageLike.Sticker -> { Timber.tag(loggerTag.value).d("Ignoring notification for sticker") + throw ResolvingException("Ignoring notification for reaction") } is NotificationContent.StateEvent.RoomMemberContent, NotificationContent.StateEvent.PolicyRuleRoom, @@ -221,29 +252,11 @@ class DefaultNotifiableEventResolver @Inject constructor( NotificationContent.StateEvent.RoomTombstone, NotificationContent.StateEvent.RoomTopic, NotificationContent.StateEvent.SpaceChild, - NotificationContent.StateEvent.SpaceParent -> null.also { + NotificationContent.StateEvent.SpaceParent -> { Timber.tag(loggerTag.value).d("Ignoring notification for state event ${content.javaClass.simpleName}") + throw ResolvingException("Ignoring notification for state event ${content.javaClass.simpleName}") } } - - return if (notifiableEvent != null) { - ResolvedPushEvent.Event(notifiableEvent) - } else if (content is NotificationContent.MessageLike.RoomRedaction) { - val redactedEventId = content.redactedEventId - if (redactedEventId == null) { - Timber.tag(loggerTag.value).d("redactedEventId is null.") - null - } else { - ResolvedPushEvent.Redaction( - sessionId = userId, - roomId = roomId, - redactedEventId = redactedEventId, - reason = content.reason, - ) - } - } else { - null - } } private fun fallbackNotifiableEvent( @@ -293,7 +306,7 @@ class DefaultNotifiableEventResolver @Inject constructor( } private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? { - if (appPreferencesStore.doesHideImagesAndVideosFlow().first()) { + if (appPreferencesStore.getTimelineMediaPreviewValueFlow().first() != MediaPreviewValue.On) { return null } val fileResult = when (val messageType = messageType) { @@ -320,7 +333,7 @@ class DefaultNotifiableEventResolver @Inject constructor( } private suspend fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? { - if (appPreferencesStore.doesHideImagesAndVideosFlow().first()) { + if (appPreferencesStore.getTimelineMediaPreviewValueFlow().first() != MediaPreviewValue.On) { return null } return when (val messageType = messageType) { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandler.kt index aff02a221f..03a9c717d8 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandler.kt @@ -39,7 +39,7 @@ class DefaultOnMissedCallNotificationHandler @Inject constructor( notificationData = notificationData, // Make sure the notifiable event is not a ringing one forceNotify = true, - ) + ).getOrNull() notifiableEvent?.let { defaultNotificationDrawerManager.onNotifiableEventReceived(it) } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ResolvingException.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ResolvingException.kt new file mode 100644 index 0000000000..11c655ec1c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ResolvingException.kt @@ -0,0 +1,10 @@ +/* + * 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 + +class ResolvingException(message: String) : Exception(message) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index dd2214650e..697ff67759 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -14,6 +14,12 @@ import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.push.impl.history.PushHistoryService +import io.element.android.libraries.push.impl.history.onDiagnosticPush +import io.element.android.libraries.push.impl.history.onInvalidPushReceived +import io.element.android.libraries.push.impl.history.onSuccess +import io.element.android.libraries.push.impl.history.onUnableToResolveEvent +import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent @@ -43,13 +49,15 @@ class DefaultPushHandler @Inject constructor( private val diagnosticPushHandler: DiagnosticPushHandler, private val elementCallEntryPoint: ElementCallEntryPoint, private val notificationChannels: NotificationChannels, + private val pushHistoryService: PushHistoryService, ) : PushHandler { /** * Called when message is received. * * @param pushData the data received in the push. + * @param providerInfo the provider info. */ - override suspend fun handle(pushData: PushData) { + override suspend fun handle(pushData: PushData, providerInfo: String) { Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}") if (buildMeta.lowPrivacyLoggingEnabled) { Timber.tag(loggerTag.value).d("## pushData: $pushData") @@ -57,18 +65,25 @@ class DefaultPushHandler @Inject constructor( incrementPushDataStore.incrementPushCounter() // Diagnostic Push if (pushData.eventId == DefaultTestPush.TEST_EVENT_ID) { + pushHistoryService.onDiagnosticPush(providerInfo) diagnosticPushHandler.handlePush() } else { - handleInternal(pushData) + handleInternal(pushData, providerInfo) } } + override suspend fun handleInvalid(providerInfo: String) { + incrementPushDataStore.incrementPushCounter() + pushHistoryService.onInvalidPushReceived(providerInfo) + } + /** * Internal receive method. * * @param pushData Object containing message data. + * @param providerInfo the provider info. */ - private suspend fun handleInternal(pushData: PushData) { + private suspend fun handleInternal(pushData: PushData, providerInfo: String) { try { if (buildMeta.lowPrivacyLoggingEnabled) { Timber.tag(loggerTag.value).d("## handleInternal() : $pushData") @@ -77,42 +92,77 @@ class DefaultPushHandler @Inject constructor( } val clientSecret = pushData.clientSecret // clientSecret should not be null. If this happens, restore default session - val userId = clientSecret - ?.let { - // Get userId from client secret - pushClientSecret.getUserIdFromSecret(clientSecret) + var reason = if (clientSecret == null) "No client secret" else "" + val userId = clientSecret?.let { + // Get userId from client secret + pushClientSecret.getUserIdFromSecret(clientSecret).also { + if (it == null) { + reason = "Unable to get userId from client secret" + } } - ?: run { - matrixAuthenticationService.getLatestSessionId() - } - if (userId == null) { - Timber.w("Unable to get a session") - return } - val resolvedPushEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId) - when (resolvedPushEvent) { - null -> Timber.tag(loggerTag.value).w("Unable to get a notification data") - is ResolvedPushEvent.Event -> { - when (val notifiableEvent = resolvedPushEvent.notifiableEvent) { - is NotifiableRingingCallEvent -> { - onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent) - handleRingingCallEvent(notifiableEvent) - } - else -> { - val userPushStore = userPushStoreFactory.getOrCreate(userId) - val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first() - if (areNotificationsEnabled) { - onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent) - } else { - Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.") - } + ?: run { + matrixAuthenticationService.getLatestSessionId().also { + if (it == null) { + if (reason.isNotEmpty()) reason += " - " + reason += "Unable to get latest sessionId" } } } - is ResolvedPushEvent.Redaction -> { - onRedactedEventReceived.onRedactedEventReceived(resolvedPushEvent) - } + if (userId == null) { + Timber.w("Unable to get a session") + pushHistoryService.onUnableToRetrieveSession( + providerInfo = providerInfo, + eventId = pushData.eventId, + roomId = pushData.roomId, + reason = reason, + ) + return } + notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId).fold( + onSuccess = { resolvedPushEvent -> + pushHistoryService.onSuccess( + providerInfo = providerInfo, + eventId = pushData.eventId, + roomId = pushData.roomId, + sessionId = userId, + comment = resolvedPushEvent.javaClass.simpleName, + ) + + when (resolvedPushEvent) { + is ResolvedPushEvent.Event -> { + when (val notifiableEvent = resolvedPushEvent.notifiableEvent) { + is NotifiableRingingCallEvent -> { + onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent) + handleRingingCallEvent(notifiableEvent) + } + else -> { + val userPushStore = userPushStoreFactory.getOrCreate(userId) + val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first() + if (areNotificationsEnabled) { + onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent) + } else { + Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.") + } + } + } + } + is ResolvedPushEvent.Redaction -> { + onRedactedEventReceived.onRedactedEventReceived(resolvedPushEvent) + } + } + }, + onFailure = { failure -> + Timber.tag(loggerTag.value).w(failure, "Unable to get a notification data") + pushHistoryService.onUnableToResolveEvent( + providerInfo = providerInfo, + eventId = pushData.eventId, + roomId = pushData.roomId, + sessionId = userId, + reason = failure.message ?: failure.javaClass.simpleName, + ) + } + ) } catch (e: Exception) { Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt index e5e472722e..2130e91c72 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt @@ -13,11 +13,20 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn -import io.element.android.libraries.push.api.store.PushDataStore +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.history.PushHistoryItem +import io.element.android.libraries.push.impl.PushDatabase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -28,6 +37,9 @@ private val Context.dataStore: DataStore by preferencesDataStore(na @ContributesBinding(AppScope::class) class DefaultPushDataStore @Inject constructor( @ApplicationContext private val context: Context, + private val pushDatabase: PushDatabase, + private val dateFormatter: DateFormatter, + private val dispatchers: CoroutineDispatchers, ) : PushDataStore { private val pushCounter = intPreferencesKey("push_counter") @@ -41,4 +53,35 @@ class DefaultPushDataStore @Inject constructor( settings[pushCounter] = currentCounterValue + 1 } } + + override fun getPushHistoryItemsFlow(): Flow> { + return pushDatabase.pushHistoryQueries.selectAll() + .asFlow() + .mapToList(dispatchers.io) + .map { items -> + items.map { pushHistory -> + PushHistoryItem( + pushDate = pushHistory.pushDate, + formattedDate = dateFormatter.format( + timestamp = pushHistory.pushDate, + mode = DateFormatterMode.Full, + useRelative = false, + ), + providerInfo = pushHistory.providerInfo, + eventId = pushHistory.eventId?.let { EventId(it) }, + roomId = pushHistory.roomId?.let { RoomId(it) }, + sessionId = pushHistory.sessionId?.let { SessionId(it) }, + hasBeenResolved = pushHistory.hasBeenResolved == 1L, + comment = pushHistory.comment, + ) + } + } + } + + override suspend fun reset() { + pushDatabase.pushHistoryQueries.removeAll() + context.dataStore.edit { + it.clear() + } + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/PushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/PushDataStore.kt new file mode 100644 index 0000000000..13c4e06347 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/PushDataStore.kt @@ -0,0 +1,25 @@ +/* + * 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.store + +import io.element.android.libraries.push.api.history.PushHistoryItem +import kotlinx.coroutines.flow.Flow + +interface PushDataStore { + val pushCounterFlow: Flow + + /** + * Get a flow of list of [PushHistoryItem]. + */ + fun getPushHistoryItemsFlow(): Flow> + + /** + * Reset the push counter to 0, and clear the database. + */ + suspend fun reset() +} diff --git a/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushHistory.sq b/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushHistory.sq new file mode 100644 index 0000000000..7a355baeb5 --- /dev/null +++ b/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushHistory.sq @@ -0,0 +1,22 @@ +CREATE TABLE PushHistory ( + pushDate INTEGER NOT NULL, + providerInfo TEXT NOT NULL, + eventId TEXT, + roomId TEXT, + sessionId TEXT, + hasBeenResolved INTEGER NOT NULL, + comment TEXT +); + +selectAll: +SELECT * FROM PushHistory ORDER BY pushDate DESC; + +insertPushHistory: +INSERT INTO PushHistory VALUES ?; + +removeAll: +DELETE FROM PushHistory; + +-- add query to keep only the last x entries +removeOldest: +DELETE FROM PushHistory WHERE rowid NOT IN (SELECT rowid FROM PushHistory ORDER BY pushDate DESC LIMIT ?); diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt index 8c76ed3259..f3333e84f7 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt @@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.push.api.GetCurrentPushProvider +import io.element.android.libraries.push.impl.store.InMemoryPushDataStore +import io.element.android.libraries.push.impl.store.PushDataStore import io.element.android.libraries.push.impl.test.FakeTestPush import io.element.android.libraries.push.impl.test.TestPush import io.element.android.libraries.push.test.FakeGetCurrentPushProvider @@ -288,6 +290,7 @@ class DefaultPushServiceTest { getCurrentPushProvider: GetCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = null), sessionObserver: SessionObserver = NoOpSessionObserver(), pushClientSecretStore: PushClientSecretStore = InMemoryPushClientSecretStore(), + pushDataStore: PushDataStore = InMemoryPushDataStore(), ): DefaultPushService { return DefaultPushService( testPush = testPush, @@ -296,6 +299,7 @@ class DefaultPushServiceTest { getCurrentPushProvider = getCurrentPushProvider, sessionObserver = sessionObserver, pushClientSecretStore = pushClientSecretStore, + pushDataStore = pushDataStore, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt new file mode 100644 index 0000000000..a88ab7ccc4 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt @@ -0,0 +1,42 @@ +/* + * 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.history + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushHistoryService( + private val onPushReceivedResult: ( + String, + EventId?, + RoomId?, + SessionId?, + Boolean, + String? + ) -> Unit = { _, _, _, _, _, _ -> lambdaError() } +) : PushHistoryService { + override fun onPushReceived( + providerInfo: String, + eventId: EventId?, + roomId: RoomId?, + sessionId: SessionId?, + hasBeenResolved: Boolean, + comment: String?, + ) { + onPushReceivedResult( + providerInfo, + eventId, + roomId, + sessionId, + hasBeenResolved, + comment + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index a194c9d67b..677727ea8c 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -69,7 +69,7 @@ class DefaultNotifiableEventResolverTest { fun `resolve event no session`() = runTest { val sut = createDefaultNotifiableEventResolver(notificationService = null) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result).isNull() + assertThat(result.isFailure).isTrue() } @Test @@ -78,7 +78,7 @@ class DefaultNotifiableEventResolverTest { notificationResult = Result.failure(AN_EXCEPTION) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result).isNull() + assertThat(result.isFailure).isTrue() } @Test @@ -87,7 +87,7 @@ class DefaultNotifiableEventResolverTest { notificationResult = Result.success(null) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result).isNull() + assertThat(result.isFailure).isTrue() } @Test @@ -106,7 +106,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -127,7 +127,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world", hasMentionOrReply = true) ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -152,7 +152,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -177,7 +177,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -196,7 +196,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Audio") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -215,7 +215,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Video") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -234,7 +234,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Voice message") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -253,7 +253,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Image") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -272,7 +272,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Sticker") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -291,7 +291,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "File") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -310,7 +310,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Location") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -329,7 +329,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Notice") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -348,7 +348,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "* Bob is happy") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -367,7 +367,7 @@ class DefaultNotifiableEventResolverTest { val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Poll: A question") ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -384,7 +384,7 @@ class DefaultNotifiableEventResolverTest { ) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result).isNull() + assertThat(result.getOrNull()).isNull() } @Test @@ -418,7 +418,7 @@ class DefaultNotifiableEventResolverTest { isUpdated = false, ) ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -452,7 +452,7 @@ class DefaultNotifiableEventResolverTest { isUpdated = false, ) ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -487,7 +487,7 @@ class DefaultNotifiableEventResolverTest { isUpdated = false, ) ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -522,7 +522,7 @@ class DefaultNotifiableEventResolverTest { isUpdated = false, ) ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -538,7 +538,7 @@ class DefaultNotifiableEventResolverTest { ) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result).isNull() + assertThat(result.getOrNull()).isNull() } @Test @@ -564,7 +564,7 @@ class DefaultNotifiableEventResolverTest { timestamp = A_FAKE_TIMESTAMP, ) ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -602,7 +602,7 @@ class DefaultNotifiableEventResolverTest { isUpdated = false ) ) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -638,7 +638,7 @@ class DefaultNotifiableEventResolverTest { ) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -675,7 +675,7 @@ class DefaultNotifiableEventResolverTest { ) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -711,7 +711,7 @@ class DefaultNotifiableEventResolverTest { ) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -733,7 +733,7 @@ class DefaultNotifiableEventResolverTest { reason = A_REDACTION_REASON, ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result).isEqualTo(expectedResult) + assertThat(result.getOrNull()).isEqualTo(expectedResult) } @Test @@ -749,46 +749,46 @@ class DefaultNotifiableEventResolverTest { ) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result).isNull() + assertThat(result.isFailure).isTrue() } @Test fun `resolve null cases`() { - testNull(NotificationContent.MessageLike.CallAnswer) - testNull(NotificationContent.MessageLike.CallHangup) - testNull(NotificationContent.MessageLike.CallCandidates) - testNull(NotificationContent.MessageLike.KeyVerificationReady) - testNull(NotificationContent.MessageLike.KeyVerificationStart) - testNull(NotificationContent.MessageLike.KeyVerificationCancel) - testNull(NotificationContent.MessageLike.KeyVerificationAccept) - testNull(NotificationContent.MessageLike.KeyVerificationKey) - testNull(NotificationContent.MessageLike.KeyVerificationMac) - testNull(NotificationContent.MessageLike.KeyVerificationDone) - testNull(NotificationContent.MessageLike.ReactionContent(relatedEventId = AN_EVENT_ID_2.value)) - testNull(NotificationContent.MessageLike.Sticker) - testNull(NotificationContent.StateEvent.PolicyRuleRoom) - testNull(NotificationContent.StateEvent.PolicyRuleServer) - testNull(NotificationContent.StateEvent.PolicyRuleUser) - testNull(NotificationContent.StateEvent.RoomAliases) - testNull(NotificationContent.StateEvent.RoomAvatar) - testNull(NotificationContent.StateEvent.RoomCanonicalAlias) - testNull(NotificationContent.StateEvent.RoomCreate) - testNull(NotificationContent.StateEvent.RoomEncryption) - testNull(NotificationContent.StateEvent.RoomGuestAccess) - testNull(NotificationContent.StateEvent.RoomHistoryVisibility) - testNull(NotificationContent.StateEvent.RoomJoinRules) - testNull(NotificationContent.StateEvent.RoomName) - testNull(NotificationContent.StateEvent.RoomPinnedEvents) - testNull(NotificationContent.StateEvent.RoomPowerLevels) - testNull(NotificationContent.StateEvent.RoomServerAcl) - testNull(NotificationContent.StateEvent.RoomThirdPartyInvite) - testNull(NotificationContent.StateEvent.RoomTombstone) - testNull(NotificationContent.StateEvent.RoomTopic) - testNull(NotificationContent.StateEvent.SpaceChild) - testNull(NotificationContent.StateEvent.SpaceParent) + testFailure(NotificationContent.MessageLike.CallAnswer) + testFailure(NotificationContent.MessageLike.CallHangup) + testFailure(NotificationContent.MessageLike.CallCandidates) + testFailure(NotificationContent.MessageLike.KeyVerificationReady) + testFailure(NotificationContent.MessageLike.KeyVerificationStart) + testFailure(NotificationContent.MessageLike.KeyVerificationCancel) + testFailure(NotificationContent.MessageLike.KeyVerificationAccept) + testFailure(NotificationContent.MessageLike.KeyVerificationKey) + testFailure(NotificationContent.MessageLike.KeyVerificationMac) + testFailure(NotificationContent.MessageLike.KeyVerificationDone) + testFailure(NotificationContent.MessageLike.ReactionContent(relatedEventId = AN_EVENT_ID_2.value)) + testFailure(NotificationContent.MessageLike.Sticker) + testFailure(NotificationContent.StateEvent.PolicyRuleRoom) + testFailure(NotificationContent.StateEvent.PolicyRuleServer) + testFailure(NotificationContent.StateEvent.PolicyRuleUser) + testFailure(NotificationContent.StateEvent.RoomAliases) + testFailure(NotificationContent.StateEvent.RoomAvatar) + testFailure(NotificationContent.StateEvent.RoomCanonicalAlias) + testFailure(NotificationContent.StateEvent.RoomCreate) + testFailure(NotificationContent.StateEvent.RoomEncryption) + testFailure(NotificationContent.StateEvent.RoomGuestAccess) + testFailure(NotificationContent.StateEvent.RoomHistoryVisibility) + testFailure(NotificationContent.StateEvent.RoomJoinRules) + testFailure(NotificationContent.StateEvent.RoomName) + testFailure(NotificationContent.StateEvent.RoomPinnedEvents) + testFailure(NotificationContent.StateEvent.RoomPowerLevels) + testFailure(NotificationContent.StateEvent.RoomServerAcl) + testFailure(NotificationContent.StateEvent.RoomThirdPartyInvite) + testFailure(NotificationContent.StateEvent.RoomTombstone) + testFailure(NotificationContent.StateEvent.RoomTopic) + testFailure(NotificationContent.StateEvent.SpaceChild) + testFailure(NotificationContent.StateEvent.SpaceParent) } - private fun testNull(content: NotificationContent) = runTest { + private fun testFailure(content: NotificationContent) = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( aNotificationData( @@ -797,7 +797,7 @@ class DefaultNotifiableEventResolverTest { ) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result).isNull() + assertThat(result.isFailure).isTrue() } private fun createDefaultNotifiableEventResolver( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt index 06a955a084..6a4cd52430 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt @@ -60,7 +60,9 @@ class DefaultOnMissedCallNotificationHandlerTest { imageLoaderHolder = FakeImageLoaderHolder(), activeNotificationsProvider = FakeActiveNotificationsProvider(), ), - callNotificationEventResolver = FakeCallNotificationEventResolver(resolveEventLambda = { _, _, _ -> aNotifiableMessageEvent() }), + callNotificationEventResolver = FakeCallNotificationEventResolver(resolveEventLambda = { _, _, _ -> + Result.success(aNotifiableMessageEvent()) + }), ) defaultOnMissedCallNotificationHandler.addMissedCallNotification( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt index e800ffdfe2..3e98f8f9e8 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt @@ -14,9 +14,9 @@ import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEv import io.element.android.tests.testutils.lambda.lambdaError class FakeNotifiableEventResolver( - private val notifiableEventResult: (SessionId, RoomId, EventId) -> ResolvedPushEvent? = { _, _, _ -> lambdaError() } + private val notifiableEventResult: (SessionId, RoomId, EventId) -> Result = { _, _, _ -> lambdaError() } ) : NotifiableEventResolver { - override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent? { + override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result { return notifiableEventResult(sessionId, roomId, eventId) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt index a8f921da0d..4df8cd2ecd 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -28,7 +28,10 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.push.impl.history.FakePushHistoryService +import io.element.android.libraries.push.impl.history.PushHistoryService import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.ResolvingException import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent @@ -42,6 +45,7 @@ import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -50,16 +54,41 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import java.time.Instant +private const val A_PUSHER_INFO = "info" + class DefaultPushHandlerTest { + @Test + fun `check handleInvalid behavior`() = runTest { + val incrementPushCounterResult = lambdaRecorder {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val defaultPushHandler = createDefaultPushHandler( + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handleInvalid(A_PUSHER_INFO) + incrementPushCounterResult.assertions() + .isCalledOnce() + onPushReceivedResult.assertions() + .isCalledOnce() + .with(value(A_PUSHER_INFO), value(null), value(null), value(null), value(false), value("Invalid push data")) + } + @Test fun `when classical PushData is received, the notification drawer is informed`() = runTest { val aNotifiableMessageEvent = aNotifiableMessageEvent() val notifiableEventResult = - lambdaRecorder { _, _, _ -> - ResolvedPushEvent.Event(aNotifiableMessageEvent) + lambdaRecorder> { _, _, _ -> + Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)) } val onNotifiableEventReceived = lambdaRecorder {} val incrementPushCounterResult = lambdaRecorder {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) val aPushData = PushData( eventId = AN_EVENT_ID, roomId = A_ROOM_ID, @@ -72,9 +101,10 @@ class DefaultPushHandlerTest { pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), - incrementPushCounterResult = incrementPushCounterResult + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, ) - defaultPushHandler.handle(aPushData) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) incrementPushCounterResult.assertions() .isCalledOnce() notifiableEventResult.assertions() @@ -83,6 +113,8 @@ class DefaultPushHandlerTest { onNotifiableEventReceived.assertions() .isCalledOnce() .with(value(aNotifiableMessageEvent)) + onPushReceivedResult.assertions() + .isCalledOnce() } @Test @@ -90,8 +122,8 @@ class DefaultPushHandlerTest { runTest { val aNotifiableMessageEvent = aNotifiableMessageEvent() val notifiableEventResult = - lambdaRecorder { _, _, _ -> - ResolvedPushEvent.Event(aNotifiableMessageEvent) + lambdaRecorder> { _, _, _ -> + Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)) } val onNotifiableEventReceived = lambdaRecorder {} val incrementPushCounterResult = lambdaRecorder {} @@ -101,6 +133,10 @@ class DefaultPushHandlerTest { unread = 0, clientSecret = A_SECRET, ) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) val defaultPushHandler = createDefaultPushHandler( onNotifiableEventReceived = onNotifiableEventReceived, notifiableEventResult = notifiableEventResult, @@ -110,15 +146,18 @@ class DefaultPushHandlerTest { userPushStore = FakeUserPushStore().apply { setNotificationEnabledForDevice(false) }, - incrementPushCounterResult = incrementPushCounterResult + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, ) - defaultPushHandler.handle(aPushData) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) incrementPushCounterResult.assertions() .isCalledOnce() notifiableEventResult.assertions() .isCalledOnce() onNotifiableEventReceived.assertions() .isNeverCalled() + onPushReceivedResult.assertions() + .isCalledOnce() } @Test @@ -126,8 +165,8 @@ class DefaultPushHandlerTest { runTest { val aNotifiableMessageEvent = aNotifiableMessageEvent() val notifiableEventResult = - lambdaRecorder { _, _, _ -> - ResolvedPushEvent.Event(aNotifiableMessageEvent) + lambdaRecorder> { _, _, _ -> + Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)) } val onNotifiableEventReceived = lambdaRecorder {} val incrementPushCounterResult = lambdaRecorder {} @@ -137,6 +176,10 @@ class DefaultPushHandlerTest { unread = 0, clientSecret = A_SECRET, ) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) val defaultPushHandler = createDefaultPushHandler( onNotifiableEventReceived = onNotifiableEventReceived, notifiableEventResult = notifiableEventResult, @@ -146,9 +189,10 @@ class DefaultPushHandlerTest { matrixAuthenticationService = FakeMatrixAuthenticationService().apply { getLatestSessionIdLambda = { A_USER_ID } }, - incrementPushCounterResult = incrementPushCounterResult + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, ) - defaultPushHandler.handle(aPushData) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) incrementPushCounterResult.assertions() .isCalledOnce() notifiableEventResult.assertions() @@ -157,6 +201,8 @@ class DefaultPushHandlerTest { onNotifiableEventReceived.assertions() .isCalledOnce() .with(value(aNotifiableMessageEvent)) + onPushReceivedResult.assertions() + .isCalledOnce() } @Test @@ -164,8 +210,8 @@ class DefaultPushHandlerTest { runTest { val aNotifiableMessageEvent = aNotifiableMessageEvent() val notifiableEventResult = - lambdaRecorder { _, _, _ -> - ResolvedPushEvent.Event(aNotifiableMessageEvent) + lambdaRecorder> { _, _, _ -> + Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)) } val onNotifiableEventReceived = lambdaRecorder {} val incrementPushCounterResult = lambdaRecorder {} @@ -175,6 +221,10 @@ class DefaultPushHandlerTest { unread = 0, clientSecret = A_SECRET, ) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) val defaultPushHandler = createDefaultPushHandler( onNotifiableEventReceived = onNotifiableEventReceived, notifiableEventResult = notifiableEventResult, @@ -184,22 +234,27 @@ class DefaultPushHandlerTest { matrixAuthenticationService = FakeMatrixAuthenticationService().apply { getLatestSessionIdLambda = { null } }, - incrementPushCounterResult = incrementPushCounterResult + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, ) - defaultPushHandler.handle(aPushData) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) incrementPushCounterResult.assertions() .isCalledOnce() notifiableEventResult.assertions() .isNeverCalled() onNotifiableEventReceived.assertions() .isNeverCalled() + onPushReceivedResult.assertions() + .isCalledOnce() } @Test fun `when classical PushData is received, but not able to resolve the event, nothing happen`() = runTest { val notifiableEventResult = - lambdaRecorder { _, _, _ -> null } + lambdaRecorder> { _, _, _ -> + Result.failure(ResolvingException("Unable to resolve")) + } val onNotifiableEventReceived = lambdaRecorder {} val incrementPushCounterResult = lambdaRecorder {} val aPushData = PushData( @@ -208,6 +263,10 @@ class DefaultPushHandlerTest { unread = 0, clientSecret = A_SECRET, ) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) val defaultPushHandler = createDefaultPushHandler( onNotifiableEventReceived = onNotifiableEventReceived, notifiableEventResult = notifiableEventResult, @@ -218,9 +277,10 @@ class DefaultPushHandlerTest { pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), - incrementPushCounterResult = incrementPushCounterResult + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, ) - defaultPushHandler.handle(aPushData) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) incrementPushCounterResult.assertions() .isCalledOnce() notifiableEventResult.assertions() @@ -228,6 +288,9 @@ class DefaultPushHandlerTest { .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) onNotifiableEventReceived.assertions() .isNeverCalled() + onPushReceivedResult.assertions() + .isCalledOnce() + .with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), any()) } @Test @@ -251,20 +314,28 @@ class DefaultPushHandlerTest { > { _, _, _, _, _, _, _, _ -> } val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) val onNotifiableEventReceived = lambdaRecorder {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) val defaultPushHandler = createDefaultPushHandler( elementCallEntryPoint = elementCallEntryPoint, notifiableEventResult = { _, _, _ -> - ResolvedPushEvent.Event(aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli())) + Result.success( + ResolvedPushEvent.Event(aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli())) + ) }, incrementPushCounterResult = {}, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), onNotifiableEventReceived = onNotifiableEventReceived, + pushHistoryService = pushHistoryService, ) - defaultPushHandler.handle(aPushData) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) handleIncomingCallLambda.assertions().isCalledOnce() onNotifiableEventReceived.assertions().isCalledOnce() + onPushReceivedResult.assertions().isCalledOnce() } @Test @@ -288,21 +359,27 @@ class DefaultPushHandlerTest { Unit, > { _, _, _, _, _, _, _, _ -> } val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) val defaultPushHandler = createDefaultPushHandler( elementCallEntryPoint = elementCallEntryPoint, onNotifiableEventReceived = onNotifiableEventReceived, notifiableEventResult = { _, _, _ -> - ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.CALL_NOTIFY)) + Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.CALL_NOTIFY))) }, incrementPushCounterResult = {}, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), + pushHistoryService = pushHistoryService, ) - defaultPushHandler.handle(aPushData) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) handleIncomingCallLambda.assertions().isNeverCalled() onNotifiableEventReceived.assertions().isCalledOnce() + onPushReceivedResult.assertions().isCalledOnce() } @Test @@ -326,11 +403,15 @@ class DefaultPushHandlerTest { Unit, > { _, _, _, _, _, _, _, _ -> } val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) val defaultPushHandler = createDefaultPushHandler( elementCallEntryPoint = elementCallEntryPoint, onNotifiableEventReceived = onNotifiableEventReceived, notifiableEventResult = { _, _, _ -> - ResolvedPushEvent.Event(aNotifiableCallEvent()) + Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent())) }, incrementPushCounterResult = {}, userPushStore = FakeUserPushStore().apply { @@ -339,10 +420,12 @@ class DefaultPushHandlerTest { pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), + pushHistoryService = pushHistoryService, ) - defaultPushHandler.handle(aPushData) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) handleIncomingCallLambda.assertions().isCalledOnce() onNotifiableEventReceived.assertions().isCalledOnce() + onPushReceivedResult.assertions().isCalledOnce() } @Test @@ -361,19 +444,26 @@ class DefaultPushHandlerTest { ) val onRedactedEventReceived = lambdaRecorder { } val incrementPushCounterResult = lambdaRecorder {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) val defaultPushHandler = createDefaultPushHandler( onRedactedEventReceived = onRedactedEventReceived, incrementPushCounterResult = incrementPushCounterResult, - notifiableEventResult = { _, _, _ -> aRedaction }, + notifiableEventResult = { _, _, _ -> Result.success(aRedaction) }, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), + pushHistoryService = pushHistoryService, ) - defaultPushHandler.handle(aPushData) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) incrementPushCounterResult.assertions() .isCalledOnce() onRedactedEventReceived.assertions().isCalledOnce() .with(value(aRedaction)) + onPushReceivedResult.assertions() + .isCalledOnce() } @Test @@ -386,20 +476,27 @@ class DefaultPushHandlerTest { clientSecret = A_SECRET, ) val diagnosticPushHandler = DiagnosticPushHandler() + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) val defaultPushHandler = createDefaultPushHandler( diagnosticPushHandler = diagnosticPushHandler, - incrementPushCounterResult = { } + incrementPushCounterResult = { }, + pushHistoryService = pushHistoryService, ) diagnosticPushHandler.state.test { - defaultPushHandler.handle(aPushData) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) awaitItem() } + onPushReceivedResult.assertions() + .isCalledOnce() } private fun createDefaultPushHandler( onNotifiableEventReceived: (NotifiableEvent) -> Unit = { lambdaError() }, onRedactedEventReceived: (ResolvedPushEvent.Redaction) -> Unit = { lambdaError() }, - notifiableEventResult: (SessionId, RoomId, EventId) -> ResolvedPushEvent? = { _, _, _ -> lambdaError() }, + notifiableEventResult: (SessionId, RoomId, EventId) -> Result = { _, _, _ -> lambdaError() }, incrementPushCounterResult: () -> Unit = { lambdaError() }, userPushStore: UserPushStore = FakeUserPushStore(), pushClientSecret: PushClientSecret = FakePushClientSecret(), @@ -408,6 +505,7 @@ class DefaultPushHandlerTest { diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(), elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(), notificationChannels: FakeNotificationChannels = FakeNotificationChannels(), + pushHistoryService: PushHistoryService = FakePushHistoryService(), ): DefaultPushHandler { return DefaultPushHandler( onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceived), @@ -425,6 +523,7 @@ class DefaultPushHandlerTest { diagnosticPushHandler = diagnosticPushHandler, elementCallEntryPoint = elementCallEntryPoint, notificationChannels = notificationChannels, + pushHistoryService = pushHistoryService, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/store/InMemoryPushDataStore.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/store/InMemoryPushDataStore.kt new file mode 100644 index 0000000000..7ea038791b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/store/InMemoryPushDataStore.kt @@ -0,0 +1,33 @@ +/* + * 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.store + +import io.element.android.libraries.push.api.history.PushHistoryItem +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class InMemoryPushDataStore( + initialPushCounter: Int = 0, + initialPushHistoryItems: List = emptyList(), + private val resetResult: () -> Unit = { lambdaError() } +) : PushDataStore { + private val mutablePushCounterFlow = MutableStateFlow(initialPushCounter) + override val pushCounterFlow: Flow = mutablePushCounterFlow.asStateFlow() + + private val mutablePushHistoryItemsFlow = MutableStateFlow(initialPushHistoryItems) + + override fun getPushHistoryItemsFlow(): Flow> { + return mutablePushHistoryItemsFlow.asStateFlow() + } + + override suspend fun reset() { + resetResult() + } +} diff --git a/libraries/push/test/build.gradle.kts b/libraries/push/test/build.gradle.kts index 7f178bc1f4..fa0db0738b 100644 --- a/libraries/push/test/build.gradle.kts +++ b/libraries/push/test/build.gradle.kts @@ -15,10 +15,10 @@ android { dependencies { api(projects.libraries.push.api) + api(projects.libraries.pushproviders.api) implementation(projects.libraries.push.impl) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) - implementation(projects.libraries.pushproviders.api) implementation(projects.tests.testutils) implementation(libs.androidx.core) implementation(libs.coil.compose) diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt index b3fac42d32..a875de68de 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.push.test import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.api.history.PushHistoryItem import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.tests.testutils.lambda.lambdaError @@ -26,6 +27,7 @@ class FakePushService( private val currentPushProvider: () -> PushProvider? = { availablePushProviders.firstOrNull() }, private val selectPushProviderLambda: suspend (SessionId, PushProvider) -> Unit = { _, _ -> lambdaError() }, private val setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() }, + private val resetPushHistoryResult: () -> Unit = { lambdaError() }, ) : PushService { override suspend fun getCurrentPushProvider(): PushProvider? { return registeredPushProvider ?: currentPushProvider() @@ -68,4 +70,26 @@ class FakePushService( override suspend fun testPush(): Boolean = simulateLongTask { testPushBlock() } + + private val pushHistoryItemsFlow = MutableStateFlow>(emptyList()) + + override fun getPushHistoryItemsFlow(): Flow> { + return pushHistoryItemsFlow + } + + fun emitPushHistoryItems(items: List) { + pushHistoryItemsFlow.value = items + } + + private val pushCounterFlow = MutableStateFlow(0) + + override val pushCounter: Flow = pushCounterFlow + + fun emitPushCounter(counter: Int) { + pushCounterFlow.value = counter + } + + override suspend fun resetPushHistory() { + resetPushHistoryResult() + } } diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt index d77bda026d..4ec395f173 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt @@ -11,11 +11,14 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.push.impl.notifications.CallNotificationEventResolver import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.tests.testutils.lambda.lambdaError class FakeCallNotificationEventResolver( - var resolveEventLambda: (sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean) -> NotifiableEvent? = { _, _, _ -> null }, + var resolveEventLambda: (sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean) -> Result = { _, _, _ -> + lambdaError() + }, ) : CallNotificationEventResolver { - override fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): NotifiableEvent? { + override fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): Result { return resolveEventLambda(sessionId, notificationData, forceNotify) } } diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt index 9e0c71442f..933790d3e9 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt @@ -12,9 +12,14 @@ import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.tests.testutils.lambda.lambdaError class FakePushHandler( - private val handleResult: (PushData) -> Unit = { lambdaError() } + private val handleResult: (PushData, String) -> Unit = { _, _ -> lambdaError() }, + private val handleInvalidResult: (String) -> Unit = { lambdaError() }, ) : PushHandler { - override suspend fun handle(pushData: PushData) { - handleResult(pushData) + override suspend fun handle(pushData: PushData, providerInfo: String) { + handleResult(pushData, providerInfo) + } + + override suspend fun handleInvalid(providerInfo: String) { + handleInvalidResult(providerInfo) } } diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt index ded2b163b5..46dca54f5b 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt @@ -8,5 +8,12 @@ package io.element.android.libraries.pushproviders.api interface PushHandler { - suspend fun handle(pushData: PushData) + suspend fun handle( + pushData: PushData, + providerInfo: String, + ) + + suspend fun handleInvalid( + providerInfo: String, + ) } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt index d03fefcac6..3fd2e77f22 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt @@ -43,8 +43,14 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { val pushData = pushParser.parse(message.data) if (pushData == null) { Timber.tag(loggerTag.value).w("Invalid data received from Firebase") + pushHandler.handleInvalid( + providerInfo = FirebaseConfig.NAME, + ) } else { - pushHandler.handle(pushData) + pushHandler.handle( + pushData = pushData, + providerInfo = FirebaseConfig.NAME, + ) } } } diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt index 59f9465298..f47b614619 100644 --- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt @@ -22,6 +22,7 @@ import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith @@ -31,16 +32,18 @@ import org.robolectric.RobolectricTestRunner class VectorFirebaseMessagingServiceTest { @Test fun `test receiving invalid data`() = runTest { - val lambda = lambdaRecorder(ensureNeverCalled = true) { } + val lambda = lambdaRecorder {} val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( - pushHandler = FakePushHandler(handleResult = lambda) + pushHandler = FakePushHandler(handleInvalidResult = lambda) ) vectorFirebaseMessagingService.onMessageReceived(RemoteMessage(Bundle())) + runCurrent() + lambda.assertions().isCalledOnce() } @Test fun `test receiving valid data`() = runTest { - val lambda = lambdaRecorder { } + val lambda = lambdaRecorder { _, _ -> } val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( pushHandler = FakePushHandler(handleResult = lambda) ) @@ -56,7 +59,10 @@ class VectorFirebaseMessagingServiceTest { advanceUntilIdle() lambda.assertions() .isCalledOnce() - .with(value(PushData(AN_EVENT_ID, A_ROOM_ID, null, A_SECRET))) + .with( + value(PushData(AN_EVENT_ID, A_ROOM_ID, null, A_SECRET)), + value(FirebaseConfig.NAME) + ) } @Test diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt index 2e48574de1..328f5be8c7 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -51,8 +51,14 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { val pushData = pushParser.parse(message, instance) if (pushData == null) { Timber.tag(loggerTag.value).w("Invalid data received from UnifiedPush") + pushHandler.handleInvalid( + providerInfo = "${UnifiedPushConfig.NAME} - $instance", + ) } else { - pushHandler.handle(pushData) + pushHandler.handle( + pushData = pushData, + providerInfo = "${UnifiedPushConfig.NAME} - $instance", + ) } } } diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt index 69502f020e..b445e73fec 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt @@ -60,9 +60,9 @@ class VectorUnifiedPushMessagingReceiverTest { } @Test - fun `onMessage valid invoke the push handler`() = runTest { + fun `onMessage valid invokes the push handler`() = runTest { val context = InstrumentationRegistry.getInstrumentation().context - val pushHandlerResult = lambdaRecorder {} + val pushHandlerResult = lambdaRecorder { _, _ -> } val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( pushHandler = FakePushHandler( handleResult = pushHandlerResult @@ -80,23 +80,25 @@ class VectorUnifiedPushMessagingReceiverTest { unread = 1, clientSecret = A_SECRET ) + ), + value( + UnifiedPushConfig.NAME + " - " + A_SECRET ) ) } @Test - fun `onMessage invalid does not invoke the push handler`() = runTest { + fun `onMessage invalid invokes the push handler invalid method`() = runTest { val context = InstrumentationRegistry.getInstrumentation().context - val pushHandlerResult = lambdaRecorder {} + val handleInvalidResult = lambdaRecorder { } val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( pushHandler = FakePushHandler( - handleResult = pushHandlerResult + handleInvalidResult = handleInvalidResult, ), ) vectorUnifiedPushMessagingReceiver.onMessage(context, "".toByteArray(), A_SECRET) advanceUntilIdle() - pushHandlerResult.assertions() - .isNeverCalled() + handleInvalidResult.assertions().isCalledOnce() } @Test diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index 4d21d63724..76462ef035 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -1,6 +1,4 @@ import extension.setupAnvil -import org.gradle.internal.extensions.stdlib.capitalized -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile /* * Copyright 2023, 2024 New Vector Ltd. @@ -52,15 +50,3 @@ sqldelight { } } } - -// Workaround for KSP not picking up the generated files from SqlDelight -androidComponents { - onVariants(selector().all()) { variant -> - afterEvaluate { - val variantName = variant.name.capitalized() - tasks.getByName("ksp${variantName}Kotlin") { - setSource(tasks.getByName("generate${variantName}SessionDatabaseInterface").outputs) - } - } - } -} diff --git a/libraries/troubleshoot/api/build.gradle.kts b/libraries/troubleshoot/api/build.gradle.kts index 003c32ec2c..6ce2d3e9f8 100644 --- a/libraries/troubleshoot/api/build.gradle.kts +++ b/libraries/troubleshoot/api/build.gradle.kts @@ -14,6 +14,7 @@ android { dependencies { implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) implementation(libs.androidx.corektx) implementation(libs.coroutines.core) } diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/PushHistoryEntryPoint.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/PushHistoryEntryPoint.kt new file mode 100644 index 0000000000..088fb387da --- /dev/null +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/PushHistoryEntryPoint.kt @@ -0,0 +1,30 @@ +/* + * 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.troubleshoot.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +interface PushHistoryEntryPoint : FeatureEntryPoint { + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onDone() + fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) + } +} diff --git a/libraries/troubleshoot/impl/build.gradle.kts b/libraries/troubleshoot/impl/build.gradle.kts index e4e5928eb7..f88ccc8f85 100644 --- a/libraries/troubleshoot/impl/build.gradle.kts +++ b/libraries/troubleshoot/impl/build.gradle.kts @@ -28,6 +28,8 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.uiStrings) api(projects.libraries.troubleshoot.api) api(projects.libraries.push.api) implementation(projects.services.analytics.api) @@ -40,6 +42,7 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(projects.services.analytics.test) testImplementation(projects.tests.testutils) + testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.push.test) testImplementation(libs.androidx.compose.ui.test.junit) testReleaseImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPoint.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPoint.kt new file mode 100644 index 0000000000..dd8f663414 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPoint.kt @@ -0,0 +1,35 @@ +/* + * 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.troubleshoot.impl.history + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultPushHistoryEntryPoint @Inject constructor() : PushHistoryEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): PushHistoryEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : PushHistoryEntryPoint.NodeBuilder { + override fun callback(callback: PushHistoryEntryPoint.Callback): PushHistoryEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } + } +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt new file mode 100644 index 0000000000..7b6d5f616d --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt @@ -0,0 +1,12 @@ +/* + * 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.troubleshoot.impl.history + +sealed interface PushHistoryEvents { + data object Reset : PushHistoryEvents +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryNode.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryNode.kt new file mode 100644 index 0000000000..347a1c2700 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryNode.kt @@ -0,0 +1,57 @@ +/* + * 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.troubleshoot.impl.history + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint +import io.element.android.services.analytics.api.ScreenTracker + +@ContributesNode(SessionScope::class) +class PushHistoryNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: PushHistoryPresenter, + private val screenTracker: ScreenTracker, +) : Node(buildContext, plugins = plugins) { + private fun onDone() { + plugins().forEach { + it.onDone() + } + } + + private fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) { + plugins().forEach { + it.onItemClick(sessionId, roomId, eventId) + } + } + + @Composable + override fun View(modifier: Modifier) { + screenTracker.TrackScreen(MobileScreen.ScreenName.NotificationTroubleshoot) + val state = presenter.present() + PushHistoryView( + state = state, + onBackClick = ::onDone, + onItemClick = ::onItemClick, + modifier = modifier, + ) + } +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt new file mode 100644 index 0000000000..d7b0592b5b --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt @@ -0,0 +1,46 @@ +/* + * 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.troubleshoot.impl.history + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.push.api.PushService +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import javax.inject.Inject + +class PushHistoryPresenter @Inject constructor( + private val pushService: PushService, +) : Presenter { + @Composable + override fun present(): PushHistoryState { + val coroutineScope = rememberCoroutineScope() + val pushCounter by pushService.pushCounter.collectAsState(0) + val pushHistory by remember { + pushService.getPushHistoryItemsFlow() + }.collectAsState(emptyList()) + + fun handleEvents(event: PushHistoryEvents) { + when (event) { + PushHistoryEvents.Reset -> coroutineScope.launch { + pushService.resetPushHistory() + } + } + } + + return PushHistoryState( + pushCounter = pushCounter, + pushHistoryItems = pushHistory.toImmutableList(), + eventSink = ::handleEvents + ) + } +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryState.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryState.kt new file mode 100644 index 0000000000..113f7d0f19 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryState.kt @@ -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.troubleshoot.impl.history + +import io.element.android.libraries.push.api.history.PushHistoryItem +import kotlinx.collections.immutable.ImmutableList + +data class PushHistoryState( + val pushCounter: Int, + val pushHistoryItems: ImmutableList, + val eventSink: (PushHistoryEvents) -> Unit, +) diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt new file mode 100644 index 0000000000..7482e22ef7 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt @@ -0,0 +1,72 @@ +/* + * 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.troubleshoot.impl.history + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.history.PushHistoryItem +import kotlinx.collections.immutable.toImmutableList + +open class PushHistoryStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aPushHistoryState(), + aPushHistoryState( + pushCounter = 123, + pushHistoryItems = listOf( + aPushHistoryItem( + hasBeenResolved = false, + comment = "An error description" + ), + aPushHistoryItem( + pushDate = 1, + providerInfo = "providerInfo2", + eventId = EventId("\$anEventId"), + roomId = RoomId("!roomId:domain"), + sessionId = SessionId("@alice:server.org"), + hasBeenResolved = true, + comment = "A comment" + ) + ) + ), + ) +} + +fun aPushHistoryState( + pushCounter: Int = 0, + pushHistoryItems: List = emptyList(), + eventSink: (PushHistoryEvents) -> Unit = {}, +) = PushHistoryState( + pushCounter = pushCounter, + pushHistoryItems = pushHistoryItems.toImmutableList(), + eventSink = eventSink, +) + +fun aPushHistoryItem( + pushDate: Long = 0, + formattedDate: String = "formattedDate", + providerInfo: String = "providerInfo", + eventId: EventId? = null, + roomId: RoomId? = null, + sessionId: SessionId? = null, + hasBeenResolved: Boolean = false, + comment: String? = null, +): PushHistoryItem { + return PushHistoryItem( + pushDate = pushDate, + formattedDate = formattedDate, + providerInfo = providerInfo, + eventId = eventId, + roomId = roomId, + sessionId = sessionId, + hasBeenResolved = hasBeenResolved, + comment = comment + ) +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt new file mode 100644 index 0000000000..76b5ff2bf0 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt @@ -0,0 +1,229 @@ +/* + * 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.troubleshoot.impl.history + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.history.PushHistoryItem +import io.element.android.libraries.troubleshoot.impl.R +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PushHistoryView( + state: PushHistoryState, + onBackClick: () -> Unit, + onItemClick: (SessionId, RoomId, EventId) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + contentWindowInsets = WindowInsets.statusBars, + topBar = { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = { + Text( + text = stringResource(R.string.screen_push_history_title), + style = ElementTheme.typography.aliasScreenTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_reset), + onClick = { + state.eventSink(PushHistoryEvents.Reset) + }, + ) + } + ) + }, + ) { padding -> + PushHistoryContent( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding), + state = state, + onItemClick = onItemClick, + ) + } +} + +@Composable +private fun PushHistoryContent( + state: PushHistoryState, + onItemClick: (SessionId, RoomId, EventId) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth() + ) { + ListItem( + headlineContent = { Text("Total number of received push") }, + trailingContent = ListItemContent.Text(state.pushCounter.toString()), + ) + LazyColumn( + modifier = Modifier.fillMaxWidth() + ) { + items( + items = state.pushHistoryItems, + key = { + it.pushDate.toString() + it.sessionId + it.roomId + it.eventId + }, + ) { pushHistory -> + PushHistoryItem( + pushHistory, + onClick = { + val sessionId = pushHistory.sessionId + val roomId = pushHistory.roomId + val eventId = pushHistory.eventId + if (sessionId != null && roomId != null && eventId != null) { + onItemClick(sessionId, roomId, eventId) + } + } + ) + } + } + } +} + +@Composable +private fun PushHistoryItem( + pushHistoryItem: PushHistoryItem, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + onClick() + }, + ) { + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + ) { + Text( + text = pushHistoryItem.formattedDate, + color = ElementTheme.colors.textPrimary, + ) + Text( + text = pushHistoryItem.providerInfo, + color = ElementTheme.colors.textPrimary, + ) + Text( + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + text = pushHistoryItem.sessionId?.value ?: "No sessionId", + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = pushHistoryItem.roomId?.value ?: "No roomId", + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = pushHistoryItem.eventId?.value ?: "No eventId", + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + pushHistoryItem.comment?.let { + Text( + modifier = Modifier.padding(top = 8.dp), + text = it, + color = if (pushHistoryItem.hasBeenResolved) { + ElementTheme.colors.textSecondary + } else { + ElementTheme.colors.textCriticalPrimary + }, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + } + if (pushHistoryItem.hasBeenResolved) { + Icon( + imageVector = CompoundIcons.CheckCircleSolid(), + modifier = Modifier.size(24.dp), + tint = ElementTheme.colors.iconSuccessPrimary, + contentDescription = null, + ) + } else { + Icon( + imageVector = CompoundIcons.Error(), + modifier = Modifier.size(24.dp), + tint = ElementTheme.colors.iconCriticalPrimary, + contentDescription = null, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun PushHistoryViewPreview( + @PreviewParameter(PushHistoryStateProvider::class) state: PushHistoryState, +) = ElementPreview { + PushHistoryView( + state = state, + onBackClick = {}, + onItemClick = { _, _, _ -> }, + ) +} diff --git a/libraries/troubleshoot/impl/src/main/res/values/localazy.xml b/libraries/troubleshoot/impl/src/main/res/values/localazy.xml index eee100d711..0427392db6 100644 --- a/libraries/troubleshoot/impl/src/main/res/values/localazy.xml +++ b/libraries/troubleshoot/impl/src/main/res/values/localazy.xml @@ -1,5 +1,6 @@ + "Push history" "Run tests" "Run tests again" "Some tests failed. Please check the details." diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt new file mode 100644 index 0000000000..ad1d9c512e --- /dev/null +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt @@ -0,0 +1,75 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.troubleshoot.impl.history + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.test.FakePushService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PushHistoryPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPushHistoryPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.pushCounter).isEqualTo(0) + assertThat(initialState.pushHistoryItems).isEmpty() + } + } + + @Test + fun `present - updating state`() = runTest { + val pushService = FakePushService() + val presenter = createPushHistoryPresenter( + pushService = pushService, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.pushCounter).isEqualTo(0) + assertThat(initialState.pushHistoryItems).isEmpty() + pushService.emitPushCounter(1) + assertThat(awaitItem().pushCounter).isEqualTo(1) + val item = aPushHistoryItem() + pushService.emitPushHistoryItems(listOf(item)) + assertThat(awaitItem().pushHistoryItems).containsExactly(item) + } + } + + @Test + fun `present - reset`() = runTest { + val resetPushHistoryResult = lambdaRecorder { } + val pushService = FakePushService( + resetPushHistoryResult = resetPushHistoryResult, + ) + val presenter = createPushHistoryPresenter( + pushService = pushService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(PushHistoryEvents.Reset) + runCurrent() + resetPushHistoryResult.assertions().isCalledOnce() + } + } + + private fun createPushHistoryPresenter( + pushService: PushService = FakePushService(), + ): PushHistoryPresenter { + return PushHistoryPresenter( + pushService = pushService, + ) + } +} diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryViewTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryViewTest.kt new file mode 100644 index 0000000000..fdb10e318f --- /dev/null +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryViewTest.kt @@ -0,0 +1,109 @@ +/* + * 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.troubleshoot.impl.history + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_FORMATTED_DATE +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.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithThreeParams +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PushHistoryViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on Reset sends a PushHistoryEvents`() { + val eventsRecorder = EventsRecorder() + rule.setPushHistoryView( + aPushHistoryState( + pushCounter = 123, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_reset) + eventsRecorder.assertSingle(PushHistoryEvents.Reset) + // Also check that the push counter is rendered + rule.onNodeWithText("123").assertExists() + } + + @Test + fun `clicking on an invalid event has no effect`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setPushHistoryView( + aPushHistoryState( + pushHistoryItems = listOf( + aPushHistoryItem( + formattedDate = A_FORMATTED_DATE, + ) + ), + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(A_FORMATTED_DATE).performClick() + // No callback invoked + } + + @Test + fun `clicking on a valid event invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val onItemClick = lambdaRecorder { _, _, _ -> } + rule.setPushHistoryView( + aPushHistoryState( + pushHistoryItems = listOf( + aPushHistoryItem( + formattedDate = A_FORMATTED_DATE, + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + sessionId = A_SESSION_ID, + ) + ), + eventSink = eventsRecorder, + ), + onItemClick = onItemClick, + ) + rule.onNodeWithText(A_FORMATTED_DATE).performClick() + onItemClick.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) + } +} + +private fun AndroidComposeTestRule.setPushHistoryView( + state: PushHistoryState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onItemClick: (SessionId, RoomId, EventId) -> Unit = EnsureNeverCalledWithThreeParams(), +) { + setContent { + PushHistoryView( + state = state, + onBackClick = onBackClick, + onItemClick = onItemClick, + ) + } +} diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index b2c69fc1a3..08c4a55d2e 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -32,7 +32,7 @@ private const val versionYear = 25 private const val versionMonth = 4 // Note: must be in [0,99] -private const val versionReleaseNumber = 0 +private const val versionReleaseNumber = 1 object Versions { const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber diff --git a/plugins/src/main/kotlin/extension/KoverExtension.kt b/plugins/src/main/kotlin/extension/KoverExtension.kt index c361aeba52..fff0e3a22a 100644 --- a/plugins/src/main/kotlin/extension/KoverExtension.kt +++ b/plugins/src/main/kotlin/extension/KoverExtension.kt @@ -49,7 +49,7 @@ private fun Project.kover(action: Action) { fun Project.setupKover() { // Create verify all task joining all existing verification tasks - task("koverVerifyAll") { + tasks.register("koverVerifyAll") { group = "verification" description = "Verifies the code coverage of all subprojects." val dependencies = listOf(":app:koverVerifyGplayDebug") + koverVariants.map { ":app:koverVerify${it.replaceFirstChar(Char::titlecase)}" } diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_LogoutView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_LogoutView_Night_10_en.png index 9e60f85a51..0344224a85 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_LogoutView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_LogoutView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:27587909106650e33d7bc7854c0f2dd7ca6e2dad0aaf6487bf266753288ec6f6 -size 24898 +oid sha256:6408d329ea127961b08cb92fcfbbdd93fc2c700191e7c97ce4ebad147647c1c4 +size 24899 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_LogoutView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_LogoutView_Night_11_en.png index 960535f462..fcddb192f4 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_LogoutView_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_LogoutView_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9e65635400bfda5d242d07ebe31113e3ef2d039f21208421023f099997d5e4f9 -size 29603 +oid sha256:c1764cc1d1fc5234ef88a1f1cf9317a234cd7ee7195a190a0e827442c0d75db2 +size 29601 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_0_en.png new file mode 100644 index 0000000000..c6ea7ea86b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:926ce46cacac7beaac403e1c4e8034398d91dc48ab5f651bc7f0639ac65fe33a +size 46759 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_1_en.png new file mode 100644 index 0000000000..edccccc989 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84c2474bc4d10e1aa3184c41b12b1bcf51d7e2b7ec801944608613aa46f50eba +size 46636 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_2_en.png new file mode 100644 index 0000000000..01cf719299 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:611d435a2b3e6e0f8c7721905f635c1c93aa217df3fe4f4b6bb38bf5700e3629 +size 34586 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_3_en.png new file mode 100644 index 0000000000..f844203887 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d9b0a865db8f87d6c1c95b8fb6d916f261d44cecec52fdebd56288f2df35f0f +size 46627 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_4_en.png new file mode 100644 index 0000000000..69f5a502d5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0505bdd3cfccf156249ab27ed0f087bd4d01d11121cb1f5e0c8450c6b7b58059 +size 46615 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_5_en.png new file mode 100644 index 0000000000..ef2041611a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a004afcec0cb40f82172c7abdc4404d1a0d879d0e23c63d0dcd1d7325cddff43 +size 46471 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png new file mode 100644 index 0000000000..a3a7ee69cf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb09af4f9c96afb60b917d9484e28bacb73c494f9dfc2f42abb482896027b7b7 +size 46767 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_0_en.png new file mode 100644 index 0000000000..a61c1185f1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4dfb5f83a129332a84f0184ba6a37ba9a95ded6fd9692a6e5710aaeb6b424c7c +size 48610 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_1_en.png new file mode 100644 index 0000000000..6c498e613b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:903d0aa3faa5b4feae990139c467a5a6662808ffb768c7a2a5cd314f9ea09ba2 +size 48482 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_2_en.png new file mode 100644 index 0000000000..992a95c8f1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a2538700b5dc993568575e7017fc26c3cba7af7b7567fac069af5aa0c6f6b5f +size 36484 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_3_en.png new file mode 100644 index 0000000000..babb6a6546 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9177ba1065f5cfe3137a16c024445332b2fe326969f5f6d65ccf6b1707deabe9 +size 48502 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_4_en.png new file mode 100644 index 0000000000..909a027708 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f07064f41aafea6a91e3314d7d3bb099918e348ef9f9154a057d90758b9fb71 +size 48480 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_5_en.png new file mode 100644 index 0000000000..e2d2f592e0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92c1c93890214c88ed0e4d1d9faee27cc671db94fe928633ccc82471c137b64a +size 48422 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en.png new file mode 100644 index 0000000000..feba53f652 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f21d860514e97b48c3fab7fef4fbf6d710854d21ea2eecc3b44a5ba340c92eb +size 48608 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_0_en.png deleted file mode 100644 index d1bd764c2a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c8f664f4a07b88fef329728a9f1ce3151a3b36b0255bcb62d4bc653a6d62b5a4 -size 54746 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_1_en.png deleted file mode 100644 index d46ecb62d4..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9471766f40c07f65aba37fb166f99c8821995e192d9fa2fe633a84dd8dc4f4ac -size 54511 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_2_en.png deleted file mode 100644 index d263193b4d..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d36aea49da81fc526b91bdd5b30fdaa974bf6ffc498fde32f68182954f3d6b44 -size 31893 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_3_en.png deleted file mode 100644 index 1f21a81383..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a76df447d4e5108fc8412eebfcab8037e356f7ed410ad247379399b3d547da11 -size 54570 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_4_en.png deleted file mode 100644 index 91f497a0ed..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Day_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7560757da0a4a8c9ffe70c4a81b6dc4d4e379db4c664d16fe4d958a27d77026d -size 54541 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_0_en.png deleted file mode 100644 index 8c9189d33a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8f73399ea5f3126559521931424428af6b6d6de578ba8799a577f59ec628aded -size 53554 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_1_en.png deleted file mode 100644 index 5398e5646b..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ba80841973fa1447940382975d5b56eff806bcf819441bc8cf25fc9b60c50eb9 -size 53265 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_2_en.png deleted file mode 100644 index 8b6f452cce..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a3907566d471c26da9d8c4f1f10fb2cc5d69db584a32f83b662e195f696df320 -size 29568 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_3_en.png deleted file mode 100644 index b1435afb3e..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e7ae58bd327e19f6c7d10c5be512fbd6ec4eeaa716b995dcc0dc081f2ff3c9c3 -size 53306 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_4_en.png deleted file mode 100644 index e64f9b5cfd..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsView_Night_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bb6e24a7ae84d488a7b9a1574ca86d08b134ff889c2f885c7b7e2b1b5044868e -size 53270 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_0_en.png index 5a6c498300..9381505a00 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e1cb2f64719f8abda45e50b9cca1a2985630736c35e9cdb32b207696ec067edf -size 57683 +oid sha256:7836dc63d1181d2b057df6baf5fe3b8a05298aaa4e23a75a79790cafff1fd451 +size 54048 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_1_en.png index 5a6c498300..9381505a00 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e1cb2f64719f8abda45e50b9cca1a2985630736c35e9cdb32b207696ec067edf -size 57683 +oid sha256:7836dc63d1181d2b057df6baf5fe3b8a05298aaa4e23a75a79790cafff1fd451 +size 54048 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_2_en.png index c9c81afac2..48046221dc 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40cdf166be2b920b5e6282835fa80654a734ad19bbefdc2c37d287447cb3b49a -size 56274 +oid sha256:cad88329f2179428e72e96499d91ebbfba0647c74fb271bbfffaea0f695d28fb +size 52595 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_0_en.png index 50fb79702e..c49b1ace09 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8ff541c779c046143e109f80fe45c5742868b1b4b9d4d5a96371b336e2f1c56 -size 55837 +oid sha256:5861efb5c4453365fa215ec675e8c1289d86fa7756b42c57dcd9c7f138cd62bb +size 52067 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_1_en.png index 50fb79702e..c49b1ace09 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8ff541c779c046143e109f80fe45c5742868b1b4b9d4d5a96371b336e2f1c56 -size 55837 +oid sha256:5861efb5c4453365fa215ec675e8c1289d86fa7756b42c57dcd9c7f138cd62bb +size 52067 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_2_en.png index 9f66038e62..67d26e583e 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d18ec536792e9e3cea6a40e8a2901e131631be7795f92f64726b8b91e8c52e34 -size 54433 +oid sha256:af268c15499c557a3c6502f6daf732ede68f7fca1539a10a420809ca0fb212a3 +size 50664 diff --git a/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_0_en.png new file mode 100644 index 0000000000..fe1f4cb059 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04cc3a21bceeee5727df5728d34eb7618a5a90949352cd02f4eaa2be77c47492 +size 13565 diff --git a/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_1_en.png new file mode 100644 index 0000000000..a1e58baca5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a8c0a12277b4335b1d01d3f5f206704b37c4cfb3693ec7d9a26ce15215ba800 +size 45566 diff --git a/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_0_en.png new file mode 100644 index 0000000000..aa1f275616 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d877e4d537fa0990dd0f2a5e14f2d00cf8dcd45a4c27fac6c0188ef2b441756 +size 13163 diff --git a/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_1_en.png new file mode 100644 index 0000000000..d61baf4fc4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f110d1684003cdd1d6e47f765bbf4f55e2b0f3ba732926df08d06297b36bb7b +size 44157 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 2495301fb8..6fbe996585 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -273,7 +273,8 @@ { "name" : ":libraries:troubleshoot:impl", "includeRegex" : [ - "troubleshoot_notifications_screen_.*" + "troubleshoot_notifications_screen_.*", + "screen\\.push_history\\..*" ] }, {