Merge pull request #3035 from element-hq/feature/bma/fixFdroidNotification

Feature/bma/fix fdroid notification
This commit is contained in:
Benoit Marty 2024-06-18 12:55:27 +02:00 committed by GitHub
commit e4bfc5ef8f
43 changed files with 1060 additions and 106 deletions

View file

@ -67,6 +67,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.login.impl) testImplementation(projects.features.login.impl)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -238,7 +238,12 @@ class LoggedInFlowNode @AssistedInject constructor(
return when (navTarget) { return when (navTarget) {
NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext) NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext)
NavTarget.LoggedInPermanent -> { NavTarget.LoggedInPermanent -> {
createNode<LoggedInNode>(buildContext) val callback = object : LoggedInNode.Callback {
override fun navigateToNotificationTroubleshoot() {
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot))
}
}
createNode<LoggedInNode>(buildContext, listOf(callback))
} }
NavTarget.RoomList -> { NavTarget.RoomList -> {
val callback = object : RoomListEntryPoint.Callback { val callback = object : RoomListEntryPoint.Callback {

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.appnav.loggedin
sealed interface LoggedInEvents {
data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvents
}

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
@ -35,11 +36,22 @@ class LoggedInNode @AssistedInject constructor(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins plugins = plugins
) { ) {
interface Callback : Plugin {
fun navigateToNotificationTroubleshoot()
}
private fun navigateToNotificationTroubleshoot() {
plugins<Callback>().forEach {
it.navigateToNotificationTroubleshoot()
}
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val loggedInState = loggedInPresenter.present() val loggedInState = loggedInPresenter.present()
LoggedInView( LoggedInView(
state = loggedInState, state = loggedInState,
navigateToNotificationTroubleshoot = ::navigateToNotificationTroubleshoot,
modifier = modifier modifier = modifier
) )
} }

View file

@ -18,15 +18,20 @@ package io.element.android.appnav.loggedin
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.CryptoSessionStateChange import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
@ -34,11 +39,17 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.pushproviders.api.RegistrationFailure
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
private val pusherTag = LoggerTag("Pusher", LoggerTag.PushLoggerTag)
class LoggedInPresenter @Inject constructor( class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
@ -49,36 +60,26 @@ class LoggedInPresenter @Inject constructor(
) : Presenter<LoggedInState> { ) : Presenter<LoggedInState> {
@Composable @Composable
override fun present(): LoggedInState { override fun present(): LoggedInState {
val isVerified by remember { val coroutineScope = rememberCoroutineScope()
sessionVerificationService.sessionVerifiedStatus.map { it == SessionVerifiedStatus.Verified } val ignoreRegistrationError by remember {
pushService.ignoreRegistrationError(matrixClient.sessionId)
}.collectAsState(initial = false) }.collectAsState(initial = false)
val pusherRegistrationState = remember<MutableState<AsyncData<Unit>>> { mutableStateOf(AsyncData.Uninitialized) }
LaunchedEffect(isVerified) { LaunchedEffect(Unit) {
if (isVerified) { sessionVerificationService.sessionVerifiedStatus
// Ensure pusher is registered .onEach { sessionVerifiedStatus ->
val currentPushProvider = pushService.getCurrentPushProvider() when (sessionVerifiedStatus) {
val result = if (currentPushProvider == null) { SessionVerifiedStatus.Unknown -> Unit
// Register with the first available push provider SessionVerifiedStatus.Verified -> {
val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect ensurePusherIsRegistered(pusherRegistrationState)
val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect }
pushService.registerWith(matrixClient, pushProvider, distributor) SessionVerifiedStatus.NotVerified -> {
} else { pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.AccountNotVerified())
val currentPushDistributor = currentPushProvider.getCurrentDistributor(matrixClient) }
if (currentPushDistributor == null) {
// Register with the first available distributor
val distributor = currentPushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
pushService.registerWith(matrixClient, currentPushProvider, distributor)
} else {
// Re-register with the current distributor
pushService.registerWith(matrixClient, currentPushProvider, currentPushDistributor)
} }
} }
result.onFailure { .launchIn(this)
Timber.e(it, "Failed to register pusher")
}
}
} }
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState() val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val networkStatus by networkMonitor.connectivity.collectAsState() val networkStatus by networkMonitor.connectivity.collectAsState()
val showSyncSpinner by remember { val showSyncSpinner by remember {
@ -86,14 +87,86 @@ class LoggedInPresenter @Inject constructor(
networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show
} }
} }
val verificationState by sessionVerificationService.sessionVerifiedStatus.collectAsState() LaunchedEffect(Unit) {
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() combine(
LaunchedEffect(verificationState, recoveryState) { sessionVerificationService.sessionVerifiedStatus,
reportCryptoStatusToAnalytics(verificationState, recoveryState) encryptionService.recoveryStateStateFlow
) { verificationState, recoveryState ->
reportCryptoStatusToAnalytics(verificationState, recoveryState)
}.launchIn(this)
}
fun handleEvent(event: LoggedInEvents) {
when (event) {
is LoggedInEvents.CloseErrorDialog -> {
pusherRegistrationState.value = AsyncData.Uninitialized
if (event.doNotShowAgain) {
coroutineScope.launch {
pushService.setIgnoreRegistrationError(matrixClient.sessionId, true)
}
}
}
}
} }
return LoggedInState( return LoggedInState(
showSyncSpinner = showSyncSpinner, showSyncSpinner = showSyncSpinner,
pusherRegistrationState = pusherRegistrationState.value,
ignoreRegistrationError = ignoreRegistrationError,
eventSink = ::handleEvent
)
}
private suspend fun ensurePusherIsRegistered(pusherRegistrationState: MutableState<AsyncData<Unit>>) {
Timber.tag(pusherTag.value).d("Ensure pusher is registered")
val currentPushProvider = pushService.getCurrentPushProvider()
val result = if (currentPushProvider == null) {
Timber.tag(pusherTag.value).d("Register with the first available push provider with at least one distributor")
val pushProvider = pushService.getAvailablePushProviders()
.firstOrNull { it.getDistributors().isNotEmpty() }
// Else fallback to the first available push provider (the list should never be empty)
?: pushService.getAvailablePushProviders().firstOrNull()
?: return Unit
.also { Timber.tag(pusherTag.value).w("No push providers available") }
.also { pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.NoProvidersAvailable()) }
val distributor = pushProvider.getDistributors().firstOrNull()
?: return Unit
.also { Timber.tag(pusherTag.value).w("No distributors available") }
.also {
// In this case, consider the push provider is chosen.
pushService.selectPushProvider(matrixClient, pushProvider)
}
.also { pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable()) }
pushService.registerWith(matrixClient, pushProvider, distributor)
} else {
val currentPushDistributor = currentPushProvider.getCurrentDistributor(matrixClient)
if (currentPushDistributor == null) {
Timber.tag(pusherTag.value).d("Register with the first available distributor")
val distributor = currentPushProvider.getDistributors().firstOrNull()
?: return Unit
.also { Timber.tag(pusherTag.value).w("No distributors available") }
.also { pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable()) }
pushService.registerWith(matrixClient, currentPushProvider, distributor)
} else {
Timber.tag(pusherTag.value).d("Re-register with the current distributor")
pushService.registerWith(matrixClient, currentPushProvider, currentPushDistributor)
}
}
result.fold(
onSuccess = {
Timber.tag(pusherTag.value).d("Pusher registered")
pusherRegistrationState.value = AsyncData.Success(Unit)
},
onFailure = {
Timber.tag(pusherTag.value).e(it, "Failed to register pusher")
if (it is RegistrationFailure) {
pusherRegistrationState.value = AsyncData.Failure(
PusherRegistrationFailure.RegistrationFailure(it.clientException, it.isRegisteringAgain)
)
} else {
pusherRegistrationState.value = AsyncData.Failure(it)
}
}
) )
} }

