たかぎとねこの忘備録

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

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-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では型チェックのみを行いたいので、noEmitfalseに、declarationtrueに、そしてemitDeclarationOnlytrueに設定する。

...
"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/classesSample.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)の情報が必要になる。

そのトークンは実機上でしか取得できないので、クライアント上でgetPermissionsAsyncrequestPermissionsAsyncを呼び出して権限の可否を確認して、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>
  )
}

まとめ

今回の記事は前回公開したこちらの記事と一緒に読んでいただけるとより理解が深まるのではと思います。

takagimeow.hatenablog.com

expo-notificationsの詳細についてはこちらの公式ドキュメントを参照していただけると助かります。

Notifications - Expo Documentation

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

loadashExpoPushTicket[][]を一次元の配列に変換する際に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[][]型の値が返されるので、lodashflattenDeepメソッドを使って(ExpoPushSuccessTicket | ExpoPushErrorReceipt)[]型の値に変換する。

取得したチケットからプッシュレシートのIDを取得して、レシートを取得する。

Expoのプッシュ通知サービスがAppleGoogleなどのプロバイダーに対してプッシュ通知の配信を完了した後、各通知に対するレシートが作成される。このレシートは最低1日までは利用可能で、古いものから順に削除されていく。

このレシートのIDは先ほど取得したチケットのidフィールドに格納されている。 レシートには対処しなければならないエラーコードが含まれている場合がある。

たとえば、通知をブロックしているまたはアプリをアンインストールしているデバイスに対して通知を送り続けると、AppleGoogleなどのプロバイダーはそのアプリをブロックすることがまれにある。

なので、レシートに含まれているエラーコードに則って開発者側で適切な処理を行う必要がある。

だからこそ、レシートの取り扱いはとても大事。

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

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

  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

Firebase Dynamic Linksでダイナミックリンクを作成する

Firebase Dynamic Linksの作成画面で各単語が意味していることや、各フィールドで求められる内容を入力する意味が最初わからなかったので、将来的なことも含めて忘備録としてダイナミックリンクの作り方を残しておく。

Firebaseのコンソール画面からDynamic Linksにアクセスする。

ざっくりとした手順は次の通り。

  1. URL接頭辞の追加
  2. 短縮URLを作成
  3. アプリへ渡すディープリンクを設定
  4. ディープリンクを開くiOSアプリを選択
  5. ディープリンクを開くAndroidアプリを選択

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"}.

Linking - Expo Documentation

アプリ上で事前に特定の画面とディープリンクのパスをマッピングしてくことをおすすめする。そこで設定した/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

「アプリで開く」を実現する、Firebase Dynamic Linksの実装と運用Tips - ログミーTech

【Firebase 入門】 Dynamic Links とは - Qiita

`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専用パッケージに変換されたため、コンパイルされたJavaScriptrequireを使ってこのパッケージをインポートできなくなったのが原因である。

一番簡単な解決策は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つがある。

  • Expo SDKのバージョン
  • iOSAndroidなどのプラットフォーム
  • リリースチャンネル
更新が反映される流れ

「アプリのビルド時に指定したリリースチャンネルにプッシュされたすべてのリリースを確認したか」

この問いに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.jsonGoogleService-Info.plistの内容が変更になった場合にも、アプリの再ビルドが必要になる。

Publishing updates - Expo Documentation

ExpoのOTA updateの仕様について

EAS Buildでは自動的にexpo publishが実行されなくなった

従来のビルドではexpo buildコマンドを実行していたが、この時の注意点としてビルドを実行する前にアプリバンドルが自動的にアップデートとして指定したリリースチャンネルに公開されてしまっていた。

EAS Buildではビルドプロセスのひとつとしてexpo publishが実行されなくなった。なので、ビルドするのと同時に誤って最新版が公開されてしまうかもという心配はいらないようだ。

Migrating from "expo build" - Expo Documentation

React Native + ExpoのアプリにHermes、EAS Build、EAS Submitを導入した