Troubleshoot notifications screen

This commit is contained in:
Benoit Marty 2024-03-26 11:36:31 +01:00
parent 5ff74caed8
commit 8588ce7a72
80 changed files with 3086 additions and 99 deletions

View file

@ -23,6 +23,11 @@ plugins {
android { android {
namespace = "io.element.android.features.preferences.impl" namespace = "io.element.android.features.preferences.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
anvil { anvil {
@ -50,6 +55,7 @@ dependencies {
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.push.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.features.lockscreen.api) implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
@ -71,12 +77,14 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl) testImplementation(projects.features.rageshake.impl)
@ -86,4 +94,6 @@ dependencies {
testImplementation(projects.services.toolbox.test) testImplementation(projects.services.toolbox.test)
testImplementation(projects.features.analytics.impl) testImplementation(projects.features.analytics.impl)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -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.developer.tracing.ConfigureTracingNode
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode 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.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.root.PreferencesRootNode
import io.element.android.features.preferences.impl.user.editprofile.EditUserProfileNode import io.element.android.features.preferences.impl.user.editprofile.EditUserProfileNode
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
@ -85,6 +86,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object NotificationSettings : NavTarget data object NotificationSettings : NavTarget
@Parcelize
data object TroubleshootNotifications : NavTarget
@Parcelize @Parcelize
data object LockScreenSettings : NavTarget data object LockScreenSettings : NavTarget
@ -177,9 +181,16 @@ class PreferencesFlowNode @AssistedInject constructor(
override fun editDefaultNotificationMode(isOneToOne: Boolean) { override fun editDefaultNotificationMode(isOneToOne: Boolean) {
backstack.push(NavTarget.EditDefaultNotificationSetting(isOneToOne)) backstack.push(NavTarget.EditDefaultNotificationSetting(isOneToOne))
} }
override fun onTroubleshootNotificationsClicked() {
backstack.push(NavTarget.TroubleshootNotifications)
}
} }
createNode<NotificationSettingsNode>(buildContext, listOf(notificationSettingsCallback)) createNode<NotificationSettingsNode>(buildContext, listOf(notificationSettingsCallback))
} }
NavTarget.TroubleshootNotifications -> {
createNode<TroubleshootNotificationsNode>(buildContext)
}
is NavTarget.EditDefaultNotificationSetting -> { is NavTarget.EditDefaultNotificationSetting -> {
val callback = object : EditDefaultNotificationSettingNode.Callback { val callback = object : EditDefaultNotificationSettingNode.Callback {
override fun openRoomNotificationSettings(roomId: RoomId) { override fun openRoomNotificationSettings(roomId: RoomId) {

View file

@ -35,6 +35,7 @@ class NotificationSettingsNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun editDefaultNotificationMode(isOneToOne: Boolean) fun editDefaultNotificationMode(isOneToOne: Boolean)
fun onTroubleshootNotificationsClicked()
} }
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()
@ -43,6 +44,10 @@ class NotificationSettingsNode @AssistedInject constructor(
callbacks.forEach { it.editDefaultNotificationMode(isOneToOne) } callbacks.forEach { it.editDefaultNotificationMode(isOneToOne) }
} }
private fun onTroubleshootNotificationsClicked() {
callbacks.forEach { it.onTroubleshootNotificationsClicked() }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -50,6 +55,7 @@ class NotificationSettingsNode @AssistedInject constructor(
state = state, state = state,
onOpenEditDefault = { openEditDefault(isOneToOne = it) }, onOpenEditDefault = { openEditDefault(isOneToOne = it) },
onBackPressed = ::navigateUp, onBackPressed = ::navigateUp,
onTroubleshootNotificationsClicked = ::onTroubleshootNotificationsClicked,
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -46,6 +46,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun NotificationSettingsView( fun NotificationSettingsView(
state: NotificationSettingsState, state: NotificationSettingsState,
onOpenEditDefault: (isOneToOne: Boolean) -> Unit, onOpenEditDefault: (isOneToOne: Boolean) -> Unit,
onTroubleshootNotificationsClicked: () -> Unit,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
modifier: Modifier = Modifier, 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 // TODO We are removing the call notification toggle until support for call notifications has been added
// onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) }, // onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) },
onInviteForMeNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(it)) }, onInviteForMeNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(it)) },
onTroubleshootNotificationsClicked = onTroubleshootNotificationsClicked,
) )
} }
AsyncActionView( AsyncActionView(
@ -99,6 +101,7 @@ private fun NotificationSettingsContentView(
// TODO We are removing the call notification toggle until support for call notifications has been added // TODO We are removing the call notification toggle until support for call notifications has been added
// onCallsNotificationsChanged: (Boolean) -> Unit, // onCallsNotificationsChanged: (Boolean) -> Unit,
onInviteForMeNotificationsChanged: (Boolean) -> Unit, onInviteForMeNotificationsChanged: (Boolean) -> Unit,
onTroubleshootNotificationsClicked: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) { if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) {
@ -163,6 +166,13 @@ private fun NotificationSettingsContentView(
onCheckedChange = onInviteForMeNotificationsChanged onCheckedChange = onInviteForMeNotificationsChanged
) )
} }
PreferenceCategory(title = "Troubleshoot") {
PreferenceText(
modifier = Modifier,
title = "Troubleshoot notifications",
onClick = onTroubleshootNotificationsClicked
)
}
} }
} }
@ -204,6 +214,7 @@ internal fun NotificationSettingsViewPreview(@PreviewParameter(NotificationSetti
state = state, state = state,
onBackPressed = {}, onBackPressed = {},
onOpenEditDefault = {}, onOpenEditDefault = {},
onTroubleshootNotificationsClicked = {},
) )
} }

