tscの代わりにBabelにトランスパイルをまかせてみた
モノリポでパッケージを作る際にtsc
の代わりにbabel
を使ってみたかったので、これを機に色々調べてみた。
参考にしたのはこのサイト。
Using Babel with TypeScript | Learn TypeScript
Babelってなんだ
Babelは主にECMAScript 2015+のコードを、古いブラウザや環境での後方互換性をもったバージョンのJavaScriptに変換するために使用されるツール。
Babelがtscに代わって使われる理由はJSXをJavaScriptに変換できるから。 tscは変換できない。なのでReactをプロジェクトで使っている場合はBabelが必要になる。
必要なパッケージのインストール
とりあえず、TypeScriptやデコレーターだったりを使いたいので、それらをトランスパイルするのに必要なパッケージをまとめてみた。
@babel/core
- Babelのコアとなるライブラリ
@babel/preset-env
- 最新のJavaScriptの機能を使いつつ、それらをサポートしていないブラウザをターゲットにするためのプラグインの集まり。
@babel/preset-typescript
- TypeScriptのコードをJavaScriptに変換するためのプログラインの集まり
@babel/cli
- Babelを実行するために使われるコマンドラインツール。
babel-plugin-transform-typescript-metadata
@babel/plugin-proposal-decorators
@babel/plugin-proposal-class-properties
- 静的なクラスプロパティとプロパティ初期化構文で宣言されたプロパティを変換するプラグイン
babel-plugin-module-resolver
tsc-alias
yarn add -D @babel/core @babel/preset-env @babel/preset-typescript @babel/cli @babel/plugin-proposal-class-properties @babel/plugin-proposal-decorators babel-plugin-module-resolver babel-plugin-transform-typescript-metadata
設定ファイルを作成する
.babelrc
というファイルをルートディレクトリに作成する。
中身は次のようにする。
{ "presets": ["@babel/preset-env", "@babel/preset-typescript"], "plugins": [ "babel-plugin-transform-typescript-metadata", ["@babel/plugin-proposal-decorators", { "version": "legacy" }], "@babel/plugin-proposal-class-properties", [ "module-resolver", { "root": ["."], "alias": { "~": "./src" } } ], ] }
babel-plugin-transform-typescript-metadata
は@babel/plugin-proposal-decorators
よりも前に記述しないといけないので注意する。
トランスパイルはBabelに任せて、tsc
では型チェックのみを行いたいので、noEmit
はfalse
に、declaration
はtrue
に、そしてemitDeclarationOnly
をtrue
に設定する。
... "compilerOptions": { ... "outDir": "dist", ... "noEmit": false, // 型定義ファイルは出力したいのでfalse "declaration": true, // 型定義ファイルの生成を指示する "emitDeclarationOnly": true, // 型定義ファイルのみ生成する ... }, ...
outDir
はBabelの出力先と一致させておく。ここではdist
を設定する。
スクリプトを追加する
build
スクリプトとしてpackage.jsonに次のコマンドを追加する。
... "scripts": { "build": "tsc -p tsconfig.json -d && tsc-alias -p tsconfig.json && npx babel ./src --extensions '.ts,.tsx' --out-dir ./dist", ... } ...
--out-file dist/index.js
ではなく--out-dir ./dist
を設定することに注意する。
もし--out-file dist/index.js
を指定してしまうと、./src/index.ts
のみがトランスパイルされて、distディレクトリに出力される。たとえ./src/index.ts
内で./src/classes/Sample.ts
をインポートしていたとしてもdist/classes
にSample.js
が出力されることはない。
tsc-alias
を使わないと~/classes/...
のようなエイリアスパスがそのまま型定義ファイルに出力されてしまう。なのでそれを防ぐためにtsc
による型定義ファイルの出力後に、tsc-alias
を使って型定義ファイル中のエイリアスパスを相対パスに置き換える必要がある。
module-resolverの設定
tsconfig.jsonで次のようにエイリアスを設定している場合
... "paths": { "~/*": ["./src/*"], }, "baseUrl": ".", ...
module-resolver
の設定は次のようにする。
... [ "module-resolver", { "root": ["."], "alias": { "~": "./src" } } ], ...
まとめ
Reactなどのフレームワークを触り始めたばかりのころに、WebpackやらBabelやらという当時の自分にはわからない単語が多すぎたせいで、なぜか今でもBabelを敬遠してしまっていた。
今回改めて自分で設定ファイル書いたりプラグイン追加してみたりしたけど、案外難しいことはなかった。
やっぱり自分で使ってみるのが一番ですね。
参考
Using Babel with TypeScript | Learn TypeScript
TypeScript: Documentation - Using Babel with TypeScript
TypeScript で型を検査する|【React/Redux】カンバンボードを実装して Web フロントエンド上級者を目指そう!|Techpit
GitHub - microsoft/tsyringe: Lightweight dependency injection container for JavaScript/TypeScript
GitHub - tleunen/babel-plugin-module-resolver: Custom module resolver plugin for Babel
Next.js + TypeScript + デコレータ + reflect-metadata を動かす
GitHub - justkey007/tsc-alias: Replace alias paths with relative paths after typescript compilation
React Native製のアプリで受け取ったプッシュ通知をタップして特定の画面に遷移させる方法
目次
- FirebaseコンソールからServer Keyを取得する
- Server KeyをExpoのサーバーにアップロードする
- 必要なパッケージをインストールする
FirebaseコンソールからServer Keyを取得する
Expoが認証情報を使って開発者の代わりにExpoのサーバーからプッシュ通知を送信するためには、Server KeyをExpoにアップロードする必要がある。
このキーはFirebaseコンソールから確認することができる。
Firebase プロジェクトの設定ページにアクセスして、Cloud Messagingタブをクリックする。
Server KeyはCloud Messaging API (Legacy)でのみ利用可能なので、デフォルトでは無効になっています。なので3点メニューをクリックして、Google Cloud ConsoleでAPIを管理をクリックします。
Google CloudコンソールのCloud Messagingの詳細ページが開かれたら、有効にするボタンをクリックする。
有効になったら、自動的に次のようなページに遷移する。
Cloud Messagingタブのページに戻ったらCloud Messaging APIが有効になっているのが確認できる。その下にサーバーキーが表示されているのでコピーして控えておく。
Server KeyをExpoのサーバーにアップロードする
先ほど控えておいたServer KeyをExpoのサーバーにアップロードする。<your-token-here>
の部分を実際のトークンに置き換えて次のコマンドを実行する。
expo push:android:upload --api-key <your-token-here>
これでアプリをインストールしているユーザーは、プロジェクトの認証情報を使用してFCMから通知を受け取ることができるようになる。
必要なパッケージをインストールする
プッシュ通知に対するハンドリングを行うのに必要なパッケージは次の2つ。
expo-device
expo-notifications
expo install expo-device expo-notifications
プッシュ通知の送信に必要なトークンを取得するための関数を実装する
プッシュ通知の送信にはメッセージを送信する宛先(Expo Push Token)の情報が必要になる。
そのトークンは実機上でしか取得できないので、クライアント上でgetPermissionsAsync
とrequestPermissionsAsync
を呼び出して権限の可否を確認して、getExpoPushTokenAsync
を使って実際のトークンを取得する。
import * as Device from "expo-device"; import { Platform } from "react-native"; import * as Notifications from "expo-notifications"; export async function registerForPushNotifications() { let token = ""; // 端末上でこの関数が実行されているかを確認する if (Device.isDevice) { // 通知の権限の状態を取得する const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; // 通知が拒否されている場合 if (existingStatus !== "granted") { // アラートを表示して、通知の許可を取得する const { status } = await Notifications.requestPermissionsAsync(); finalStatus = status; } // 結局通知を拒否された場合 if (finalStatus !== "granted") { alert("Failed to get push token for push notification!"); return; } token = (await Notifications.getExpoPushTokenAsync()).data; } else { alert("Must use physical device for Push Notifications"); } if (Platform.OS === "android") { Notifications.setNotificationChannelAsync("default", { name: "default", importance: Notifications.AndroidImportance.MAX, vibrationPattern: [0, 250, 250, 250], lightColor: "#FF231F7C", }); } return token; }
OSがAndroidの場合は、setNotificationChannelAsync
メソッドを呼び出して、第一引数で指定したチャンネルIDにチャンネル構成を割り当てる。
こにれより、サーバー側でExpoPushMessage
型のオブジェクトを組み立てる時に、channelId
にここで指定したチャンネルIDを指定すると、ここで設定したチャンネル構成で通知が届くようになる。
トークンの取得に成功したら、任意のタイミングでサーバーに送信して保存するようにしてみる。
// screens/SetupWizard/NotificationSetupScreen.tsx ... const expoPushTokenMutation = useFunctionsCall< UpdateConfigWithExpoPushTokenRequestData, UpdateConfigWithExpoPushTokenResponseData >(functions, "updateConfigWithExpoPushToken"); ... // 通知権限の許可とExpo Push Tokenを取得する const token = await registerForPushNotifications(); // トークンをexpo_push_tokensカラムの配列に追加する if (token) { expoPushTokenMutation.mutate({ expo_push_token: token, }); } ...
通知がタップされた場合の挙動を定義するためのフックを実装する
expo-notifications
から公開されているuseLastNotificationResponse
フックを使うことで、直近でタップした通知の情報をレスポンスとして取得することができる。
この内容をuseEffect
で処理することで、アプリがフォアグラウンドだろうと、閉じていた状態から通知をタップしてアプリが起動した状態だろうと、そのタップした通知のdata
から求められている遷移先をurl
として取得することができる。
// hooks/useExpoNotifications.ts import * as Notifications from "expo-notifications"; import { useEffect } from "react"; import * as Linking from "expo-linking"; export function useExpoNotifications() { const lastNotificationResponse = Notifications.useLastNotificationResponse(); useEffect(() => { // data(JSON)の例 // { "url": "scheme://path/into/app" } if ( lastNotificationResponse && lastNotificationResponse.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER ) { if ( lastNotificationResponse.notification.request.content.data.url && typeof lastNotificationResponse.notification.request.content.data .url === "string" ) { Linking.openURL( lastNotificationResponse.notification.request.content.data.url ); } } }, [lastNotificationResponse]); return { response: lastNotificationResponse, }; }
あとはこのフックをRootNavigator
などで呼び出すと良い。すると通知がタップされれば任意のアクションを実行することができる。
ここでは特定の画面に遷移するようにしている。
// navigation/index.tsx ... const Stack = createNativeStackNavigator<RootStackParamList>(); ... function RootNavigator() { ... const { response } = useExpoNotifications(); ... return ( <Stack.Navigator> ... </Stack.Navigator> ) }
まとめ
今回の記事は前回公開したこちらの記事と一緒に読んでいただけるとより理解が深まるのではと思います。
expo-notifications
の詳細についてはこちらの公式ドキュメントを参照していただけると助かります。
FirebaseからReact Native製アプリにプッシュ通知を送信する方法
スタンドアロンアプリをExpoで開発中に、プッシュ通知をタップして特定の画面を開く機能を実装したくなった。
BFFとしてFirebase Functionsを使用していたので、忘備録としてFirebase FunctionsからExpoで構築されたReact Native製アプリにプッシュ通知を送信する方法を簡易的にまとめてみた。
目次
- アクセストークンを発行する
- アクセストークンをFirebase Functionsのシークレットに追加する
- 必要なパッケージをインストールする
- 新しいExpo SDKクライアントを作成する
- メッセージを作成する
- メッセージを送信してチケットを取得する
- 取得したチケットからレシートを取得する
- Pub/Subを使って実際にプッシュ通知を送信する
プッシュ通知のセキュリティを強化するためアクセストークンを発行する
ExpoコンソールのAccess tokensのページにアクセスする。
https://expo.dev/accounts/アカウントID/settings/access-tokens
Access Tokenの設定ページで、Enhanced Security for Push Notificationsという項目がある。これをONに設定するとプッシュ通知の送信にアクセストークンを必須にすることができる。
CreateボタンをクリックしてToken nameにわかりやすいトークンの名前を入力する。
例えばproject-name-access-token
のような感じ。
入力が終了したら、Generate New Tokenをクリックする。 新しく作成されたアクセストークンが一度だけ表示されるので、コピーして保管しておく。
アクセストークンを指定しないでプッシュ通知を送信しようとするとInsufficient permissions to send push notifications to ...
というエラーが発生する。
発行したアクセストークンをFirebase Functionsのシークレットを追加する
シークレット名はEXPO_ACCESS_TOKEN
にする。
firebase functions:secrets:set EXPO_ACCESS_TOKEN
必要なパッケージをインストールする
インストールするパッケージは次の3つ。
expo-server-sdk
@google-cloud/pubsub
lodash
yarn add expo-server-sdk @google-cloud/pubsub lodash
loadash
はExpoPushTicket[][]
を一次元の配列に変換する際にfalattenDeep
メソッドを使うためにインストールする。
新しいExpo SDKクライアントを作成する
expo-server-sdk
から公開されているExpo
クラスを使って新しいインスタンスを作成する。
初期化時にオプションを渡すことができる。
プッシュ通知のセキュリティ設定を行なっている場合は、アクセストークンが必要になる。すでに発行している場合はここで渡す。
// src/helpers/expo-notifications/getExpo.ts import { Expo } from "expo-server-sdk"; export function getExpo() { const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN ?? "", }); return expo; }
送信するメッセージの配列を作成する
プッシュ通知を送信する前に、送信する内容や情報が含まれたExpoPushMessage
型のメッセージオブジェクトを作成する必要がある。
そのメッセージを送信相手の数だけ作成して、配列として返す関数を作成する。
// src/helpers/expo-notifications/sendMessages.ts import * as functions from "firebase-functions"; import { Expo } from "expo-server-sdk"; import type { ExpoPushMessage } from "expo-server-sdk"; export async function createMessages<T extends {}>({ expoPushTokens, title, body, data, }: { expoPushTokens: string[]; title: string; body: string; data: T; }) { const messages: ExpoPushMessage[] = []; expoPushTokens.forEach((pushToken) => { if (!Expo.isExpoPushToken(pushToken)) { functions.logger.log( `Push token ${pushToken} is not a valid Expo push token` ); } else { messages.push({ to: pushToken, sound: "default", title, body, data, }); } }); return messages; }
to
に送信先のExpo Push Tokenを指定する。
この値がトークンとして正しいかどうかはExpo.IsExpoPushToken
を使って検証する。
それぞれのトークンはExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]
のような形式になっている。
作成したメッセージを送信して、チケットを取得する
Expoのプッシュ通知サービスでは、通知のバッチを受け付けている。
なので、1000人に対してプッシュ通知を送信するために1000件のリクエストを送信する必要はない。
そのため、リクエストの数を減らして通知を圧縮するためにバッチ処理が推奨されている。
// src/helpers/expo-notifications/sendMessages.ts import { getExpo } from "./getExpo"; import type { ExpoPushMessage } from 'expo-server-sdk'; import _ from 'lodash'; export async function sendMessages(messages: ExpoPushMessage[]) { const expo = getExpo(); const ticketChunk = expo.chunkPushNotifications(messages); const tickets = await Promise.all( ticketChunk.map(async (chunk) => { const ticketChunk = await expo.sendPushNotificationsAsync(chunk); return ticketChunk; }), ); const flattenedTickets = _.flattenDeep(tickets); return flattenedTickets; }
chunkPushNotifications
メソッドを使用してExpoPushMessage[][]
型の値を取得する。そしてsendPushNotificationsAsync
を利用してExpoPushMessage[]
型の値を渡して、1度に1つのチャンクを送信する。
とてもシンプルな方法だが、このおかげで負荷をうまく分散させることができる。
送信後、ExpoPushTicket[][]
型の値が返されるので、lodash
のflattenDeep
メソッドを使って(ExpoPushSuccessTicket | ExpoPushErrorReceipt)[]
型の値に変換する。
取得したチケットからプッシュレシートのIDを取得して、レシートを取得する。
Expoのプッシュ通知サービスがAppleやGoogleなどのプロバイダーに対してプッシュ通知の配信を完了した後、各通知に対するレシートが作成される。このレシートは最低1日までは利用可能で、古いものから順に削除されていく。
このレシートのIDは先ほど取得したチケットのidフィールドに格納されている。 レシートには対処しなければならないエラーコードが含まれている場合がある。
たとえば、通知をブロックしているまたはアプリをアンインストールしているデバイスに対して通知を送り続けると、AppleやGoogleなどのプロバイダーはそのアプリをブロックすることがまれにある。
なので、レシートに含まれているエラーコードに則って開発者側で適切な処理を行う必要がある。
だからこそ、レシートの取り扱いはとても大事。
// src/helpers/expo-notifications/sendPushNotifications.pubsub.ts ... export async function sendPushNotifications(event: functions.pubsub.Message) { try { const { title, body, url } = event.json; if (!title || !body || !url) { return 0; } if ( typeof title !== "string" || typeof body !== "string" || typeof url !== "string" ) { return 0; } const expo = getExpo(); // TODO: Expo Push Tokenの配列を準備する const pushTokens = getExpoPushTokens(); // メッセージをすべてのユーザー分準備する const messages = await createMessages({ expoPushTokens: pushTokens, title, body, data: { url, }, }); // プッシュ通知を送信して、チケットを取得する const tickets = await sendMessages(messages); // 取得したチケットから、レシートIDを取得する const receiptIds: string[] = []; tickets.forEach((ticket) => { if (ticket.status === "ok") { receiptIds.push(ticket.id); } }); // レシートIDを使ってレシートを検索し、プッシュ通知プロバイダへの配信が成功したかを確認する const receiptIdChunks = expo.chunkPushNotificationReceiptIds(receiptIds); for (const chunk of receiptIdChunks) { const receipts = await expo.getPushNotificationReceiptsAsync(chunk); // eslint-disable-next-line guard-for-in for (const receiptId in receipts) { const receipt = receipts[receiptId]; if (receipt.status === "ok") { continue; } else if ( receipt.status === "error" && receipt.details && receipt.details.error ) { functions.logger.warn( `There was an error sending a notification: ${receipt.message}` ); functions.logger.warn(`The error code is ${receipt.details.error}`); } else if (receipt.status === "error") { functions.logger.warn( `There was an error sending a notification: ${receipt.message}` ); } } } } catch (e) { functions.logger.error("PubSub message was not JSON", e); } return 0; }
ここで実装した関数をPub/Subとして公開する
// src/index.ts ... import { sendPushNotifications as send_push_notifications } from "./helpers/expo-notifications/sendPushNotifications.pubsub"; ... export const sendPushNotifications = functions .region(REGION) .runWith({ secrets: ["EXPO_ACCESS_TOKEN"], }) .pubsub.topic("send-push-notifications") .onPublish(send_push_notifications);
具体的なエラーコードの詳細はExpoのドキュメントを参照してほしい。
Sending Notifications with Expo's Push API - Expo Documentation
Pub/Subを呼び出して、プッシュ通知をデバイスに向けて送信する
アプリでプッシュ通知をタップした時に、目的の画面を開けるようにするため、data
にはurl
を含めるようにする。
// src/helpers/expo-notifications/triggerSendPushNotifications.pubsub.ts import { PubSub } from "@google-cloud/pubsub"; import * as functions from "firebase-functions"; export async function triggerSendPushNotifications( context: functions.EventContext ) { const title = "プッシュ通知のタイトル"; const body = "プッシュ通知の本文"; const url = "scheme://path/into/app"; const pubsub = new PubSub(); const dataBuffer = Buffer.from( JSON.stringify({ title, body, url, }) ); const result = await pubsub.topic("send-push-notifications").publishMessage({ data: dataBuffer, }); return result; }
スケジュール通りにプッシュ通知を送信したい場合は次のように上記の関数を公開する。
// src/index.ts ... import { triggerSendPushNotifications as trigger_send_push_notifications } from "./helpers/expo-notifications/triggerSendPushNotifications.pubsub"; ... export const triggerSendPushNotifications = functions .region(REGION) .runWith({ secrets: ["APP_VARIANT", "IP_ADDR_WITH_PORT", "EXPO_ACCESS_TOKEN"], }) .pubsub.schedule("15 8 * * *") .onRun(trigger_send_push_notifications); ...
まとめ
今回こちらで紹介した内容の詳細については、Expoのドキュメントに詳しく書かれています。
興味を持たれた方はぜひ参照してみてください。
Sending Notifications with Expo's Push API - Expo Documentation
GitHub - expo/expo-server-sdk-node: Server-side library for working with Expo using Node.js
備考
この続編記事として受け取ったプッシュ通知をタップして特定の画面に遷移する方法を公開する予定です。
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
Firebase Dynamic Linksでダイナミックリンクを作成する
Firebase Dynamic Linksの作成画面で各単語が意味していることや、各フィールドで求められる内容を入力する意味が最初わからなかったので、将来的なことも含めて忘備録としてダイナミックリンクの作り方を残しておく。
Firebaseのコンソール画面からDynamic Linksにアクセスする。
ざっくりとした手順は次の通り。
URL接頭辞の追加
URL接頭辞とは、短縮URLのパスを設定するときに使用するホスト名の部分のこと。
デフォルトでは*.page.link
といったドメインが用意されている。もちろん自前のカスタムドメインを使用することもできる。
このドメインを含む短縮URLにアクセスすると、事前に登録しておいたアプリを端末上で開いて、そのアプリにディープリンクを渡して特定の画面に遷移することができるようになる。
ドメインを選択したら続行をクリックする。
確認したら完了をクリックする。
画面真ん中の左上に先ほど設定したURL接頭辞が表示されていれば成功。
続いて、このURL接頭辞を使ったダイナミックリンクを作成していく。
短縮URLの作成
短縮URLに設定するパスを入力していく。デフォルトではランダムな短い英数字が入力されている。もちろんお客さんにとってわかりやすい意味のある単語に修正しても良い。
プレビューで表示を確認して、よければ次へをクリックする。
アプリへ渡すディープリンクを設定
ディープリンクはアプリにデータを渡すために使われる。例えば、ディープリンクがpath/into/app
というパスになっていたら、path
の部分がアプリを表すホスト名の役割をは対していて、実際に必要なデータは/into/app
以降である。ここの部分をアプリで解析して、移動したい画面を認識しその画面を立ち上げるといった使い方をする。そしてそこにクエリパラメーターなどが付与されているとそれらもデータとして解釈することができる。
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"}.
アプリ上で事前に特定の画面とディープリンクのパスをマッピングしてくことをおすすめする。そこで設定した/path/into/app
を入力フィールド入力する。
説明としてどんな画面を開くディープリンクなのかを入力しておくとわかりやすい。
できたら次へをクリックする。
ディープリンクを開くiOSアプリを選択
このディープリンクを使ってiOS端末上でアプリを開きたい場合は、Firebaseプロジェクトに登録してあるiOS アプリを選択する。その際にApp Store IDやチーム IDを追加しておかないと警告が出て次へ進むことができないので注意する。
iOSアプリを開く予定がない場合はブラウザディープリンクのURLを開くを選択しておく。
できたら次へをクリックする。
ディープリンクを開くAndroidアプリを選択
このディープリンクを使ってAndroid端末上でアプリを開きたい場合は、Firebaseプロジェクトに登録してあるAndroidアプリを選択する。
アプリがインストールされていない場合のユーザーの移動先を選択することができるので、お好みで設定する。
そして、キャンペーントラッキングなどの詳細オプションを適当に入力して作成をクリックしたら完了。
あとは発行されたダイナミックリンクをアプリがインストールされているそれぞれの端末で開いて、目的の画面が立ち上がるかどうかを確認すればよい。
参考
Firebase Dynamic Links | Firebase ドキュメント
Dynamic Links のカスタム ドメインを設定する | Firebase Dynamic Links
`node-fetch`を使用したときに発生する`Error [ERR_REQUIRE_ESM]: require() of ES Module`の解決方法
Firebase Functionsでnode-fetch
を使用してFirebase Emulatorで動かそうとしたら次のようなエラーが発生した。
Error: Failed to load function definition from source: Failed to generate manifest from function source: Error [ERR_REQUIRE_ESM]: require() of ES Module /.../node_modules/node-fetch/src/index.js from /.../firebase/functions/lib/routes/tab-one/activities/getActivities.js not supported. Instead change the require of index.js in /...firebase/functions/lib/routes/tab-one/activities/getActivities.js to a dynamic import() which is available in all CommonJS modules.
これはnode-fetch
のバージョン3からESM専用パッケージに変換されたため、コンパイルされたJavaScriptでrequire
を使ってこのパッケージをインポートできなくなったのが原因である。
一番簡単な解決策はCommonJSでビルドされた最後のバージョンであるnode-fetch@2.6.6
をインストールすることである。
このバージョンでは型定義ファイルは自動的に含まれていないので、別途インストールする必要がある。
yarn add node-fetch@2.6.6 yarn add -D @types/node-fetch@2.x
Fix - node-fetch Error [ERR_REQUIRE_ESM]: not supported | bobbyhadz
Expoのマネージドアプリのアップデートに関するメモ
expo publish
について
expo publish
を実行するとプロジェクトの永続的なURLが発行される。
これはExpo Goアプリで開くことができる。
そして、アプリの画像、フォント、動画などのアセットがすべてCDNにアップロードされる。
expo publish
を実行した際に指定したリリースチャンネルとインストールされたアプリのリリースチャンネルが同じであり、そのプッシュされたリリースは最新版でかつ互換性のあるリリースである場合は、ユーザーはスタンドアロンなアプリを通してそのアップデートを取得することができる。
これは、いわゆるストアを経由しないでアップデートを配布できるOTAという仕組み。
互換性とは
互換性に与える影響には主に次の3つがある。
更新が反映される流れ
「アプリのビルド時に指定したリリースチャンネルにプッシュされたすべてのリリースを確認したか」
この問いにYES
の場合はすでに最新版が反映されているので何も行われない。
NO
の場合はリリースチャンネルにプッシュされた最新版のリリースを取得する。
「互換性があるか?そしてSDKバージョンの互換性は大丈夫か」という問いが行われ、YES
の場合にのみ最新版のリリースの反映が行われてアプリが更新される。
expo publish
による更新が反映できない場合に考えられること
app.jsonの特定のフィールドに対して変更を加えた場合は、expo publish
を通して最新リリースを配信するのではなく、アプリのバイナリを再度ビルドしてストア経由でアップデートを配信する必要がある。
特定のフィールドとは次の通り。
ios
キーに設定するオブジェクトのいかなる内容android
キーに設定するオブジェクトのいかなる内容notification
キーに設定するオブジェクトのいかなる内容splash
キーに設定するオブジェクトのいかなる内容icon
キーに設定する文字列の内容name
キーに設定する文字列の内容owner
キーに設定する文字列の内容scheme
キーに設定する文字列の内容facebookScheme
キーに設定する文字列の内容assetBundlePatterns
キーに設定する配列の内容
そしてもちろん、Expo SDKのバージョンが変更された場合もexpo publish
を通してアプリの更新を行うことはできないので、SDKのバージョンを上げる際には注意が必要になる。
加えて、Firebaseプロジェクトにアプリを追加した際に発行されるgoogle-services.jsonやGoogleService-Info.plistの内容が変更になった場合にも、アプリの再ビルドが必要になる。
Publishing updates - Expo Documentation
EAS Buildでは自動的にexpo publish
が実行されなくなった
従来のビルドではexpo build
コマンドを実行していたが、この時の注意点としてビルドを実行する前にアプリバンドルが自動的にアップデートとして指定したリリースチャンネルに公開されてしまっていた。
EAS Buildではビルドプロセスのひとつとしてexpo publish
が実行されなくなった。なので、ビルドするのと同時に誤って最新版が公開されてしまうかもという心配はいらないようだ。