From a1d8322738de8e2a90eee6147cbeb65dd6834f51 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 11 Apr 2025 12:56:54 +0200 Subject: [PATCH] Introduce PushHistoryService to store data about the received push (#4573) * Introduce PushHistoryService to store data about the received push Add a push database. * Update screenshots * Improve preview. * Update screenshots * Add missing test. * Add test for PushHistoryView * Fix configuration issue. Was: w: /libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt:35:27 Cannot access class 'PushProvider' in the expression type. While it may work, this case indicates a configuration mistake and can lead to avoidable compilation errors, so it may be forbidden soon. Check your module classpath for missing or conflicting dependencies. --------- Co-authored-by: ElementBot --- .../android/appnav/LoggedInFlowNode.kt | 9 + .../preferences/api/PreferencesEntryPoint.kt | 4 + .../preferences/impl/PreferencesFlowNode.kt | 28 +++ .../notifications/NotificationSettingsNode.kt | 6 + .../notifications/NotificationSettingsView.kt | 10 + .../impl/src/main/res/values/localazy.xml | 1 + .../NotificationSettingsViewTest.kt | 18 ++ .../android/libraries/matrix/test/TestData.kt | 1 + .../android/libraries/push/api/PushService.kt | 16 ++ .../push/api/history/PushHistoryItem.kt | 34 +++ .../libraries/push/api/store/PushDataStore.kt | 14 -- libraries/push/impl/build.gradle.kts | 16 ++ .../libraries/push/impl/DefaultPushService.kt | 13 + .../impl/history/DefaultPushHistoryService.kt | 48 ++++ .../push/impl/history/PushHistoryService.kt | 98 ++++++++ .../push/impl/history/di/PushHistoryModule.kt | 43 ++++ .../CallNotificationEventResolver.kt | 18 +- .../DefaultNotifiableEventResolver.kt | 90 ++++--- .../DefaultOnMissedCallNotificationHandler.kt | 2 +- .../impl/notifications/ResolvingException.kt | 10 + .../push/impl/push/DefaultPushHandler.kt | 116 ++++++--- .../push/impl/store/DefaultPushDataStore.kt | 45 +++- .../push/impl/store/PushDataStore.kt | 25 ++ .../libraries/push/impl/db/PushHistory.sq | 22 ++ .../push/impl/DefaultPushServiceTest.kt | 4 + .../impl/history/FakePushHistoryService.kt | 42 ++++ .../DefaultNotifiableEventResolverTest.kt | 128 +++++----- ...aultOnMissedCallNotificationHandlerTest.kt | 4 +- .../FakeNotifiableEventResolver.kt | 4 +- .../push/impl/push/DefaultPushHandlerTest.kt | 159 +++++++++--- .../push/impl/store/InMemoryPushDataStore.kt | 33 +++ libraries/push/test/build.gradle.kts | 2 +- .../libraries/push/test/FakePushService.kt | 24 ++ .../FakeCallNotificationEventResolver.kt | 7 +- .../push/test/test/FakePushHandler.kt | 11 +- .../pushproviders/api/PushHandler.kt | 9 +- .../VectorFirebaseMessagingService.kt | 8 +- .../VectorFirebaseMessagingServiceTest.kt | 14 +- .../VectorUnifiedPushMessagingReceiver.kt | 8 +- .../VectorUnifiedPushMessagingReceiverTest.kt | 16 +- libraries/troubleshoot/api/build.gradle.kts | 1 + .../troubleshoot/api/PushHistoryEntryPoint.kt | 30 +++ libraries/troubleshoot/impl/build.gradle.kts | 3 + .../history/DefaultPushHistoryEntryPoint.kt | 35 +++ .../impl/history/PushHistoryEvents.kt | 12 + .../impl/history/PushHistoryNode.kt | 57 +++++ .../impl/history/PushHistoryPresenter.kt | 46 ++++ .../impl/history/PushHistoryState.kt | 17 ++ .../impl/history/PushHistoryStateProvider.kt | 72 ++++++ .../impl/history/PushHistoryView.kt | 229 ++++++++++++++++++ .../impl/src/main/res/values/localazy.xml | 1 + .../impl/history/PushHistoryPresenterTest.kt | 75 ++++++ .../impl/history/PushHistoryViewTest.kt | 109 +++++++++ ...res.logout.impl_LogoutView_Night_10_en.png | 4 +- ...res.logout.impl_LogoutView_Night_11_en.png | 4 +- ....impl.history_PushHistoryView_Day_0_en.png | 3 + ....impl.history_PushHistoryView_Day_1_en.png | 3 + ...mpl.history_PushHistoryView_Night_0_en.png | 3 + ...mpl.history_PushHistoryView_Night_1_en.png | 3 + tools/localazy/config.json | 3 +- 60 files changed, 1656 insertions(+), 214 deletions(-) create mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/history/PushHistoryItem.kt delete mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/di/PushHistoryModule.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ResolvingException.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/PushDataStore.kt create mode 100644 libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushHistory.sq create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/store/InMemoryPushDataStore.kt create mode 100644 libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/PushHistoryEntryPoint.kt create mode 100644 libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPoint.kt create mode 100644 libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt create mode 100644 libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryNode.kt create mode 100644 libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt create mode 100644 libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryState.kt create mode 100644 libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt create mode 100644 libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt create mode 100644 libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt create mode 100644 libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryViewTest.kt create mode 100644 tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_1_en.png 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/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/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..3b54fef5eb 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -58,6 +58,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/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/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/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..c8c90fe9bd 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 @@ -59,7 +60,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 +74,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 +124,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 +146,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 +169,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 +181,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 +201,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 +251,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( 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/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/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/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\\..*" ] }, {