Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 6ec5d1aa authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

fix: show degraded access message for temporary Play failures

parent 62d79455
Loading
Loading
Loading
Loading
Loading
+44 −0
Original line number Diff line number Diff line
@@ -23,12 +23,16 @@ import android.os.SystemClock
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.mapper.toApplication
import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.domain.application.ApplicationDomain
import foundation.e.apps.domain.auth.AuthError
import foundation.e.apps.domain.auth.AuthRefreshSnapshot
import foundation.e.apps.domain.auth.AuthRefreshState
import foundation.e.apps.domain.auth.AuthSession
import foundation.e.apps.domain.auth.AuthStore
import foundation.e.apps.domain.startup.ObserveOnboardingStatusUseCase
import foundation.e.apps.domain.startup.ResolveStartupDestinationUseCase
import foundation.e.apps.domain.startup.ResolveStartupTocGateUseCase
@@ -66,6 +70,7 @@ class MainActivityViewModel @Inject constructor(
    private val startupStateMachine =
        MainActivityStartupStateMachine(resolveStartupDestinationUseCase)
    private var lastStartupRefreshAtElapsedRealtime = -STARTUP_REFRESH_TTL_MS
    private var lastReportedTemporaryPlayFailureKey: TemporaryPlayFailureKey? = null
    private val isRefreshingStartupData = AtomicBoolean(false)

    init {
@@ -98,6 +103,12 @@ class MainActivityViewModel @Inject constructor(

    private var ignoreSessionError = false

    private enum class TemporaryPlayFailureKey {
        NETWORK_FAILURE,
        RATE_LIMITED,
        UNKNOWN,
    }

    fun shouldIgnoreSessionError(): Boolean = ignoreSessionError

    fun ignoreSessionError() {
@@ -153,6 +164,7 @@ class MainActivityViewModel @Inject constructor(
        )

        if (authRefreshState is AuthRefreshState.Completed) {
            reportTemporaryPlayFailureIfNeeded(authRefreshState.snapshot)
            viewModelScope.launch {
                sessionManager.reportFaultyTokenIfNeeded()
            }
@@ -163,6 +175,38 @@ class MainActivityViewModel @Inject constructor(
        }
    }

    private fun reportTemporaryPlayFailureIfNeeded(snapshot: AuthRefreshSnapshot) {
        val playFailureKey = snapshot.firstFailureFor(AuthStore.PLAY_STORE)
            ?.temporaryPlayFailureKey()
        if (!snapshot.hasActiveSessions || playFailureKey == null) {
            lastReportedTemporaryPlayFailureKey = null
            return
        }

        if (playFailureKey == lastReportedTemporaryPlayFailureKey) {
            return
        }

        lastReportedTemporaryPlayFailureKey = playFailureKey
        emitUiEffect(
            MainActivityUiEffect.ShowSnackbarMessage(
                R.string.common_apps_data_load_error_description,
            )
        )
    }

    private fun AuthError.temporaryPlayFailureKey(): TemporaryPlayFailureKey? {
        return when (this) {
            is AuthError.NetworkFailure -> TemporaryPlayFailureKey.NETWORK_FAILURE
            is AuthError.RateLimited -> TemporaryPlayFailureKey.RATE_LIMITED
            is AuthError.Unknown -> TemporaryPlayFailureKey.UNKNOWN

            is AuthError.InvalidToken,
            is AuthError.LoginRequired,
            is AuthError.UserActionRequired -> null
        }
    }

    fun onCurrentDestinationChanged(destinationId: Int?) {
        applyStartupState(startupStateMachine.onDestinationChanged(destinationId))
    }
+112 −2
Original line number Diff line number Diff line
@@ -38,8 +38,8 @@ import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.domain.application.ApplicationDomain
import foundation.e.apps.domain.auth.AuthError
import foundation.e.apps.domain.auth.AuthRefreshEntry
import foundation.e.apps.domain.auth.AuthRefreshState
import foundation.e.apps.domain.auth.AuthRefreshSnapshot
import foundation.e.apps.domain.auth.AuthRefreshState
import foundation.e.apps.domain.auth.AuthResult
import foundation.e.apps.domain.auth.AuthSession
import foundation.e.apps.domain.auth.AuthStore
@@ -49,8 +49,8 @@ import foundation.e.apps.domain.install.GetOtherStoreUpdateConfirmationUseCase
import foundation.e.apps.domain.install.OtherStoreUpdateConfirmation
import foundation.e.apps.domain.startup.ObserveOnboardingStatusUseCase
import foundation.e.apps.domain.startup.OnboardingStatus
import foundation.e.apps.domain.startup.ResolveStartupTocGateUseCase
import foundation.e.apps.domain.startup.ResolveStartupDestinationUseCase
import foundation.e.apps.domain.startup.ResolveStartupTocGateUseCase
import foundation.e.apps.domain.startup.StartupDestination
import foundation.e.apps.util.MainCoroutineRule
import io.mockk.coEvery
@@ -175,6 +175,32 @@ class MainActivityViewModelTest {
        )
    }

    private fun openSourceSuccessSnapshot(): AuthRefreshSnapshot {
        return AuthRefreshSnapshot(
            entries = listOf(
                AuthRefreshEntry(
                    store = AuthStore.OPEN_SOURCE,
                    result = AuthResult.Success(AuthSession.OpenSourceSession),
                ),
            )
        )
    }

    private fun degradedPlayFailureSnapshot(error: AuthError): AuthRefreshSnapshot {
        return AuthRefreshSnapshot(
            entries = listOf(
                AuthRefreshEntry(
                    store = AuthStore.OPEN_SOURCE,
                    result = AuthResult.Success(AuthSession.OpenSourceSession),
                ),
                AuthRefreshEntry(
                    store = AuthStore.PLAY_STORE,
                    result = AuthResult.Failure(error),
                ),
            )
        )
    }

    @Test
    fun `handleAuthRefreshState routes to sign in when no sessions are available`() =
        runTest(mainCoroutineRule.testDispatcher) {
@@ -276,6 +302,7 @@ class MainActivityViewModelTest {
    @Test
    fun `handleAuthRefreshState routes to sign in when play needs recovery but open source is active`() =
        runTest(mainCoroutineRule.testDispatcher) {
            val effect = async { viewModel.uiEffects.first() }
            val authRefreshSnapshot = AuthRefreshSnapshot(
                entries = listOf(
                    AuthRefreshEntry(
@@ -299,6 +326,8 @@ class MainActivityViewModelTest {

            assertThat(viewModel.startupUiState.value.destination)
                .isEqualTo(StartupDestination.SIGN_IN)
            assertThat(effect.isCompleted).isFalse()
            effect.cancel()
            coVerify(exactly = 1) { sessionManager.reportFaultyTokenIfNeeded() }
        }

@@ -320,6 +349,87 @@ class MainActivityViewModelTest {
            coVerify(exactly = 1) { sessionManager.reportFaultyTokenIfNeeded() }
        }

    @Test
    fun `handleAuthRefreshState informs user when common apps are temporarily unavailable`() =
        runTest(mainCoroutineRule.testDispatcher) {
            val effect = async { viewModel.uiEffects.first() }
            val authRefreshSnapshot = degradedPlayFailureSnapshot(
                AuthError.NetworkFailure(
                    isTimeout = true,
                    message = "Timed out",
                )
            )

            viewModel.handleAuthRefreshState(AuthRefreshState.Completed(authRefreshSnapshot))
            advanceUntilIdle()

            assertThat(effect.await()).isEqualTo(
                MainActivityUiEffect.ShowSnackbarMessage(
                    R.string.common_apps_data_load_error_description,
                )
            )
            coVerify(exactly = 1) { sessionManager.reportFaultyTokenIfNeeded() }
        }

    @Test
    fun `handleAuthRefreshState deduplicates temporary Play failure snackbar until state clears`() =
        runTest(mainCoroutineRule.testDispatcher) {
            val expectedSnackbar = MainActivityUiEffect.ShowSnackbarMessage(
                R.string.common_apps_data_load_error_description,
            )
            val firstEffect = async { viewModel.uiEffects.first() }

            viewModel.handleAuthRefreshState(
                AuthRefreshState.Completed(
                    degradedPlayFailureSnapshot(
                        AuthError.NetworkFailure(
                            isTimeout = true,
                            message = "Timed out",
                        )
                    )
                )
            )
            advanceUntilIdle()

            assertThat(firstEffect.await()).isEqualTo(expectedSnackbar)

            val repeatedEffect = async { viewModel.uiEffects.first() }
            viewModel.handleAuthRefreshState(
                AuthRefreshState.Completed(
                    degradedPlayFailureSnapshot(
                        AuthError.NetworkFailure(
                            isTimeout = false,
                            message = "Connection reset",
                        )
                    )
                )
            )
            advanceUntilIdle()

            assertThat(repeatedEffect.isCompleted).isFalse()
            repeatedEffect.cancel()

            viewModel.handleAuthRefreshState(
                AuthRefreshState.Completed(openSourceSuccessSnapshot())
            )
            advanceUntilIdle()

            val resetEffect = async { viewModel.uiEffects.first() }
            viewModel.handleAuthRefreshState(
                AuthRefreshState.Completed(
                    degradedPlayFailureSnapshot(
                        AuthError.NetworkFailure(
                            isTimeout = true,
                            message = "Timed out",
                        )
                    )
                )
            )
            advanceUntilIdle()

            assertThat(resetEffect.await()).isEqualTo(expectedSnackbar)
        }

    @Test
    fun `handleAuthRefreshState emits finish effect after successful Play login refresh`() =
        runTest(mainCoroutineRule.testDispatcher) {