View file

@ -16,6 +16,11 @@
package io.element.android.appnav.loggedin package io.element.android.appnav.loggedin
import io.element.android.libraries.architecture.AsyncData
data class LoggedInState( data class LoggedInState(
val showSyncSpinner: Boolean, val showSyncSpinner: Boolean,
val pusherRegistrationState: AsyncData<Unit>,
val ignoreRegistrationError: Boolean,
val eventSink: (LoggedInEvents) -> Unit,
) )

View file

@ -17,18 +17,23 @@
package io.element.android.appnav.loggedin package io.element.android.appnav.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> { open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState> override val values: Sequence<LoggedInState>
get() = sequenceOf( get() = sequenceOf(
aLoggedInState(false), aLoggedInState(),
aLoggedInState(true), aLoggedInState(showSyncSpinner = true),
// Add other state here aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
) )
} }
fun aLoggedInState( fun aLoggedInState(
showSyncSpinner: Boolean = true, showSyncSpinner: Boolean = false,
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
) = LoggedInState( ) = LoggedInState(
showSyncSpinner = showSyncSpinner, showSyncSpinner = showSyncSpinner,
pusherRegistrationState = pusherRegistrationState,
ignoreRegistrationError = false,
eventSink = {},
) )

View file

@ -22,13 +22,19 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogWithDoNotShowAgain
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.exception.isNetworkError
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun LoggedInView( fun LoggedInView(
state: LoggedInState, state: LoggedInState,
navigateToNotificationTroubleshoot: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( Box(
@ -41,12 +47,53 @@ fun LoggedInView(
isVisible = state.showSyncSpinner, isVisible = state.showSyncSpinner,
) )
} }
when (state.pusherRegistrationState) {
is AsyncData.Uninitialized,
is AsyncData.Loading,
is AsyncData.Success -> Unit
is AsyncData.Failure -> {
state.pusherRegistrationState.errorOrNull()
?.takeIf { !state.ignoreRegistrationError }
?.getReason()
?.let { reason ->
ErrorDialogWithDoNotShowAgain(
content = stringResource(id = CommonStrings.common_error_registering_pusher_android, reason),
cancelText = stringResource(id = CommonStrings.common_settings),
onDismiss = {
state.eventSink(LoggedInEvents.CloseErrorDialog(it))
},
onCancel = {
state.eventSink(LoggedInEvents.CloseErrorDialog(false))
navigateToNotificationTroubleshoot()
}
)
}
}
}
}
private fun Throwable.getReason(): String? {
return when (this) {
is PusherRegistrationFailure.RegistrationFailure -> {
if (isRegisteringAgain && clientException.isNetworkError()) {
// When registering again, ignore network error
null
} else {
clientException.message ?: "Unknown error"
}
}
is PusherRegistrationFailure.AccountNotVerified -> null
is PusherRegistrationFailure.NoDistributorsAvailable -> "No distributors available"
is PusherRegistrationFailure.NoProvidersAvailable -> "No providers available"
else -> "Other error"
}
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview { internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
LoggedInView( LoggedInView(
state = state state = state,
navigateToNotificationTroubleshoot = {},
) )
} }

View file

@ -0,0 +1,34 @@
/*
* 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.appnav.loggedin
import io.element.android.libraries.matrix.api.exception.ClientException
sealed class PusherRegistrationFailure : Exception() {
class AccountNotVerified : PusherRegistrationFailure()
class NoProvidersAvailable : PusherRegistrationFailure()
class NoDistributorsAvailable : PusherRegistrationFailure()
/**
* @param clientException the failure that occurred.
* @param isRegisteringAgain true if the server should already have a the same pusher registered.
*/
class RegistrationFailure(
val clientException: ClientException,
val isRegisteringAgain: Boolean,
) : PusherRegistrationFailure()
}

View file

