React NativeでFirebaseのダイナミックリンクをハンドリングしてみる
Firebase Authに登録されたメールアドレスを認証したあと特定の画面に遷移して結果を表示したいというモチベーションが沸々と湧きあがった。
流れとしては次のような感じ。
firebase/auth
のsendEmailVerification
メソッドを利用してユーザーのメールアドレス宛に認証メールを送信する。- ユーザーは届いたメールに書かれたダイナミックリンクをクリックする。
- ブラウザが立ち上がり、デフォルトのメールアクションハンドラが実行された後アプリに戻る。
- sendEmailVerificationメソッドを呼び出したときに指定した続行URLとマッチするスクリーンが立ち上がり、メールの認証が完了したことをユーザーに伝える。
簡単な図にすると次のような感じ。
面倒な作業を減らすために、メールのアクションハンドラをカスタマイズすることはせず、Firebaseでデフォルトで提供されているアクションハンドラを利用する。
今回はAndroidでディープリンクを処理する方法を紹介。iOSでユニバーサルリンクを処理する場合はディープリンクに設定したホスト名のサーバーの/.well-known/apple-app-site-association
にAASAファイルを配置しないといけないので少々大変。
もちろんAndroidの場合でも、リンクをタップしたときにどのアプリで開くかのダイアログを表示しないで直接アプリを開かせたい場合は、アプリのIDとアプリが開くべきリンクを指定したjsonファイルを/.well-known/assetlinks.json
に配置する必要がある。ただ、基本的にはapp.jsonのandroid.intentFilters
に設定を追加すれば目的は達成できるのでiOSより簡単。
android.intentFilters
app.jsonを編集してandroid
の下にintentFilters
を追加する。
// app.json ... "android": { ... "intentFilters": [ { "action": "VIEW", "data": [ { "scheme": "http", "host": "myapp.com", "pathPrefix": "/" } ], "category": ["BROWSABLE", "DEFAULT"] } ] ... } ...
この設定により、myapp.com へのリンクを処理するためにアプリがダイアログで表示されるようになる。
host
に*.myapp.com
のように*(ワイルドカード)
を使用することで、www.myapp.com
やdev.myapp.com
などにも対応することができる。そして必ずhost
属性を指定する場合はscheme
属性の設定をしないといけない。ここではhttp
を指定している。
ここで、pathPrefix
にrecords
と指定した場合、records
の部分がホスト名扱いになる。
https://myapp.com/records
そしてそれ以降のパスがディープリンクによって開きたい画面を表すパスの部分になる。
https://myapp.com/records/activities
これによって、https://myapp.com/users/
ではアプリを開かないがhttps://myapp.com/records/
とマッチするリンクのときだけアプリを開くことが可能になる。
If you opened a URL like myapp://somepath/into/app?hello=world, this would alert Linked to app with hostname: somepath, path: into/app and data: {"hello":"world"}.
intent-filter
のdata
の詳細については公式ドキュメントを参照して欲しい。
Screens
この機能を実装するために用意する画面は次の2つ。
- 認証メールを送信する画面
SendEmailVerificationScreen.tsx
- 認証メールを送信する際に指定した続行URLと対応する画面
EmailActionHandlerScreen.tsx
Firebase JavaScript SDK
通常のFirebaseの処理を行う際はreact-query-firebase
を使用する。
react-query-firebase
の詳細についてはこちらの記事を参照してほしい。
React Native Firebase
Firebaseのダイナミックリンク経由でアプリを開くために、Expoのマネージドワークフローアプリにreact-native-firebase
を導入する。
もちろん、公式ではFirebase JS SDKを使用することが推奨されているのだけど、ダイナミックリンクをアプリで処理するためにはこのライブラリがどうしても必要。
Expo Goではこのライブラリのメソッドを使うことはできないので、開発環境では呼び出し箇所をすべてコメントアウトしておくことをおすすめする。
必要なパッケージは次の二つ。
@react-native-firebase/app
@react-native-fireabase/dynamic-links
expo install @react-native-firebase/app @react-native-firebase/dynamic-links
app.jsonのplugins
に@react-native-firebase/app
を追加する。
// app.json ... "plugins": [ "@react-native-firebase/app" ] ...
ここで、@react-native-firebase/dynamic-links
もplugins
に追加してしまうと次のようなエラーが発生してしまうので注意する。
PluginError: Package "@react-native-firebase/dynamic-links" does not contain a valid config plugin.
app.jsonのios.googleServicesFile
にGoogleSerfice-Info.plist
のパスを指定する。そしてandroid.googleServicesFile
にgoogle-services.json
のパスを指定する。これらのファイルはFirebaseプロジェクトにそれぞれのプラットフォームのアプリを追加したときにダウンロードできる。
// app.json ... "ios": { ... "googleServicesFile": "./secrets/GoogleService-Info.plist", ... }, "android": { ... "googleServicesFile": "./secrets/google-services.json", ... }, ...
認証メールを送信する画面を準備する
SendEmailVerificationScreen.tsxを作成する。
認証メールの送信には、react-query-firebase
のuseAuthSendEmailVerification
フックを利用する。
// screens/TabFour/SendEmailVerificationScreen.tsx import { useAuthSendEmailVerification, useAuthUser, } from "@react-query-firebase/auth"; import { useCallback, useEffect, useRef } from "react"; import { Alert } from "react-native"; import { auth } from "../../helpers/firebase"; import type { SendEmailVerificationScreenProps as Props } from "../../types"; export function SendEmailVerificationScreen({ navigation }: Props) { const authQuery = useAuthUser(["user"], auth); const mutation = useAuthSendEmailVerification(); const handlePress = useCallback(() => { if (authQuery.data && !authQuery.data.emailVerified) { mutation.mutate( { user: authQuery.data, actionCodeSettings: { url: "https://myapp.com/email-action-handler", handleCodeInApp: true, iOS: { bundleId: "com.name.appname", }, android: { packageName: "com.name.appname", installApp: true, minimumVersion: "12", }, dynamicLinkDomain: "firebase-project-name.page.link", }, }, { onSuccess(data, variables, context) { Alert.alert("認証メールの送信に成功しました", "", [ { text: "OK", style: "cancel", onPress: () => { navigation.goBack(); }, }, ]); }, onError(error, variables) { Alert.alert("認証メールの送信に失敗しました", `${error.message}`, [ { text: "OK", style: "cancel", onPress: () => { navigation.goBack(); }, }, ]); }, } ); } }, [authQuery.data]); useEffect(() => { if (authQuery.data && authQuery.data.emailVerified) { Alert.alert("あなたは既に認証されています", `前の画面に戻ります`, [ { text: "OK", style: "cancel", onPress: () => { navigation.goBack(); }, }, ]); } if (authQuery.data && !authQuery.data.emailVerified) { Alert.alert("メールアドレスを認証", ``, [ { text: "キャンセル", style: "cancel", onPress: () => { navigation.goBack(); }, }, { text: "OK", onPress: () => { handlePress(); }, }, ]); } return () => {}; }, [authQuery.data]); return ( <></> ); }
ここで設定したactionCode
を使って認証メールを送信すると、ユーザーのメールアドレス宛に認証メールが届く。そのメールには次の形式のリンクが貼られている。
https://firebase-project-name.page.link?link=https://firebase-project-name.firebaseapp.com/__/auth/action?apiKey%3D...
dynamicLinkDomain
に設定したFirebase Dynamic Linksのダイナミックリンクドメインを使ってリンクが開かれるようにできる。
android.packageName
に指定した値はapn
とibi
クエリパラーメーターに付与される。
url
に指定したディープリンクが続行URLとしてcontinueUrl
クエリパラメーターに設定される。そしてこの値は、必ずFirebase ConsoleのAuthenticationのSettingsタブにある承認済みドメインに追加されていることを確認する。
そのほかの設定については次のリンクを参照して欲しい。
Firebase Authenticationにて、アクションURLにリンク先で使いたいURLをもたせる - Qiita
ダイナミックリンクの作成については次の記事を参照してほしい。
【Firebase 入門】 Dynamic Links とは - Qiita
「アプリで開く」を実現する、Firebase Dynamic Linksの実装と運用Tips - ログミーTech
React Navigationに設定するリンクオプションを準備する
LinkingOptions
型の変数を用意する。この変数はすべてのリンクオプションを保持するオブジェクト。
起動時のディープリンクを処理するためにgetInitialURL
を定義する。
// navigation/LinkingConfiguration.ts import { LinkingOptions } from "@react-navigation/native"; import * as Linking from "expo-linking"; import dynamicLinks from "@react-native-firebase/dynamic-links"; ... const prefix = Linking.createURL("/"); const linking: LinkingOptions<RootStackParamList> = { ... // 'http://myapp.com'に指定した部分は実際に存在しなくても問題ない prefixes: [prefix, "http://myapp.com"], async getInitialURL() { // NOTE: ExpoGoで実行する場合はreact-native-firebaseを使用している部分をすべてコメントアウトする // FirebaseのDynamicLinkによって起動させられたかを確認する const initialLink = await dynamicLinks().getInitialLink(); if (initialLink) { const continueUrl = parseDynamicLink(initialLink.url); return continueUrl; } // アプリがディープリンクによって開かれたかどうかを確認する const url = await Linking.getInitialURL(); if (url) { return url; } // 'myapp://activities' return `${prefix}activities`; }, ...
Linking.createURL
が出力するディープリンクは環境によって異なる。
例えばスタンドアロンな場合はyourscheme://path
という結果になる。
Expo Goで実行する場合はexp://128.0.0.1:19000/--/path
のような出力結果になる。
詳しくはこちらを参照してほしい。
prefixes
に指定する値はこの値と、ディープリンク経由でアプリを開く際に使用されるディープリンクのスキーム付きのホスト名を指定する。
例えば、http://myapp.com/path/into/app
というディープリンクでこのアプリを開いて欲しい場合はhttp://myapp.com
の部分を指定する。
ちなみに、ここで指定したホスト名はランダムな値で問題ないらしいので、この機能を実装するためにドメインを所有する必要はない。
Firebaseのダイナミックリンク経由でアプリが開かれた場合はdynamicLinks().getInitialLink()
を呼び出すことで、このアプリをきっかけをつくったダイナミックリンクを取得することができる。
それ以外のディープリンクによってアプリが開かれた場合はLinking.getInitialURL()
を呼び出すことでこのアプリを起動させたURLを取得することができる。
どのメソッドからも出力結果を取得できなかった場合には、デフォルトの画面を開くようにするために特定のスクリーンのパスを返す。
次に、アプリがフォアグラウンドで起動中の場合にディープリンクによってアプリが開かれたときに呼び出されるコールバック関数を登録するためのsubscribe
を定義する。
... subscribe: (listener) => { // NOTE: ExpoGoで実行する場合はreact-native-firebaseを使用している部分をすべてコメントアウトする const unsubscribeFirebase = dynamicLinks().onLink(({ url }) => { const continueUrl = parseDynamicLink(url); listener(continueUrl); }); const onReceiveURL = ({ url }: { url: string }) => { listener(url); }; const linkingSubscription = Linking.addEventListener("url", onReceiveURL); return () => { // NOTE: ExpoGoで実行する場合はreact-native-firebaseを使用している部分をすべてコメントアウトする unsubscribeFirebase(); linkingSubscription.remove(); }; }, ...
たびたび登場するparseDynamicLink
については次の節で解説する。
そして最後に、ディープリンクと対応するスクリーンを設定するためにconfig
を定義する。
// navigation/LinkingConfiguration.ts ... config: { screens: { ... DeepLink: { screens: { EmailActionHandler: { path: "email-action-handler", exact: true, }, }, }, ... NotFound: "*", }, }, ...
この設定により、http://myapp.com/email-action-handler
というディープリンクでEmailActionHandlerScreen
を表示させることができる。
そしてこのlinking
を@react-navigation/native
のNavigationContainer
に渡す。
import { NavigationContainer, DefaultTheme, DarkTheme, } from "@react-navigation/native"; import React from "react"; import { ColorSchemeName } from "react-native"; import LinkingConfiguration from "./LinkingConfiguration"; ... export default function Navigation({ colorScheme, }: { colorScheme: ColorSchemeName; }) { return ( <NavigationContainer linking={LinkingConfiguration} theme={colorScheme === "dark" ? DarkTheme : DefaultTheme} > <RootNavigator /> </NavigationContainer> ); } ...
ダイナミックリンクから続行URLを取り出す関数を定義する
認証メールに書かれたリンクからアプリを起動した場合、dynamicLinks().onLink()
で取得できるURLやdynamicLinks().getInitialLink()
で取得できるinitialLink
には次の形式のURLが格納される。
"https://firebase-project-name.firebaseapp.com/__/auth/action?apiKey=xxxx&mode=verifyEmail&oobCode=xxxx&continueUrl=http://myapp.com/email-action-handler&lang=ja";
ここで、ナビゲーションに必要なのはconitnueUrl
クエリパラメーターなので、それを取り出す関数をparseDynamicLink
として実装する。
認証とは関係のない通常のFirebaseのダイナミックリンクの場合は、コンソール画面からダイナミックリンクを生成した際に指定した続行URLが渡されるので、それをそのまま返すようにする。
// helpers/parseDynamicLink.ts import * as Linking from "expo-linking"; export function parseDynamicLink(deepLink: string) { let continueUrl = deepLink; const parsedDeepLink = Linking.parse(deepLink); const keys = Object.keys(parsedDeepLink.queryParams ?? {}); if ( parsedDeepLink.queryParams && "continueUrl" in parsedDeepLink.queryParams && typeof parsedDeepLink.queryParams["continueUrl"] === "string" ) { continueUrl = parsedDeepLink.queryParams["continueUrl"]; } return continueUrl; }
types.tsxでスクリーンの型を定義する
RootNavigator
で使用するStack
を作成する際に使用するcreateNativeStackNavigator
に渡す型としてRootStackParamList
を定義するが、ディープリンクを処理するためのナビゲーターを追加する場合は次のような感じにする。
// types.tsx export type RootStackParamList = { ... DeepLink: NavigatorScreenParams<DeepLinkStackParamList> | undefined; ... };
そして、DeepLinkStackParamList
は次のような感じ。
// types.tsx ... export type DeepLinkStackParamList = { EmailActionHandler: undefined; }; export type DeepLinkStackScreenProps< Screen extends keyof DeepLinkStackParamList > = CompositeScreenProps< NativeStackScreenProps<DeepLinkStackParamList, Screen>, NativeStackScreenProps<RootStackParamList> >; ...
今回、メールアクションハンドラとしてスクリーンを定義するわけではなく、続行URLの遷移先としてアプリのスクリーンを実装したいだけなので、mode
やoobCode
を受け取ろうとして次のように定義しないようにする。これをやってしまうと、上記の実装だとアプリがクラッシュしてしまう可能性がある。
export type DeepLinkStackParamList = { EmailActionHandler: { mode: "resetPassword" | "recoverEmail" | "verifyEmail"; oobCode: string; apiKey: string; continueUrl: string; lang?: string; }; };
スクリーンが受け取るPropsの型は次のような感じ。
// types.tsx export type EmailActionHandlerScreenProps = DeepLinkStackScreenProps<"EmailActionHandler">;
続行URLとマッチするEmailActionHandlerScreenを実装
// screens/DeepLink/EmailActionHandlerScreen.tsx import { useAuthUser } from "@react-query-firebase/auth"; import { useCallback, useEffect, useRef } from "react"; import { Alert } from "react-native"; import { auth } from "../../helpers/firebase"; import i18n from "i18n-js"; import type { EmailActionHandlerScreenProps as Props } from "../../types"; import { CommonActions } from "@react-navigation/native"; import { Box, Button, Center, Text, useTheme, VStack } from "native-base"; import LottieView from "lottie-react-native"; const lottieFile = require("../../assets/lottie/verified-badge.json"); export function EmailActionHandlerScreen({ navigation, route }: Props) { const authQuery = useAuthUser(["user"], auth); const animation = useRef<LottieView>(null); const theme = useTheme(); useEffect(() => { animation.current?.play(); return () => { animation.current?.pause(); } }, []); useEffect(() => { if (authQuery.data && !authQuery.data.emailVerified) { Alert.alert("あなたは認証されていません", `前の画面に戻ります`, [ { text: "OK", style: "cancel", onPress: () => { navigation.goBack(); }, }, ]); } return () => {}; }, [authQuery.data]); const handlePress = useCallback(() => { navigation.dispatch((navigationState) => { return CommonActions.reset({ index: 0, routes: [ { name: "Root", }, ], }); }); }, []); return ( <VStack flex={1} safeArea justifyContent={"center"} bg="white"> <Center> <LottieView autoPlay ref={animation} style={{ width: theme["sizes"]["1/2"], // backgroundColor: "#fff", }} source={lottieFile} /> <VStack py={12}> <Text textAlign={"center"} fontWeight="bold"> {`${i18n.t("congratulations")}!`} </Text> <Box mb={3} /> <Text textAlign={"center"} fontWeight="bold"> {i18n.t("your_email_address_has_been_verified")} </Text> </VStack> </Center> <VStack flex={1} justifyContent={"flex-end"} px={3}> <Button mb={3} onPress={handlePress}> {i18n.t("back_to_top")} </Button> </VStack> </VStack> ); }
各画面とナビゲーターの配置については、上記の内容を参考にしながら各アプリの目的に沿って配置することをおすすめする。ここでは範囲外なので割愛させてもらう。
ちなみに、EmailActionHandlerScreen
については、次のようにDeepLinkNavigator
を実装してルートのスタックナビゲーターに配置すると良いかも。
// navigation/DeepLinkNavigation.tsx import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { DeepLinkStackParamList } from "../types"; import i18n from "i18n-js"; import { EmailActionHandlerScreen } from "../screens/DeepLink/EmailActionHandlerScreen"; const DeepLinkStack = createNativeStackNavigator<DeepLinkStackParamList>(); export function DeepLinkNavigator() { return ( <DeepLinkStack.Navigator screenOptions={{ headerTitleAlign: "center", }} > <DeepLinkStack.Screen name="EmailActionHandler" component={EmailActionHandlerScreen} options={{ title: i18n.t("email_verification"), }} /> </DeepLinkStack.Navigator> ); }
実装したDeepLinkNavigator
はmodal
で開くようにするとかっこよくなるのでおすすめ。
// navigation/index.tsx ... function RootNavigator() { ... return ( <Stack.Navigator> ... <Stack.Group screenOptions={{ presentation: "modal" }}> <Stack.Screen name="DeepLink" component={DeepLinkNavigator} options={{ headerShown: false }} /> </Stack.Group> ... </Stack.Navigator> ); }
参考
https://reactnavigation.org/docs/deep-linking/#third-party-integrations
Part1
Part2
Expo + react-native-firebase Dynamic Links | by Ben Kass | Medium