Loading app/src/main/java/foundation/e/apps/ui/settings/SettingsViewModel.kt +117 −29 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -66,6 +71,7 @@ class SettingsViewModel @Inject constructor( authSessionRepository.currentSession, playStoreAccountRepository.account, sourceSelectionRepository.sourceSelection, sessionStateController.authRefreshState, ::toSettingsUiState, ).distinctUntilChanged() .stateIn( Loading Loading @@ -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( Loading @@ -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, Loading @@ -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, Loading @@ -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, ) } } } } } app/src/test/java/foundation/e/apps/feature/main/MainActivityStartupStateMachineTest.kt +26 −0 Original line number Diff line number Diff line Loading @@ -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) Loading app/src/test/java/foundation/e/apps/feature/main/MainActivityViewModelTest.kt +29 −0 Original line number Diff line number Diff line Loading @@ -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) { Loading app/src/test/java/foundation/e/apps/ui/settings/SettingsViewModelTest.kt +113 −2 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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) { Loading Loading @@ -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) { Loading Loading @@ -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), Loading @@ -272,6 +382,7 @@ class SettingsViewModelTest { sourceSelectionRepository = sourceSelectionRepository, ), periodicUpdatesScheduler = periodicUpdatesScheduler, resolvePlayAuthStatusUseCase = ResolvePlayAuthStatusUseCase(), ) } Loading Loading @@ -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>) { Loading domain/src/main/kotlin/foundation/e/apps/domain/auth/AuthError.kt +12 −0 Original line number Diff line number Diff line Loading @@ -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
app/src/main/java/foundation/e/apps/ui/settings/SettingsViewModel.kt +117 −29 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -66,6 +71,7 @@ class SettingsViewModel @Inject constructor( authSessionRepository.currentSession, playStoreAccountRepository.account, sourceSelectionRepository.sourceSelection, sessionStateController.authRefreshState, ::toSettingsUiState, ).distinctUntilChanged() .stateIn( Loading Loading @@ -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( Loading @@ -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, Loading @@ -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, Loading @@ -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, ) } } } } }
app/src/test/java/foundation/e/apps/feature/main/MainActivityStartupStateMachineTest.kt +26 −0 Original line number Diff line number Diff line Loading @@ -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) Loading
app/src/test/java/foundation/e/apps/feature/main/MainActivityViewModelTest.kt +29 −0 Original line number Diff line number Diff line Loading @@ -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) { Loading
app/src/test/java/foundation/e/apps/ui/settings/SettingsViewModelTest.kt +113 −2 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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) { Loading Loading @@ -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) { Loading Loading @@ -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), Loading @@ -272,6 +382,7 @@ class SettingsViewModelTest { sourceSelectionRepository = sourceSelectionRepository, ), periodicUpdatesScheduler = periodicUpdatesScheduler, resolvePlayAuthStatusUseCase = ResolvePlayAuthStatusUseCase(), ) } Loading Loading @@ -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>) { Loading
domain/src/main/kotlin/foundation/e/apps/domain/auth/AuthError.kt +12 −0 Original line number Diff line number Diff line Loading @@ -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 } }