Firebase Cloud MessagingからPWAにPUSH通知 (Androidのみ)


先日obniz IoTコンテストに参加して「obniz Board 1Yと距離センサー VL53L0X 使用した鍵閉め忘れ通知アプリ」という作品を投稿したのですが、その中でFirebaseを利用してPWAへのPUSH通知を実装しました。
コンテストでは時間が限られていたので、通知をタップしてアプリのページを開くなど実装できなかった機能があったため改めて調べ直してまとめてみました。

ところで、2021年5月現在、PWAでPUSH通知を受けられるのはモバイルだとAndroidのみでiOSは残念ながらできません。iOS向けにPUSH通知をしたい場合はやはりアプリを提供する必要があります。
また、PWAはAndroidとデスクトップのChromeで若干実装が異なっているので注意が必要です。

サンプルプロジェクトについて

サンプルのFirebaseプロジェクトを以下のGithubリポジトリに公開しています。動作するコード全体はリポジトリを確認ください。

https://github.com/hrendoh/fcm-push-to-pwa-example

プロジェクトのコードは、Firestore、Functions、Hostingを有効にしてプロジェクトを作成したものにコードを追加しています。

プログラムの流れは以下のとおりです。

  1. サービスワーカーを登録、サービスワーカー側でpushの受信と通知クリックイベントのリスナーを登録
  2. Firebase Cloud Messagingのデバイストークンを取得しFirebaseに保存
  3. Firebase functionsでFirebaseに保存されているトークンを使用し通知メッセージを送信
  4. サービスワーカーでpushメッセージを受信し、デバイスの通知を表示
  5. 通知をタップするとPWAを表示

Android端末で表示した通知をタップするとPWAが開きます

デスクトップ版のChromeでも通知を受信できます。

PWA側の実装

フロントのPWAは、push通知を処理するサービスワーカーの登録、デバイストークンの取得と保存を実装します。

サービスワーカーの登録

ServiceWorkerContainer.register()メソッドでサービスワーカーを登録します。

// public/app.js

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/firebase-messaging-sw.js')
    .then(function (registration) {
      console.log('sw.js registration successful with scope: ', registration.scope);
    }, function (err) {
      alert('sw.js registration failed: ' + err);
    });
}

Firebase SDKのfirebase.messagingを使用する場合、サービスワーカーのファイル名は「firebase-messaging-sw.js」固定である必要があります。他の名前ですと以下のgetTokenメソッドを実行したときにスコープが異なっているというエラーが返されます。

登録トークンを取得しサーバーに保存

アプリにウェブ認証情報を構成するに説明されている通りgetTokenメソッドを使って、クライアントのデバイストークンを生成、すでに登録されていれば取得します。

// public/app.js

function initMessaging() {
  const messaging = firebase.messaging();

  // デバイス登録トークンを取得し、Firestoreに保存
  messaging.getToken({ vapidKey: VAPI_KEY}).then((currentToken) => {
    if (currentToken) {
      storeToken(currentToken);

    } else {
      // Show permission request.
      alert('No registration token available. Request permission to generate one.');
    }
  }).catch((err) => {
    alert('An error occurred while retrieving token. ' + err);
  });
}

VAPI_KEYは、Firebaseコンソールの[プロジェクトの概要]からWebアプリを開き、[Cloud Messaging]タブの[ウェブプッシュ証明書] > [鍵ペア]の値に置き換えます。

サンプルでは取得したトークンをプロジェクトのFirestoreに保存しています。
サーバー側のfunctionsではFirestoreに保存されたトークンを宛先に指定してPUSH通知を送信します。

// public/app.js

async function storeToken(currentToken) {
  const db = firebase.firestore();
  const docRef = await db.collection("tokens").doc("anonymous_user");
  docRef.set({
    token: currentToken,
    timestamp: new Date()
  });
  console.log("Document save with ID: ", docRef.id);
}

こちらはあくまで確認用途のりあえず実装で、どのデバイスでgetTokenしても1つのドキュメントを上書きします。
つまり、最後にアクセスしたデバイスが通知先になります。

実際には、加えて以下の対応をする必要があります。

  • ユーザごとにトークンは保存して、適切な宛先に通知を送信する
  • 毎回PWAを表示するたびにFirestoreのドキュメントを更新しにいってしまうので次回以降は保存しない

また、通知の購読の開始はPushManager.getSubscription()でも可能ですが、FCMを利用する場合はサーバー側の実装でfirebaseライブラリを使用するのであればgetTokenでトークンを取得する手順で特に問題ないでしょう。

通知の受信

