たかぎとねこの忘備録

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

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

備考

この続編記事として受け取ったプッシュ通知をタップして特定の画面に遷移する方法を公開する予定です。