@ -18,23 +18,39 @@ package io.element.android.appnav.loggedin
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CryptoSessionStateChange import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.test.FakePushService import io.element.android.libraries.push.test.FakePushService
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushproviders.test.FakePushProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -51,6 +67,8 @@ class LoggedInPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse() assertThat(initialState.showSyncSpinner).isFalse()
assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue()
assertThat(initialState.ignoreRegistrationError).isFalse()
} }
} }
@ -90,16 +108,12 @@ class LoggedInPresenterTest {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN) encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
skipItems(2)
skipItems(4)
assertThat(analyticsService.capturedEvents.size).isEqualTo(1) assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents[0]).isInstanceOf(CryptoSessionStateChange::class.java) assertThat(analyticsService.capturedEvents[0]).isInstanceOf(CryptoSessionStateChange::class.java)
assertThat(analyticsService.capturedUserProperties.size).isEqualTo(1) assertThat(analyticsService.capturedUserProperties.size).isEqualTo(1)
assertThat(analyticsService.capturedUserProperties[0].recoveryState).isEqualTo(UserProperties.RecoveryState.Incomplete) assertThat(analyticsService.capturedUserProperties[0].recoveryState).isEqualTo(UserProperties.RecoveryState.Incomplete)
assertThat(analyticsService.capturedUserProperties[0].verificationState).isEqualTo(UserProperties.VerificationState.Verified) assertThat(analyticsService.capturedUserProperties[0].verificationState).isEqualTo(UserProperties.VerificationState.Verified)
// ensure a sync status change does not trigger a new capture // ensure a sync status change does not trigger a new capture
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show) roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
skipItems(1) skipItems(1)
@ -107,17 +121,399 @@ class LoggedInPresenterTest {
} }
} }
@Test
fun `present - ensure default pusher is not registered if session is not verified`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val pushService = createFakePushService(registerWithLambda = lambda)
val verificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified
)
val presenter = createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = verificationService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java)
lambda.assertions()
.isNeverCalled()
}
}
@Test
fun `present - ensure default pusher is registered with default provider`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushService = createFakePushService(
registerWithLambda = lambda,
)
val presenter = createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions()
.isCalledOnce()
.with(
// MatrixClient
any(),
// PushProvider with highest priority (lower index)
value(pushService.getAvailablePushProviders()[0]),
// First distributor
value(pushService.getAvailablePushProviders()[0].getDistributors()[0]),
)
}
}
@Test
fun `present - ensure default pusher is registered with default provider - fail to register`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.failure(AN_EXCEPTION)
}
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushService = createFakePushService(
registerWithLambda = lambda,
)
val presenter = createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isFailure()).isTrue()
lambda.assertions()
.isCalledOnce()
.with(
// MatrixClient
any(),
// PushProvider with highest priority (lower index)
value(pushService.getAvailablePushProviders()[0]),
// First distributor
value(pushService.getAvailablePushProviders()[0].getDistributors()[0]),
)
}
}
@Test
fun `present - ensure current provider is registered with current distributor`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val distributor = Distributor("aDistributorValue1", "aDistributorName1")
val pushProvider = FakePushProvider(
index = 0,
name = "aFakePushProvider0",
distributors = listOf(
Distributor("aDistributorValue0", "aDistributorName0"),
distributor,
),
currentDistributor = { distributor },
)
val pushService = createFakePushService(
pushProvider1 = pushProvider,
currentPushProvider = { pushProvider },
registerWithLambda = lambda,
)
val presenter = createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions()
.isCalledOnce()
.with(
// MatrixClient
any(),
// Current push provider
value(pushProvider),
// Current distributor
value(distributor),
)
}
}
@Test
fun `present - if current push provider does not have current distributor, the first one is used`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushProvider = FakePushProvider(
index = 0,
name = "aFakePushProvider0",
distributors = listOf(
Distributor("aDistributorValue0", "aDistributorName0"),
Distributor("aDistributorValue1", "aDistributorName1"),
),
currentDistributor = { null },
)
val pushService = createFakePushService(
pushProvider0 = pushProvider,
currentPushProvider = { pushProvider },
registerWithLambda = lambda,
)
val presenter = createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions()
.isCalledOnce()
.with(
// MatrixClient
any(),
// PushProvider with highest priority (lower index)
value(pushService.getAvailablePushProviders()[0]),
// First distributor
value(pushService.getAvailablePushProviders()[0].getDistributors()[0]),
)
}
}
@Test
fun `present - if current push provider does not have distributors, nothing happen`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushProvider = FakePushProvider(
index = 0,
name = "aFakePushProvider0",
distributors = emptyList(),
)
val pushService = createFakePushService(
pushProvider0 = pushProvider,
currentPushProvider = { pushProvider },
registerWithLambda = lambda,
)
val presenter = createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
lambda.assertions()
.isNeverCalled()
}
}
@Test
fun `present - case no push provider available provider`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.Verified)
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
val pushService = createFakePushService(
pushProvider0 = null,
pushProvider1 = null,
registerWithLambda = lambda,
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
)
val presenter = createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java)
lambda.assertions()
.isNeverCalled()
// Reset the error and do not show again
finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = true))
skipItems(1)
setIgnoreRegistrationErrorLambda.assertions()
.isCalledOnce()
.with(
// SessionId
value(A_SESSION_ID),
// Ignore
value(true),
)
val lastState = awaitItem()
assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue()
assertThat(lastState.ignoreRegistrationError).isTrue()
}
}
@Test
fun `present - case one push provider but no distributor available`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val selectPushProviderLambda = lambdaRecorder<MatrixClient, PushProvider, Unit> { _, _ -> }
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushProvider = FakePushProvider(
index = 0,
name = "aFakePushProvider",
distributors = emptyList(),
)
val pushService = createFakePushService(
pushProvider0 = pushProvider,
pushProvider1 = null,
registerWithLambda = lambda,
selectPushProviderLambda = selectPushProviderLambda,
)
val presenter = createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
lambda.assertions()
.isNeverCalled()
selectPushProviderLambda.assertions()
.isCalledOnce()
.with(
// MatrixClient
any(),
// PushProvider
value(pushProvider),
)
// Reset the error
finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = false))
val lastState = awaitItem()
assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue()
}
}
@Test
fun `present - case two push providers but first one does not have distributor - second one will be used`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushProvider0 = FakePushProvider(
index = 0,
name = "aFakePushProvider0",
distributors = emptyList(),
)
val distributor = Distributor("aDistributorValue1", "aDistributorName1")
val pushProvider1 = FakePushProvider(
index = 1,
name = "aFakePushProvider1",
distributors = listOf(distributor),
)
val pushService = createFakePushService(
pushProvider0 = pushProvider0,
pushProvider1 = pushProvider1,
registerWithLambda = lambda,
)
val presenter = createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions().isCalledOnce()
.with(
// MatrixClient
any(),
// PushProvider with the distributor
value(pushProvider1),
// First distributor of second push provider
value(distributor),
)
}
}
private fun createFakePushService(
pushProvider0: PushProvider? = FakePushProvider(
index = 0,
name = "aFakePushProvider0",
distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")),
currentDistributor = { null },
),
pushProvider1: PushProvider? = FakePushProvider(
index = 1,
name = "aFakePushProvider1",
distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")),
currentDistributor = { null },
),
registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ ->
Result.success(Unit)
},
selectPushProviderLambda: (MatrixClient, PushProvider) -> Unit = { _, _ -> lambdaError() },
currentPushProvider: () -> PushProvider? = { null },
setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() },
): PushService {
return FakePushService(
availablePushProviders = listOfNotNull(pushProvider0, pushProvider1),
registerWithLambda = registerWithLambda,
currentPushProvider = currentPushProvider,
selectPushProviderLambda = selectPushProviderLambda,
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
)
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
}
private fun createLoggedInPresenter( private fun createLoggedInPresenter(
roomListService: RoomListService = FakeRoomListService(), roomListService: RoomListService = FakeRoomListService(),
networkStatus: NetworkStatus = NetworkStatus.Offline, networkStatus: NetworkStatus = NetworkStatus.Offline,
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
encryptionService: FakeEncryptionService = FakeEncryptionService(), sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
encryptionService: EncryptionService = FakeEncryptionService(),
pushService: PushService = FakePushService(),
): LoggedInPresenter { ): LoggedInPresenter {
return LoggedInPresenter( return LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService), matrixClient = FakeMatrixClient(roomListService = roomListService),
networkMonitor = FakeNetworkMonitor(networkStatus), networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = FakePushService(), pushService = pushService,
sessionVerificationService = FakeSessionVerificationService(), sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService, analyticsService = analyticsService,
encryptionService = encryptionService encryptionService = encryptionService
) )

