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

Commit 62d79455 authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

fix(auth): preserve Play identity during auth recovery

parent 6eeb3827
Loading
Loading
Loading
Loading
+117 −29
Original line number Diff line number Diff line
@@ -22,14 +22,17 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.ExistingPeriodicWorkPolicy
import dagger.hilt.android.lifecycle.HiltViewModel
import foundation.e.apps.domain.auth.AuthRefreshState
import foundation.e.apps.domain.auth.AuthSession
import foundation.e.apps.domain.auth.AuthSessionRepository
import foundation.e.apps.domain.auth.PlayAuthStatus
import foundation.e.apps.domain.auth.PlayStoreAccount
import foundation.e.apps.domain.auth.PlayStoreAccountRepository
import foundation.e.apps.domain.auth.PlayStoreLoginMode
import foundation.e.apps.domain.auth.ResolvePlayAuthStatusUseCase
import foundation.e.apps.domain.source.AppSource
import foundation.e.apps.domain.source.SourceSelection
import foundation.e.apps.domain.source.SourceSelectionRepository
import foundation.e.apps.feature.auth.session.SessionStateController
import foundation.e.apps.feature.auth.source.SourceSelectionUpdateException
import foundation.e.apps.feature.auth.source.UpdateSourceSelectionCoordinator
import foundation.e.apps.updates.PeriodicUpdatesScheduler
@@ -49,8 +52,10 @@ class SettingsViewModel @Inject constructor(
    private val authSessionRepository: AuthSessionRepository,
    private val playStoreAccountRepository: PlayStoreAccountRepository,
    private val sourceSelectionRepository: SourceSelectionRepository,
    private val sessionStateController: SessionStateController,
    private val updateSourceSelectionCoordinator: UpdateSourceSelectionCoordinator,
    private val periodicUpdatesScheduler: PeriodicUpdatesScheduler,
    private val resolvePlayAuthStatusUseCase: ResolvePlayAuthStatusUseCase,
) : ViewModel() {
    private sealed interface SourceSelectionRequestOutcome {
        data object Ignore : SourceSelectionRequestOutcome
@@ -66,6 +71,7 @@ class SettingsViewModel @Inject constructor(
        authSessionRepository.currentSession,
        playStoreAccountRepository.account,
        sourceSelectionRepository.sourceSelection,
        sessionStateController.authRefreshState,
        ::toSettingsUiState,
    ).distinctUntilChanged()
        .stateIn(
@@ -117,7 +123,7 @@ class SettingsViewModel @Inject constructor(

        return when {
            currentState.isLoading -> SourceSelectionRequestOutcome.Ignore
            source == AppSource.PLAY_STORE && currentState.requiresPlayStoreLogin ->
            source == AppSource.PLAY_STORE && isSelected && currentState.requiresPlayStoreLogin ->
                SourceSelectionRequestOutcome.EmitEvent(SettingsUiEvent.PlayStoreLoginRequired)

            !updatedSelection.hasAnySelected -> SourceSelectionRequestOutcome.EmitEvent(
@@ -132,8 +138,54 @@ class SettingsViewModel @Inject constructor(
        session: AuthSession,
        playStoreAccount: PlayStoreAccount?,
        sourceSelection: SourceSelection,
        authRefreshState: AuthRefreshState,
    ): SettingsUiState {
        return when (session) {
        val playAuthStatus = resolvePlayAuthStatusUseCase(
            session = session,
            account = playStoreAccount,
            authRefreshState = authRefreshState,
        )

        return playAuthStatus.toSettingsUiState(
            session = session,
            sourceSelection = sourceSelection,
        ) ?: session.toFallbackSettingsUiState(sourceSelection)
    }

    private fun PlayAuthStatus.toSettingsUiState(
        session: AuthSession,
        sourceSelection: SourceSelection,
    ): SettingsUiState? {
        return when (this) {
            is PlayAuthStatus.Active -> playStoreAccountState(
                account = account,
                accountAction = SettingsAccountAction.LOGOUT,
                requiresPlayStoreLogin = false,
                sourceSelection = sourceSelection,
            )

            PlayAuthStatus.Anonymous -> anonymousAccountState(sourceSelection)

            is PlayAuthStatus.NeedsUserRecovery -> playStoreRecoveryState(
                account = account,
                session = session,
                sourceSelection = sourceSelection,
            )

            is PlayAuthStatus.TemporarilyUnavailable -> playStoreRecoveryState(
                account = account,
                session = session,
                sourceSelection = sourceSelection,
            )

            PlayAuthStatus.LoggedOut -> null
        }
    }

    private fun AuthSession.toFallbackSettingsUiState(
        sourceSelection: SourceSelection,
    ): SettingsUiState {
        return when (this) {
            AuthSession.Unauthenticated -> SettingsUiState(
                isLoading = false,
                sourceSelection = sourceSelection,
@@ -146,9 +198,15 @@ class SettingsViewModel @Inject constructor(
                sourceSelection = sourceSelection,
            )

            is AuthSession.PlayStoreSession -> {
                if (session.loginMode == PlayStoreLoginMode.ANONYMOUS) {
                    SettingsUiState(
            is AuthSession.PlayStoreSession -> SettingsUiState(
                isLoading = false,
                sourceSelection = sourceSelection,
            )
        }
    }

    private fun anonymousAccountState(sourceSelection: SourceSelection): SettingsUiState {
        return SettingsUiState(
            isLoading = false,
            accountMode = SettingsAccountMode.ANONYMOUS,
            accountAction = SettingsAccountAction.LOGOUT,
@@ -156,22 +214,52 @@ class SettingsViewModel @Inject constructor(
            showAnonymousUpdateInterval = true,
            sourceSelection = sourceSelection,
        )
    }

    private fun playStoreRecoveryState(
        account: PlayStoreAccount?,
        session: AuthSession,
        sourceSelection: SourceSelection,
    ): SettingsUiState? {
        return account
            ?.let { recoveryAccount ->
                playStoreAccountState(
                    account = recoveryAccount,
                    accountAction = SettingsAccountAction.LOGIN,
                    requiresPlayStoreLogin = true,
                    sourceSelection = sourceSelection,
                )
            }
            ?: if (session is AuthSession.PlayStoreSession) {
                playStoreAccountState(
                    account = null,
                    accountAction = SettingsAccountAction.LOGIN,
                    requiresPlayStoreLogin = true,
                    sourceSelection = sourceSelection,
                )
            } else {
                    val resolvedEmail = playStoreAccount?.email.orEmpty()
                    SettingsUiState(
                null
            }
    }

    private fun playStoreAccountState(
        account: PlayStoreAccount?,
        accountAction: SettingsAccountAction,
        requiresPlayStoreLogin: Boolean,
        sourceSelection: SourceSelection,
    ): SettingsUiState {
        val resolvedEmail = account?.email.orEmpty()
        return SettingsUiState(
            isLoading = false,
            accountMode = SettingsAccountMode.PLAY_STORE,
                        accountTitle = playStoreAccount?.displayName
            accountTitle = account?.displayName
                ?.takeUnless { it.isBlank() }
                ?: resolvedEmail,
            email = resolvedEmail,
                        avatarUrl = playStoreAccount?.avatarUrl,
                        accountAction = SettingsAccountAction.LOGOUT,
                        requiresPlayStoreLogin = false,
            avatarUrl = account?.avatarUrl,
            accountAction = accountAction,
            requiresPlayStoreLogin = requiresPlayStoreLogin,
            sourceSelection = sourceSelection,
        )
    }
}
        }
    }
}
+26 −0
Original line number Diff line number Diff line
@@ -89,6 +89,32 @@ class MainActivityStartupStateMachineTest {
        assertThat(state.destination).isEqualTo(StartupDestination.SIGN_IN)
    }

    @Test
    fun `auth refresh routes to sign in when play needs user recovery with open source active`() {
        stateMachine.onTocAcceptanceChanged(isTocAccepted = true)
        val snapshot = AuthRefreshSnapshot(
            entries = listOf(
                AuthRefreshEntry(
                    store = AuthStore.OPEN_SOURCE,
                    result = AuthResult.Success(AuthSession.OpenSourceSession),
                ),
                AuthRefreshEntry(
                    store = AuthStore.PLAY_STORE,
                    result = AuthResult.Failure(
                        AuthError.UserActionRequired(
                            store = AuthStore.PLAY_STORE,
                            message = "Consent required",
                        ),
                    ),
                ),
            ),
        )

        val state = stateMachine.onAuthRefreshStateChanged(AuthRefreshState.Completed(snapshot))

        assertThat(state.destination).isEqualTo(StartupDestination.SIGN_IN)
    }

    @Test
    fun `auth refresh finishes after successful play login when close after login is requested`() {
        stateMachine.onTocAcceptanceChanged(isTocAccepted = true)
+29 −0
Original line number Diff line number Diff line
@@ -273,6 +273,35 @@ class MainActivityViewModelTest {
            coVerify(exactly = 1) { sessionManager.reportFaultyTokenIfNeeded() }
        }

    @Test
    fun `handleAuthRefreshState routes to sign in when play needs recovery but open source is active`() =
        runTest(mainCoroutineRule.testDispatcher) {
            val authRefreshSnapshot = AuthRefreshSnapshot(
                entries = listOf(
                    AuthRefreshEntry(
                        store = AuthStore.OPEN_SOURCE,
                        result = AuthResult.Success(AuthSession.OpenSourceSession),
                    ),
                    AuthRefreshEntry(
                        store = AuthStore.PLAY_STORE,
                        result = AuthResult.Failure(
                            AuthError.UserActionRequired(
                                store = AuthStore.PLAY_STORE,
                                message = "Consent required",
                            )
                        ),
                    ),
                )
            )

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

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

    @Test
    fun `handleAuthRefreshState checks faulty token reporting on successful refresh completion`() =
        runTest(mainCoroutineRule.testDispatcher) {
+113 −2
Original line number Diff line number Diff line
@@ -20,14 +20,18 @@ package foundation.e.apps.ui.settings

import androidx.work.ExistingPeriodicWorkPolicy
import com.google.common.truth.Truth.assertThat
import foundation.e.apps.domain.auth.AuthError
import foundation.e.apps.domain.auth.AuthRefreshEntry
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.AuthSessionRepository
import foundation.e.apps.domain.auth.AuthStore
import foundation.e.apps.domain.auth.PlayStoreAccount
import foundation.e.apps.domain.auth.PlayStoreAccountRepository
import foundation.e.apps.domain.auth.PlayStoreLoginMode
import foundation.e.apps.domain.auth.ResolvePlayAuthStatusUseCase
import foundation.e.apps.domain.source.AppSource
import foundation.e.apps.domain.source.SourceSelection
import foundation.e.apps.domain.source.SourceSelectionRepository
@@ -134,6 +138,70 @@ class SettingsViewModelTest {
        assertThat(viewModel.uiState.value.requiresPlayStoreLogin).isFalse()
    }

    @Test
    fun `uiState keeps play store account visible when refresh needs user recovery`() =
        runTest(mainCoroutineRule.testDispatcher) {
            val account = PlayStoreAccount(
                email = "user@example.com",
                displayName = "User Example",
                avatarUrl = "https://example.com/avatar.png",
            )
            val sessionStateController = FakeSessionStateController(
                authRefreshState = AuthRefreshState.Completed(
                    AuthRefreshSnapshot(
                        entries = listOf(
                            AuthRefreshEntry(
                                store = AuthStore.PLAY_STORE,
                                result = AuthResult.Failure(
                                    AuthError.UserActionRequired(
                                        store = AuthStore.PLAY_STORE,
                                        message = "Consent required",
                                    ),
                                ),
                            ),
                        ),
                    ),
                ),
            )
            val viewModel = buildViewModel(
                session = AuthSession.Unauthenticated,
                account = account,
                sessionStateController = sessionStateController,
            )

            advanceUntilIdle()

            assertThat(viewModel.uiState.value.isLoading).isFalse()
            assertThat(viewModel.uiState.value.accountMode).isEqualTo(SettingsAccountMode.PLAY_STORE)
            assertThat(viewModel.uiState.value.accountTitle).isEqualTo("User Example")
            assertThat(viewModel.uiState.value.email).isEqualTo("user@example.com")
            assertThat(viewModel.uiState.value.accountAction).isEqualTo(SettingsAccountAction.LOGIN)
            assertThat(viewModel.uiState.value.requiresPlayStoreLogin).isTrue()
        }

    @Test
    fun `uiState keeps play store account visible when session is missing but account remains`() =
        runTest(mainCoroutineRule.testDispatcher) {
            val account = PlayStoreAccount(
                email = "user@example.com",
                displayName = "User Example",
                avatarUrl = "https://example.com/avatar.png",
            )
            val viewModel = buildViewModel(
                session = AuthSession.Unauthenticated,
                account = account,
            )

            advanceUntilIdle()

            assertThat(viewModel.uiState.value.isLoading).isFalse()
            assertThat(viewModel.uiState.value.accountMode).isEqualTo(SettingsAccountMode.PLAY_STORE)
            assertThat(viewModel.uiState.value.accountTitle).isEqualTo("User Example")
            assertThat(viewModel.uiState.value.email).isEqualTo("user@example.com")
            assertThat(viewModel.uiState.value.accountAction).isEqualTo(SettingsAccountAction.LOGIN)
            assertThat(viewModel.uiState.value.requiresPlayStoreLogin).isTrue()
        }

    @Test
    fun `onSourceSelectionRequested emits warning when deselecting last source`() =
        runTest(mainCoroutineRule.testDispatcher) {
@@ -180,6 +248,47 @@ class SettingsViewModelTest {
            assertThat(event.await()).isEqualTo(SettingsUiEvent.PlayStoreLoginRequired)
        }

    @Test
    fun `onSourceSelectionRequested allows disabling play source when play auth needs recovery`() =
        runTest(mainCoroutineRule.testDispatcher) {
            val sourceSelectionRepository = FakeSourceSelectionRepository(SourceSelection.DEFAULT)
            val sessionStateController = FakeSessionStateController(
                authRefreshState = AuthRefreshState.Completed(
                    AuthRefreshSnapshot(
                        entries = listOf(
                            AuthRefreshEntry(
                                store = AuthStore.PLAY_STORE,
                                result = AuthResult.Failure(
                                    AuthError.UserActionRequired(
                                        store = AuthStore.PLAY_STORE,
                                        message = "Consent required",
                                    ),
                                ),
                            ),
                        ),
                    ),
                ),
            )
            val viewModel = buildViewModel(
                session = AuthSession.Unauthenticated,
                account = PlayStoreAccount(email = "user@example.com"),
                sourceSelectionRepository = sourceSelectionRepository,
                sessionStateController = sessionStateController,
            )

            advanceUntilIdle()
            viewModel.onSourceSelectionRequested(AppSource.PLAY_STORE, false)
            advanceUntilIdle()

            assertThat(sourceSelectionRepository.currentSourceSelection()).isEqualTo(
                SourceSelection(
                    isOpenSourceSelected = true,
                    isPlayStoreSelected = false,
                    isPwaSelected = true,
                )
            )
        }

    @Test
    fun `onSourceSelectionRequested persists selection and refreshes sessions`() =
        runTest(mainCoroutineRule.testDispatcher) {
@@ -265,6 +374,7 @@ class SettingsViewModelTest {
            authSessionRepository = FakeAuthSessionRepository(session),
            playStoreAccountRepository = FakePlayStoreAccountRepository(account),
            sourceSelectionRepository = sourceSelectionRepository,
            sessionStateController = sessionStateController,
            updateSourceSelectionCoordinator = UpdateSourceSelectionCoordinator(
                pendingUpdatesRepository = FakePendingUpdatesRepository(),
                periodicUpdatesScheduler = mockk<PeriodicUpdatesScheduler>(relaxed = true),
@@ -272,6 +382,7 @@ class SettingsViewModelTest {
                sourceSelectionRepository = sourceSelectionRepository,
            ),
            periodicUpdatesScheduler = periodicUpdatesScheduler,
            resolvePlayAuthStatusUseCase = ResolvePlayAuthStatusUseCase(),
        )
    }

@@ -324,14 +435,14 @@ class SettingsViewModelTest {
    }

    private class FakeSessionStateController(
        authRefreshState: AuthRefreshState = AuthRefreshState.Pending,
        private val refreshFailure: Exception? = null,
    ) : SessionStateController {
        var clearLoadedSessionsCallCount = 0
        var refreshCallCount = 0

        override val activeSessions: StateFlow<List<AuthSession>> = MutableStateFlow(emptyList())
        override val authRefreshState: StateFlow<AuthRefreshState> =
            MutableStateFlow(AuthRefreshState.Pending)
        override val authRefreshState: StateFlow<AuthRefreshState> = MutableStateFlow(authRefreshState)
        override val authRefreshSnapshot: StateFlow<AuthRefreshSnapshot?> = MutableStateFlow(null)

        override suspend fun refreshSessions(storesToReset: List<AuthStore>) {
+12 −0
Original line number Diff line number Diff line
@@ -53,3 +53,15 @@ fun AuthError.describe(): String {
        is AuthError.Unknown -> message ?: "unknown auth failure"
    }
}

fun AuthError.requiresUserRecovery(): Boolean {
    return when (this) {
        is AuthError.InvalidToken,
        is AuthError.LoginRequired,
        is AuthError.UserActionRequired -> true

        is AuthError.NetworkFailure,
        is AuthError.RateLimited,
        is AuthError.Unknown -> false
    }
}
Loading