Merge pull request #3035 from element-hq/feature/bma/fixFdroidNotification
Feature/bma/fix fdroid notification
This commit is contained in:
commit
e4bfc5ef8f
43 changed files with 1060 additions and 106 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:92c0b21d5de4540e7b3b784d18531cdf428bd0c9f13bdd445f811d6df73ef50b
|
||||||
|
size 36779
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9613aed2c45f172c7afec90e6205cba7f6112c6108d577c5377e5c3b07720426
|
||||||
|
size 34664
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5aab6b3399b7826b3d1081474c6c0647bcdec933caef6bf563ea3f90d2c99bd1
|
||||||
|
size 13688
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3f232b2d478fbbb30f50333514d8678f9eec114e2119ef64e3429887a12b452b
|
||||||
|
size 12114
|
||||||
Loading…
Add table
Add a link
Reference in a new issue