View file

@ -32,6 +32,9 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
@Parcelize @Parcelize
data object NotificationSettings : InitialTarget data object NotificationSettings : InitialTarget
@Parcelize
data object NotificationTroubleshoot : InitialTarget
} }
data class Params(val initialElement: InitialTarget) : NodeInputs data class Params(val initialElement: InitialTarget) : NodeInputs

View file

@ -51,4 +51,5 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint
internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) { internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) {
is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root
is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings
PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot -> PreferencesFlowNode.NavTarget.TroubleshootNotifications
} }

View file

@ -44,6 +44,7 @@ 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
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.canPop
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -190,7 +191,11 @@ class PreferencesFlowNode @AssistedInject constructor(
notificationTroubleShootEntryPoint.nodeBuilder(this, buildContext) notificationTroubleShootEntryPoint.nodeBuilder(this, buildContext)
.callback(object : NotificationTroubleShootEntryPoint.Callback { .callback(object : NotificationTroubleShootEntryPoint.Callback {
override fun onDone() { override fun onDone() {
backstack.pop() if (backstack.canPop()) {
backstack.pop()
} else {
navigateUp()
}
} }
}) })
.build() .build()

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.push.api.PushService
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import javax.inject.Inject import javax.inject.Inject
@ -47,6 +48,7 @@ class DefaultClearCacheUseCase @Inject constructor(
private val okHttpClient: Provider<OkHttpClient>, private val okHttpClient: Provider<OkHttpClient>,
private val ftueService: FtueService, private val ftueService: FtueService,
private val migrationScreenStore: MigrationScreenStore, private val migrationScreenStore: MigrationScreenStore,
private val pushService: PushService,
) : ClearCacheUseCase { ) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) { override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Clear Matrix cache // Clear Matrix cache
@ -64,6 +66,8 @@ class DefaultClearCacheUseCase @Inject constructor(
ftueService.reset() ftueService.reset()
// Clear migration screen store // Clear migration screen store
migrationScreenStore.reset() migrationScreenStore.reset()
// Ensure any error will be displayed again
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
// Ensure the app is restarted // Ensure the app is restarted
defaultCacheService.onClearedCache(matrixClient.sessionId) defaultCacheService.onClearedCache(matrixClient.sessionId)
} }

View file

@ -329,13 +329,11 @@ class NotificationSettingsPresenterTest {
val pushProvider1 = FakePushProvider( val pushProvider1 = FakePushProvider(
index = 0, index = 0,
name = "aFakePushProvider0", name = "aFakePushProvider0",
isAvailable = true,
distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")),
) )
val pushProvider2 = FakePushProvider( val pushProvider2 = FakePushProvider(
index = 1, index = 1,
name = "aFakePushProvider1", name = "aFakePushProvider1",
isAvailable = true,
distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")), distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")),
) )
return FakePushService( return FakePushService(

View file

@ -22,8 +22,11 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.test.FakeFtueService import io.element.android.features.ftue.test.FakeFtueService
import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.test.FakePushService
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -48,6 +51,10 @@ class DefaultClearCacheUseCaseTest {
val migrationScreenStore = InMemoryMigrationScreenStore( val migrationScreenStore = InMemoryMigrationScreenStore(
resetLambda = resetMigrationLambda, resetLambda = resetMigrationLambda,
) )
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
val pushService = FakePushService(
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
)
val sut = DefaultClearCacheUseCase( val sut = DefaultClearCacheUseCase(
context = InstrumentationRegistry.getInstrumentation().context, context = InstrumentationRegistry.getInstrumentation().context,
matrixClient = matrixClient, matrixClient = matrixClient,
@ -55,13 +62,16 @@ class DefaultClearCacheUseCaseTest {
defaultCacheService = defaultCacheService, defaultCacheService = defaultCacheService,
okHttpClient = { OkHttpClient.Builder().build() }, okHttpClient = { OkHttpClient.Builder().build() },
ftueService = ftueService, ftueService = ftueService,
migrationScreenStore = migrationScreenStore migrationScreenStore = migrationScreenStore,
pushService = pushService,
) )
defaultCacheService.clearedCacheEventFlow.test { defaultCacheService.clearedCacheEventFlow.test {
sut.invoke() sut.invoke()
clearCacheLambda.assertions().isCalledOnce() clearCacheLambda.assertions().isCalledOnce()
resetFtueLambda.assertions().isCalledOnce() resetFtueLambda.assertions().isCalledOnce()
resetMigrationLambda.assertions().isCalledOnce() resetMigrationLambda.assertions().isCalledOnce()
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
.with(value(matrixClient.sessionId), value(false))
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId) assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
} }
} }

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.architecture.appyx
import com.bumble.appyx.navmodel.backstack.BackStack
fun <T : Any> BackStack<T>.canPop(): Boolean {
val elements = elements.value
return elements.any { it.targetState == BackStack.State.ACTIVE } &&
elements.any { it.targetState == BackStack.State.STASHED }
}

View file

@ -0,0 +1,90 @@
/*
* 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.designsystem.components.dialogs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ErrorDialogWithDoNotShowAgain(
content: String,
onDismiss: (Boolean) -> Unit,
modifier: Modifier = Modifier,
title: String = ErrorDialogDefaults.title,
submitText: String = ErrorDialogDefaults.submitText,
cancelText: String? = null,
onCancel: () -> Unit = {},
) {
var doNotShowAgain by remember { mutableStateOf(false) }
BasicAlertDialog(
modifier = modifier,
onDismissRequest = { onDismiss(doNotShowAgain) }
) {
SimpleAlertDialogContent(
title = title,
submitText = submitText,
cancelText = cancelText,
onSubmitClick = { onDismiss(doNotShowAgain) },
onCancelClick = onCancel,
) {
Column {
Text(
text = content,
style = ElementTheme.materialTypography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = doNotShowAgain, onCheckedChange = { doNotShowAgain = it })
Text(
text = stringResource(id = CommonStrings.common_do_not_show_this_again),
style = ElementTheme.materialTypography.bodyMedium,
)
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun ErrorDialogWithDoNotShowAgainPreview() = ElementPreview {
ErrorDialogWithDoNotShowAgain(
content = "Content",
onDismiss = {},
)
}

View file

@ -20,3 +20,7 @@ sealed class ClientException(message: String) : Exception(message) {
class Generic(message: String) : ClientException(message) class Generic(message: String) : ClientException(message)
class Other(message: String) : ClientException(message) class Other(message: String) : ClientException(message)
} }
fun ClientException.isNetworkError(): Boolean {
return this is ClientException.Generic && message?.contains("error sending request for url", ignoreCase = true) == true
}

View file

@ -17,9 +17,11 @@
package io.element.android.libraries.matrix.impl.pushers package io.element.android.libraries.matrix.impl.pushers
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData
import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData
import io.element.android.libraries.matrix.impl.exception.mapClientException
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.HttpPusherData import org.matrix.rustcomponents.sdk.HttpPusherData
@ -52,6 +54,7 @@ class RustPushersService(
lang = setHttpPusherData.lang lang = setHttpPusherData.lang
) )
} }
.mapFailure { it.mapClientException() }
} }
} }

View file

@ -24,8 +24,10 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class FakeSessionVerificationService : SessionVerificationService { class FakeSessionVerificationService(
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown) initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown,
) : SessionVerificationService {
private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus)
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial) private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
private var _needsSessionVerification = MutableStateFlow(true) private var _needsSessionVerification = MutableStateFlow(true)
var shouldFail = false var shouldFail = false

View file

@ -17,8 +17,10 @@
package io.element.android.libraries.push.api package io.element.android.libraries.push.api
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
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 kotlinx.coroutines.flow.Flow
interface PushService { interface PushService {
/** /**
@ -27,8 +29,7 @@ interface PushService {
suspend fun getCurrentPushProvider(): PushProvider? suspend fun getCurrentPushProvider(): PushProvider?
/** /**
* Return the list of push providers, available at compile time, and * Return the list of push providers, available at compile time, sorted by index.
* available at runtime, sorted by index.
*/ */
fun getAvailablePushProviders(): List<PushProvider> fun getAvailablePushProviders(): List<PushProvider>
@ -43,6 +44,18 @@ interface PushService {
distributor: Distributor, distributor: Distributor,
): Result<Unit> ): Result<Unit>
/**
* Store the given push provider as the current one, but do not register.
* To be used when there is no distributor available.
*/
suspend fun selectPushProvider(
matrixClient: MatrixClient,
pushProvider: PushProvider,
)
fun ignoreRegistrationError(sessionId: SessionId): Flow<Boolean>
suspend fun setIgnoreRegistrationError(sessionId: SessionId, ignore: Boolean)
/** /**
* Return false in case of early error. * Return false in case of early error.
*/ */

