From 8588ce7a7232398802de586575c0b7a5a90c0415 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 26 Mar 2024 11:36:31 +0100 Subject: [PATCH] Troubleshoot notifications screen --- features/preferences/impl/build.gradle.kts | 10 + .../preferences/impl/PreferencesFlowNode.kt | 11 + .../notifications/NotificationSettingsNode.kt | 6 + .../notifications/NotificationSettingsView.kt | 11 + .../TroubleshootNotificationsEvents.kt | 23 ++ .../TroubleshootNotificationsNode.kt | 44 ++++ .../TroubleshootNotificationsPresenter.kt | 58 +++++ .../TroubleshootNotificationsState.kt | 24 ++ .../TroubleshootNotificationsStateProvider.kt | 119 ++++++++++ .../TroubleshootNotificationsView.kt | 220 ++++++++++++++++++ .../troubleshoot/TroubleshootTestSuite.kt | 109 +++++++++ .../TroubleshootTestSuiteState.kt | 26 +++ .../FakeNotificationTroubleshootTest.kt | 82 +++++++ ...TroubleshootNotificationsPresenterTests.kt | 126 ++++++++++ .../TroubleshootNotificationsViewTest.kt | 115 +++++++++ .../androidutils/system/SystemUtils.kt | 8 + .../NotificationTroubleshootTest.kt | 29 +++ .../NotificationTroubleshootTestDelegate.kt | 81 +++++++ .../NotificationTroubleshootTestState.kt | 31 +++ .../core/notifications/TestFilterData.kt | 21 ++ libraries/permissions/api/build.gradle.kts | 1 - libraries/permissions/impl/build.gradle.kts | 2 + .../impl/DefaultPermissionStateProvider.kt | 7 +- .../impl/action/AndroidPermissionActions.kt | 4 +- ...ficationTroubleshootCheckPermissionTest.kt | 66 ++++++ .../impl/action/FakePermissionActions.kt | 5 +- ...tionTroubleshootCheckPermissionTestTest.kt | 100 ++++++++ .../push/api/GetCurrentPushProvider.kt | 21 ++ .../android/libraries/push/api/PushService.kt | 6 +- .../push/api/gateway/PushGatewayFailure.kt | 4 +- libraries/push/impl/build.gradle.kts | 2 + .../impl/DefaultGetCurrentPushProvider.kt | 41 ++++ .../libraries/push/impl/DefaultPushService.kt | 10 +- .../libraries/push/impl/PushersManager.kt | 15 +- .../notifications/NotificationDisplayer.kt | 18 +- .../notifications/TestNotificationReceiver.kt | 8 +- .../TestNotificationReceiverBinding.kt | 25 ++ .../factories/NotificationCreator.kt | 4 +- .../push/impl/push/DefaultPushHandler.kt | 5 +- .../pushgateway/PushGatewayNotification.kt | 2 + .../pushgateway/PushGatewayNotifyRequest.kt | 7 +- .../troubleshoot/CurrentPushProviderTest.kt | 58 +++++ .../troubleshoot/DiagnosticPushHandler.kt | 33 +++ .../troubleshoot/NotificationClickHandler.kt | 33 +++ .../impl/troubleshoot/NotificationTest.kt | 94 ++++++++ .../impl/troubleshoot/PushLoopbackTest.kt | 102 ++++++++ .../impl/troubleshoot/PushProvidersTest.kt | 59 +++++ .../DefaultNotificationDrawerManagerTest.kt | 4 +- .../notifications/NotificationFactoryTest.kt | 4 +- ...nFactory.kt => FakeNotificationCreator.kt} | 8 +- .../fake/FakeNotificationDisplayer.kt | 5 + .../CurrentPushProviderTestTest.kt | 60 +++++ .../impl/troubleshoot/NotificationTestTest.kt | 88 +++++++ .../impl/troubleshoot/PushLoopbackTestTest.kt | 143 ++++++++++++ .../troubleshoot/PushProvidersTestTest.kt | 64 +++++ .../push/test/FakeGetCurrentPushProvider.kt | 25 ++ .../libraries/push/test/FakePushService.kt | 8 +- .../api/CurrentUserPushConfig.kt | 22 ++ .../pushproviders/api/PushProvider.kt | 5 +- .../pushproviders/firebase/build.gradle.kts | 2 + .../firebase/FirebasePushProvider.kt | 28 +-- .../pushproviders/firebase/FirebaseStore.kt | 16 +- .../firebase/FirebaseTroubleshooter.kt | 32 +-- .../firebase/IsPlayServiceAvailable.kt | 47 ++++ .../troubleshoot/FirebaseAvailabilityTest.kt | 65 ++++++ .../troubleshoot/FirebaseTokenTest.kt | 73 ++++++ .../firebase/FakeFirebaseTroubleshooter.kt | 27 +++ .../firebase/InMemoryFirebaseStore.kt | 27 +++ .../FirebaseAvailabilityTestTest.kt | 67 ++++++ .../troubleshoot/FirebaseTokenTestTest.kt | 78 +++++++ libraries/pushproviders/test/build.gradle.kts | 27 +++ .../pushproviders/test/FakePushProvider.kt | 45 ++++ .../unifiedpush/build.gradle.kts | 3 + .../UnifiedPushDistributorProvider.kt | 47 ++++ .../unifiedpush/UnifiedPushProvider.kt | 32 +-- .../OpenDistributorWebPageAction.kt | 41 ++++ .../troubleshoot/UnifiedPushTest.kt | 70 ++++++ .../FakeOpenDistributorWebPageAction.kt | 23 ++ .../FakeUnifiedPushDistributorProvider.kt | 32 +++ .../troubleshoot/UnifiedPushTestTest.kt | 81 +++++++ 80 files changed, 3086 insertions(+), 99 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsEvents.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsNode.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsPresenter.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsState.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsStateProvider.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsView.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootTestSuite.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootTestSuiteState.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/FakeNotificationTroubleshootTest.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsPresenterTests.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsViewTest.kt create mode 100644 libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTest.kt create mode 100644 libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTestDelegate.kt create mode 100644 libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTestState.kt create mode 100644 libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/TestFilterData.kt create mode 100644 libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt create mode 100644 libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTestTest.kt create mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultGetCurrentPushProvider.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiverBinding.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTest.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/DiagnosticPushHandler.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationClickHandler.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTest.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt rename libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/{FakeAndroidNotificationFactory.kt => FakeNotificationCreator.kt} (85%) create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTestTest.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTestTest.kt create mode 100644 libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakeGetCurrentPushProvider.kt create mode 100644 libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/CurrentUserPushConfig.kt create mode 100644 libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/IsPlayServiceAvailable.kt create mode 100644 libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTest.kt create mode 100644 libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt create mode 100644 libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTroubleshooter.kt create mode 100644 libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/InMemoryFirebaseStore.kt create mode 100644 libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt create mode 100644 libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt create mode 100644 libraries/pushproviders/test/build.gradle.kts create mode 100644 libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt create mode 100644 libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushDistributorProvider.kt create mode 100644 libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/OpenDistributorWebPageAction.kt create mode 100644 libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTest.kt create mode 100644 libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeOpenDistributorWebPageAction.kt create mode 100644 libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeUnifiedPushDistributorProvider.kt create mode 100644 libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index ee0658ddc6..415f0e36b4 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -23,6 +23,11 @@ plugins { android { namespace = "io.element.android.features.preferences.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } anvil { @@ -50,6 +55,7 @@ dependencies { implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.permissions.api) + implementation(projects.libraries.push.api) implementation(projects.features.rageshake.api) implementation(projects.features.lockscreen.api) implementation(projects.features.analytics.api) @@ -71,12 +77,14 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(libs.test.mockk) + testImplementation(libs.test.robolectric) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushstore.test) testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.impl) @@ -86,4 +94,6 @@ dependencies { testImplementation(projects.services.toolbox.test) testImplementation(projects.features.analytics.impl) testImplementation(projects.tests.testutils) + testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) } 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 c27a5ec14e..5f57ad8b5e 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,6 +39,7 @@ import io.element.android.features.preferences.impl.developer.DeveloperSettingsN import io.element.android.features.preferences.impl.developer.tracing.ConfigureTracingNode import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode +import io.element.android.features.preferences.impl.notifications.troubleshoot.TroubleshootNotificationsNode import io.element.android.features.preferences.impl.root.PreferencesRootNode import io.element.android.features.preferences.impl.user.editprofile.EditUserProfileNode import io.element.android.libraries.architecture.BackstackView @@ -85,6 +86,9 @@ class PreferencesFlowNode @AssistedInject constructor( @Parcelize data object NotificationSettings : NavTarget + @Parcelize + data object TroubleshootNotifications : NavTarget + @Parcelize data object LockScreenSettings : NavTarget @@ -177,9 +181,16 @@ class PreferencesFlowNode @AssistedInject constructor( override fun editDefaultNotificationMode(isOneToOne: Boolean) { backstack.push(NavTarget.EditDefaultNotificationSetting(isOneToOne)) } + + override fun onTroubleshootNotificationsClicked() { + backstack.push(NavTarget.TroubleshootNotifications) + } } createNode(buildContext, listOf(notificationSettingsCallback)) } + NavTarget.TroubleshootNotifications -> { + createNode(buildContext) + } 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 122d13a817..621b0ed8b1 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 @@ -35,6 +35,7 @@ class NotificationSettingsNode @AssistedInject constructor( ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun editDefaultNotificationMode(isOneToOne: Boolean) + fun onTroubleshootNotificationsClicked() } private val callbacks = plugins() @@ -43,6 +44,10 @@ class NotificationSettingsNode @AssistedInject constructor( callbacks.forEach { it.editDefaultNotificationMode(isOneToOne) } } + private fun onTroubleshootNotificationsClicked() { + callbacks.forEach { it.onTroubleshootNotificationsClicked() } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() @@ -50,6 +55,7 @@ class NotificationSettingsNode @AssistedInject constructor( state = state, onOpenEditDefault = { openEditDefault(isOneToOne = it) }, onBackPressed = ::navigateUp, + onTroubleshootNotificationsClicked = ::onTroubleshootNotificationsClicked, 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 e68b9fc8b5..8f59ffcdba 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 @@ -46,6 +46,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun NotificationSettingsView( state: NotificationSettingsState, onOpenEditDefault: (isOneToOne: Boolean) -> Unit, + onTroubleshootNotificationsClicked: () -> Unit, onBackPressed: () -> Unit, modifier: Modifier = Modifier, ) { @@ -77,6 +78,7 @@ fun NotificationSettingsView( // TODO We are removing the call notification toggle until support for call notifications has been added // onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) }, onInviteForMeNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(it)) }, + onTroubleshootNotificationsClicked = onTroubleshootNotificationsClicked, ) } AsyncActionView( @@ -99,6 +101,7 @@ private fun NotificationSettingsContentView( // TODO We are removing the call notification toggle until support for call notifications has been added // onCallsNotificationsChanged: (Boolean) -> Unit, onInviteForMeNotificationsChanged: (Boolean) -> Unit, + onTroubleshootNotificationsClicked: () -> Unit, ) { val context = LocalContext.current if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) { @@ -163,6 +166,13 @@ private fun NotificationSettingsContentView( onCheckedChange = onInviteForMeNotificationsChanged ) } + PreferenceCategory(title = "Troubleshoot") { + PreferenceText( + modifier = Modifier, + title = "Troubleshoot notifications", + onClick = onTroubleshootNotificationsClicked + ) + } } } @@ -204,6 +214,7 @@ internal fun NotificationSettingsViewPreview(@PreviewParameter(NotificationSetti state = state, onBackPressed = {}, onOpenEditDefault = {}, + onTroubleshootNotificationsClicked = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsEvents.kt new file mode 100644 index 0000000000..a6b361de13 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.notifications.troubleshoot + +sealed interface TroubleshootNotificationsEvents { + data object StartTests : TroubleshootNotificationsEvents + data object RetryFailedTests : TroubleshootNotificationsEvents + data class QuickFix(val testIndex: Int) : TroubleshootNotificationsEvents +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsNode.kt new file mode 100644 index 0000000000..1ac7c0c079 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsNode.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.notifications.troubleshoot + +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 dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class TroubleshootNotificationsNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: TroubleshootNotificationsPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + TroubleshootNotificationsView( + state = state, + onBackPressed = ::navigateUp, + modifier = modifier, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsPresenter.kt new file mode 100644 index 0000000000..1ccbf86ce9 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsPresenter.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.notifications.troubleshoot + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.launch +import javax.inject.Inject + +class TroubleshootNotificationsPresenter @Inject constructor( + private val troubleshootTestSuite: TroubleshootTestSuite, +) : Presenter { + @Composable + override fun present(): TroubleshootNotificationsState { + val coroutineScope = rememberCoroutineScope() + LaunchedEffect(Unit) { + troubleshootTestSuite.start(this) + } + + val testSuiteState by troubleshootTestSuite.state.collectAsState() + fun handleEvents(event: TroubleshootNotificationsEvents) { + when (event) { + TroubleshootNotificationsEvents.StartTests -> coroutineScope.launch { + troubleshootTestSuite.runTestSuite(this) + } + is TroubleshootNotificationsEvents.QuickFix -> coroutineScope.launch { + troubleshootTestSuite.quickFix(event.testIndex, this) + } + TroubleshootNotificationsEvents.RetryFailedTests -> coroutineScope.launch { + troubleshootTestSuite.retryFailedTest(this) + } + } + } + + return TroubleshootNotificationsState( + testSuiteState = testSuiteState, + eventSink = ::handleEvents + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsState.kt new file mode 100644 index 0000000000..55032b2fe9 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.notifications.troubleshoot + +data class TroubleshootNotificationsState( + val testSuiteState: TroubleshootTestSuiteState, + val eventSink: (TroubleshootNotificationsEvents) -> Unit, +) { + val hasFailedTests: Boolean = testSuiteState.mainState.isFailure() +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsStateProvider.kt new file mode 100644 index 0000000000..316bd72ad7 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsStateProvider.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.notifications.troubleshoot + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import kotlinx.collections.immutable.toImmutableList + +open class TroubleshootNotificationsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateIdle(), + aTroubleshootTestStateIdle(), + aTroubleshootTestStateIdle(visible = false), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateInProgress(), + aTroubleshootTestStateIdle(), + aTroubleshootTestStateIdle(), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateInProgress(), + aTroubleshootTestStateIdle(), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateWaitingForUser(), + aTroubleshootTestStateIdle(), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateFailure(hasQuickFix = true), + aTroubleshootTestStateInProgress(), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateFailure(hasQuickFix = true), + aTroubleshootTestStateFailure(hasQuickFix = false), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateSuccess(), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateWaitingForUser(), + ) + ), + ) +} + +fun aTroubleshootNotificationsState( + tests: List = emptyList(), + eventSink: (TroubleshootNotificationsEvents) -> Unit = {}, +) = TroubleshootNotificationsState( + eventSink = eventSink, + testSuiteState = TroubleshootTestSuiteState( + mainState = tests.computeMainState(), + tests = tests.toImmutableList(), + ), +) + +fun aTroubleshootTestState( + status: NotificationTroubleshootTestState.Status, + name: String = "Test", + description: String = "Description", +): NotificationTroubleshootTestState { + return NotificationTroubleshootTestState( + name = name, + description = description, + status = status, + ) +} + +fun aTroubleshootTestStateIdle(visible: Boolean = true) = + aTroubleshootTestState(status = NotificationTroubleshootTestState.Status.Idle(visible = visible)) + +fun aTroubleshootTestStateInProgress() = + aTroubleshootTestState(status = NotificationTroubleshootTestState.Status.InProgress) + +fun aTroubleshootTestStateWaitingForUser() = + aTroubleshootTestState(status = NotificationTroubleshootTestState.Status.WaitingForUser) + +fun aTroubleshootTestStateSuccess() = + aTroubleshootTestState(status = NotificationTroubleshootTestState.Status.Success) + +fun aTroubleshootTestStateFailure(hasQuickFix: Boolean) = + aTroubleshootTestState(status = NotificationTroubleshootTestState.Status.Failure(hasQuickFix = hasQuickFix)) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsView.kt new file mode 100644 index 0000000000..cadffa1ba0 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsView.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.notifications.troubleshoot + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState.Status +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +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.Text +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent + +/** + * A view that allows a user edit their global notification settings. + */ +@Composable +fun TroubleshootNotificationsView( + state: TroubleshootNotificationsState, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, +) { + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + if (state.hasFailedTests) { + state.eventSink(TroubleshootNotificationsEvents.RetryFailedTests) + } + } + else -> Unit + } + } + + PreferencePage( + modifier = modifier, + onBackPressed = onBackPressed, + title = "Troubleshoot notifications", + ) { + TroubleshootNotificationsContent(state) + } +} + +@Composable +private fun TroubleshootTestView( + testState: NotificationTroubleshootTestState, + onQuickFixClicked: () -> Unit, +) { + if ((testState.status as? Status.Idle)?.visible == false) return + ListItem( + headlineContent = { Text(text = testState.name) }, + supportingContent = { Text(text = testState.description) }, + trailingContent = when (testState.status) { + is Status.Idle -> null + Status.InProgress -> ListItemContent.Custom { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + Status.WaitingForUser -> ListItemContent.Custom { + Icon( + contentDescription = null, + modifier = Modifier.size(24.dp), + imageVector = CompoundIcons.Info(), + tint = ElementTheme.colors.iconAccentTertiary + ) + } + Status.Success -> ListItemContent.Custom { + Icon( + contentDescription = null, + modifier = Modifier.size(24.dp), + imageVector = CompoundIcons.Check(), + tint = ElementTheme.colors.iconAccentTertiary + ) + } + is Status.Failure -> ListItemContent.Custom { + Icon( + contentDescription = null, + modifier = Modifier.size(24.dp), + imageVector = CompoundIcons.Error(), + tint = ElementTheme.colors.textCriticalPrimary + ) + } + } + ) + if ((testState.status as? Status.Failure)?.hasQuickFix == true) { + ListItem( + headlineContent = { + }, + trailingContent = ListItemContent.Custom { + Button( + text = "Attempt to fix", + onClick = onQuickFixClicked + ) + } + ) + } +} + +@Composable +private fun TroubleshootNotificationsContent(state: TroubleshootNotificationsState) { + when (state.testSuiteState.mainState) { + AsyncAction.Loading, + AsyncAction.Confirming, + is AsyncAction.Success, + is AsyncAction.Failure -> { + TestSuiteView( + testSuiteState = state.testSuiteState, + onQuickFixClicked = { + state.eventSink(TroubleshootNotificationsEvents.QuickFix(it)) + } + ) + } + AsyncAction.Uninitialized -> Unit + } + when (state.testSuiteState.mainState) { + AsyncAction.Uninitialized -> { + ListItem(headlineContent = { + Text( + text = "Run the tests to detect any issue in your configuration " + + "that may make notifications not behave as expected." + ) + }) + RunTestButton(state = state) + } + AsyncAction.Loading -> Unit + is AsyncAction.Failure -> { + ListItem(headlineContent = { + Text(text = "Some tests failed, please check the details.") + }) + RunTestButton(state = state) + } + AsyncAction.Confirming -> { + ListItem(headlineContent = { + Text( + text = "Some tests require your attention. Please check the details." + ) + }) + } + is AsyncAction.Success -> { + ListItem(headlineContent = { + Text( + text = "All tests passed successfully." + ) + }) + } + } +} + +@Composable +private fun RunTestButton(state: TroubleshootNotificationsState) { + ListItem( + headlineContent = { + Button( + text = if (state.testSuiteState.mainState is AsyncAction.Failure) "Run tests again" else "Run tests", + onClick = { + state.eventSink(TroubleshootNotificationsEvents.StartTests) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + ) +} + +@Composable +private fun TestSuiteView( + testSuiteState: TroubleshootTestSuiteState, + onQuickFixClicked: (Int) -> Unit, +) { + testSuiteState.tests.forEachIndexed { index, testState -> + TroubleshootTestView( + testState = testState, + onQuickFixClicked = { + onQuickFixClicked(index) + }, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TroubleshootNotificationsViewPreview( + @PreviewParameter(TroubleshootNotificationsStateProvider::class) state: TroubleshootNotificationsState, +) = ElementPreview { + TroubleshootNotificationsView( + state = state, + onBackPressed = {}, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootTestSuite.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootTestSuite.kt new file mode 100644 index 0000000000..4996d278de --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootTestSuite.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.notifications.troubleshoot + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.notifications.NotificationTroubleshootTest +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.core.notifications.TestFilterData +import io.element.android.libraries.push.api.GetCurrentPushProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +class TroubleshootTestSuite @Inject constructor( + private val notificationTroubleshootTests: Set<@JvmSuppressWildcards NotificationTroubleshootTest>, + private val getCurrentPushProvider: GetCurrentPushProvider, +) { + lateinit var tests: List + + private val _state: MutableStateFlow = MutableStateFlow( + TroubleshootTestSuiteState( + mainState = AsyncAction.Uninitialized, + tests = emptyList().toImmutableList() + ) + ) + val state: StateFlow = _state + + suspend fun start(coroutineScope: CoroutineScope) { + val testFilterData = TestFilterData( + currentPushProviderName = getCurrentPushProvider.getCurrentPushProvider() + ) + tests = notificationTroubleshootTests + .filter { it.isRelevant(testFilterData) } + .sortedBy { it.order } + tests.forEach { + // Observe the state of the tests + it.state.onEach { + emitState() + }.launchIn(coroutineScope) + } + } + + suspend fun runTestSuite(coroutineScope: CoroutineScope) { + tests.forEach { + it.reset() + } + tests.forEach { + it.run(coroutineScope) + } + } + + suspend fun retryFailedTest(coroutineScope: CoroutineScope) { + tests + .filter { it.state.value.status is NotificationTroubleshootTestState.Status.Failure } + .forEach { + it.run(coroutineScope) + } + } + + private fun emitState() { + val states = tests.map { it.state.value } + _state.tryEmit( + TroubleshootTestSuiteState( + mainState = states.computeMainState(), + tests = states.toImmutableList() + ) + ) + } + + suspend fun quickFix(testIndex: Int, coroutineScope: CoroutineScope) { + tests[testIndex].quickFix(coroutineScope) + } +} + +fun List.computeMainState(): AsyncAction { + val isIdle = all { it.status is NotificationTroubleshootTestState.Status.Idle } + val isRunning = any { it.status is NotificationTroubleshootTestState.Status.InProgress } + return when { + isIdle -> AsyncAction.Uninitialized + isRunning -> AsyncAction.Loading + else -> { + if (any { it.status is NotificationTroubleshootTestState.Status.WaitingForUser }) { + AsyncAction.Confirming + } else if (any { it.status is NotificationTroubleshootTestState.Status.Failure }) { + AsyncAction.Failure(Exception("Some tests failed")) + } else { + AsyncAction.Success(Unit) + } + } + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootTestSuiteState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootTestSuiteState.kt new file mode 100644 index 0000000000..e516b65109 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootTestSuiteState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.notifications.troubleshoot + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import kotlinx.collections.immutable.ImmutableList + +data class TroubleshootTestSuiteState( + val mainState: AsyncAction, + val tests: ImmutableList, +) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/FakeNotificationTroubleshootTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/FakeNotificationTroubleshootTest.kt new file mode 100644 index 0000000000..e619a62972 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/FakeNotificationTroubleshootTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.notifications.troubleshoot + +import io.element.android.libraries.core.notifications.NotificationTroubleshootTest +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeNotificationTroubleshootTest( + override val order: Int = 0, + private val defaultName: String = "test name", + private val defaultDescription: String = "test description", + private val firstStatus: NotificationTroubleshootTestState.Status = NotificationTroubleshootTestState.Status.Idle(visible = true), + private val runAction: () -> NotificationTroubleshootTestState? = { null }, + private val resetAction: () -> NotificationTroubleshootTestState? = { null }, + private val quickFixAction: () -> NotificationTroubleshootTestState? = { null }, +) : NotificationTroubleshootTest { + private val _state = MutableStateFlow( + NotificationTroubleshootTestState( + name = defaultName, + description = defaultDescription, + status = firstStatus + ) + ) + override val state: StateFlow = _state.asStateFlow() + + override suspend fun run(coroutineScope: CoroutineScope) { + updateState(NotificationTroubleshootTestState.Status.InProgress) + runAction()?.let { + _state.tryEmit(it) + } + } + + override fun reset() { + updateState( + name = defaultName, + description = defaultDescription, + status = firstStatus, + ) + resetAction()?.let { + _state.tryEmit(it) + } + } + + override suspend fun quickFix(coroutineScope: CoroutineScope) { + updateState(NotificationTroubleshootTestState.Status.InProgress) + quickFixAction()?.let { + _state.tryEmit(it) + } + } + + fun updateState( + status: NotificationTroubleshootTestState.Status, + name: String = defaultName, + description: String = defaultDescription, + ) { + _state.tryEmit( + NotificationTroubleshootTestState( + name = name, + description = description, + status = status, + ) + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsPresenterTests.kt new file mode 100644 index 0000000000..e6e712b1b8 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsPresenterTests.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.notifications.troubleshoot + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.notifications.NotificationTroubleshootTest +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.push.test.FakeGetCurrentPushProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class TroubleshootNotificationsPresenterTests { + @Test + fun `present - initial state`() = runTest { + val presenter = createTroubleshootNotificationsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.testSuiteState.tests).isEmpty() + assertThat(initialState.testSuiteState.mainState).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - start test`() = runTest { + val troubleshootTestSuite = createTroubleshootTestSuite( + tests = setOf(FakeNotificationTroubleshootTest()) + ) + val presenter = createTroubleshootNotificationsPresenter( + troubleshootTestSuite = troubleshootTestSuite, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(TroubleshootNotificationsEvents.StartTests) + skipItems(1) + val stateAfterStart = awaitItem() + assertThat(stateAfterStart.testSuiteState.mainState).isEqualTo(AsyncAction.Loading) + } + } + + @Test + fun `present - start failed test`() = runTest { + val troubleshootTestSuite = createTroubleshootTestSuite( + tests = setOf( + FakeNotificationTroubleshootTest( + firstStatus = NotificationTroubleshootTestState.Status.Failure(hasQuickFix = false) + ) + ) + ) + val presenter = createTroubleshootNotificationsPresenter( + troubleshootTestSuite = troubleshootTestSuite, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(TroubleshootNotificationsEvents.RetryFailedTests) + skipItems(1) + val stateAfterStart = awaitItem() + assertThat(stateAfterStart.testSuiteState.mainState).isEqualTo(AsyncAction.Loading) + } + } + + @Test + fun `present - quick fix test`() = runTest { + val troubleshootTestSuite = createTroubleshootTestSuite( + tests = setOf( + FakeNotificationTroubleshootTest( + firstStatus = NotificationTroubleshootTestState.Status.Failure(hasQuickFix = false) + ) + ) + ) + val presenter = createTroubleshootNotificationsPresenter( + troubleshootTestSuite = troubleshootTestSuite, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.testSuiteState.mainState).isInstanceOf(AsyncAction.Failure::class.java) + initialState.eventSink(TroubleshootNotificationsEvents.QuickFix(0)) + val stateAfterStart = awaitItem() + assertThat(stateAfterStart.testSuiteState.mainState).isEqualTo(AsyncAction.Loading) + } + } + + private fun createTroubleshootTestSuite( + tests: Set = emptySet(), + currentPushProvider: String? = null, + ): TroubleshootTestSuite { + return TroubleshootTestSuite( + notificationTroubleshootTests = tests, + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider), + ) + } + + private fun createTroubleshootNotificationsPresenter( + troubleshootTestSuite: TroubleshootTestSuite = createTroubleshootTestSuite(), + ): TroubleshootNotificationsPresenter { + return TroubleshootNotificationsPresenter( + troubleshootTestSuite = troubleshootTestSuite, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsViewTest.kt new file mode 100644 index 0000000000..af2b37d7c0 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/troubleshoot/TroubleshootNotificationsViewTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.notifications.troubleshoot + +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.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class TroubleshootNotificationsViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `press menu back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setTroubleshootNotificationsView( + state = aTroubleshootNotificationsState( + eventSink = eventsRecorder + ), + onBackPressed = it, + ) + rule.pressBack() + } + } + + @Test + fun `clicking on run test emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setTroubleshootNotificationsView( + aTroubleshootNotificationsState( + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText("Run tests").performClick() + eventsRecorder.assertSingle(TroubleshootNotificationsEvents.StartTests) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on run test again emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setTroubleshootNotificationsView( + aTroubleshootNotificationsState( + tests = listOf(aTroubleshootTestStateFailure(hasQuickFix = false)), + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText("Run tests again").performClick() + eventsRecorder.assertList( + listOf( + TroubleshootNotificationsEvents.RetryFailedTests, + TroubleshootNotificationsEvents.StartTests, + ) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on quick fix emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setTroubleshootNotificationsView( + aTroubleshootNotificationsState( + tests = listOf(aTroubleshootTestStateFailure(hasQuickFix = true)), + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText("Attempt to fix").performClick() + eventsRecorder.assertList( + listOf( + TroubleshootNotificationsEvents.RetryFailedTests, + TroubleshootNotificationsEvents.QuickFix(0), + ) + ) + } +} + +private fun AndroidComposeTestRule.setTroubleshootNotificationsView( + state: TroubleshootNotificationsState, + onBackPressed: () -> Unit = EnsureNeverCalled(), +) { + setContent { + TroubleshootNotificationsView( + state = state, + onBackPressed = onBackPressed, + ) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 422307ffd9..9f473a7089 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.androidutils.system +import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent @@ -73,6 +74,9 @@ fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResu val intent = Intent() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS + if (this !is Activity && activityResultLauncher == null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName) } else { intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS @@ -152,8 +156,12 @@ fun Context.startSharePlainTextIntent( fun Context.openUrlInExternalApp( url: String, errorMessage: String = getString(R.string.error_no_compatible_app_found), + inNewTask: Boolean = false, ) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + if (inNewTask) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } try { startActivity(intent) } catch (activityNotFoundException: ActivityNotFoundException) { diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTest.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTest.kt new file mode 100644 index 0000000000..755e186e8b --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.core.notifications + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +interface NotificationTroubleshootTest { + val order: Int + val state: StateFlow + fun isRelevant(data: TestFilterData): Boolean = true + suspend fun run(coroutineScope: CoroutineScope) + fun reset() + suspend fun quickFix(coroutineScope: CoroutineScope) {} +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTestDelegate.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTestDelegate.kt new file mode 100644 index 0000000000..9711677253 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTestDelegate.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.core.notifications + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * A NotificationTroubleshootTest delegate, with common pattern for running and resetting. + */ +class NotificationTroubleshootTestDelegate( + private val defaultName: String, + private val defaultDescription: String, + private val visibleWhenIdle: Boolean = true, + private val hasQuickFix: Boolean = false, + private val fakeDelay: Long = 0L, +) { + private val _state: MutableStateFlow = MutableStateFlow( + NotificationTroubleshootTestState( + name = defaultName, + description = defaultDescription, + status = NotificationTroubleshootTestState.Status.Idle(visibleWhenIdle), + ) + ) + + val state: StateFlow = _state.asStateFlow() + + fun updateState( + status: NotificationTroubleshootTestState.Status, + name: String = defaultName, + description: String = defaultDescription, + ) { + _state.tryEmit( + NotificationTroubleshootTestState( + name = name, + description = description, + status = status, + ) + ) + } + + fun reset() { + updateState(NotificationTroubleshootTestState.Status.Idle(visibleWhenIdle)) + } + + suspend fun start() { + updateState(NotificationTroubleshootTestState.Status.InProgress) + delay(fakeDelay) + } + + fun done(isSuccess: Boolean = true) { + updateState( + if (isSuccess) { + NotificationTroubleshootTestState.Status.Success + } else { + NotificationTroubleshootTestState.Status.Failure(hasQuickFix) + } + ) + } + + companion object { + const val SHORT_DELAY = 300L + const val LONG_DELAY = 500L + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTestState.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTestState.kt new file mode 100644 index 0000000000..9657be4b3b --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/NotificationTroubleshootTestState.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.core.notifications + +data class NotificationTroubleshootTestState( + val name: String, + val description: String, + val status: Status, +) { + sealed interface Status { + data class Idle(val visible: Boolean) : Status + data object InProgress : Status + data object WaitingForUser : Status + data object Success : Status + data class Failure(val hasQuickFix: Boolean) : Status + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/TestFilterData.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/TestFilterData.kt new file mode 100644 index 0000000000..e22fefb7f1 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/notifications/TestFilterData.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.core.notifications + +data class TestFilterData( + val currentPushProviderName: String?, +) diff --git a/libraries/permissions/api/build.gradle.kts b/libraries/permissions/api/build.gradle.kts index 32d3776419..cb60c6c0a7 100644 --- a/libraries/permissions/api/build.gradle.kts +++ b/libraries/permissions/api/build.gradle.kts @@ -24,7 +24,6 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) diff --git a/libraries/permissions/impl/build.gradle.kts b/libraries/permissions/impl/build.gradle.kts index 7d05d9a1d7..82904bc750 100644 --- a/libraries/permissions/impl/build.gradle.kts +++ b/libraries/permissions/impl/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) + implementation(projects.services.toolbox.api) api(projects.libraries.permissions.api) testImplementation(libs.test.junit) @@ -57,6 +58,7 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.permissions.test) + testImplementation(projects.services.toolbox.test) testImplementation(projects.tests.testutils) ksp(libs.showkase.processor) diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt index c05df3de46..61a82aafff 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt @@ -17,6 +17,8 @@ package io.element.android.libraries.permissions.impl import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext @@ -33,7 +35,10 @@ class DefaultPermissionStateProvider @Inject constructor( private val permissionsStore: PermissionsStore, ) : PermissionStateProvider { override fun isPermissionGranted(permission: String): Boolean { - return context.checkSelfPermission(permission) == android.content.pm.PackageManager.PERMISSION_GRANTED + return ContextCompat.checkSelfPermission( + context, + permission, + ) == PackageManager.PERMISSION_GRANTED } override suspend fun setPermissionDenied(permission: String, value: Boolean) = permissionsStore.setPermissionDenied(permission, value) diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt index 6370e839a3..405b61a809 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt @@ -18,7 +18,7 @@ package io.element.android.libraries.permissions.impl.action import android.content.Context import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.androidutils.system.openAppSettingsPage +import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import javax.inject.Inject @@ -28,6 +28,6 @@ class AndroidPermissionActions @Inject constructor( @ApplicationContext private val context: Context ) : PermissionActions { override fun openSettings() { - context.openAppSettingsPage() + context.startNotificationSettingsIntent() } } diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt new file mode 100644 index 0000000000..501b00e14a --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.permissions.impl.troubleshoot + +import android.Manifest +import android.os.Build +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.core.notifications.NotificationTroubleshootTest +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestDelegate +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.permissions.api.PermissionStateProvider +import io.element.android.libraries.permissions.impl.action.PermissionActions +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class NotificationTroubleshootCheckPermissionTest @Inject constructor( + private val permissionStateProvider: PermissionStateProvider, + private val sdkVersionProvider: BuildVersionSdkIntProvider, + private val permissionActions: PermissionActions, +) : NotificationTroubleshootTest { + override val order: Int = 0 + + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = "Check permissions", + defaultDescription = "Ensure that the application can show notifications.", + hasQuickFix = true, + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + + override val state: StateFlow = delegate.state + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val result = if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { + permissionStateProvider.isPermissionGranted(Manifest.permission.POST_NOTIFICATIONS) + } else { + true + } + delegate.done(result) + } + + override fun reset() = delegate.reset() + + override suspend fun quickFix(coroutineScope: CoroutineScope) { + // Do not bother about asking the permission inline, just lead the user to the settings + permissionActions.openSettings() + } +} diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt index fa17329900..f6709a7f3d 100644 --- a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt @@ -16,11 +16,14 @@ package io.element.android.libraries.permissions.impl.action -class FakePermissionActions : PermissionActions { +class FakePermissionActions( + val openSettingsAction: () -> Unit = {} +) : PermissionActions { var openSettingsCalled = false private set override fun openSettings() { + openSettingsAction() openSettingsCalled = true } } diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTestTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTestTest.kt new file mode 100644 index 0000000000..80c07e2ce0 --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTestTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.permissions.impl.troubleshoot + +import android.os.Build +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.permissions.impl.FakePermissionStateProvider +import io.element.android.libraries.permissions.impl.action.FakePermissionActions +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class NotificationTroubleshootCheckPermissionTestTest { + @Test + fun `test NotificationTroubleshootCheckPermissionTest below TIRAMISU success`() = runTest { + val sut = NotificationTroubleshootCheckPermissionTest( + permissionStateProvider = FakePermissionStateProvider(), + sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkInt = Build.VERSION_CODES.TIRAMISU - 1), + permissionActions = FakePermissionActions() + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test NotificationTroubleshootCheckPermissionTest TIRAMISU success`() = runTest { + val sut = NotificationTroubleshootCheckPermissionTest( + permissionStateProvider = FakePermissionStateProvider(), + sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkInt = Build.VERSION_CODES.TIRAMISU), + permissionActions = FakePermissionActions() + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test NotificationTroubleshootCheckPermissionTest TIRAMISU error`() = runTest { + val permissionStateProvider = FakePermissionStateProvider( + permissionGranted = false + ) + val actions = FakePermissionActions( + openSettingsAction = { + permissionStateProvider.setPermissionGranted() + } + ) + val sut = NotificationTroubleshootCheckPermissionTest( + permissionStateProvider = permissionStateProvider, + sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkInt = Build.VERSION_CODES.TIRAMISU), + permissionActions = actions + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(true)) + // Quick fix + launch { + sut.quickFix(this) + // Run the test again (IRL it will be done thanks to the resuming of the application) + sut.run(this) + } + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt new file mode 100644 index 0000000000..0c6ed41929 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.api + +interface GetCurrentPushProvider { + suspend fun getCurrentPushProvider(): String? +} 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 5f4736e5ab..abfc328e9f 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 @@ -37,6 +37,8 @@ interface PushService { */ suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) - // TODO Move away - suspend fun testPush() + /** + * Return false in case of early error. + */ + suspend fun testPush(): Boolean } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt index c7814a1796..07cf9acf52 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt @@ -16,6 +16,6 @@ package io.element.android.libraries.push.api.gateway -sealed class PushGatewayFailure : Throwable(cause = null) { - data object PusherRejected : PushGatewayFailure() +sealed class PushGatewayFailure : Exception() { + class PusherRejected : PushGatewayFailure() } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 7df972c3c3..f16f7f23f0 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -69,6 +69,8 @@ dependencies { testImplementation(libs.coil.test) testImplementation(libs.coroutines.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushproviders.test) testImplementation(projects.tests.testutils) testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.toolbox.impl) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultGetCurrentPushProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultGetCurrentPushProvider.kt new file mode 100644 index 0000000000..bdf36b7f31 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultGetCurrentPushProvider.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.api.GetCurrentPushProvider +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.currentSessionId +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultGetCurrentPushProvider @Inject constructor( + private val pushStoreFactory: UserPushStoreFactory, + private val appNavigationStateService: AppNavigationStateService, +) : GetCurrentPushProvider { + override suspend fun getCurrentPushProvider(): String? { + return appNavigationStateService + .appNavigationState + .value + .navigationState + .currentSessionId() + ?.let { pushStoreFactory.create(it) } + ?.getPushProviderName() + } +} 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 e60cd8b014..5a4df57b47 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 @@ -19,6 +19,7 @@ package io.element.android.libraries.push.impl import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.push.api.GetCurrentPushProvider import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager import io.element.android.libraries.pushproviders.api.Distributor @@ -32,6 +33,7 @@ class DefaultPushService @Inject constructor( private val pushersManager: PushersManager, private val userPushStoreFactory: UserPushStoreFactory, private val pushProviders: Set<@JvmSuppressWildcards PushProvider>, + private val getCurrentPushProvider: GetCurrentPushProvider, ) : PushService { override fun notificationStyleChanged() { defaultNotificationDrawerManager.notificationStyleChanged() @@ -58,7 +60,11 @@ class DefaultPushService @Inject constructor( userPushStore.setPushProviderName(pushProvider.name) } - override suspend fun testPush() { - pushersManager.testPush() + override suspend fun testPush(): Boolean { + val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider() + val pushProvider = pushProviders.find { it.name == currentPushProvider } ?: return false + val config = pushProvider.getCurrentUserPushConfig() ?: return false + pushersManager.testPush(config) + return true } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt index d4424da492..dbefc03126 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt @@ -23,9 +23,11 @@ import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.MatrixClient 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.pusher.SetHttpPusherData import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig import io.element.android.libraries.pushproviders.api.PusherSubscriber import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret @@ -45,16 +47,14 @@ class PushersManager @Inject constructor( private val pushClientSecret: PushClientSecret, private val userPushStoreFactory: UserPushStoreFactory, ) : PusherSubscriber { - // TODO Move this to the PushProvider API - suspend fun testPush() { + suspend fun testPush(config: CurrentUserPushConfig) { pushGatewayNotifyRequest.execute( PushGatewayNotifyRequest.Params( - // unifiedPushHelper.getPushGateway() ?: return - url = "TODO", + url = config.url, appId = PushConfig.PUSHER_APP_ID, - // unifiedPushHelper.getEndpointOrToken().orEmpty() - pushKey = "TODO", - eventId = TEST_EVENT_ID + pushKey = config.pushKey, + eventId = TEST_EVENT_ID, + roomId = TEST_ROOM_ID, ) ) } @@ -112,5 +112,6 @@ class PushersManager @Inject constructor( companion object { val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID") + val TEST_ROOM_ID = RoomId("!room:domain") } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt index 2cb01ba2f7..04202bbb2f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -17,7 +17,6 @@ package io.element.android.libraries.push.impl.notifications import android.Manifest -import android.annotation.SuppressLint import android.app.Notification import android.content.Context import android.content.pm.PackageManager @@ -32,12 +31,13 @@ class NotificationDisplayer @Inject constructor( ) { private val notificationManager = NotificationManagerCompat.from(context) - fun showNotificationMessage(tag: String?, id: Int, notification: Notification) { + fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean { if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { Timber.w("Not allowed to notify.") - return + return false } notificationManager.notify(tag, id, notification) + return true } fun cancelNotificationMessage(tag: String?, id: Int) { @@ -53,15 +53,21 @@ class NotificationDisplayer @Inject constructor( } } - @SuppressLint("LaunchActivityFromNotification") - fun displayDiagnosticNotification(notification: Notification) { - showNotificationMessage( + fun displayDiagnosticNotification(notification: Notification): Boolean { + return showNotificationMessage( tag = "DIAGNOSTIC", id = NOTIFICATION_ID_DIAGNOSTIC, notification = notification ) } + fun dismissDiagnosticNotification() { + cancelNotificationMessage( + tag = "DIAGNOSTIC", + id = NOTIFICATION_ID_DIAGNOSTIC + ) + } + /** * Cancel the foreground notification service. */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt index 152ed0a03e..0bc3e69bfb 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt @@ -19,9 +19,15 @@ package io.element.android.libraries.push.impl.notifications import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.push.impl.troubleshoot.NotificationClickHandler +import javax.inject.Inject class TestNotificationReceiver : BroadcastReceiver() { + @Inject lateinit var notificationClickHandler: NotificationClickHandler + override fun onReceive(context: Context, intent: Intent) { - // TODO The test notification has been clicked, notify the ui + context.bindings().inject(this) + notificationClickHandler.handleNotificationClick() } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiverBinding.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiverBinding.kt new file mode 100644 index 0000000000..6390bff885 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiverBinding.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.di.AppScope + +@ContributesTo(AppScope::class) +interface TestNotificationReceiverBinding { + fun inject(service: TestNotificationReceiver) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt index 2beddc53f9..67c8973d13 100755 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt @@ -299,6 +299,7 @@ class NotificationCreator @Inject constructor( } fun createDiagnosticNotification(): Notification { + val intent = pendingIntentFactory.createTestPendingIntent() return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest()) .setContentTitle(buildMeta.applicationName) .setContentText(stringProvider.getString(R.string.notification_test_push_notification_content)) @@ -308,7 +309,8 @@ class NotificationCreator @Inject constructor( .setPriority(NotificationCompat.PRIORITY_MAX) .setCategory(NotificationCompat.CATEGORY_STATUS) .setAutoCancel(true) - .setContentIntent(pendingIntentFactory.createTestPendingIntent()) + .setContentIntent(intent) + .setDeleteIntent(intent) .build() } 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 a930db2708..5de0620eca 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 @@ -27,6 +27,7 @@ import io.element.android.libraries.push.impl.PushersManager import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver import io.element.android.libraries.push.impl.store.DefaultPushDataStore +import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushstore.api.UserPushStoreFactory @@ -51,6 +52,7 @@ class DefaultPushHandler @Inject constructor( // private val actionIds: NotificationActionIds, private val buildMeta: BuildMeta, private val matrixAuthenticationService: MatrixAuthenticationService, + private val diagnosticPushHandler: DiagnosticPushHandler, ) : PushHandler { private val coroutineScope = CoroutineScope(SupervisorJob()) @@ -75,8 +77,7 @@ class DefaultPushHandler @Inject constructor( // Diagnostic Push if (pushData.eventId == PushersManager.TEST_EVENT_ID) { - // val intent = Intent(actionIds.push) - // TODO The test push has been received, notify the ui + diagnosticPushHandler.handlePush() return } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt index 9e52d94049..5e341e3286 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt @@ -23,6 +23,8 @@ import kotlinx.serialization.Serializable internal data class PushGatewayNotification( @SerialName("event_id") val eventId: String, + @SerialName("room_id") + val roomId: String, /** * Required. This is an array of devices that the notification should be sent to. */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt index 7130e38d6e..e8c01493ab 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.push.impl.pushgateway import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.network.RetrofitFactory import io.element.android.libraries.push.api.gateway.PushGatewayFailure import javax.inject.Inject @@ -27,7 +28,8 @@ class PushGatewayNotifyRequest @Inject constructor( val url: String, val appId: String, val pushKey: String, - val eventId: EventId + val eventId: EventId, + val roomId: RoomId, ) suspend fun execute(params: Params) { @@ -40,6 +42,7 @@ class PushGatewayNotifyRequest @Inject constructor( PushGatewayNotifyBody( PushGatewayNotification( eventId = params.eventId.value, + roomId = params.roomId.value, devices = listOf( PushGatewayDevice( params.appId, @@ -51,7 +54,7 @@ class PushGatewayNotifyRequest @Inject constructor( ) if (response.rejectedPushKeys.contains(params.pushKey)) { - throw PushGatewayFailure.PusherRejected + throw PushGatewayFailure.PusherRejected() } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTest.kt new file mode 100644 index 0000000000..6b8f69e8db --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.core.notifications.NotificationTroubleshootTest +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestDelegate +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.api.GetCurrentPushProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class CurrentPushProviderTest @Inject constructor( + private val getCurrentPushProvider: GetCurrentPushProvider, +) : NotificationTroubleshootTest { + override val order = 110 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = "Current push provider", + defaultDescription = "Get the name of the current provider.", + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + override val state: StateFlow = delegate.state + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val provider = getCurrentPushProvider.getCurrentPushProvider() + if (provider != null) { + delegate.updateState( + description = "Current push provider: $provider", + status = NotificationTroubleshootTestState.Status.Success + ) + } else { + delegate.updateState( + description = "No push providers selected", + status = NotificationTroubleshootTestState.Status.Failure(false) + ) + } + } + + override fun reset() = delegate.reset() +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/DiagnosticPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/DiagnosticPushHandler.kt new file mode 100644 index 0000000000..21b78161d9 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/DiagnosticPushHandler.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import javax.inject.Inject + +@SingleIn(AppScope::class) +class DiagnosticPushHandler @Inject constructor() { + private val _state = MutableSharedFlow() + val state: SharedFlow = _state + + suspend fun handlePush() { + _state.emit(Unit) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationClickHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationClickHandler.kt new file mode 100644 index 0000000000..29f5fe0b9f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationClickHandler.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import javax.inject.Inject + +@SingleIn(AppScope::class) +class NotificationClickHandler @Inject constructor() { + private val _state = MutableSharedFlow(extraBufferCapacity = 1) + val state: SharedFlow = _state + + fun handleNotificationClick() { + _state.tryEmit(Unit) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTest.kt new file mode 100644 index 0000000000..b2e7ef223c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.core.notifications.NotificationTroubleshootTest +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestDelegate +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.impl.notifications.NotificationDisplayer +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@ContributesMultibinding(AppScope::class) +class NotificationTest @Inject constructor( + private val notificationCreator: NotificationCreator, + private val notificationDisplayer: NotificationDisplayer, + private val notificationClickHandler: NotificationClickHandler +) : NotificationTroubleshootTest { + override val order = 50 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = "Display notification", + defaultDescription = "Check that the application can display notification", + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + override val state: StateFlow = delegate.state + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val notification = notificationCreator.createDiagnosticNotification() + val result = notificationDisplayer.displayDiagnosticNotification(notification) + if (result) { + coroutineScope.listenToNotificationClick() + delegate.updateState( + description = "Please click on the notification to continue the test.", + status = NotificationTroubleshootTestState.Status.WaitingForUser + ) + } else { + delegate.updateState( + description = "Cannot display the notification.", + status = NotificationTroubleshootTestState.Status.Failure(false) + ) + } + } + + private fun CoroutineScope.listenToNotificationClick() = launch { + val job = launch { + notificationClickHandler.state.first() + Timber.d("Notification clicked!") + } + val s = withTimeoutOrNull(30.seconds) { + job.join() + } + job.cancel() + if (s == null) { + notificationDisplayer.dismissDiagnosticNotification() + delegate.updateState( + description = "The notification has not been clicked.", + status = NotificationTroubleshootTestState.Status.Failure(false) + ) + } else { + delegate.updateState( + description = "The notification has been clicked!", + status = NotificationTroubleshootTestState.Status.Success + ) + } + }.invokeOnCompletion { + // Ensure that the notification is cancelled when the screen is left + notificationDisplayer.dismissDiagnosticNotification() + } + + override fun reset() = delegate.reset() +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt new file mode 100644 index 0000000000..86d7b437a7 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.core.notifications.NotificationTroubleshootTest +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestDelegate +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.api.gateway.PushGatewayFailure +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@ContributesMultibinding(AppScope::class) +class PushLoopbackTest @Inject constructor( + private val pushService: PushService, + private val diagnosticPushHandler: DiagnosticPushHandler, + private val clock: SystemClock, +) : NotificationTroubleshootTest { + override val order = 500 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = "Test Push loopback", + defaultDescription = "Ensure that the application is receiving push.", + ) + override val state: StateFlow = delegate.state + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val startTime = clock.epochMillis() + val completable = CompletableDeferred() + val job = coroutineScope.launch { + diagnosticPushHandler.state.first() + completable.complete(clock.epochMillis() - startTime) + } + val testPushResult = try { + pushService.testPush() + } catch (pusherRejected: PushGatewayFailure.PusherRejected) { + delegate.updateState( + description = "Error: pusher has rejected the request.", + status = NotificationTroubleshootTestState.Status.Failure(false) + ) + job.cancel() + return + } catch (e: Exception) { + Timber.e(e, "Failed to test push") + delegate.updateState( + description = "Error: ${e.message}.", + status = NotificationTroubleshootTestState.Status.Failure(false) + ) + job.cancel() + return + } + if (!testPushResult) { + delegate.updateState( + description = "Error, cannot test push.", + status = NotificationTroubleshootTestState.Status.Failure(false) + ) + job.cancel() + return + } + val result = withTimeoutOrNull(10.seconds) { + completable.await() + } + job.cancel() + if (result == null) { + delegate.updateState( + description = "Error, timeout waiting for push.", + status = NotificationTroubleshootTestState.Status.Failure(false) + ) + } else { + delegate.updateState( + description = "Push loopback took $result ms", + status = NotificationTroubleshootTestState.Status.Success + ) + } + } + + override fun reset() = delegate.reset() +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt new file mode 100644 index 0000000000..7beca906b9 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.core.notifications.NotificationTroubleshootTest +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestDelegate +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.pushproviders.api.PushProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class PushProvidersTest @Inject constructor( + pushProviders: Set<@JvmSuppressWildcards PushProvider>, +) : NotificationTroubleshootTest { + private val sortedPushProvider = pushProviders.sortedBy { it.index } + override val order = 100 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = "Detect push providers", + defaultDescription = "Ensure that the application has at least one push provider.", + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + override val state: StateFlow = delegate.state + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val result = sortedPushProvider.isNotEmpty() + if (result) { + delegate.updateState( + description = "Found ${sortedPushProvider.size} push providers: ${sortedPushProvider.joinToString { it.name }}", + status = NotificationTroubleshootTestState.Status.Success + ) + } else { + delegate.updateState( + description = "No push providers found", + status = NotificationTroubleshootTestState.Status.Failure(false) + ) + } + } + + override fun reset() = delegate.reset() +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index e641029c0d..a8058fbcc5 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.test.A_SPACE_ID import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.core.aBuildMeta -import io.element.android.libraries.push.impl.notifications.fake.FakeAndroidNotificationFactory +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoaderHolder import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator @@ -118,7 +118,7 @@ class DefaultNotificationDrawerManagerTest { NotificationIdProvider(), NotificationDisplayer(context), NotificationFactory( - FakeAndroidNotificationFactory().instance, + FakeNotificationCreator().instance, FakeRoomGroupMessageCreator().instance, FakeSummaryGroupMessageCreator().instance, ) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt index 50ee91f448..cd1d19b3a7 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt @@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.push.impl.notifications.fake.FakeAndroidNotificationFactory +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator @@ -41,7 +41,7 @@ private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roo @RunWith(RobolectricTestRunner::class) class NotificationFactoryTest { - private val androidNotificationFactory = FakeAndroidNotificationFactory() + private val androidNotificationFactory = FakeNotificationCreator() private val roomGroupMessageCreator = FakeRoomGroupMessageCreator() private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt similarity index 85% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt index 6637b64979..5029166f06 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt @@ -23,7 +23,7 @@ import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiab import io.mockk.every import io.mockk.mockk -class FakeAndroidNotificationFactory { +class FakeNotificationCreator { val instance = mockk() fun givenCreateRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification { @@ -37,4 +37,10 @@ class FakeAndroidNotificationFactory { every { instance.createSimpleEventNotification(event) } returns mockNotification return mockNotification } + + fun givenCreateDiagnosticNotification(): Notification { + val mockNotification = mockk() + every { instance.createDiagnosticNotification() } returns mockNotification + return mockNotification + } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt index 9af681490a..d737c8eae2 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.push.impl.notifications.NotificationDisplayer import io.element.android.libraries.push.impl.notifications.NotificationIdProvider import io.mockk.confirmVerified +import io.mockk.every import io.mockk.mockk import io.mockk.verify import io.mockk.verifyOrder @@ -27,6 +28,10 @@ import io.mockk.verifyOrder class FakeNotificationDisplayer { val instance = mockk(relaxed = true) + fun givenDisplayDiagnosticNotificationResult(result: Boolean) { + every { instance.displayDiagnosticNotification(any()) } returns result + } + fun verifySummaryCancelled() { verify { instance.cancelNotificationMessage(tag = null, NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTestTest.kt new file mode 100644 index 0000000000..8506d826b5 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTestTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.push.test.FakeGetCurrentPushProvider +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CurrentPushProviderTestTest { + @Test + fun `test CurrentPushProviderTest with a push provider`() = runTest { + val sut = CurrentPushProviderTest( + getCurrentPushProvider = FakeGetCurrentPushProvider("foo") + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + assertThat(lastItem.description).contains("foo") + } + } + + @Test + fun `test CurrentPushProviderTest without push provider`() = runTest { + val sut = CurrentPushProviderTest( + getCurrentPushProvider = FakeGetCurrentPushProvider(null) + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(false)) + } + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt new file mode 100644 index 0000000000..fcef1dcbab --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class NotificationTestTest { + private val fakeNotificationCreator = FakeNotificationCreator().apply { + givenCreateDiagnosticNotification() + } + private val fakeNotificationDisplayer = FakeNotificationDisplayer().apply { + givenDisplayDiagnosticNotificationResult(true) + } + + private val notificationClickHandler = NotificationClickHandler() + + @Test + fun `test NotificationTest notification cannot be displayed`() = runTest { + fakeNotificationDisplayer.givenDisplayDiagnosticNotificationResult(false) + val sut = createNotificationTest() + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isInstanceOf(NotificationTroubleshootTestState.Status.Failure::class.java) + } + } + + @Test + fun `test NotificationTest user does not click on notification`() = runTest { + val sut = createNotificationTest() + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.WaitingForUser) + assertThat(awaitItem().status).isInstanceOf(NotificationTroubleshootTestState.Status.Failure::class.java) + } + } + + @Test + fun `test NotificationTest user clicks on notification`() = runTest { + val sut = createNotificationTest() + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.WaitingForUser) + notificationClickHandler.handleNotificationClick() + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + private fun createNotificationTest(): NotificationTest { + return NotificationTest( + notificationCreator = fakeNotificationCreator.instance, + notificationDisplayer = fakeNotificationDisplayer.instance, + notificationClickHandler = notificationClickHandler + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt new file mode 100644 index 0000000000..354ec60bb9 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import io.element.android.libraries.push.api.gateway.PushGatewayFailure +import io.element.android.libraries.push.test.FakePushService +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PushLoopbackTestTest { + @Test + fun `test PushLoopbackTest timeout - push is not received`() = runTest { + val diagnosticPushHandler = DiagnosticPushHandler() + val sut = PushLoopbackTest( + pushService = FakePushService(), + diagnosticPushHandler = diagnosticPushHandler, + clock = FakeSystemClock() + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(false)) + assertThat(lastItem.description).contains("timeout") + } + } + + @Test + fun `test PushLoopbackTest PusherRejected error`() = runTest { + val diagnosticPushHandler = DiagnosticPushHandler() + val sut = PushLoopbackTest( + pushService = FakePushService( + testPushBlock = { + throw PushGatewayFailure.PusherRejected() + } + ), + diagnosticPushHandler = diagnosticPushHandler, + clock = FakeSystemClock() + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(false)) + assertThat(lastItem.description).contains("rejected") + } + } + + @Test + fun `test PushLoopbackTest setup error`() = runTest { + val diagnosticPushHandler = DiagnosticPushHandler() + val sut = PushLoopbackTest( + pushService = FakePushService( + testPushBlock = { false } + ), + diagnosticPushHandler = diagnosticPushHandler, + clock = FakeSystemClock() + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(false)) + assertThat(lastItem.description).contains("cannot test push") + } + } + + @Test + fun `test PushLoopbackTest other error`() = runTest { + val diagnosticPushHandler = DiagnosticPushHandler() + val sut = PushLoopbackTest( + pushService = FakePushService( + testPushBlock = { + throw AN_EXCEPTION + } + ), + diagnosticPushHandler = diagnosticPushHandler, + clock = FakeSystemClock() + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(false)) + assertThat(lastItem.description).contains(A_FAILURE_REASON) + } + } + + @Test + fun `test PushLoopbackTest push is received`() = runTest { + val diagnosticPushHandler = DiagnosticPushHandler() + val sut = PushLoopbackTest( + pushService = FakePushService(testPushBlock = { + diagnosticPushHandler.handlePush() + true + }), + diagnosticPushHandler = diagnosticPushHandler, + clock = FakeSystemClock() + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTestTest.kt new file mode 100644 index 0000000000..644392c11b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTestTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.pushproviders.test.FakePushProvider +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PushProvidersTestTest { + @Test + fun `test PushProvidersTest with empty list`() = runTest { + val sut = PushProvidersTest( + pushProviders = emptySet(), + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(false)) + } + } + + @Test + fun `test PushProvidersTest with 2 push providers`() = runTest { + val sut = PushProvidersTest( + pushProviders = setOf( + FakePushProvider(name = "foo"), + FakePushProvider(name = "bar"), + ), + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + assertThat(lastItem.description).contains("foo") + assertThat(lastItem.description).contains("bar") + } + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakeGetCurrentPushProvider.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakeGetCurrentPushProvider.kt new file mode 100644 index 0000000000..76363c9d99 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakeGetCurrentPushProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.test + +import io.element.android.libraries.push.api.GetCurrentPushProvider + +class FakeGetCurrentPushProvider( + private val currentPushProvider: String? +) : GetCurrentPushProvider { + override suspend fun getCurrentPushProvider(): String? = currentPushProvider +} 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 7b6422cd1b..969815ec66 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 @@ -20,8 +20,11 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.push.api.PushService import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.tests.testutils.simulateLongTask -class FakePushService : PushService { +class FakePushService( + private val testPushBlock: suspend () -> Boolean = { true } +) : PushService { override fun notificationStyleChanged() { } @@ -32,6 +35,7 @@ class FakePushService : PushService { override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) { } - override suspend fun testPush() { + override suspend fun testPush(): Boolean = simulateLongTask { + testPushBlock() } } diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/CurrentUserPushConfig.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/CurrentUserPushConfig.kt new file mode 100644 index 0000000000..bfd6488904 --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/CurrentUserPushConfig.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.api + +data class CurrentUserPushConfig( + val url: String, + val pushKey: String, +) diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt index 3d8349a117..4e9b818dd4 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt @@ -49,8 +49,5 @@ interface PushProvider { */ suspend fun unregister(matrixClient: MatrixClient) - /** - * Attempt to troubleshoot the push provider. - */ - suspend fun troubleshoot(): Result + suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? } diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts index 186ab121fd..55dca139b0 100644 --- a/libraries/pushproviders/firebase/build.gradle.kts +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -51,8 +51,10 @@ dependencies { exclude(group = "com.google.firebase", module = "firebase-measurement-connector") } + testImplementation(libs.coroutines.test) testImplementation(libs.test.junit) testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) testImplementation(projects.tests.testutils) } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt index 4c3d6d3a20..317d49f3b6 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt @@ -16,14 +16,11 @@ package io.element.android.libraries.pushproviders.firebase -import android.content.Context -import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.common.GoogleApiAvailability import com.squareup.anvil.annotations.ContributesMultibinding import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushproviders.api.PusherSubscriber @@ -34,25 +31,15 @@ private val loggerTag = LoggerTag("FirebasePushProvider", LoggerTag.PushLoggerTa @ContributesMultibinding(AppScope::class) class FirebasePushProvider @Inject constructor( - @ApplicationContext private val context: Context, private val firebaseStore: FirebaseStore, - private val firebaseTroubleshooter: FirebaseTroubleshooter, private val pusherSubscriber: PusherSubscriber, + private val isPlayServiceAvailable: IsPlayServiceAvailable, ) : PushProvider { override val index = FirebaseConfig.INDEX override val name = FirebaseConfig.NAME override fun isAvailable(): Boolean { - // The PlayServices has to be available - val apiAvailability = GoogleApiAvailability.getInstance() - val resultCode = apiAvailability.isGooglePlayServicesAvailable(context) - return if (resultCode == ConnectionResult.SUCCESS) { - Timber.tag(loggerTag.value).d("Google Play Services is available") - true - } else { - Timber.tag(loggerTag.value).w("Google Play Services is not available") - false - } + return isPlayServiceAvailable.isAvailable() } override fun getDistributors(): List { @@ -73,7 +60,12 @@ class FirebasePushProvider @Inject constructor( pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) } - override suspend fun troubleshoot(): Result { - return firebaseTroubleshooter.troubleshoot() + override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { + return firebaseStore.getFcmToken()?.let { fcmToken -> + CurrentUserPushConfig( + url = FirebaseConfig.PUSHER_HTTP_URL, + pushKey = fcmToken + ) + } } } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt index 0342c67462..0614e2065c 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt @@ -18,20 +18,28 @@ package io.element.android.libraries.pushproviders.firebase import android.content.SharedPreferences import androidx.core.content.edit +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.DefaultPreferences import javax.inject.Inject /** * This class store the Firebase token in SharedPrefs. */ -class FirebaseStore @Inject constructor( +interface FirebaseStore { + fun getFcmToken(): String? + fun storeFcmToken(token: String?) +} + +@ContributesBinding(AppScope::class) +class DefaultFirebaseStore @Inject constructor( @DefaultPreferences private val sharedPrefs: SharedPreferences, -) { - fun getFcmToken(): String? { +) : FirebaseStore { + override fun getFcmToken(): String? { return sharedPrefs.getString(PREFS_KEY_FCM_TOKEN, null) } - fun storeFcmToken(token: String?) { + override fun storeFcmToken(token: String?) { sharedPrefs.edit { putString(PREFS_KEY_FCM_TOKEN, token) } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt index f3efba1a16..6d205c42ae 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt @@ -16,25 +16,28 @@ package io.element.android.libraries.pushproviders.firebase -import android.content.Context -import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.common.GoogleApiAvailability import com.google.firebase.messaging.FirebaseMessaging -import io.element.android.libraries.di.ApplicationContext +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import timber.log.Timber import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine +interface FirebaseTroubleshooter { + suspend fun troubleshoot(): Result +} + /** * This class force retrieving and storage of the Firebase token. */ -class FirebaseTroubleshooter @Inject constructor( - @ApplicationContext private val context: Context, +@ContributesBinding(AppScope::class) +class DefaultFirebaseTroubleshooter @Inject constructor( private val newTokenHandler: FirebaseNewTokenHandler, -) { - suspend fun troubleshoot(): Result { + private val isPlayServiceAvailable: IsPlayServiceAvailable, +) : FirebaseTroubleshooter { + override suspend fun troubleshoot(): Result { return runCatching { val token = retrievedFirebaseToken() newTokenHandler.handle(token) @@ -44,7 +47,7 @@ class FirebaseTroubleshooter @Inject constructor( private suspend fun retrievedFirebaseToken(): String { return suspendCoroutine { continuation -> // 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features' - if (checkPlayServices(context)) { + if (isPlayServiceAvailable.isAvailable()) { try { FirebaseMessaging.getInstance().token .addOnSuccessListener { token -> @@ -65,15 +68,4 @@ class FirebaseTroubleshooter @Inject constructor( } } } - - /** - * Check the device to make sure it has the Google Play Services APK. If - * it doesn't, display a dialog that allows users to download the APK from - * the Google Play Store or enable it in the device's system settings. - */ - private fun checkPlayServices(context: Context): Boolean { - val apiAvailability = GoogleApiAvailability.getInstance() - val resultCode = apiAvailability.isGooglePlayServicesAvailable(context) - return resultCode == ConnectionResult.SUCCESS - } } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/IsPlayServiceAvailable.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/IsPlayServiceAvailable.kt new file mode 100644 index 0000000000..50d9d4fe6f --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/IsPlayServiceAvailable.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.firebase + +import android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import timber.log.Timber +import javax.inject.Inject + +interface IsPlayServiceAvailable { + fun isAvailable(): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultIsPlayServiceAvailable @Inject constructor( + @ApplicationContext private val context: Context, +) : IsPlayServiceAvailable { + override fun isAvailable(): Boolean { + val apiAvailability = GoogleApiAvailability.getInstance() + val resultCode = apiAvailability.isGooglePlayServicesAvailable(context) + return if (resultCode == ConnectionResult.SUCCESS) { + Timber.d("Google Play Services is available") + true + } else { + Timber.w("Google Play Services is not available") + false + } + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTest.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTest.kt new file mode 100644 index 0000000000..e0891d6081 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.firebase.troubleshoot + +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.core.notifications.NotificationTroubleshootTest +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestDelegate +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.core.notifications.TestFilterData +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig +import io.element.android.libraries.pushproviders.firebase.IsPlayServiceAvailable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class FirebaseAvailabilityTest @Inject constructor( + private val isPlayServiceAvailable: IsPlayServiceAvailable, +) : NotificationTroubleshootTest { + override val order = 300 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = "Check Firebase", + defaultDescription = "Ensure that Firebase is available.", + visibleWhenIdle = false, + fakeDelay = NotificationTroubleshootTestDelegate.LONG_DELAY, + ) + override val state: StateFlow = delegate.state + + override fun isRelevant(data: TestFilterData): Boolean { + return data.currentPushProviderName == FirebaseConfig.NAME + } + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val result = isPlayServiceAvailable.isAvailable() + if (result) { + delegate.updateState( + description = "Firebase is available", + status = NotificationTroubleshootTestState.Status.Success + ) + } else { + delegate.updateState( + description = "Firebase is not available", + status = NotificationTroubleshootTestState.Status.Failure(false) + ) + } + } + + override fun reset() = delegate.reset() +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt new file mode 100644 index 0000000000..7c8ce5dfef --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.firebase.troubleshoot + +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.core.notifications.NotificationTroubleshootTest +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestDelegate +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.core.notifications.TestFilterData +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig +import io.element.android.libraries.pushproviders.firebase.FirebaseStore +import io.element.android.libraries.pushproviders.firebase.FirebaseTroubleshooter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class FirebaseTokenTest @Inject constructor( + private val firebaseStore: FirebaseStore, + private val firebaseTroubleshooter: FirebaseTroubleshooter, +) : NotificationTroubleshootTest { + override val order = 310 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = "Check Firebase token", + defaultDescription = "Ensure that Firebase token is available.", + visibleWhenIdle = false, + fakeDelay = NotificationTroubleshootTestDelegate.LONG_DELAY, + ) + override val state: StateFlow = delegate.state + + override fun isRelevant(data: TestFilterData): Boolean { + return data.currentPushProviderName == FirebaseConfig.NAME + } + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val token = firebaseStore.getFcmToken() + if (token != null) { + delegate.updateState( + description = "Firebase token: ${token.take(8)}*****", + status = NotificationTroubleshootTestState.Status.Success + ) + } else { + delegate.updateState( + description = "Firebase token is not known", + status = NotificationTroubleshootTestState.Status.Failure(true) + ) + } + } + + override fun reset() = delegate.reset() + + override suspend fun quickFix(coroutineScope: CoroutineScope) { + delegate.start() + firebaseTroubleshooter.troubleshoot() + run(coroutineScope) + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTroubleshooter.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTroubleshooter.kt new file mode 100644 index 0000000000..b0dae793cd --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTroubleshooter.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.firebase + +import io.element.android.tests.testutils.simulateLongTask + +class FakeFirebaseTroubleshooter( + private val troubleShootResult: () -> Result = { Result.success(Unit) } +) : FirebaseTroubleshooter { + override suspend fun troubleshoot(): Result = simulateLongTask { + troubleShootResult() + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/InMemoryFirebaseStore.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/InMemoryFirebaseStore.kt new file mode 100644 index 0000000000..f298b9f30e --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/InMemoryFirebaseStore.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.firebase + +class InMemoryFirebaseStore( + private var token: String? = null +) : FirebaseStore { + override fun getFcmToken(): String? = token + + override fun storeFcmToken(token: String?) { + this.token = token + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt new file mode 100644 index 0000000000..eea622849b --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.firebase.troubleshoot + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.pushproviders.firebase.IsPlayServiceAvailable +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FirebaseAvailabilityTestTest { + @Test + fun `test FirebaseAvailabilityTest success`() = runTest { + val sut = FirebaseAvailabilityTest( + isPlayServiceAvailable = object : IsPlayServiceAvailable { + override fun isAvailable(): Boolean { + return true + } + } + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test FirebaseAvailabilityTest failure`() = runTest { + val sut = FirebaseAvailabilityTest( + isPlayServiceAvailable = object : IsPlayServiceAvailable { + override fun isAvailable(): Boolean { + return false + } + } + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(false)) + } + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt new file mode 100644 index 0000000000..0463939234 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.firebase.troubleshoot + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.pushproviders.firebase.FakeFirebaseTroubleshooter +import io.element.android.libraries.pushproviders.firebase.InMemoryFirebaseStore +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FirebaseTokenTestTest { + @Test + fun `test FirebaseTokenTest success`() = runTest { + val sut = FirebaseTokenTest( + firebaseStore = InMemoryFirebaseStore(FAKE_TOKEN), + firebaseTroubleshooter = FakeFirebaseTroubleshooter(), + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + assertThat(lastItem.description).contains(FAKE_TOKEN.take(8)) + assertThat(lastItem.description).doesNotContain(FAKE_TOKEN) + } + } + + @Test + fun `test FirebaseTokenTest error`() = runTest { + val firebaseStore = InMemoryFirebaseStore(null) + val sut = FirebaseTokenTest( + firebaseStore = firebaseStore, + firebaseTroubleshooter = FakeFirebaseTroubleshooter( + troubleShootResult = { + firebaseStore.storeFcmToken(FAKE_TOKEN) + Result.success(Unit) + } + ), + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(true)) + // Quick fix + sut.quickFix(this) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + companion object { + private const val FAKE_TOKEN = "abcdefghijk" + } +} diff --git a/libraries/pushproviders/test/build.gradle.kts b/libraries/pushproviders/test/build.gradle.kts new file mode 100644 index 0000000000..ddb68ed43f --- /dev/null +++ b/libraries/pushproviders/test/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.pushproviders.test" +} + +dependencies { + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.pushproviders.api) +} diff --git a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt new file mode 100644 index 0000000000..8d8b94ec19 --- /dev/null +++ b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.test + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider + +class FakePushProvider( + override val index: Int = 0, + override val name: String = "aFakePushProvider", + private val isAvailable: Boolean = true, + private val distributors: List = emptyList() +) : PushProvider { + override fun isAvailable(): Boolean = isAvailable + + override fun getDistributors(): List = distributors + + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { + // No-op + } + + override suspend fun unregister(matrixClient: MatrixClient) { + // No-op + } + + override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { + return null + } +} diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts index a3968f4ecc..ecceed6026 100644 --- a/libraries/pushproviders/unifiedpush/build.gradle.kts +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(projects.libraries.pushproviders.api) implementation(projects.libraries.architecture) implementation(projects.libraries.core) + implementation(projects.services.appnavstate.api) implementation(projects.services.toolbox.api) implementation(projects.libraries.network) @@ -50,8 +51,10 @@ dependencies { // UnifiedPush library api(libs.unifiedpush) + testImplementation(libs.coroutines.test) testImplementation(libs.test.junit) testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) testImplementation(projects.tests.testutils) } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushDistributorProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushDistributorProvider.kt new file mode 100644 index 0000000000..5c9249e8f4 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushDistributorProvider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.system.getApplicationLabel +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.pushproviders.api.Distributor +import org.unifiedpush.android.connector.UnifiedPush +import javax.inject.Inject + +interface UnifiedPushDistributorProvider { + fun getDistributors(): List +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushDistributorProvider @Inject constructor( + @ApplicationContext private val context: Context, +) : UnifiedPushDistributorProvider { + override fun getDistributors(): List { + val distributors = UnifiedPush.getDistributors(context) + return distributors.mapNotNull { + if (it == context.packageName) { + // Exclude self + null + } else { + Distributor(it, context.getApplicationLabel(it)) + } + } + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt index 5d0a0da7a1..e7ea1841c5 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt @@ -16,17 +16,16 @@ package io.element.android.libraries.pushproviders.unifiedpush -import android.content.Context import com.squareup.anvil.annotations.ContributesMultibinding -import io.element.android.libraries.androidutils.system.getApplicationLabel import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret -import org.unifiedpush.android.connector.UnifiedPush +import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.currentSessionId import timber.log.Timber import javax.inject.Inject @@ -34,10 +33,12 @@ private val loggerTag = LoggerTag("UnifiedPushProvider", LoggerTag.PushLoggerTag @ContributesMultibinding(AppScope::class) class UnifiedPushProvider @Inject constructor( - @ApplicationContext private val context: Context, + private val unifiedPushDistributorProvider: UnifiedPushDistributorProvider, private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase, private val unRegisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, private val pushClientSecret: PushClientSecret, + private val unifiedPushStore: UnifiedPushStore, + private val appNavigationStateService: AppNavigationStateService, ) : PushProvider { override val index = UnifiedPushConfig.INDEX override val name = UnifiedPushConfig.NAME @@ -54,15 +55,7 @@ class UnifiedPushProvider @Inject constructor( } override fun getDistributors(): List { - val distributors = UnifiedPush.getDistributors(context) - return distributors.mapNotNull { - if (it == context.packageName) { - // Exclude self - null - } else { - Distributor(it, context.getApplicationLabel(it)) - } - } + return unifiedPushDistributorProvider.getDistributors() } override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { @@ -75,7 +68,14 @@ class UnifiedPushProvider @Inject constructor( unRegisterUnifiedPushUseCase.execute(clientSecret) } - override suspend fun troubleshoot(): Result { - TODO("Not yet implemented") + override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { + val currentSession = appNavigationStateService.appNavigationState.value.navigationState.currentSessionId() ?: return null + val clientSecret = pushClientSecret.getSecretForUser(currentSession) + val url = unifiedPushStore.getPushGateway(clientSecret) ?: return null + val pushKey = unifiedPushStore.getEndpoint(clientSecret) ?: return null + return CurrentUserPushConfig( + url = url, + pushKey = pushKey, + ) } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/OpenDistributorWebPageAction.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/OpenDistributorWebPageAction.kt new file mode 100644 index 0000000000..daad9afda3 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/OpenDistributorWebPageAction.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.system.openUrlInExternalApp +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +interface OpenDistributorWebPageAction { + fun execute() +} + +@ContributesBinding(AppScope::class) +class DefaultOpenDistributorWebPageAction @Inject constructor( + @ApplicationContext private val context: Context, +) : OpenDistributorWebPageAction { + override fun execute() { + // Open the distributor download page + context.openUrlInExternalApp( + url = "https://unifiedpush.org/users/distributors/", + inNewTask = true + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTest.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTest.kt new file mode 100644 index 0000000000..3d612b0613 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.core.notifications.NotificationTroubleshootTest +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestDelegate +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.core.notifications.TestFilterData +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushConfig +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushDistributorProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class UnifiedPushTest @Inject constructor( + private val unifiedPushDistributorProvider: UnifiedPushDistributorProvider, + private val openDistributorWebPageAction: OpenDistributorWebPageAction, +) : NotificationTroubleshootTest { + override val order = 400 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = "Check UnifiedPush", + defaultDescription = "Ensure that UnifiedPush distributors are available.", + visibleWhenIdle = false, + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + override val state: StateFlow = delegate.state + + override fun isRelevant(data: TestFilterData): Boolean { + return data.currentPushProviderName == UnifiedPushConfig.NAME + } + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val distributors = unifiedPushDistributorProvider.getDistributors() + if (distributors.isNotEmpty()) { + delegate.updateState( + description = "Distributors found: ${distributors.joinToString { it.name }}", + status = NotificationTroubleshootTestState.Status.Success + ) + } else { + delegate.updateState( + description = "No push distributors found", + status = NotificationTroubleshootTestState.Status.Failure(true) + ) + } + } + + override fun reset() = delegate.reset() + + override suspend fun quickFix(coroutineScope: CoroutineScope) { + openDistributorWebPageAction.execute() + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeOpenDistributorWebPageAction.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeOpenDistributorWebPageAction.kt new file mode 100644 index 0000000000..ca91807fb9 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeOpenDistributorWebPageAction.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +class FakeOpenDistributorWebPageAction( + private val executeAction: () -> Unit = {} +) : OpenDistributorWebPageAction { + override fun execute() = executeAction() +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeUnifiedPushDistributorProvider.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeUnifiedPushDistributorProvider.kt new file mode 100644 index 0000000000..e9734956d7 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeUnifiedPushDistributorProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushDistributorProvider + +class FakeUnifiedPushDistributorProvider( + private var getDistributorsResult: List = emptyList() +) : UnifiedPushDistributorProvider { + override fun getDistributors(): List { + return getDistributorsResult + } + + fun setDistributorsResult(list: List) { + getDistributorsResult = list + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt new file mode 100644 index 0000000000..e7a8ccc680 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.notifications.NotificationTroubleshootTestState +import io.element.android.libraries.pushproviders.api.Distributor +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class UnifiedPushTestTest { + @Test + fun `test UnifiedPushTest success`() = runTest { + val sut = UnifiedPushTest( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + Distributor("value", "Name"), + ) + ), + openDistributorWebPageAction = FakeOpenDistributorWebPageAction(), + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test UnifiedPushTest error`() = runTest { + val providers = FakeUnifiedPushDistributorProvider() + val sut = UnifiedPushTest( + unifiedPushDistributorProvider = providers, + openDistributorWebPageAction = FakeOpenDistributorWebPageAction( + executeAction = { + providers.setDistributorsResult( + listOf( + Distributor("value", "Name"), + ) + ) + } + ), + ) + launch { + sut.run(this) + } + sut.state.test { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(true)) + // Quick fix + launch { + sut.quickFix(this) + sut.run(this) + } + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } +}