Signing in to an app with your email address and clicking a link can be very convenient. There are no passwords to remember and just by signing in you also verify your email address. This option is also provided with Firebase Authentication and there’s documentation on how to integrate this form of sign-in to your app.

The process for signing in or signing up is basically this:

  • The user enters their email address in the app
  • The app stores the email address locally and triggers Firebase Auth to send link for sign-in to the user
  • The user clicks the link on the same device used for signing in
  • This triggers a deep link that opens your app
  • The app checks if the link is for sign-in and then calls Firebase Auth with the stored email address to complete the sign-in

Using with the Navigation component

Most modern Android apps use the Jetpack Navigation Component for navigation. Navigation handles a bunch of things, including deep links. A deep link is an url that is handled by your app to link directly to a specific part of the app.

For apps that use login, the recommendation is to redirect to the login flow when sign-in is required. This flow might be multiple screens. When the user is logged in, the back stack can be popped to return to the starting destination.

When signing in with a link, this flow is interrupted, since the user needs to click a link outside the context of the app. So how do we return to the right destination when the link is clicked? This can be done using a deep link.

Firebase Auth sign-in deep links ultimately redirect to the email action url for the Firebase project. This url is used for things like password reset emails and other auth related email actions. There’s no documented format for these links, so it’s only possible to set up a deep link url for the <project id>.firebaseapp.com host. But by doing this the app will potentially also open for links that we don’t want to handle, even for webpages if the default hosting domain is being used.

In stead, the Firebase documentation states the deep link uri should be passed to FirebaseAuth.isSignInWithEmailLink() which then returns true if the uri that is sent to the app is indeed the result of a sign-in.

This complicates things a bit, because we don’t know in advance what link we are going to handle, so we can’t just add the deep link to our navigation graph. Without it, navigation will not handle the link and navigate to a destination that can handle it. Fortunately, due how the sign-in links work, an intent filter for the (unknown) deep link is not required: by default the dynamic link handling will open the app launcher activity.

Solution

Handle the sign-in, we need to figure out if a sign-in link is being sent to the app, and if this is the case the navigation graph needs to be updated so that the exact deep link is associated with the destination that will handle the login result. Then, in our destination, we need to extract the link, and pass it to FirebaseAuth to complete the sign-in.

Deferring initialisation of the NavHost

When using xml layouts, the NavHost is usually added to the activity layout like this:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout>
<androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>

Note that the app:navGraph attribute specifies the graph that is used for the application: @navigation/nav_graph. Because the graph might need to be updated when the app is started from sign-in, the app:navGraph attribute needs to be removed.

Now the navigation graph can be inflated in the activity in onPostCreate():

    // onPostCreate is called after the NavHost fragment is added
    override fun onPostCreate(savedInstanceState: Bundle?) {
        super.onPostCreate(savedInstanceState)
        // To prevent hardcoding (parts) of the sign in deep link,
        // we setup the nav graph here and optionally add
        // the incoming deep link to the graph as needed.
        // This only needs to be done if this activity is launched fresh, so we check
        // for a null savedInstanceState.
        
        val navController = findNavController(R.id.nav_host_fragment)
        val navGraph = navController.navInflater.inflate(R.navigation.nav_graph)
       
        if (savedInstanceState == null) {
            val link = intent.data?.toString()

            // if we have a sign in link, add it to the graph
            if (link != null && FirebaseAuth.getInstance().isSignInWithEmailLink(link)) {
                addDeeplinkToLoginDestination(navGraph, link)
            }
            navController.setGraph(navGraph, intent.extras)
        } else {
            navController.setGraph(navGraph, savedInstanceState)
        }
    }

When the app is launched fresh, any intent data is converted to a string. Then the navigation graph is inflated. If the link is a sign-in link, the deep link is added to the graph. Finally, the graph is set on the NavHost.

To add the link to the graph, the destination that needs to handle the login can be retrieved from the graph. Then the deep link can be added.

    private fun addDeeplinkToLoginDestination(navGraph: NavGraph, link: String) {
        val loginGraph = navGraph.findNode(R.id.nav_login) as NavGraph
        // our destination for handling the login link
        val loginWithLinkDestination =
            requireNotNull(loginGraph.findNode(R.id.loginWithLinkFragment))
        loginWithLinkDestination.addDeepLink(link)
    }

With the steps above, we’ve ensured that the deep link sent from the sign-in link will be handled by navigation, and that our loginWithLinkFragment destination will handle it.

To actually perform the sign-in the link needs to be sent to FirebaseAuth. Navigation conveniently passes the original deep link intent in the NavController.KEY_DEEP_LINK_INTENT argument.

    val intent = arguments?.getParcelable<Intent>(NavController.KEY_DEEP_LINK_INTENT)
    val link = intent?.data?.toString()
    if (link != null && FirebaseAuth.getInstance().isSignInWithEmailLink(link)) {
        val email = getUserEmail(requireContext()) ?: error("No email was stored")
        FirebaseAuth.getInstance().signInWithEmailLink(email, link).addOnSuccessListener {
            findNavController().popBackStack(R.id.nav_login, true)
        }.addOnFailureListener {
            Log.e(TAG, "Could not sign in with the email link")
            findNavController().popBackStack(R.id.nav_login, false)
        }
    }

If the sign-in is successful, all that is needed is to pop the backstack to exit the login flow. Otherwise, the backstack is also popped but returning to the start of the login flow. Of course there are many ways to handle this, including sending a result back, check out the documentation on conditional navigation for an example.

Full example

The full working example of this code can be found on GitHub. Before you go check it out, please note the following:

Fixed start destination for navigation

The documentation recommends to have a fixed starting destination for your navigation graph, conditionally navigating to the login flow. The example follows this recommendation. I sometimes encounter apps that do some funny business with the start destination, so I’d like to take the opportunity to call this out :)

No messing with launchMode

Another recommendation is to use the default launch mode for the activity, and not set it to singleTop or similar. The example also follows this recommendation, and I’d like to point this out too because it is tempting to not follow this recommendation, we’ve probably all been there. Changing the launchMode can lead to various issues, so it’s better to avoid changing it.

It’s sample code

Finally, the example implements the calls dealing with FirebaseAuth directly within the Fragments. This is just to keep the example simple. In a real, production grade app this logic would be moved to different places, depending on the architecture that is used for the app.

With that said, I hope this is useful. Let me know on Twitter :)