1

Please read my whole post before redirecting me to the official doumentation on conditional navigation. I've already read that and couldn't figure it out. I'll explain what is confusing me, but first I'll explain my goals and include some snippets of what I have so far and what are the issues with it.

My goal is to have the following login flows:

  1. Unauthorized - That's when the user installs the app for first time or had used the logout. After the splashcreen, which is implemented with the new api, the user is redirected to LoginFragment. It has a login button that will sign the user with email, fetch data and redirect to HomeFragment.

  2. Authorized - When the user comes back to the app after closing it. After the splashscreen, the user is redirected to HomeFragment. However, in this case the splashscreen must stay until the data from firebase is being fetched and only when it's ready to navigate to HomeFragment.

My first problem with conditional navigation guide is that they use a main fragment, which I don't have at all. Please don't suggest me to make splashfragment, I'm using the new api, so I can't do that.

I believe that, my start destination should be my HomeFragment instead.

However, this snippet they have provided:

userViewModel.user.observe(viewLifecycleOwner, Observer { user ->
            if (user != null) {
                showWelcomeMessage()
            } else {
                navController.navigate(R.id.login_fragment)
            }
        })

Basically the showWelcomeMessage() is equivalent of initing/fetching user data in my case. However that wouldn't exactly be a good place to do, because as I said earlier, the splashscreen should stay while data is being fetched, so I can't do the fetching here. I don't want to be showing an empty fragment.

Also another problem with their snippets (when adapting for my case):

savedStateHandle.getLiveData<Boolean>(LoginFragment.LOGIN_SUCCESSFUL)
                .observe(currentBackStackEntry, Observer { success ->
                    if (!success) {
                        val startDestination = navController.graph.startDestination
                        val navOptions = NavOptions.Builder()
                                .setPopUpTo(startDestination, true)
                                .build()
                        navController.navigate(startDestination, null, navOptions)
                    }
                })

I can't pop to startDestination, because my start should be ProfileFragment/HomeFragment and it wouldn't make any sense.

If my startDestination is LoginFragment instead, then it will flash on screen after the splashscreen and before going to home, due to the load time of the data, which looks ugly.

I guess I should be doing something completely different. Like using MainActivity as the supposedly non-existant main fragment. So I tried to gather more info.

Introducing - android template by Denis Rudenko

I did like how he changes the graph's startDestination based on authentication state and I did something simillar, here are my snippets, as simplified as I could (removed exception handling etc).

