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.
Signing in with a link
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
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.
Handling the sign-in link
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 Fragment
s. 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 :)