Firebaseのヘルプ Service Worker での通知オプションの設定 に記載されているコードでも良いのですが、push通知を受信するだけならfirebaseの初期化は不要で、標準のAPIのみでも実装できます。

push通知の受信は、PushEventをリッスンします。

// public/firebase-messaging-sw.js

self.addEventListener('push', async (event) => {
  console.log('The client sent me a message.');
  const payload = await event.data.json();
  console.log(payload);
  // https://developer.mozilla.org/ja/docs/Web/Progressive_web_apps/Re-engageable_Notifications_Push
  event.waitUntil(
    self.registration.showNotification(
      payload.notification.title,
      {
        body: payload.notification.body,
        icon: payload.notification.icon,
      }
    )
  );
});

event.waitUntilの箇所についてですが、以下はMDNのExtendableEvent.waitUntil()メソッドの説明の引用です。

ExtendableEvent.waitUntil() メソッドは、作業が進行中であることをイベントディスパッチャーに通知します。 また、その作業が成功したかどうかを検出するためにも使用できます。 サービスワーカーの場合、waitUntil() は、Promise が確定するまで作業が進行中であることをブラウザーに通知し、サービスワーカーがその作業を完了させたい場合にサービスワーカーを終了させません。

showNotificationはPromiseを返すメソッドですのでwaitUtilでラップする必要があるわけです。

通知をタップしたらPWAを開く

デバイスの通知をタップした際に、PWAをフォワグラウンドにもどすにはServiceWorkerGlobalScope: notificationclick イベントのリスナーを登録します。

// public/firebase-messaging-sw.js

self.addEventListener('notificationclick', function(event) {
  console.log('On notification click');

  // Data can be attached to the notification so that you
  // can process it in the notificationclick handler.
  console.log('Notification Tag:', event.notification.tag);
  console.log('Notification Data:', event.notification.data);
  event.notification.close();

  const url = 'https://your-project-id.web.app/';
  // This looks to see if the current is already open and
  // focuses if it is
  event.waitUntil(clients.matchAll({
    type: "window",
    includeUncontrolled: true
  }).then(function(clientList) {
    console.log(clientList);
    for (const client of clientList) {
      if (client.url === url && 'focus' in client)
        return client.focus();
    }
    if (clients.openWindow)
      return clients.openWindow(url);
  }));
});

ハンドラーの引数eventはNotificationEventで、NotificationEvent.notificationのインスタンスを取得できます。
そのcloseメソッドを呼び出すとデバイスの通知を閉じることができます。

次にClients.matchAll()メソッドで、関連するサービスワーカーのリストを取得して、PWAのURLと合致するものがあればフォーカスします。合致するものがなければ新しくウィンドウを開きます。
matchAllのオプションincludeUncontrolledは「true」を指定する必要があります。

サーバー側(functions)の実装

通知テスト用のfunctionです。

sendToDeviceメソッドに、Firestoreに保存されたトークンを指定して通知を含むペイロードを送信しています。

// functions/index.js

exports.sendMessage = functions.region('asia-northeast1').https.onRequest(async (request, response) => {
  const tokenSnapshot = await db.collection('tokens').orderBy('timestamp', 'desc').limit(1).get();
  if (!tokenSnapshot.empty) {
    const doc = tokenSnapshot.docs[0];
    const data = doc.data();
    var payload = {
      notification: {
        title: 'こんにちは',
        body: 'テストメッセージです',
        icon: 'icons/icon-192.png',
      }
    };
    try {
      await admin.messaging().sendToDevice(data.token, payload);
      response.status(200).send('Successfully sent message.');
    } catch (error) {
      response.status(500).send('Error sending message: ' + error);
    }
  }
});

Firebase Admin SDK での以前の send メソッドによるとsendToDeviceメソッドは古い書き方なようなので、sendメソッドに置き換えたほうが良さそうです。

デスクトップ版Chromeでの動作の違い

AndroidのChromeとデスクトップ版のChromeでは実装が異なるところがいくつかあるようです。

通知の受信だけならサービスワーカーの登録は不要

通知をタップした際に発火されるnotificationclickイベントを処理する必要がなければ、通知自体はサービスワーカーが無くても表示されます。

AndroidではNotificationコンストラクタが使えない

デスクトップ版のChromeでは、new Notificationでも通知を生成できますが、Android版のChromeには実装されていません。

参考サイト

GoogleChome / samplesPush Messaging and Notification Sample
ブラウザプッシュ通知とユーザー個別に内容を送信する実装方法 in GAMY
サービスワーカーのデバッグは「chrome://inspect/#service-workers」を開く
Web Push Notifications: Timely, Relevant, and Precise

,