In MainActivity.kt :


    private lateinit var navController: NavController

    private var readyToDismissSplash = false
    private var isNameSet = false

    @Inject
    lateinit var userManager: UserManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        installSplashScreen()

        observeSplashScreenVisibility()
        _binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        lifecycleScope.apply {
            launch {
                withContext(Dispatchers.IO) {
                    val startDestination = provideStartDestination()
                    withContext(Dispatchers.Main) {
                        initNavigation(startDestination)
                    }
                }
            }
        }
    }

    ...

    private fun observeSplashScreenVisibility() {
        val content: View = findViewById(android.R.id.content)
        content.viewTreeObserver.addOnPreDrawListener(object :
            ViewTreeObserver.OnPreDrawListener {
            override fun onPreDraw(): Boolean {
                return if (readyToDismissSplash) {
                    // The content is ready; start drawing.
                    content.viewTreeObserver.removeOnPreDrawListener(this)
                    true
                } else {
                    // The content is not ready; suspend.
                    false
                }
            }
        })
    }

    ...

    private fun provideStartDestination(): Int {
        val currentUser = FirebaseAuth.getInstance().currentUser
        return if (currentUser == null) {
            R.id.nav_login
        } else {
            initUser()
            R.id.nav_home
        }
    }

    ...

    fun initNavigation(startDestination: Int) {
        (supportFragmentManager.findFragmentById(R.id.nav_host_fragment_content_main) as NavHostFragment).also { navHost ->
            val navInflater = navHost.navController.navInflater
            val navGraph = navInflater.inflate(R.navigation.nav_graph)
            navGraph.setStartDestination(startDestination)
            navHost.navController.graph = navGraph
            navController = navHost.navController
            setSupportActionBar(binding.appBarMain.toolbar)
            setupActionBarWithNavController(navController, drawerLayout)
            navigationView.setupWithNavController(navController)
            setupOnDestinationChangeListener(drawerLayout)
            if (startDestination == R.id.nav_login) {
                readyToDismissSplash = true
            }
        }
    }

    ...

    fun initUser(shouldInitNavigation: Boolean = false) {
        job = lifecycleScope.launch {
            userManager.userData
                .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
                .collect { result ->
                    when (result) {
                        is Resource.Success -> {
                            if (!isNameSet) {
                                val user = result.data!!
                                val displayName = "${user.firstName} ${user.lastName}"
                                setupDisplayName(displayName)
                                readyToDismissSplash = true
                                isNameSet = true
                            }
                            if (shouldInitNav) {
                                withContext(Dispatchers.Main) {
                                    initNavigation(R.id.nav_home)
                                }
                            }
                        }
                        //handle sad paths
                    }
                }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        _binding = null
        job?.cancel()
        job = null
    }

In UserManager.kt. The flow I'm observing from MainActivity.kt and from my UserRepository™ (I might need better names/implementation for these, open to suggestions, might not even need a repository and only use-cases, but not sure how that works and it's out of scope of this post I guess) which VMs use:

    val userData: Flow<Resource<User>>
        get() = _userData

    private val _userData = callbackFlow<Resource<User>> {
        trySend(Resource.Loading())

        val currentUser = FirebaseAuth.getInstance().currentUser
        val email = currentUser?.email!!
        try {
            documentRef = db.collection(USERS_COLLECTION)
                .whereEqualTo(USER_EMAIL, email)

            queryRef = documentRef?.limit(1)?.get()?.await()

            listener = documentRef?.addSnapshotListener { snapshot, error ->
                //handle errors 
                if(error){
                    trySend(Resource.Error(...))
                    return@addSnapshotListener
                }
                queryRef = snapshot
                val data = snapshot.documents[0].toUser()
                if (data != null) {
                    trySend(Resource.Success(data))
                } else {
                    trySend(Resource.Error(...))
                }
            }!!
        } catch (e: FirebaseFirestoreException) {
            trySend(Resource.Error(...))
        } 

        awaitClose { listener?.remove() }
    }.shareIn(
        externalScope,
        replay = 1,
        started = SharingStarted.WhileSubscribed(
            stopTimeoutMillis = 5000L
        )
    )


HomeFragment.kt just observes the LiveData/Flows coming from UserManager -> UserRepositry -> HomeViewModel -> HomeFragment. Nothing special, so I won't be including snippets for now, but if someone requires I'll.

In LoginFragment.kt:

    private var googleLoginLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { activityResult ->
        collectResult(activityResult)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        _binding = FragmentLoginBinding.bind(view)
        binding.loginButton.setOnClickListener {
            val signInIntent = loginViewModel.getSignInIntent()
            googleLoginLauncher.launch(signInIntent)
        }
    }

    private fun collectResult(activityResult: ActivityResult?) {
        lifecycleScope.launch(Dispatchers.Main) {
            val data: Intent? = activityResult?.data
            val task = GoogleSignIn.getSignedInAccountFromIntent(data)
        
            loginViewModel.signIn(task)
                .flowWithLifecycle(lifecycle)
                .collect { signInResult ->
                    when (signInResult) {
                        is SignInResult.Success -> {
                            (activity as MainActivity).initUser(true)
                            binding.progressBar.hide()
                        }
                        is SignInResult.Error -> {
                            binding.progressBar.hide()
                            // handle sad path
                        }
                        is SignInResult.Loading -> binding.progressBar.show()
                    }
                }
        }
    }

loginViewModel.signIn:

fun signIn(task: Task<GoogleSignInAccount>) =
        authService.signInWithGoogle(task)

authService.signInWithGoogle:

fun signInWithGoogle(task: Task<GoogleSignInAccount>) = flow {
        emit(SignInResult.Loading)
        try {
            val account: GoogleSignInAccount =
                task.getResult(ApiException::class.java)

            val credential = GoogleAuthProvider
                .getCredential(account.idToken, null)

            val result = firebaseAuth.signInWithCredential(credential).await()

            val user = SignInResult.Success(result.user)
            if (user.data != null) {
                emit(user) // return
            } else {
                signOut()
                emit(SignInResult.Error())
            }
        } catch (e: ApiException) {
            // handle sad path
        }
    }