View file

@ -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
}

View file

@ -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<Plugin>,
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,
)
}
}

View file

@ -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<TroubleshootNotificationsState> {
@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
)
}
}

View file

@ -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()
}

View file

@ -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<TroubleshootNotificationsState> {
override val values: Sequence<TroubleshootNotificationsState>
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<NotificationTroubleshootTestState> = 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))

View file

@ -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 = {},
)
}

View file

@ -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<NotificationTroubleshootTest>
private val _state: MutableStateFlow<TroubleshootTestSuiteState> = MutableStateFlow(
TroubleshootTestSuiteState(
mainState = AsyncAction.Uninitialized,
tests = emptyList<NotificationTroubleshootTestState>().toImmutableList()
)
)
val state: StateFlow<TroubleshootTestSuiteState> = _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<NotificationTroubleshootTestState>.computeMainState(): AsyncAction<Unit> {
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)
}
}
}
}

View file

@ -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<Unit>,
val tests: ImmutableList<NotificationTroubleshootTestState>,
)

View file

@ -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<NotificationTroubleshootTestState> = _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,
)
)
}
}

View file

@ -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<NotificationTroubleshootTest> = emptySet(),
currentPushProvider: String? = null,
): TroubleshootTestSuite {
return TroubleshootTestSuite(
notificationTroubleshootTests = tests,
getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider),
)
}
private fun createTroubleshootNotificationsPresenter(
troubleshootTestSuite: TroubleshootTestSuite = createTroubleshootTestSuite(),
): TroubleshootNotificationsPresenter {
return TroubleshootNotificationsPresenter(
troubleshootTestSuite = troubleshootTestSuite,
)
}
}

View file

