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:
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 toHomeFragment
.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 toHomeFragment
.
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?