Loading app/src/main/java/foundation/e/apps/feature/main/MainActivityViewModel.kt +44 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 { Loading Loading @@ -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() { Loading Loading @@ -153,6 +164,7 @@ class MainActivityViewModel @Inject constructor( ) if (authRefreshState is AuthRefreshState.Completed) { reportTemporaryPlayFailureIfNeeded(authRefreshState.snapshot) viewModelScope.launch { sessionManager.reportFaultyTokenIfNeeded() } Loading @@ -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)) } Loading app/src/test/java/foundation/e/apps/feature/main/MainActivityViewModelTest.kt +112 −2 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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) { Loading Loading @@ -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( Loading @@ -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() } } Loading @@ -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) { Loading Loading
app/src/main/java/foundation/e/apps/feature/main/MainActivityViewModel.kt +44 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 { Loading Loading @@ -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() { Loading Loading @@ -153,6 +164,7 @@ class MainActivityViewModel @Inject constructor( ) if (authRefreshState is AuthRefreshState.Completed) { reportTemporaryPlayFailureIfNeeded(authRefreshState.snapshot) viewModelScope.launch { sessionManager.reportFaultyTokenIfNeeded() } Loading @@ -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)) } Loading
app/src/test/java/foundation/e/apps/feature/main/MainActivityViewModelTest.kt +112 −2 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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) { Loading Loading @@ -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( Loading @@ -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() } } Loading @@ -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) { Loading