What happens now is when user logs in, goes to FragmentB.kt, which contains a recyclerview populated with data from FragmentBViewModel (this VM uses the same repository that uses the UserManager as does the HomeViewModel) and interacts with RV's data - for example deletes or edits a row, the operation is successfull, it does send a proper query to Firebase all that good stuff, but the user gets redirected to the home screen. FragmentB gets destoyed for some reason, I've put a breakpoint in onDestroyView _binding = null and it indeed goes there.

If user closes and reopens the app this will no longer happen. Even if they restart the app immediatelly after first time login, the issue is gone. My theory is that something goes wrong when going from login flow 1 to 2, because I'm using my initNavigation method twice - in onCreate when initing start destination as R.id.nav_login and then a second time from LoginFragment calling activity.initUser(true).

The "fix" I've came up with is really bad and not something I want to do. It's also really slow, but basically replacing:

(activity as MainActivity).initUser(true)

with

findNavController().popBackStack()
(activity as MainActivity).recreate()

This basically makes it like the user is coming back to the app after restart, going straight to Authorized login flow.

So how do I properly handle conditional navigation while waiting user data? Maybe I can make the data load faster with saveStateHandle, but I don't get how to use it. I don't really understand the documentation about it - where/when exactly to use it. Also I tested the app offline, because firebase supports caching and handles that and weird enough the splashscreen stayed for the same amount of time as with online (when fetching). How is that even possible, shouldn't loading from cache be so much faster?

pollux552
  • 107
  • 1
  • 10
  • If you are interested in using a clean Firebase authentication without the need of any splash screen, then I recommend you read the following article, [How to handle Firebase Authentication in clean architecture using Jetpack Compose?](https://medium.com/firebase-tips-tricks/how-to-handle-firebase-authentication-in-clean-architecture-using-jetpack-compose-e9929c0e31f8) – Alex Mamo Dec 24 '21 at 11:39
  • @AlexMamo I've seen this and the video with it. You mention that the splashscreen api works only on 12+ but that's not true, it is backwards compatible. The only limitation below api 31 is lack of animated svg. Otherwise, great article and video even though I'm not into compose just yet. Unfortunately, it doesn't help me, because as I've explained I can't use LoginFragment/AuthScreen as start destination, because when user is signed in and data is still loading, it will flash the LoginFragment on screen, before going Home, which is ugly. – pollux552 Dec 27 '21 at 04:32
  • That "flash" can be solved using the solution that exists [here](https://stackoverflow.com/questions/68633717/topappbar-flashing-when-navigating-with-compose-navigation). – Alex Mamo Dec 27 '21 at 08:23
  • @AlexMamo I'm not using compose and I'm not talking about this type of flash. By flash I meant that, if the LoginFragment is my starting destination, it will show on screen for very short time (~0.5-0.75s) while data is loading. I can instantly transfer to Home if there is firebase.current user != null (if use is authenticated), which will prevent the "flash", but then HomeFragment will be empty for short period while data is loading, which is also ugly. – pollux552 Dec 28 '21 at 17:02
  • If you understand Java, you might also try this, [How to create a clean Firebase authentication using MVVM?](https://medium.com/firebase-tips-tricks/how-to-create-a-clean-firebase-authentication-using-mvvm-37f9b8eb7336). Is achieved using activities. – Alex Mamo Dec 28 '21 at 20:20

1 Answers1

0

I don't think the intended purpose of the new splash screen is obscuring background app initialisations, but rather showing animations upon your app's cold and warm starts. If you insist, there is a provision to extend the animation duration, so that your Firebase data is fully loaded then navigate to HomeFragment. The LoginFragment will not be shown, removing the flash problem.

My recommendation would be you make HomeFragment your start destination then after the splash screen simply show a loader informing the user the there is some data still being loaded after which you will render it (show the UI), since the user is already authenticated.

When the user installs the app for first time or had used the logout, from the HomeFragment, redirect them to the LoginFragment.

Makari Kevin
  • 111
  • 1
  • 4