たかぎとねこの忘備録

プログラミングに関する忘備録を自分用に残しときます。マサカリ怖い。

React NativeでFirebaseのダイナミックリンクをハンドリングしてみる

Firebase Authに登録されたメールアドレスを認証したあと特定の画面に遷移して結果を表示したいというモチベーションが沸々と湧きあがった。

流れとしては次のような感じ。

  1. firebase/authsendEmailVerificationメソッドを利用してユーザーのメールアドレス宛に認証メールを送信する。
  2. ユーザーは届いたメールに書かれたダイナミックリンクをクリックする。
  3. ブラウザが立ち上がり、デフォルトのメールアクションハンドラが実行された後アプリに戻る。
  4. sendEmailVerificationメソッドを呼び出したときに指定した続行URLとマッチするスクリーンが立ち上がり、メールの認証が完了したことをユーザーに伝える。

簡単な図にすると次のような感じ。

面倒な作業を減らすために、メールのアクションハンドラをカスタマイズすることはせず、Firebaseでデフォルトで提供されているアクションハンドラを利用する。

今回はAndroidディープリンクを処理する方法を紹介。iOSでユニバーサルリンクを処理する場合はディープリンクに設定したホスト名のサーバーの/.well-known/apple-app-site-associationにAASAファイルを配置しないといけないので少々大変。

Linking - Expo Documentation

もちろんAndroidの場合でも、リンクをタップしたときにどのアプリで開くかのダイアログを表示しないで直接アプリを開かせたい場合は、アプリのIDとアプリが開くべきリンクを指定したjsonファイルを/.well-known/assetlinks.jsonに配置する必要がある。ただ、基本的にはapp.jsonandroid.intentFiltersに設定を追加すれば目的は達成できるのでiOSより簡単。

Linking - Expo Documentation

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.comdev.myapp.comなどにも対応することができる。そして必ずhost属性を指定する場合はscheme属性の設定をしないといけない。ここではhttpを指定している。

ここで、pathPrefixrecordsと指定した場合、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"}.

Linking - Expo Documentation

intent-filterdataの詳細については公式ドキュメントを参照して欲しい。

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を使用することが推奨されているのだけど、ダイナミックリンクをアプリで処理するためにはこのライブラリがどうしても必要。

docs.expo.dev

Expo Goではこのライブラリのメソッドを使うことはできないので、開発環境では呼び出し箇所をすべてコメントアウトしておくことをおすすめする。

必要なパッケージは次の二つ。

  • @react-native-firebase/app
  • @react-native-fireabase/dynamic-links
expo install @react-native-firebase/app @react-native-firebase/dynamic-links

app.jsonplugins@react-native-firebase/appを追加する。

// app.json
...
"plugins": [
  "@react-native-firebase/app"
]
...

ここで、@react-native-firebase/dynamic-linkspluginsに追加してしまうと次のようなエラーが発生してしまうので注意する。

PluginError: Package "@react-native-firebase/dynamic-links" does not contain a valid config plugin.

app.jsonios.googleServicesFileGoogleSerfice-Info.plistのパスを指定する。そしてandroid.googleServicesFilegoogle-services.jsonのパスを指定する。これらのファイルはFirebaseプロジェクトにそれぞれのプラットフォームのアプリを追加したときにダウンロードできる。

// app.json
...
"ios": {
  ...
  "googleServicesFile": "./secrets/GoogleService-Info.plist",
  ...
},
"android": {
  ...
  "googleServicesFile": "./secrets/google-services.json",
  ...
},
...

認証メールを送信する画面を準備する

SendEmailVerificationScreen.tsxを作成する。

認証メールの送信には、react-query-firebaseuseAuthSendEmailVerificationフックを利用する。

// 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に指定した値はapnibiクエリパラーメーターに付与される。

urlに指定したディープリンクが続行URLとしてcontinueUrlクエリパラメーターに設定される。そしてこの値は、必ずFirebase ConsoleのAuthenticationSettingsタブにある承認済みドメインに追加されていることを確認する。

そのほかの設定については次のリンクを参照して欲しい。

メール アクションで状態を渡す  |  Firebase

Firebase Authenticationにて、アクションURLにリンク先で使いたいURLをもたせる - Qiita

ダイナミックリンクの作成については次の記事を参照してほしい。

takagimeow.hatenablog.com

【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/nativeNavigationContainerに渡す。

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の遷移先としてアプリのスクリーンを実装したいだけなので、modeoobCodeを受け取ろうとして次のように定義しないようにする。これをやってしまうと、上記の実装だとアプリがクラッシュしてしまう可能性がある。

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>
  );
}

実装したDeepLinkNavigatormodalで開くようにするとかっこよくなるのでおすすめ。

// 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

Linking - Expo Documentation

Part1

Working with React Navigation V5, Firebase Cloud Messaging, and Firebase Dynamic Links | by TribalScale Inc. | TribalScale | Medium

Part2

Working with React Navigation V5, Firebase Cloud Messaging, and Firebase Dynamic Links | by TribalScale Inc. | TribalScale | Medium

How to Create Universal Link in Expo Using Firebase Dynamic Link | by Germa Vinsmoke | JavaScript in Plain English

Expo + react-native-firebase Dynamic Links | by Ben Kass | Medium