View file

@ -19,12 +19,14 @@ 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.matrix.api.core.SessionId
import io.element.android.libraries.push.api.GetCurrentPushProvider 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.test.TestPush import io.element.android.libraries.push.impl.test.TestPush
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.UserPushStoreFactory import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import kotlinx.coroutines.flow.Flow
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -42,7 +44,6 @@ class DefaultPushService @Inject constructor(
override fun getAvailablePushProviders(): List<PushProvider> { override fun getAvailablePushProviders(): List<PushProvider> {
return pushProviders return pushProviders
.filter { it.isAvailable() }
.sortedBy { it.index } .sortedBy { it.index }
} }
@ -51,7 +52,7 @@ class DefaultPushService @Inject constructor(
pushProvider: PushProvider, pushProvider: PushProvider,
distributor: Distributor, distributor: Distributor,
): Result<Unit> { ): Result<Unit> {
Timber.d("Registering with ${pushProvider.name}/${distributor.name}}") Timber.d("Registering with ${pushProvider.name}/${distributor.name}")
val userPushStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) val userPushStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId)
val currentPushProviderName = userPushStore.getPushProviderName() val currentPushProviderName = userPushStore.getPushProviderName()
val currentPushProvider = pushProviders.find { it.name == currentPushProviderName } val currentPushProvider = pushProviders.find { it.name == currentPushProviderName }
@ -72,6 +73,23 @@ class DefaultPushService @Inject constructor(
return pushProvider.registerWith(matrixClient, distributor) return pushProvider.registerWith(matrixClient, distributor)
} }
override suspend fun selectPushProvider(
matrixClient: MatrixClient,
pushProvider: PushProvider,
) {
Timber.d("Select ${pushProvider.name}")
val userPushStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId)
userPushStore.setPushProviderName(pushProvider.name)
}
override fun ignoreRegistrationError(sessionId: SessionId): Flow<Boolean> {
return userPushStoreFactory.getOrCreate(sessionId).ignoreRegistrationError()
}
override suspend fun setIgnoreRegistrationError(sessionId: SessionId, ignore: Boolean) {
userPushStoreFactory.getOrCreate(sessionId).setIgnoreRegistrationError(ignore)
}
override suspend fun testPush(): Boolean { override suspend fun testPush(): Boolean {
val pushProvider = getCurrentPushProvider() ?: return false val pushProvider = getCurrentPushProvider() ?: return false
val config = pushProvider.getCurrentUserPushConfig() ?: return false val config = pushProvider.getCurrentUserPushConfig() ?: return false

View file

@ -18,14 +18,17 @@ package io.element.android.libraries.push.impl
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.PushConfig import io.element.android.appconfig.PushConfig
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta 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.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData
import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData
import io.element.android.libraries.pushproviders.api.PusherSubscriber import io.element.android.libraries.pushproviders.api.PusherSubscriber
import io.element.android.libraries.pushproviders.api.RegistrationFailure
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
import timber.log.Timber import timber.log.Timber
@ -50,7 +53,8 @@ class DefaultPusherSubscriber @Inject constructor(
gateway: String, gateway: String,
): Result<Unit> { ): Result<Unit> {
val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId)
if (userDataStore.getCurrentRegisteredPushKey() == pushKey) { val isRegisteringAgain = userDataStore.getCurrentRegisteredPushKey() == pushKey
if (isRegisteringAgain) {
Timber.tag(loggerTag.value) Timber.tag(loggerTag.value)
.d("Unnecessary to register again the same pusher, but do it in case the pusher has been removed from the server") .d("Unnecessary to register again the same pusher, but do it in case the pusher has been removed from the server")
} }
@ -61,8 +65,14 @@ class DefaultPusherSubscriber @Inject constructor(
.onSuccess { .onSuccess {
userDataStore.setCurrentRegisteredPushKey(pushKey) userDataStore.setCurrentRegisteredPushKey(pushKey)
} }
.onFailure { throwable -> .mapFailure { throwable ->
Timber.tag(loggerTag.value).e(throwable, "Unable to register the pusher") Timber.tag(loggerTag.value).e(throwable, "Unable to register the pusher")
if (throwable is ClientException) {
// It should always be the case.
RegistrationFailure(throwable, isRegisteringAgain = isRegisteringAgain)
} else {
throwable
}
} }
} }

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.push.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.api.GetCurrentPushProvider import io.element.android.libraries.push.api.GetCurrentPushProvider
import io.element.android.libraries.push.impl.test.FakeTestPush import io.element.android.libraries.push.impl.test.FakeTestPush
@ -33,6 +34,7 @@ import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushSto
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -205,6 +207,20 @@ class DefaultPushServiceTest {
assertThat(result).containsExactly(aPushProvider1, aPushProvider2, aPushProvider3).inOrder() assertThat(result).containsExactly(aPushProvider1, aPushProvider2, aPushProvider3).inOrder()
} }
@Test
fun `test setIgnoreRegistrationError is sent to the store`() = runTest {
val userPushStore = FakeUserPushStore().apply {
}
val defaultPushService = createDefaultPushService(
userPushStoreFactory = FakeUserPushStoreFactory(
userPushStore = { userPushStore },
),
)
assertThat(defaultPushService.ignoreRegistrationError(A_SESSION_ID).first()).isFalse()
defaultPushService.setIgnoreRegistrationError(A_SESSION_ID, true)
assertThat(defaultPushService.ignoreRegistrationError(A_SESSION_ID).first()).isTrue()
}
private fun createDefaultPushService( private fun createDefaultPushService(
testPush: TestPush = FakeTestPush(), testPush: TestPush = FakeTestPush(),
userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(),

View file

@ -17,10 +17,14 @@
package io.element.android.libraries.push.test package io.element.android.libraries.push.test
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.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.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakePushService( class FakePushService(
private val testPushBlock: suspend () -> Boolean = { true }, private val testPushBlock: suspend () -> Boolean = { true },
@ -28,9 +32,12 @@ class FakePushService(
private val registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ -> private val registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ ->
Result.success(Unit) Result.success(Unit)
}, },
private val currentPushProvider: () -> PushProvider? = { availablePushProviders.firstOrNull() },
private val selectPushProviderLambda: suspend (MatrixClient, PushProvider) -> Unit = { _, _ -> lambdaError() },
private val setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() },
) : PushService { ) : PushService {
override suspend fun getCurrentPushProvider(): PushProvider? { override suspend fun getCurrentPushProvider(): PushProvider? {
return registeredPushProvider ?: availablePushProviders.firstOrNull() return registeredPushProvider ?: currentPushProvider()
} }
override fun getAvailablePushProviders(): List<PushProvider> { override fun getAvailablePushProviders(): List<PushProvider> {
@ -52,6 +59,21 @@ class FakePushService(
} }
} }
override suspend fun selectPushProvider(matrixClient: MatrixClient, pushProvider: PushProvider) {
selectPushProviderLambda(matrixClient, pushProvider)
}
private val ignoreRegistrationError = MutableStateFlow(false)
override fun ignoreRegistrationError(sessionId: SessionId): Flow<Boolean> {
return ignoreRegistrationError
}
override suspend fun setIgnoreRegistrationError(sessionId: SessionId, ignore: Boolean) {
ignoreRegistrationError.value = ignore
setIgnoreRegistrationErrorLambda(sessionId, ignore)
}
override suspend fun testPush(): Boolean = simulateLongTask { override suspend fun testPush(): Boolean = simulateLongTask {
testPushBlock() testPushBlock()
} }

View file

@ -33,10 +33,8 @@ interface PushProvider {
val name: String val name: String
/** /**
* Return true if the push provider is available on this device. * Return the list of available distributors.
*/ */
fun isAvailable(): Boolean
fun getDistributors(): List<Distributor> fun getDistributors(): List<Distributor>
/** /**

View file

@ -17,8 +17,21 @@
package io.element.android.libraries.pushproviders.api package io.element.android.libraries.pushproviders.api
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.exception.ClientException
interface PusherSubscriber { interface PusherSubscriber {
/**
* Register a pusher. Note that failure will be a [RegistrationFailure].
*/
suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result<Unit> suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result<Unit>
/**
* Unregister a pusher.
*/
suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result<Unit> suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result<Unit>
} }
class RegistrationFailure(
val clientException: ClientException,
val isRegisteringAgain: Boolean
) : Exception(clientException)