@ -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<ComponentActivity>()
@Test
fun `press menu back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvents>(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<TroubleshootNotificationsEvents>()
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<TroubleshootNotificationsEvents>()
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<TroubleshootNotificationsEvents>()
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTroubleshootNotificationsView(
state: TroubleshootNotificationsState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
) {
setContent {
TroubleshootNotificationsView(
state = state,
onBackPressed = onBackPressed,
)
}
}

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.androidutils.system package io.element.android.libraries.androidutils.system
import android.app.Activity
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -73,6 +74,9 @@ fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResu
val intent = Intent() val intent = Intent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS 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) intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
} else { } else {
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
@ -152,8 +156,12 @@ fun Context.startSharePlainTextIntent(
fun Context.openUrlInExternalApp( fun Context.openUrlInExternalApp(
url: String, url: String,
errorMessage: String = getString(R.string.error_no_compatible_app_found), errorMessage: String = getString(R.string.error_no_compatible_app_found),
inNewTask: Boolean = false,
) { ) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
if (inNewTask) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try { try {
startActivity(intent) startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) { } catch (activityNotFoundException: ActivityNotFoundException) {

View file

@ -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<NotificationTroubleshootTestState>
fun isRelevant(data: TestFilterData): Boolean = true
suspend fun run(coroutineScope: CoroutineScope)
fun reset()
suspend fun quickFix(coroutineScope: CoroutineScope) {}
}

View file

@ -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<NotificationTroubleshootTestState> = MutableStateFlow(
NotificationTroubleshootTestState(
name = defaultName,
description = defaultDescription,
status = NotificationTroubleshootTestState.Status.Idle(visibleWhenIdle),
)
)
val state: StateFlow<NotificationTroubleshootTestState> = _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
}
}

View file

@ -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
}
}

View file

@ -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?,
)

View file

@ -24,7 +24,6 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
api(projects.libraries.permissions.api) api(projects.libraries.permissions.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
@ -57,6 +58,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -17,6 +17,8 @@
package io.element.android.libraries.permissions.impl package io.element.android.libraries.permissions.impl
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
@ -33,7 +35,10 @@ class DefaultPermissionStateProvider @Inject constructor(
private val permissionsStore: PermissionsStore, private val permissionsStore: PermissionsStore,
) : PermissionStateProvider { ) : PermissionStateProvider {
override fun isPermissionGranted(permission: String): Boolean { 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) override suspend fun setPermissionDenied(permission: String, value: Boolean) = permissionsStore.setPermissionDenied(permission, value)

View file

@ -18,7 +18,7 @@ package io.element.android.libraries.permissions.impl.action
import android.content.Context import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding 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.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
@ -28,6 +28,6 @@ class AndroidPermissionActions @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) : PermissionActions { ) : PermissionActions {
override fun openSettings() { override fun openSettings() {
context.openAppSettingsPage() context.startNotificationSettingsIntent()
} }
} }

View file

@ -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<NotificationTroubleshootTestState> = 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()
}
}

View file

@ -16,11 +16,14 @@
package io.element.android.libraries.permissions.impl.action package io.element.android.libraries.permissions.impl.action
class FakePermissionActions : PermissionActions { class FakePermissionActions(
val openSettingsAction: () -> Unit = {}
) : PermissionActions {
var openSettingsCalled = false var openSettingsCalled = false
private set private set
override fun openSettings() { override fun openSettings() {
openSettingsAction()
openSettingsCalled = true openSettingsCalled = true
} }
} }

View file

@ -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)
}
}
}

View file

@ -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?
}

View file

@ -37,6 +37,8 @@ interface PushService {
*/ */
suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) 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
} }

View file

@ -16,6 +16,6 @@
package io.element.android.libraries.push.api.gateway package io.element.android.libraries.push.api.gateway
sealed class PushGatewayFailure : Throwable(cause = null) { sealed class PushGatewayFailure : Exception() {
data object PusherRejected : PushGatewayFailure() class PusherRejected : PushGatewayFailure()
} }

View file

@ -69,6 +69,8 @@ dependencies {
testImplementation(libs.coil.test) testImplementation(libs.coil.test)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.toolbox.impl) testImplementation(projects.services.toolbox.impl)

View file

@ -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()
}
}

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.push.impl
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient 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.api.PushService
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
@ -32,6 +33,7 @@ class DefaultPushService @Inject constructor(
private val pushersManager: PushersManager, private val pushersManager: PushersManager,
private val userPushStoreFactory: UserPushStoreFactory, private val userPushStoreFactory: UserPushStoreFactory,
private val pushProviders: Set<@JvmSuppressWildcards PushProvider>, private val pushProviders: Set<@JvmSuppressWildcards PushProvider>,
private val getCurrentPushProvider: GetCurrentPushProvider,
) : PushService { ) : PushService {
override fun notificationStyleChanged() { override fun notificationStyleChanged() {
defaultNotificationDrawerManager.notificationStyleChanged() defaultNotificationDrawerManager.notificationStyleChanged()
@ -58,7 +60,11 @@ class DefaultPushService @Inject constructor(
userPushStore.setPushProviderName(pushProvider.name) userPushStore.setPushProviderName(pushProvider.name)
} }
override suspend fun testPush() { override suspend fun testPush(): Boolean {
pushersManager.testPush() val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider()
val pushProvider = pushProviders.find { it.name == currentPushProvider } ?: return false
val config = pushProvider.getCurrentUserPushConfig() ?: return false
pushersManager.testPush(config)
return true
} }
} }

View file

@ -23,9 +23,11 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient 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.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.core.SessionId
import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData
import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest 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.pushproviders.api.PusherSubscriber
import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
@ -45,16 +47,14 @@ class PushersManager @Inject constructor(
private val pushClientSecret: PushClientSecret, private val pushClientSecret: PushClientSecret,
private val userPushStoreFactory: UserPushStoreFactory, private val userPushStoreFactory: UserPushStoreFactory,
) : PusherSubscriber { ) : PusherSubscriber {
// TODO Move this to the PushProvider API suspend fun testPush(config: CurrentUserPushConfig) {
suspend fun testPush() {
pushGatewayNotifyRequest.execute( pushGatewayNotifyRequest.execute(
PushGatewayNotifyRequest.Params( PushGatewayNotifyRequest.Params(
// unifiedPushHelper.getPushGateway() ?: return url = config.url,
url = "TODO",
appId = PushConfig.PUSHER_APP_ID, appId = PushConfig.PUSHER_APP_ID,
// unifiedPushHelper.getEndpointOrToken().orEmpty() pushKey = config.pushKey,
pushKey = "TODO", eventId = TEST_EVENT_ID,
eventId = TEST_EVENT_ID roomId = TEST_ROOM_ID,
) )
) )
} }
@ -112,5 +112,6 @@ class PushersManager @Inject constructor(
companion object { companion object {
val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID") val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID")
val TEST_ROOM_ID = RoomId("!room:domain")
} }
} }

View file

@ -17,7 +17,6 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification import android.app.Notification
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
@ -32,12 +31,13 @@ class NotificationDisplayer @Inject constructor(
) { ) {
private val notificationManager = NotificationManagerCompat.from(context) 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) { if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
Timber.w("Not allowed to notify.") Timber.w("Not allowed to notify.")
return return false
} }
notificationManager.notify(tag, id, notification) notificationManager.notify(tag, id, notification)
return true
} }
fun cancelNotificationMessage(tag: String?, id: Int) { fun cancelNotificationMessage(tag: String?, id: Int) {
@ -53,15 +53,21 @@ class NotificationDisplayer @Inject constructor(
} }
} }
@SuppressLint("LaunchActivityFromNotification") fun displayDiagnosticNotification(notification: Notification): Boolean {
fun displayDiagnosticNotification(notification: Notification) { return showNotificationMessage(
showNotificationMessage(
tag = "DIAGNOSTIC", tag = "DIAGNOSTIC",
id = NOTIFICATION_ID_DIAGNOSTIC, id = NOTIFICATION_ID_DIAGNOSTIC,
notification = notification notification = notification
) )
} }
fun dismissDiagnosticNotification() {
cancelNotificationMessage(
tag = "DIAGNOSTIC",
id = NOTIFICATION_ID_DIAGNOSTIC
)
}
/** /**
* Cancel the foreground notification service. * Cancel the foreground notification service.
*/ */

View file

@ -19,9 +19,15 @@ package io.element.android.libraries.push.impl.notifications
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent 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() { class TestNotificationReceiver : BroadcastReceiver() {
@Inject lateinit var notificationClickHandler: NotificationClickHandler
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
// TODO The test notification has been clicked, notify the ui context.bindings<TestNotificationReceiverBinding>().inject(this)
notificationClickHandler.handleNotificationClick()
} }
} }

View file

@ -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)
}

View file

@ -299,6 +299,7 @@ class NotificationCreator @Inject constructor(
} }
fun createDiagnosticNotification(): Notification { fun createDiagnosticNotification(): Notification {
val intent = pendingIntentFactory.createTestPendingIntent()
return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest()) return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest())
.setContentTitle(buildMeta.applicationName) .setContentTitle(buildMeta.applicationName)
.setContentText(stringProvider.getString(R.string.notification_test_push_notification_content)) .setContentText(stringProvider.getString(R.string.notification_test_push_notification_content))
@ -308,7 +309,8 @@ class NotificationCreator @Inject constructor(
.setPriority(NotificationCompat.PRIORITY_MAX) .setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_STATUS) .setCategory(NotificationCompat.CATEGORY_STATUS)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pendingIntentFactory.createTestPendingIntent()) .setContentIntent(intent)
.setDeleteIntent(intent)
.build() .build()
} }

View file

@ -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.DefaultNotificationDrawerManager
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver 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.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.PushData
import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushproviders.api.PushHandler
import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.UserPushStoreFactory
@ -51,6 +52,7 @@ class DefaultPushHandler @Inject constructor(
// private val actionIds: NotificationActionIds, // private val actionIds: NotificationActionIds,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val matrixAuthenticationService: MatrixAuthenticationService, private val matrixAuthenticationService: MatrixAuthenticationService,
private val diagnosticPushHandler: DiagnosticPushHandler,
) : PushHandler { ) : PushHandler {
private val coroutineScope = CoroutineScope(SupervisorJob()) private val coroutineScope = CoroutineScope(SupervisorJob())
@ -75,8 +77,7 @@ class DefaultPushHandler @Inject constructor(
// Diagnostic Push // Diagnostic Push
if (pushData.eventId == PushersManager.TEST_EVENT_ID) { if (pushData.eventId == PushersManager.TEST_EVENT_ID) {
// val intent = Intent(actionIds.push) diagnosticPushHandler.handlePush()
// TODO The test push has been received, notify the ui
return return
} }

View file

@ -23,6 +23,8 @@ import kotlinx.serialization.Serializable
internal data class PushGatewayNotification( internal data class PushGatewayNotification(
@SerialName("event_id") @SerialName("event_id")
val eventId: String, val eventId: String,
@SerialName("room_id")
val roomId: String,
/** /**
* Required. This is an array of devices that the notification should be sent to. * Required. This is an array of devices that the notification should be sent to.
*/ */

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.push.impl.pushgateway 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.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.network.RetrofitFactory import io.element.android.libraries.network.RetrofitFactory
import io.element.android.libraries.push.api.gateway.PushGatewayFailure import io.element.android.libraries.push.api.gateway.PushGatewayFailure
import javax.inject.Inject import javax.inject.Inject
@ -27,7 +28,8 @@ class PushGatewayNotifyRequest @Inject constructor(
val url: String, val url: String,
val appId: String, val appId: String,
val pushKey: String, val pushKey: String,
val eventId: EventId val eventId: EventId,
val roomId: RoomId,
) )
suspend fun execute(params: Params) { suspend fun execute(params: Params) {
@ -40,6 +42,7 @@ class PushGatewayNotifyRequest @Inject constructor(
PushGatewayNotifyBody( PushGatewayNotifyBody(
PushGatewayNotification( PushGatewayNotification(
eventId = params.eventId.value, eventId = params.eventId.value,
roomId = params.roomId.value,
devices = listOf( devices = listOf(
PushGatewayDevice( PushGatewayDevice(
params.appId, params.appId,
@ -51,7 +54,7 @@ class PushGatewayNotifyRequest @Inject constructor(
) )
if (response.rejectedPushKeys.contains(params.pushKey)) { if (response.rejectedPushKeys.contains(params.pushKey)) {
throw PushGatewayFailure.PusherRejected throw PushGatewayFailure.PusherRejected()
} }
} }
} }

View file

@ -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<NotificationTroubleshootTestState> = 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()
}

View file

@ -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<Unit>()
val state: SharedFlow<Unit> = _state
suspend fun handlePush() {
_state.emit(Unit)
}
}

View file

@ -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<Unit>(extraBufferCapacity = 1)
val state: SharedFlow<Unit> = _state
fun handleNotificationClick() {
_state.tryEmit(Unit)
}
}

View file

@ -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<NotificationTroubleshootTestState> = 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()
}

View file

@ -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<NotificationTroubleshootTestState> = delegate.state
override suspend fun run(coroutineScope: CoroutineScope) {
delegate.start()
val startTime = clock.epochMillis()
val completable = CompletableDeferred<Long>()
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()
}

View file

@ -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<NotificationTroubleshootTestState> = 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()
}

View file

@ -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.A_THREAD_ID
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.core.aBuildMeta 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.FakeImageLoaderHolder
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
@ -118,7 +118,7 @@ class DefaultNotificationDrawerManagerTest {
NotificationIdProvider(), NotificationIdProvider(),
NotificationDisplayer(context), NotificationDisplayer(context),
NotificationFactory( NotificationFactory(
FakeAndroidNotificationFactory().instance, FakeNotificationCreator().instance,
FakeRoomGroupMessageCreator().instance, FakeRoomGroupMessageCreator().instance,
FakeSummaryGroupMessageCreator().instance, FakeSummaryGroupMessageCreator().instance,
) )

View file

@ -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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.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.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
@ -41,7 +41,7 @@ private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roo
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class NotificationFactoryTest { class NotificationFactoryTest {
private val androidNotificationFactory = FakeAndroidNotificationFactory() private val androidNotificationFactory = FakeNotificationCreator()
private val roomGroupMessageCreator = FakeRoomGroupMessageCreator() private val roomGroupMessageCreator = FakeRoomGroupMessageCreator()
private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator()

View file

@ -23,7 +23,7 @@ import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiab
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
class FakeAndroidNotificationFactory { class FakeNotificationCreator {
val instance = mockk<NotificationCreator>() val instance = mockk<NotificationCreator>()
fun givenCreateRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification { fun givenCreateRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification {
@ -37,4 +37,10 @@ class FakeAndroidNotificationFactory {
every { instance.createSimpleEventNotification(event) } returns mockNotification every { instance.createSimpleEventNotification(event) } returns mockNotification
return mockNotification return mockNotification
} }
fun givenCreateDiagnosticNotification(): Notification {
val mockNotification = mockk<Notification>()
every { instance.createDiagnosticNotification() } returns mockNotification
return mockNotification
}
} }

View file

@ -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.NotificationDisplayer
import io.element.android.libraries.push.impl.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.NotificationIdProvider
import io.mockk.confirmVerified import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import io.mockk.verifyOrder import io.mockk.verifyOrder
@ -27,6 +28,10 @@ import io.mockk.verifyOrder
class FakeNotificationDisplayer { class FakeNotificationDisplayer {
val instance = mockk<NotificationDisplayer>(relaxed = true) val instance = mockk<NotificationDisplayer>(relaxed = true)
fun givenDisplayDiagnosticNotificationResult(result: Boolean) {
every { instance.displayDiagnosticNotification(any()) } returns result
}
fun verifySummaryCancelled() { fun verifySummaryCancelled() {
verify { instance.cancelNotificationMessage(tag = null, NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)) } verify { instance.cancelNotificationMessage(tag = null, NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)) }
} }

View file

@ -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))
}
}
}

View file

@ -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
)
}
}

View file

@ -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)
}
}
}

View file

@ -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")
}
}
}

View file

@ -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
}

View file

@ -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.push.api.PushService
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider 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() { override fun notificationStyleChanged() {
} }
@ -32,6 +35,7 @@ class FakePushService : PushService {
override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) { override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) {
} }
override suspend fun testPush() { override suspend fun testPush(): Boolean = simulateLongTask {
testPushBlock()
} }
} }

View file

@ -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,
)

View file

@ -49,8 +49,5 @@ interface PushProvider {
*/ */
suspend fun unregister(matrixClient: MatrixClient) suspend fun unregister(matrixClient: MatrixClient)
/** suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig?
* Attempt to troubleshoot the push provider.
*/
suspend fun troubleshoot(): Result<Unit>
} }

View file

@ -51,8 +51,10 @@ dependencies {
exclude(group = "com.google.firebase", module = "firebase-measurement-connector") exclude(group = "com.google.firebase", module = "firebase-measurement-connector")
} }
testImplementation(libs.coroutines.test)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
} }

View file

@ -16,14 +16,11 @@
package io.element.android.libraries.pushproviders.firebase 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 com.squareup.anvil.annotations.ContributesMultibinding
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope 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.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.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushproviders.api.PusherSubscriber import io.element.android.libraries.pushproviders.api.PusherSubscriber
@ -34,25 +31,15 @@ private val loggerTag = LoggerTag("FirebasePushProvider", LoggerTag.PushLoggerTa
@ContributesMultibinding(AppScope::class) @ContributesMultibinding(AppScope::class)
class FirebasePushProvider @Inject constructor( class FirebasePushProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val firebaseStore: FirebaseStore, private val firebaseStore: FirebaseStore,
private val firebaseTroubleshooter: FirebaseTroubleshooter,
private val pusherSubscriber: PusherSubscriber, private val pusherSubscriber: PusherSubscriber,
private val isPlayServiceAvailable: IsPlayServiceAvailable,
) : PushProvider { ) : PushProvider {
override val index = FirebaseConfig.INDEX override val index = FirebaseConfig.INDEX
override val name = FirebaseConfig.NAME override val name = FirebaseConfig.NAME
override fun isAvailable(): Boolean { override fun isAvailable(): Boolean {
// The PlayServices has to be available return isPlayServiceAvailable.isAvailable()
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
}
} }
override fun getDistributors(): List<Distributor> { override fun getDistributors(): List<Distributor> {
@ -73,7 +60,12 @@ class FirebasePushProvider @Inject constructor(
pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL)
} }
override suspend fun troubleshoot(): Result<Unit> { override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? {
return firebaseTroubleshooter.troubleshoot() return firebaseStore.getFcmToken()?.let { fcmToken ->
CurrentUserPushConfig(
url = FirebaseConfig.PUSHER_HTTP_URL,
pushKey = fcmToken
)
}
} }
} }

View file

@ -18,20 +18,28 @@ package io.element.android.libraries.pushproviders.firebase
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.core.content.edit 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 io.element.android.libraries.di.DefaultPreferences
import javax.inject.Inject import javax.inject.Inject
/** /**
* This class store the Firebase token in SharedPrefs. * 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, @DefaultPreferences private val sharedPrefs: SharedPreferences,
) { ) : FirebaseStore {
fun getFcmToken(): String? { override fun getFcmToken(): String? {
return sharedPrefs.getString(PREFS_KEY_FCM_TOKEN, null) return sharedPrefs.getString(PREFS_KEY_FCM_TOKEN, null)
} }
fun storeFcmToken(token: String?) { override fun storeFcmToken(token: String?) {
sharedPrefs.edit { sharedPrefs.edit {
putString(PREFS_KEY_FCM_TOKEN, token) putString(PREFS_KEY_FCM_TOKEN, token)
} }

View file

@ -16,25 +16,28 @@
package io.element.android.libraries.pushproviders.firebase 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 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 timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
interface FirebaseTroubleshooter {
suspend fun troubleshoot(): Result<Unit>
}
/** /**
* This class force retrieving and storage of the Firebase token. * This class force retrieving and storage of the Firebase token.
*/ */
class FirebaseTroubleshooter @Inject constructor( @ContributesBinding(AppScope::class)
@ApplicationContext private val context: Context, class DefaultFirebaseTroubleshooter @Inject constructor(
private val newTokenHandler: FirebaseNewTokenHandler, private val newTokenHandler: FirebaseNewTokenHandler,
) { private val isPlayServiceAvailable: IsPlayServiceAvailable,
suspend fun troubleshoot(): Result<Unit> { ) : FirebaseTroubleshooter {
override suspend fun troubleshoot(): Result<Unit> {
return runCatching { return runCatching {
val token = retrievedFirebaseToken() val token = retrievedFirebaseToken()
newTokenHandler.handle(token) newTokenHandler.handle(token)
@ -44,7 +47,7 @@ class FirebaseTroubleshooter @Inject constructor(
private suspend fun retrievedFirebaseToken(): String { private suspend fun retrievedFirebaseToken(): String {
return suspendCoroutine { continuation -> return suspendCoroutine { continuation ->
// 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features' // '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 { try {
FirebaseMessaging.getInstance().token FirebaseMessaging.getInstance().token
.addOnSuccessListener { 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
}
} }

View file

@ -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
}
}
}

View file

@ -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<NotificationTroubleshootTestState> = 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()
}

View file

@ -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<NotificationTroubleshootTestState> = 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)
}
}

View file

@ -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<Unit> = { Result.success(Unit) }
) : FirebaseTroubleshooter {
override suspend fun troubleshoot(): Result<Unit> = simulateLongTask {
troubleShootResult()
}
}

View file

@ -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
}
}

View file

@ -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))
}
}
}

View file

@ -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"
}
}

View file

@ -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)
}

View file

@ -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<Distributor> = emptyList()
) : PushProvider {
override fun isAvailable(): Boolean = isAvailable
override fun getDistributors(): List<Distributor> = 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
}
}

View file

@ -37,6 +37,7 @@ dependencies {
implementation(projects.libraries.pushproviders.api) implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
implementation(projects.libraries.network) implementation(projects.libraries.network)
@ -50,8 +51,10 @@ dependencies {
// UnifiedPush library // UnifiedPush library
api(libs.unifiedpush) api(libs.unifiedpush)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
} }

View file

@ -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<Distributor>
}
@ContributesBinding(AppScope::class)
class DefaultUnifiedPushDistributorProvider @Inject constructor(
@ApplicationContext private val context: Context,
) : UnifiedPushDistributorProvider {
override fun getDistributors(): List<Distributor> {
val distributors = UnifiedPush.getDistributors(context)
return distributors.mapNotNull {
if (it == context.packageName) {
// Exclude self
null
} else {
Distributor(it, context.getApplicationLabel(it))
}
}
}
}

View file

@ -16,17 +16,16 @@
package io.element.android.libraries.pushproviders.unifiedpush package io.element.android.libraries.pushproviders.unifiedpush
import android.content.Context
import com.squareup.anvil.annotations.ContributesMultibinding 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.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope 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.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.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret 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 timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -34,10 +33,12 @@ private val loggerTag = LoggerTag("UnifiedPushProvider", LoggerTag.PushLoggerTag
@ContributesMultibinding(AppScope::class) @ContributesMultibinding(AppScope::class)
class UnifiedPushProvider @Inject constructor( class UnifiedPushProvider @Inject constructor(
@ApplicationContext private val context: Context, private val unifiedPushDistributorProvider: UnifiedPushDistributorProvider,
private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase, private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase,
private val unRegisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, private val unRegisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase,
private val pushClientSecret: PushClientSecret, private val pushClientSecret: PushClientSecret,
private val unifiedPushStore: UnifiedPushStore,
private val appNavigationStateService: AppNavigationStateService,
) : PushProvider { ) : PushProvider {
override val index = UnifiedPushConfig.INDEX override val index = UnifiedPushConfig.INDEX
override val name = UnifiedPushConfig.NAME override val name = UnifiedPushConfig.NAME
@ -54,15 +55,7 @@ class UnifiedPushProvider @Inject constructor(
} }
override fun getDistributors(): List<Distributor> { override fun getDistributors(): List<Distributor> {
val distributors = UnifiedPush.getDistributors(context) return unifiedPushDistributorProvider.getDistributors()
return distributors.mapNotNull {
if (it == context.packageName) {
// Exclude self
null
} else {
Distributor(it, context.getApplicationLabel(it))
}
}
} }
override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) {
@ -75,7 +68,14 @@ class UnifiedPushProvider @Inject constructor(
unRegisterUnifiedPushUseCase.execute(clientSecret) unRegisterUnifiedPushUseCase.execute(clientSecret)
} }
override suspend fun troubleshoot(): Result<Unit> { override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? {
TODO("Not yet implemented") 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,
)
} }
} }

View file

@ -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
)
}
}

View file

@ -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<NotificationTroubleshootTestState> = 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()
}
}

View file

@ -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()
}

View file

@ -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<Distributor> = emptyList()
) : UnifiedPushDistributorProvider {
override fun getDistributors(): List<Distributor> {
return getDistributorsResult
}
fun setDistributorsResult(list: List<Distributor>) {
getDistributorsResult = list
}
}

View file

@ -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)
}
}
}