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 7b5e7c4c00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1060 additions and 106 deletions

View file

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

View file

@ -238,7 +238,12 @@ class LoggedInFlowNode @AssistedInject constructor(
return when (navTarget) {
NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext)
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 -> {
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.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@ -35,11 +36,22 @@ class LoggedInNode @AssistedInject constructor(
buildContext = buildContext,
plugins = plugins
) {
interface Callback : Plugin {
fun navigateToNotificationTroubleshoot()
}
private fun navigateToNotificationTroubleshoot() {
plugins<Callback>().forEach {
it.navigateToNotificationTroubleshoot()
}
}
@Composable
override fun View(modifier: Modifier) {
val loggedInState = loggedInPresenter.present()
LoggedInView(
state = loggedInState,
navigateToNotificationTroubleshoot = ::navigateToNotificationTroubleshoot,
modifier = modifier
)
}

View file

@ -18,15 +18,20 @@ package io.element.android.appnav.loggedin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.UserProperties
import io.element.android.features.networkmonitor.api.NetworkMonitor
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.core.log.logger.LoggerTag
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.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.SessionVerifiedStatus
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 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 javax.inject.Inject
private val pusherTag = LoggerTag("Pusher", LoggerTag.PushLoggerTag)
class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val networkMonitor: NetworkMonitor,
@ -49,36 +60,26 @@ class LoggedInPresenter @Inject constructor(
) : Presenter<LoggedInState> {
@Composable
override fun present(): LoggedInState {
val isVerified by remember {
sessionVerificationService.sessionVerifiedStatus.map { it == SessionVerifiedStatus.Verified }
val coroutineScope = rememberCoroutineScope()
val ignoreRegistrationError by remember {
pushService.ignoreRegistrationError(matrixClient.sessionId)
}.collectAsState(initial = false)
LaunchedEffect(isVerified) {
if (isVerified) {
// Ensure pusher is registered
val currentPushProvider = pushService.getCurrentPushProvider()
val result = if (currentPushProvider == null) {
// Register with the first available push provider
val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
pushService.registerWith(matrixClient, pushProvider, distributor)
} else {
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)
val pusherRegistrationState = remember<MutableState<AsyncData<Unit>>> { mutableStateOf(AsyncData.Uninitialized) }
LaunchedEffect(Unit) {
sessionVerificationService.sessionVerifiedStatus
.onEach { sessionVerifiedStatus ->
when (sessionVerifiedStatus) {
SessionVerifiedStatus.Unknown -> Unit
SessionVerifiedStatus.Verified -> {
ensurePusherIsRegistered(pusherRegistrationState)
}
SessionVerifiedStatus.NotVerified -> {
pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.AccountNotVerified())
}
}
}
result.onFailure {
Timber.e(it, "Failed to register pusher")
}
}
.launchIn(this)
}
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val networkStatus by networkMonitor.connectivity.collectAsState()
val showSyncSpinner by remember {
@ -86,14 +87,86 @@ class LoggedInPresenter @Inject constructor(
networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show
}
}
val verificationState by sessionVerificationService.sessionVerifiedStatus.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
LaunchedEffect(verificationState, recoveryState) {
reportCryptoStatusToAnalytics(verificationState, recoveryState)
LaunchedEffect(Unit) {
combine(
sessionVerificationService.sessionVerifiedStatus,
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(
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
import io.element.android.libraries.architecture.AsyncData
data class LoggedInState(
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
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState>
get() = sequenceOf(
aLoggedInState(false),
aLoggedInState(true),
// Add other state here
aLoggedInState(),
aLoggedInState(showSyncSpinner = true),
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
)
}
fun aLoggedInState(
showSyncSpinner: Boolean = true,
showSyncSpinner: Boolean = false,
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
) = LoggedInState(
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.PreviewsDayNight
import io.element.android.libraries.matrix.api.exception.isNetworkError
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LoggedInView(
state: LoggedInState,
navigateToNotificationTroubleshoot: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
@ -41,12 +47,53 @@ fun LoggedInView(
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
@Composable
internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
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.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus
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.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.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.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
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.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.tests.testutils.WarmUpRule
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 org.junit.Rule
import org.junit.Test
@ -51,6 +67,8 @@ class LoggedInPresenterTest {
}.test {
val initialState = awaitItem()
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.INCOMPLETE)
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
skipItems(4)
skipItems(2)
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents[0]).isInstanceOf(CryptoSessionStateChange::class.java)
assertThat(analyticsService.capturedUserProperties.size).isEqualTo(1)
assertThat(analyticsService.capturedUserProperties[0].recoveryState).isEqualTo(UserProperties.RecoveryState.Incomplete)
assertThat(analyticsService.capturedUserProperties[0].verificationState).isEqualTo(UserProperties.VerificationState.Verified)
// ensure a sync status change does not trigger a new capture
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
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(
roomListService: RoomListService = FakeRoomListService(),
networkStatus: NetworkStatus = NetworkStatus.Offline,
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
encryptionService: FakeEncryptionService = FakeEncryptionService(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
encryptionService: EncryptionService = FakeEncryptionService(),
pushService: PushService = FakePushService(),
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService),
networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = FakePushService(),
sessionVerificationService = FakeSessionVerificationService(),
pushService = pushService,
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService,
encryptionService = encryptionService
)

View file

@ -32,6 +32,9 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
@Parcelize
data object NotificationSettings : InitialTarget
@Parcelize
data object NotificationTroubleshoot : InitialTarget
}
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) {
is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root
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.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.canPop
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ -190,7 +191,11 @@ class PreferencesFlowNode @AssistedInject constructor(
notificationTroubleShootEntryPoint.nodeBuilder(this, buildContext)
.callback(object : NotificationTroubleShootEntryPoint.Callback {
override fun onDone() {
backstack.pop()
if (backstack.canPop()) {
backstack.pop()
} else {
navigateUp()
}
}
})
.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.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.push.api.PushService
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import javax.inject.Inject
@ -47,6 +48,7 @@ class DefaultClearCacheUseCase @Inject constructor(
private val okHttpClient: Provider<OkHttpClient>,
private val ftueService: FtueService,
private val migrationScreenStore: MigrationScreenStore,
private val pushService: PushService,
) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Clear Matrix cache
@ -64,6 +66,8 @@ class DefaultClearCacheUseCase @Inject constructor(
ftueService.reset()
// Clear migration screen store
migrationScreenStore.reset()
// Ensure any error will be displayed again
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
// Ensure the app is restarted
defaultCacheService.onClearedCache(matrixClient.sessionId)
}

View file

@ -329,13 +329,11 @@ class NotificationSettingsPresenterTest {
val pushProvider1 = FakePushProvider(
index = 0,
name = "aFakePushProvider0",
isAvailable = true,
distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")),
)
val pushProvider2 = FakePushProvider(
index = 1,
name = "aFakePushProvider1",
isAvailable = true,
distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")),
)
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.preferences.impl.DefaultCacheService
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.push.test.FakePushService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
@ -48,6 +51,10 @@ class DefaultClearCacheUseCaseTest {
val migrationScreenStore = InMemoryMigrationScreenStore(
resetLambda = resetMigrationLambda,
)
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
val pushService = FakePushService(
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
)
val sut = DefaultClearCacheUseCase(
context = InstrumentationRegistry.getInstrumentation().context,
matrixClient = matrixClient,
@ -55,13 +62,16 @@ class DefaultClearCacheUseCaseTest {
defaultCacheService = defaultCacheService,
okHttpClient = { OkHttpClient.Builder().build() },
ftueService = ftueService,
migrationScreenStore = migrationScreenStore
migrationScreenStore = migrationScreenStore,
pushService = pushService,
)
defaultCacheService.clearedCacheEventFlow.test {
sut.invoke()
clearCacheLambda.assertions().isCalledOnce()
resetFtueLambda.assertions().isCalledOnce()
resetMigrationLambda.assertions().isCalledOnce()
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
.with(value(matrixClient.sessionId), value(false))
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 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
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.SetHttpPusherData
import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData
import io.element.android.libraries.matrix.impl.exception.mapClientException
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.HttpPusherData
@ -52,6 +54,7 @@ class RustPushersService(
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.StateFlow
class FakeSessionVerificationService : SessionVerificationService {
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
class FakeSessionVerificationService(
initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown,
) : SessionVerificationService {
private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus)
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
private var _needsSessionVerification = MutableStateFlow(true)
var shouldFail = false

View file

@ -17,8 +17,10 @@
package io.element.android.libraries.push.api
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
import kotlinx.coroutines.flow.Flow
interface PushService {
/**
@ -27,8 +29,7 @@ interface PushService {
suspend fun getCurrentPushProvider(): PushProvider?
/**
* Return the list of push providers, available at compile time, and
* available at runtime, sorted by index.
* Return the list of push providers, available at compile time, sorted by index.
*/
fun getAvailablePushProviders(): List<PushProvider>
@ -43,6 +44,18 @@ interface PushService {
distributor: Distributor,
): 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.
*/

View file

@ -19,12 +19,14 @@ package io.element.android.libraries.push.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.GetCurrentPushProvider
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.impl.test.TestPush
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import kotlinx.coroutines.flow.Flow
import timber.log.Timber
import javax.inject.Inject
@ -42,7 +44,6 @@ class DefaultPushService @Inject constructor(
override fun getAvailablePushProviders(): List<PushProvider> {
return pushProviders
.filter { it.isAvailable() }
.sortedBy { it.index }
}
@ -51,7 +52,7 @@ class DefaultPushService @Inject constructor(
pushProvider: PushProvider,
distributor: Distributor,
): 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 currentPushProviderName = userPushStore.getPushProviderName()
val currentPushProvider = pushProviders.find { it.name == currentPushProviderName }
@ -72,6 +73,23 @@ class DefaultPushService @Inject constructor(
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 {
val pushProvider = getCurrentPushProvider() ?: 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 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.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.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.UnsetHttpPusherData
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.clientsecret.PushClientSecret
import timber.log.Timber
@ -50,7 +53,8 @@ class DefaultPusherSubscriber @Inject constructor(
gateway: String,
): Result<Unit> {
val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId)
if (userDataStore.getCurrentRegisteredPushKey() == pushKey) {
val isRegisteringAgain = userDataStore.getCurrentRegisteredPushKey() == pushKey
if (isRegisteringAgain) {
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")
}
@ -61,8 +65,14 @@ class DefaultPusherSubscriber @Inject constructor(
.onSuccess {
userDataStore.setCurrentRegisteredPushKey(pushKey)
}
.onFailure { throwable ->
.mapFailure { throwable ->
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 io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.api.GetCurrentPushProvider
import io.element.android.libraries.push.impl.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.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -205,6 +207,20 @@ class DefaultPushServiceTest {
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(
testPush: TestPush = FakeTestPush(),
userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(),

View file

@ -17,10 +17,14 @@
package io.element.android.libraries.push.test
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakePushService(
private val testPushBlock: suspend () -> Boolean = { true },
@ -28,9 +32,12 @@ class FakePushService(
private val registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result<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 {
override suspend fun getCurrentPushProvider(): PushProvider? {
return registeredPushProvider ?: availablePushProviders.firstOrNull()
return registeredPushProvider ?: currentPushProvider()
}
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 {
testPushBlock()
}

View file

@ -33,10 +33,8 @@ interface PushProvider {
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>
/**

View file

@ -17,8 +17,21 @@
package io.element.android.libraries.pushproviders.api
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.exception.ClientException
interface PusherSubscriber {
/**
* Register a pusher. Note that failure will be a [RegistrationFailure].
*/
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>
}
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 name = FirebaseConfig.NAME
override fun isAvailable(): Boolean {
return isPlayServiceAvailable.isAvailable()
}
override fun getDistributors(): List<Distributor> {
return listOf(firebaseDistributor)
return listOfNotNull(
firebaseDistributor.takeIf { isPlayServiceAvailable.isAvailable() }
)
}
override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result<Unit> {

View file

@ -38,12 +38,23 @@ class FirebasePushProviderTest {
}
@Test
fun `getDistributors return the unique distributor`() {
val firebasePushProvider = createFirebasePushProvider()
fun `getDistributors return the unique distributor if available`() {
val firebasePushProvider = createFirebasePushProvider(
isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = true)
)
val result = firebasePushProvider.getDistributors()
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
fun `getCurrentDistributor always return the unique distributor`() = runTest {
val firebasePushProvider = createFirebasePushProvider()
@ -51,22 +62,6 @@ class FirebasePushProviderTest {
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
fun `register ok`() = runTest {
val matrixClient = FakeMatrixClient()

View file

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

View file

@ -17,7 +17,6 @@
package io.element.android.libraries.pushproviders.unifiedpush
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.matrix.api.MatrixClient
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.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.currentSessionId
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("UnifiedPushProvider", LoggerTag.PushLoggerTag)
@ContributesMultibinding(AppScope::class)
class UnifiedPushProvider @Inject constructor(
private val unifiedPushDistributorProvider: UnifiedPushDistributorProvider,
@ -43,17 +39,6 @@ class UnifiedPushProvider @Inject constructor(
override val index = UnifiedPushConfig.INDEX
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> {
return unifiedPushDistributorProvider.getDistributors()
}

View file

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

View file

@ -29,6 +29,9 @@ interface UserPushStore {
fun getNotificationEnabledForDevice(): Flow<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.
*/

View file

@ -44,6 +44,7 @@ dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
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.preferencesDataStoreFile
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.matrix.api.core.SessionId
import io.element.android.libraries.pushstore.api.UserPushStore
@ -61,6 +62,7 @@ class UserPushStoreDataStore(
private val pushProviderName = stringPreferencesKey("pushProviderName")
private val currentPushKey = stringPreferencesKey("currentPushKey")
private val notificationEnabled = booleanPreferencesKey("notificationEnabled")
private val ignoreRegistrationError = booleanPreferencesKey("ignoreRegistrationError")
override suspend fun getPushProviderName(): String? {
return context.dataStore.data.first()[pushProviderName]
@ -100,6 +102,16 @@ class UserPushStoreDataStore(
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() {
context.dataStore.edit {
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 {
private var currentRegisteredPushKey: String? = null
private val notificationEnabledForDevice = MutableStateFlow(true)
private val ignoreRegistrationError = MutableStateFlow(false)
override suspend fun getPushProviderName(): String? {
return pushProviderName
}
@ -53,6 +54,14 @@ class FakeUserPushStore(
return true
}
override fun ignoreRegistrationError(): Flow<Boolean> {
return ignoreRegistrationError
}
override suspend fun setIgnoreRegistrationError(ignore: Boolean) {
ignoreRegistrationError.value = ignore
}
override suspend fun reset() {
}
}

View file

@ -129,12 +129,16 @@
<string name="common_decryption_error">"Decryption error"</string>
<string name="common_developer_options">"Developer options"</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_editing">"Editing"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Encryption enabled"</string>
<string name="common_enter_your_pin">"Enter your PIN"</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_failed">"Failed"</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