View file

@ -38,12 +38,10 @@ class FirebasePushProvider @Inject constructor(
override val index = FirebaseConfig.INDEX override val index = FirebaseConfig.INDEX
override val name = FirebaseConfig.NAME override val name = FirebaseConfig.NAME
override fun isAvailable(): Boolean {
return isPlayServiceAvailable.isAvailable()
}
override fun getDistributors(): List<Distributor> { override fun getDistributors(): List<Distributor> {
return listOf(firebaseDistributor) return listOfNotNull(
firebaseDistributor.takeIf { isPlayServiceAvailable.isAvailable() }
)
} }
override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result<Unit> { override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result<Unit> {

View file

@ -38,12 +38,23 @@ class FirebasePushProviderTest {
} }
@Test @Test
fun `getDistributors return the unique distributor`() { fun `getDistributors return the unique distributor if available`() {
val firebasePushProvider = createFirebasePushProvider() val firebasePushProvider = createFirebasePushProvider(
isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = true)
)
val result = firebasePushProvider.getDistributors() val result = firebasePushProvider.getDistributors()
assertThat(result).containsExactly(Distributor("Firebase", "Firebase")) assertThat(result).containsExactly(Distributor("Firebase", "Firebase"))
} }
@Test
fun `getDistributors return empty list if service is not available`() {
val firebasePushProvider = createFirebasePushProvider(
isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = false)
)
val result = firebasePushProvider.getDistributors()
assertThat(result).isEmpty()
}
@Test @Test
fun `getCurrentDistributor always return the unique distributor`() = runTest { fun `getCurrentDistributor always return the unique distributor`() = runTest {
val firebasePushProvider = createFirebasePushProvider() val firebasePushProvider = createFirebasePushProvider()
@ -51,22 +62,6 @@ class FirebasePushProviderTest {
assertThat(result).isEqualTo(Distributor("Firebase", "Firebase")) assertThat(result).isEqualTo(Distributor("Firebase", "Firebase"))
} }
@Test
fun `isAvailable true`() {
val firebasePushProvider = createFirebasePushProvider(
isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = true)
)
assertThat(firebasePushProvider.isAvailable()).isTrue()
}
@Test
fun `isAvailable false`() {
val firebasePushProvider = createFirebasePushProvider(
isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = false)
)
assertThat(firebasePushProvider.isAvailable()).isFalse()
}
@Test @Test
fun `register ok`() = runTest { fun `register ok`() = runTest {
val matrixClient = FakeMatrixClient() val matrixClient = FakeMatrixClient()

View file

@ -25,14 +25,12 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakePushProvider( class FakePushProvider(
override val index: Int = 0, override val index: Int = 0,
override val name: String = "aFakePushProvider", override val name: String = "aFakePushProvider",
private val isAvailable: Boolean = true,
private val distributors: List<Distributor> = listOf(Distributor("aDistributorValue", "aDistributorName")), private val distributors: List<Distributor> = listOf(Distributor("aDistributorValue", "aDistributorName")),
private val currentDistributor: () -> Distributor? = { distributors.firstOrNull() },
private val currentUserPushConfig: CurrentUserPushConfig? = null, private val currentUserPushConfig: CurrentUserPushConfig? = null,
private val registerWithResult: (MatrixClient, Distributor) -> Result<Unit> = { _, _ -> lambdaError() }, private val registerWithResult: (MatrixClient, Distributor) -> Result<Unit> = { _, _ -> lambdaError() },
private val unregisterWithResult: (MatrixClient) -> Result<Unit> = { lambdaError() }, private val unregisterWithResult: (MatrixClient) -> Result<Unit> = { lambdaError() },
) : PushProvider { ) : PushProvider {
override fun isAvailable(): Boolean = isAvailable
override fun getDistributors(): List<Distributor> = distributors override fun getDistributors(): List<Distributor> = distributors
override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result<Unit> { override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result<Unit> {
@ -40,7 +38,7 @@ class FakePushProvider(
} }
override suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? { override suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? {
return distributors.firstOrNull() return currentDistributor()
} }
override suspend fun unregister(matrixClient: MatrixClient): Result<Unit> { override suspend fun unregister(matrixClient: MatrixClient): Result<Unit> {

View file

@ -17,7 +17,6 @@
package io.element.android.libraries.pushproviders.unifiedpush package io.element.android.libraries.pushproviders.unifiedpush
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.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.pushproviders.api.CurrentUserPushConfig import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig
@ -26,11 +25,8 @@ 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 io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.currentSessionId import io.element.android.services.appnavstate.api.currentSessionId
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("UnifiedPushProvider", LoggerTag.PushLoggerTag)
@ContributesMultibinding(AppScope::class) @ContributesMultibinding(AppScope::class)
class UnifiedPushProvider @Inject constructor( class UnifiedPushProvider @Inject constructor(
private val unifiedPushDistributorProvider: UnifiedPushDistributorProvider, private val unifiedPushDistributorProvider: UnifiedPushDistributorProvider,
@ -43,17 +39,6 @@ class UnifiedPushProvider @Inject constructor(
override val index = UnifiedPushConfig.INDEX override val index = UnifiedPushConfig.INDEX
override val name = UnifiedPushConfig.NAME override val name = UnifiedPushConfig.NAME
override fun isAvailable(): Boolean {
val isAvailable = getDistributors().isNotEmpty()
return if (isAvailable) {
Timber.tag(loggerTag.value).d("UnifiedPush is available")
true
} else {
Timber.tag(loggerTag.value).w("UnifiedPush is not available")
false
}
}
override fun getDistributors(): List<Distributor> { override fun getDistributors(): List<Distributor> {
return unifiedPushDistributorProvider.getDistributors() return unifiedPushDistributorProvider.getDistributors()
} }

View file

@ -58,7 +58,6 @@ class UnifiedPushProviderTest {
) )
val result = unifiedPushProvider.getDistributors() val result = unifiedPushProvider.getDistributors()
assertThat(result).containsExactly(Distributor("value", "Name")) assertThat(result).containsExactly(Distributor("value", "Name"))
assertThat(unifiedPushProvider.isAvailable()).isTrue()
} }
@Test @Test
@ -70,7 +69,6 @@ class UnifiedPushProviderTest {
) )
val result = unifiedPushProvider.getDistributors() val result = unifiedPushProvider.getDistributors()
assertThat(result).isEmpty() assertThat(result).isEmpty()
assertThat(unifiedPushProvider.isAvailable()).isFalse()
} }
@Test @Test

View file

@ -29,6 +29,9 @@ interface UserPushStore {
fun getNotificationEnabledForDevice(): Flow<Boolean> fun getNotificationEnabledForDevice(): Flow<Boolean>
suspend fun setNotificationEnabledForDevice(enabled: Boolean) suspend fun setNotificationEnabledForDevice(enabled: Boolean)
fun ignoreRegistrationError(): Flow<Boolean>
suspend fun setIgnoreRegistrationError(ignore: Boolean)
/** /**
* Return true if Pin code is disabled, or if user set the settings to see full notification content. * Return true if Pin code is disabled, or if user set the settings to see full notification content.
*/ */

View file

@ -44,6 +44,7 @@ dependencies {
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

View file

@ -25,6 +25,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import androidx.datastore.preferences.preferencesDataStoreFile import androidx.datastore.preferences.preferencesDataStoreFile
import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.UserPushStore
@ -61,6 +62,7 @@ class UserPushStoreDataStore(
private val pushProviderName = stringPreferencesKey("pushProviderName") private val pushProviderName = stringPreferencesKey("pushProviderName")
private val currentPushKey = stringPreferencesKey("currentPushKey") private val currentPushKey = stringPreferencesKey("currentPushKey")
private val notificationEnabled = booleanPreferencesKey("notificationEnabled") private val notificationEnabled = booleanPreferencesKey("notificationEnabled")
private val ignoreRegistrationError = booleanPreferencesKey("ignoreRegistrationError")
override suspend fun getPushProviderName(): String? { override suspend fun getPushProviderName(): String? {
return context.dataStore.data.first()[pushProviderName] return context.dataStore.data.first()[pushProviderName]
@ -100,6 +102,16 @@ class UserPushStoreDataStore(
return true return true
} }
override fun ignoreRegistrationError(): Flow<Boolean> {
return context.dataStore.data.map { it[ignoreRegistrationError].orFalse() }
}
override suspend fun setIgnoreRegistrationError(ignore: Boolean) {
context.dataStore.edit {
it[ignoreRegistrationError] = ignore
}
}
override suspend fun reset() { override suspend fun reset() {
context.dataStore.edit { context.dataStore.edit {
it.clear() it.clear()

View file

@ -0,0 +1,105 @@
/*
* 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.pushstore.impl
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class UserPushStoreDataStoreTest {
@Test
fun `test getPushProviderName`() = runTest {
val sut = createUserPushStoreDataStore()
assertThat(sut.getPushProviderName()).isNull()
sut.setPushProviderName("name")
assertThat(sut.getPushProviderName()).isEqualTo("name")
}
@Test
fun `test getCurrentRegisteredPushKey`() = runTest {
val sut = createUserPushStoreDataStore()
assertThat(sut.getCurrentRegisteredPushKey()).isNull()
sut.setCurrentRegisteredPushKey("aKey")
assertThat(sut.getCurrentRegisteredPushKey()).isEqualTo("aKey")
sut.setCurrentRegisteredPushKey(null)
assertThat(sut.getCurrentRegisteredPushKey()).isNull()
}
@Test
fun `test getNotificationEnabledForDevice`() = runTest {
val sut = createUserPushStoreDataStore()
assertThat(sut.getNotificationEnabledForDevice().first()).isTrue()
sut.setNotificationEnabledForDevice(false)
assertThat(sut.getNotificationEnabledForDevice().first()).isFalse()
sut.setNotificationEnabledForDevice(true)
assertThat(sut.getNotificationEnabledForDevice().first()).isTrue()
}
@Test
fun `test useCompleteNotificationFormat`() = runTest {
val sut = createUserPushStoreDataStore()
assertThat(sut.useCompleteNotificationFormat()).isTrue()
}
@Test
fun `test ignoreRegistrationError`() = runTest {
val sut = createUserPushStoreDataStore()
assertThat(sut.ignoreRegistrationError().first()).isFalse()
sut.setIgnoreRegistrationError(true)
assertThat(sut.ignoreRegistrationError().first()).isTrue()
sut.setIgnoreRegistrationError(false)
assertThat(sut.ignoreRegistrationError().first()).isFalse()
}
@Test
fun `test reset`() = runTest {
val sut = createUserPushStoreDataStore()
sut.setPushProviderName("name")
sut.setCurrentRegisteredPushKey("aKey")
sut.setNotificationEnabledForDevice(false)
sut.setIgnoreRegistrationError(true)
sut.reset()
assertThat(sut.getPushProviderName()).isNull()
assertThat(sut.getCurrentRegisteredPushKey()).isNull()
assertThat(sut.getNotificationEnabledForDevice().first()).isTrue()
assertThat(sut.ignoreRegistrationError().first()).isFalse()
}
@Test
fun `ensure a store is created per session`() = runTest {
val sut1 = createUserPushStoreDataStore()
sut1.setPushProviderName("name")
val sut2 = createUserPushStoreDataStore(A_SESSION_ID_2)
assertThat(sut1.getPushProviderName()).isEqualTo("name")
assertThat(sut2.getPushProviderName()).isNull()
}
private fun createUserPushStoreDataStore(
sessionId: SessionId = A_SESSION_ID,
) = UserPushStoreDataStore(
context = InstrumentationRegistry.getInstrumentation().context,
userId = sessionId,
)
}

View file

@ -25,6 +25,7 @@ class FakeUserPushStore(
) : UserPushStore { ) : UserPushStore {
private var currentRegisteredPushKey: String? = null private var currentRegisteredPushKey: String? = null
private val notificationEnabledForDevice = MutableStateFlow(true) private val notificationEnabledForDevice = MutableStateFlow(true)
private val ignoreRegistrationError = MutableStateFlow(false)
override suspend fun getPushProviderName(): String? { override suspend fun getPushProviderName(): String? {
return pushProviderName return pushProviderName
} }
@ -53,6 +54,14 @@ class FakeUserPushStore(
return true return true
} }
override fun ignoreRegistrationError(): Flow<Boolean> {
return ignoreRegistrationError
}
override suspend fun setIgnoreRegistrationError(ignore: Boolean) {
ignoreRegistrationError.value = ignore
}
override suspend fun reset() { override suspend fun reset() {
} }
} }

View file

@ -129,12 +129,16 @@
<string name="common_decryption_error">"Decryption error"</string> <string name="common_decryption_error">"Decryption error"</string>
<string name="common_developer_options">"Developer options"</string> <string name="common_developer_options">"Developer options"</string>
<string name="common_direct_chat">"Direct chat"</string> <string name="common_direct_chat">"Direct chat"</string>
<string name="common_do_not_show_this_again">"Do not show this again"</string>
<string name="common_edited_suffix">"(edited)"</string> <string name="common_edited_suffix">"(edited)"</string>
<string name="common_editing">"Editing"</string> <string name="common_editing">"Editing"</string>
<string name="common_emote">"* %1$s %2$s"</string> <string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Encryption enabled"</string> <string name="common_encryption_enabled">"Encryption enabled"</string>
<string name="common_enter_your_pin">"Enter your PIN"</string> <string name="common_enter_your_pin">"Enter your PIN"</string>
<string name="common_error">"Error"</string> <string name="common_error">"Error"</string>
<string name="common_error_registering_pusher_android">"An error occurred, you may not receive notifications for new messages. Please troubleshoot notifications from the settings.
Reason: %1$s."</string>
<string name="common_everyone">"Everyone"</string> <string name="common_everyone">"Everyone"</string>
<string name="common_failed">"Failed"</string> <string name="common_failed">"Failed"</string>
<string name="common_favourite">"Favourite"</string> <string name="common_favourite">"Favourite"</string>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:92c0b21d5de4540e7b3b784d18531cdf428bd0c9f13bdd445f811d6df73ef50b
size 36779

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9613aed2c45f172c7afec90e6205cba7f6112c6108d577c5377e5c3b07720426
size 34664

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5aab6b3399b7826b3d1081474c6c0647bcdec933caef6bf563ea3f90d2c99bd1
size 13688

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f232b2d478fbbb30f50333514d8678f9eec114e2119ef64e3429887a12b452b
size 12114