diff --git a/ground/src/main/java/com/google/android/ground/MainActivity.kt b/ground/src/main/java/com/google/android/ground/MainActivity.kt index d085ad4460..df02686f23 100644 --- a/ground/src/main/java/com/google/android/ground/MainActivity.kt +++ b/ground/src/main/java/com/google/android/ground/MainActivity.kt @@ -26,9 +26,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavDirections import androidx.navigation.fragment.NavHostFragment +import com.google.android.ground.MainUiState.HomeScreen +import com.google.android.ground.MainUiState.PermissionDeniedDialog +import com.google.android.ground.MainUiState.SignInProgressDialog +import com.google.android.ground.MainUiState.SignInScreen +import com.google.android.ground.MainUiState.SurveySelector +import com.google.android.ground.MainUiState.TermsOfService import com.google.android.ground.databinding.MainActBinding import com.google.android.ground.repository.UserRepository import com.google.android.ground.system.ActivityCallback @@ -83,34 +91,25 @@ class MainActivity : AbstractActivity() { viewModel = viewModelFactory[this, MainViewModel::class.java] lifecycleScope.launch { - viewModel.navigationRequests.filterNotNull().collect { updateUi(binding.root, it) } + repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.mainUiState.filterNotNull().collect { updateUi(binding.root, it) } + } } } private fun updateUi(viewGroup: ViewGroup, uiState: MainUiState) { - when (uiState) { - MainUiState.OnPermissionDenied -> { - showPermissionDeniedDialog(viewGroup) - } - MainUiState.OnUserSignedOut -> { - navigateTo(SignInFragmentDirections.showSignInScreen()) - } - MainUiState.TosNotAccepted -> { - navigateTo(SignInFragmentDirections.showTermsOfService(false)) - } - MainUiState.NoActiveSurvey -> { - navigateTo(SurveySelectorFragmentDirections.showSurveySelectorScreen(true)) - } - MainUiState.ShowHomeScreen -> { - navigateTo(HomeScreenFragmentDirections.showHomeScreen()) - } - MainUiState.OnUserSigningIn -> { - onSignInProgress(true) - } + Timber.d("UI state changed: $uiState") + if (uiState != SignInProgressDialog) { + dismissSignInDialog() } - if (uiState != MainUiState.OnUserSigningIn) { - onSignInProgress(false) + when (uiState) { + PermissionDeniedDialog -> showPermissionDeniedDialog(viewGroup) + SignInScreen -> navigateTo(SignInFragmentDirections.showSignInScreen()) + TermsOfService -> navigateTo(SignInFragmentDirections.showTermsOfService(false)) + SurveySelector -> navigateTo(SurveySelectorFragmentDirections.showSurveySelectorScreen(true)) + HomeScreen -> navigateTo(HomeScreenFragmentDirections.showHomeScreen()) + SignInProgressDialog -> showSignInDialog() } } @@ -193,10 +192,6 @@ class MainActivity : AbstractActivity() { return currentFragment is BackPressListener && currentFragment.onBack() } - private fun onSignInProgress(visible: Boolean) { - if (visible) showSignInDialog() else dismissSignInDialog() - } - private fun showSignInDialog() { if (signInProgressDialog == null) { signInProgressDialog = modalSpinner(this, layoutInflater, R.string.signing_in) diff --git a/ground/src/main/java/com/google/android/ground/MainUiState.kt b/ground/src/main/java/com/google/android/ground/MainUiState.kt index 206598ca62..033414f59f 100644 --- a/ground/src/main/java/com/google/android/ground/MainUiState.kt +++ b/ground/src/main/java/com/google/android/ground/MainUiState.kt @@ -18,15 +18,15 @@ package com.google.android.ground sealed class MainUiState { - data object OnPermissionDenied : MainUiState() + data object PermissionDeniedDialog : MainUiState() - data object OnUserSignedOut : MainUiState() + data object SignInScreen : MainUiState() - data object OnUserSigningIn : MainUiState() + data object SignInProgressDialog : MainUiState() - data object TosNotAccepted : MainUiState() + data object TermsOfService : MainUiState() - data object NoActiveSurvey : MainUiState() + data object SurveySelector : MainUiState() - data object ShowHomeScreen : MainUiState() + data object HomeScreen : MainUiState() } diff --git a/ground/src/main/java/com/google/android/ground/MainViewModel.kt b/ground/src/main/java/com/google/android/ground/MainViewModel.kt index aa696d8cfe..8cab8ebc63 100644 --- a/ground/src/main/java/com/google/android/ground/MainViewModel.kt +++ b/ground/src/main/java/com/google/android/ground/MainViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes.SIGN_IN_CANCELLED import com.google.android.gms.common.api.ApiException +import com.google.android.ground.MainUiState.* import com.google.android.ground.coroutines.IoDispatcher import com.google.android.ground.domain.usecases.survey.ReactivateLastSurveyUseCase import com.google.android.ground.model.User @@ -29,14 +30,15 @@ import com.google.android.ground.repository.TermsOfServiceRepository import com.google.android.ground.repository.UserRepository import com.google.android.ground.system.auth.AuthenticationManager import com.google.android.ground.system.auth.SignInState +import com.google.android.ground.system.auth.SignInState.* import com.google.android.ground.ui.common.AbstractViewModel import com.google.android.ground.ui.common.SharedViewModel import com.google.android.ground.util.isPermissionDeniedException import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -55,37 +57,36 @@ constructor( authenticationManager: AuthenticationManager, ) : AbstractViewModel() { - private val _navigationRequests: MutableSharedFlow = MutableSharedFlow() - var navigationRequests: SharedFlow = _navigationRequests.asSharedFlow() + private val _mainUiState = MutableStateFlow(null) + var mainUiState: StateFlow = _mainUiState.asStateFlow() /** The window insets determined by the activity. */ val windowInsets: MutableLiveData = MutableLiveData() init { - viewModelScope.launch { - // TODO: Check auth status whenever fragments resumes - authenticationManager.signInState.collect { - _navigationRequests.emit(onSignInStateChange(it)) - } - } + viewModelScope.launch { authenticationManager.signInState.collect { onSignInStateChange(it) } } } - private suspend fun onSignInStateChange(signInState: SignInState): MainUiState = + /** Reacts to changes to the authentication state, updating the main UI state accordingly. */ + private suspend fun onSignInStateChange(signInState: SignInState) { + Timber.d("Sign in state changed. New state: $signInState") when (signInState) { - is SignInState.Error -> onUserSignInError(signInState.error) - is SignInState.SignedIn -> onUserSignedIn(signInState.user) - is SignInState.SignedOut -> onUserSignedOut() - is SignInState.SigningIn -> MainUiState.OnUserSigningIn + is SigningIn -> _mainUiState.value = SignInProgressDialog + is SignedIn -> onUserSignedIn(signInState.user) + is SignedOut -> onUserSignedOut() + is Error -> onUserSignInError(signInState.error) } + } - private fun onUserSignInError(error: Throwable): MainUiState { - Timber.e(error, "Sign in failed") - return if (error.isPermissionDeniedException()) { - MainUiState.OnPermissionDenied + private fun onUserSignInError(error: Throwable) { + if (error.isPermissionDeniedException()) { + Timber.d(error, "User does not have permission to sign in") + _mainUiState.value = PermissionDeniedDialog } else if (error.isSignInCancelledException()) { Timber.d("User cancelled sign in") - MainUiState.OnUserSignedOut + _mainUiState.value = SignInScreen } else { + Timber.d(error, "Unknown sign in error") // TODO(#1808): Display some error dialog to the user with a helpful user-readable message. onUserSignedOut() } @@ -94,7 +95,7 @@ constructor( private fun Throwable.isSignInCancelledException() = this is ApiException && statusCode == SIGN_IN_CANCELLED - private fun onUserSignedOut(): MainUiState { + private fun onUserSignedOut() { // Scope of subscription is until view model is cleared. Dispose it manually otherwise, firebase // attempts to maintain a connection even after user has logged out and throws an error. surveyRepository.clearActiveSurvey() @@ -104,19 +105,19 @@ constructor( // currently being done to prevent one user's data to be submitted as another user after // re-login. viewModelScope.launch { withContext(ioDispatcher) { localDatabase.clearAllTables() } } - return MainUiState.OnUserSignedOut + _mainUiState.value = SignInScreen } - private suspend fun onUserSignedIn(user: User): MainUiState = + private suspend fun onUserSignedIn(user: User) = try { userRepository.saveUserDetails(user) if (!isTosAccepted()) { - MainUiState.TosNotAccepted + _mainUiState.value = TermsOfService } else if (!attemptToReactiveLastActiveSurvey()) { - MainUiState.NoActiveSurvey + _mainUiState.value = SurveySelector } else { // Everything is fine, show the home screen - MainUiState.ShowHomeScreen + _mainUiState.value = HomeScreen } } catch (e: Throwable) { onUserSignInError(e) diff --git a/ground/src/main/java/com/google/android/ground/system/auth/GoogleAuthenticationManager.kt b/ground/src/main/java/com/google/android/ground/system/auth/GoogleAuthenticationManager.kt index 8035bec4cb..a7b112a72e 100644 --- a/ground/src/main/java/com/google/android/ground/system/auth/GoogleAuthenticationManager.kt +++ b/ground/src/main/java/com/google/android/ground/system/auth/GoogleAuthenticationManager.kt @@ -27,6 +27,8 @@ import com.google.android.ground.coroutines.ApplicationScope import com.google.android.ground.model.User import com.google.android.ground.system.ActivityResult import com.google.android.ground.system.ActivityStreams +import com.google.android.ground.system.auth.SignInState.SignedIn +import com.google.android.ground.system.auth.SignInState.SignedOut import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth @@ -69,9 +71,11 @@ constructor( override val signInState: Flow = _signInStateFlow.asStateFlow().filterNotNull() override fun initInternal() { + Timber.d("Listening to Firebase auth state") firebaseAuth.addAuthStateListener { auth -> + Timber.d("Firebase auth state changed") val user = auth.currentUser?.toUser() - setState(if (user == null) SignInState.SignedOut else SignInState.SignedIn(user)) + setState(if (user == null) SignedOut else SignedIn(user)) } } @@ -92,7 +96,7 @@ constructor( override fun signOut() { firebaseAuth.signOut() - setState(SignInState.SignedOut) + setState(SignedOut) activityStreams.withActivity { getGoogleSignInClient(it).signOut() } } @@ -119,7 +123,7 @@ constructor( .addOnFailureListener { setState(SignInState.Error(it)) } private fun onFirebaseAuthSuccess(authResult: AuthResult) { - setState(SignInState.SignedIn(authResult.user!!.toUser())) + setState(SignedIn(authResult.user!!.toUser())) } private fun getFirebaseAuthCredential(googleAccount: GoogleSignInAccount): AuthCredential = diff --git a/ground/src/main/java/com/google/android/ground/ui/startup/StartupFragment.kt b/ground/src/main/java/com/google/android/ground/ui/startup/StartupFragment.kt index b21ebe0954..d17bb2ad84 100644 --- a/ground/src/main/java/com/google/android/ground/ui/startup/StartupFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/startup/StartupFragment.kt @@ -26,6 +26,7 @@ import com.google.android.ground.ui.common.AbstractFragment import com.google.android.ground.ui.common.EphemeralPopups import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import timber.log.Timber @@ -53,6 +54,8 @@ class StartupFragment : AbstractFragment() { viewLifecycleOwner.lifecycleScope.launch { try { viewModel.initializeLogin() + } catch (e: CancellationException) { + Timber.d(e, "Startup job cancelled") } catch (t: Throwable) { onInitFailed(t) } diff --git a/ground/src/main/java/com/google/android/ground/ui/startup/StartupViewModel.kt b/ground/src/main/java/com/google/android/ground/ui/startup/StartupViewModel.kt index 73ec0cef28..59cd84af51 100644 --- a/ground/src/main/java/com/google/android/ground/ui/startup/StartupViewModel.kt +++ b/ground/src/main/java/com/google/android/ground/ui/startup/StartupViewModel.kt @@ -19,6 +19,7 @@ import com.google.android.ground.repository.UserRepository import com.google.android.ground.system.GoogleApiManager import com.google.android.ground.ui.common.AbstractViewModel import javax.inject.Inject +import timber.log.Timber class StartupViewModel @Inject @@ -29,7 +30,9 @@ internal constructor( /** Initializes the login flow, installing Google Play Services if necessary. */ suspend fun initializeLogin() { + Timber.d("Checking for Play services") googleApiManager.installGooglePlayServices() + Timber.d("Initializing user repository") userRepository.init() } } diff --git a/ground/src/test/java/com/google/android/ground/MainViewModelTest.kt b/ground/src/test/java/com/google/android/ground/MainViewModelTest.kt index 149e2a331d..9b962ec84b 100644 --- a/ground/src/test/java/com/google/android/ground/MainViewModelTest.kt +++ b/ground/src/test/java/com/google/android/ground/MainViewModelTest.kt @@ -75,11 +75,11 @@ class MainViewModelTest : BaseHiltTest() { fun testSignInStateChanged_onSignedOut() = runWithTestDispatcher { setupUserPreferences() - viewModel.navigationRequests.test { + viewModel.mainUiState.test { fakeAuthenticationManager.signOut() advanceUntilIdle() - assertThat(awaitItem()).isEqualTo(MainUiState.OnUserSignedOut) + assertThat(awaitItem()).isEqualTo(MainUiState.SignInScreen) verifyUserPreferencesCleared() verifyUserNotSaved() assertThat(tosRepository.isTermsOfServiceAccepted).isFalse() @@ -88,11 +88,11 @@ class MainViewModelTest : BaseHiltTest() { @Test fun testSignInStateChanged_onSigningIn() = runWithTestDispatcher { - viewModel.navigationRequests.test { + viewModel.mainUiState.test { fakeAuthenticationManager.setState(SignInState.SigningIn) advanceUntilIdle() - assertThat(awaitItem()).isEqualTo(MainUiState.OnUserSigningIn) + assertThat(awaitItem()).isEqualTo(MainUiState.SignInProgressDialog) verifyUserNotSaved() assertThat(tosRepository.isTermsOfServiceAccepted).isFalse() } @@ -106,11 +106,11 @@ class MainViewModelTest : BaseHiltTest() { tosRepository.isTermsOfServiceAccepted = false fakeRemoteDataStore.termsOfService = Result.success(FakeData.TERMS_OF_SERVICE) - viewModel.navigationRequests.test { + viewModel.mainUiState.test { fakeAuthenticationManager.signIn() advanceUntilIdle() - assertThat(awaitItem()).isEqualTo(MainUiState.TosNotAccepted) + assertThat(awaitItem()).isEqualTo(MainUiState.TermsOfService) verifyUserSaved() assertThat(tosRepository.isTermsOfServiceAccepted).isFalse() } @@ -121,11 +121,11 @@ class MainViewModelTest : BaseHiltTest() { tosRepository.isTermsOfServiceAccepted = false fakeRemoteDataStore.termsOfService = null - viewModel.navigationRequests.test { + viewModel.mainUiState.test { fakeAuthenticationManager.signIn() advanceUntilIdle() - assertThat(awaitItem()).isEqualTo(MainUiState.TosNotAccepted) + assertThat(awaitItem()).isEqualTo(MainUiState.TermsOfService) verifyUserSaved() assertThat(tosRepository.isTermsOfServiceAccepted).isFalse() } @@ -142,12 +142,12 @@ class MainViewModelTest : BaseHiltTest() { ) ) - viewModel.navigationRequests.test { + viewModel.mainUiState.test { fakeAuthenticationManager.signIn() advanceUntilIdle() // TODO(#2667): Update these implementation to make it clearer why this would be the case. assertThat(tosRepository.isTermsOfServiceAccepted).isFalse() - assertThat(awaitItem()).isEqualTo(MainUiState.TosNotAccepted) + assertThat(awaitItem()).isEqualTo(MainUiState.TermsOfService) } } @@ -157,12 +157,12 @@ class MainViewModelTest : BaseHiltTest() { tosRepository.isTermsOfServiceAccepted = false fakeRemoteDataStore.termsOfService = Result.failure(Error("user error")) - viewModel.navigationRequests.test { + viewModel.mainUiState.test { fakeAuthenticationManager.signIn() advanceUntilIdle() assertThat(tosRepository.isTermsOfServiceAccepted).isFalse() - assertThat(awaitItem()).isEqualTo(MainUiState.TosNotAccepted) + assertThat(awaitItem()).isEqualTo(MainUiState.TermsOfService) } } @@ -170,11 +170,11 @@ class MainViewModelTest : BaseHiltTest() { fun testSignInStateChanged_onSignInError() = runWithTestDispatcher { setupUserPreferences() - viewModel.navigationRequests.test { + viewModel.mainUiState.test { fakeAuthenticationManager.setState(SignInState.Error(Exception())) advanceUntilIdle() - assertThat(awaitItem()).isEqualTo(MainUiState.OnUserSignedOut) + assertThat(awaitItem()).isEqualTo(MainUiState.SignInScreen) verifyUserPreferencesCleared() verifyUserNotSaved() assertThat(tosRepository.isTermsOfServiceAccepted).isFalse()